4801 lines
238 KiB
Python
4801 lines
238 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
from pathlib import Path
|
||
import time
|
||
from typing import Callable, Iterable
|
||
|
||
from OCC.Core.BRep import BRep_Tool
|
||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
|
||
from OCC.Core.BRepBndLib import brepbndlib
|
||
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
|
||
from OCC.Core.BRepBuilderAPI import (
|
||
BRepBuilderAPI_MakeFace,
|
||
BRepBuilderAPI_MakeVertex,
|
||
BRepBuilderAPI_MakeWire,
|
||
BRepBuilderAPI_Transform,
|
||
)
|
||
from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape
|
||
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
||
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
||
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||
from OCC.Core.BRepGProp import brepgprop
|
||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
|
||
from OCC.Core.Bnd import Bnd_Box
|
||
from OCC.Core.GeomAbs import (
|
||
GeomAbs_BSplineCurve,
|
||
GeomAbs_BSplineSurface,
|
||
GeomAbs_BezierCurve,
|
||
GeomAbs_BezierSurface,
|
||
GeomAbs_Circle,
|
||
GeomAbs_Cone,
|
||
GeomAbs_Cylinder,
|
||
GeomAbs_Ellipse,
|
||
GeomAbs_Hyperbola,
|
||
GeomAbs_Line,
|
||
GeomAbs_OffsetSurface,
|
||
GeomAbs_OtherCurve,
|
||
GeomAbs_OtherSurface,
|
||
GeomAbs_Parabola,
|
||
GeomAbs_Plane,
|
||
GeomAbs_Sphere,
|
||
GeomAbs_SurfaceOfExtrusion,
|
||
GeomAbs_SurfaceOfRevolution,
|
||
GeomAbs_Torus,
|
||
)
|
||
from OCC.Core.GProp import GProp_GProps
|
||
from OCC.Core.IFSelect import IFSelect_RetDone
|
||
from OCC.Core.Interface import Interface_Static
|
||
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
|
||
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
|
||
from OCC.Core.ShapeFix import ShapeFix_Shape
|
||
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
||
from OCC.Core.TDF import TDF_Label, TDF_LabelSequence
|
||
from OCC.Core.TDocStd import TDocStd_Document
|
||
from OCC.Core.TopAbs import (
|
||
TopAbs_EDGE,
|
||
TopAbs_EXTERNAL,
|
||
TopAbs_FACE,
|
||
TopAbs_FORWARD,
|
||
TopAbs_IN,
|
||
TopAbs_INTERNAL,
|
||
TopAbs_OUT,
|
||
TopAbs_REVERSED,
|
||
TopAbs_SOLID,
|
||
TopAbs_VERTEX,
|
||
TopAbs_WIRE,
|
||
)
|
||
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
||
from OCC.Core.TopLoc import TopLoc_Location
|
||
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
|
||
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
|
||
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
|
||
|
||
from .constants import CURVE_TYPES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
|
||
from .export import ExportMixin
|
||
from .features import FeatureMixin
|
||
from .operations import OperationMixin
|
||
from .polydata import PolydataMixin
|
||
from .recognition_priority import (
|
||
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_plain_step,
|
||
_load_with_xcaf,
|
||
_parse_product_names,
|
||
_prepare_shape_for_step_export,
|
||
_write_step,
|
||
)
|
||
|
||
|
||
class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, PolydataMixin):
|
||
def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape):
|
||
self.filename = filename
|
||
self.parts = parts
|
||
self.shape = shape
|
||
self.faces: list[TopoDS_Shape] = []
|
||
self.face_logical_ids: list[int] = []
|
||
self.face_part_ids: list[int] = []
|
||
self.face_solid_ids: list[int] = []
|
||
self.edges: list[TopoDS_Shape] = []
|
||
self.edge_part_ids: list[int] = []
|
||
self.edge_solid_ids: list[int] = []
|
||
self.solids: list[tuple[int, TopoDS_Shape]] = []
|
||
self._quick_face_info_cache: dict[int, dict[str, object]] = {}
|
||
self._face_info_cache: dict[int, dict[str, object]] = {}
|
||
self._feature_info_cache: dict[int, dict[str, object]] = {}
|
||
self._edge_info_cache: dict[int, dict[str, object]] = {}
|
||
self._face_edge_ids_cache: dict[int, list[int]] = {}
|
||
self._edge_face_ids_cache: dict[int, list[int]] = {}
|
||
self._same_domain_face_ids_cache: dict[int, list[int]] = {}
|
||
self._face_first_level_topology_cache: dict[int, dict[str, object]] = {}
|
||
self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {}
|
||
self._face_first_level_fact_cache: dict[tuple[int, str], dict[str, object]] = {}
|
||
self._edge_vertex_points_cache: dict[int, tuple[tuple[float, float, float], ...]] = {}
|
||
self._edge_vertex_key_edge_ids_cache: dict[tuple[int, int, int, int, int], set[int]] | None = None
|
||
self._edge_first_level_topology_cache: dict[int, dict[str, object]] = {}
|
||
self._edge_first_level_fact_cache: dict[int, dict[str, object]] = {}
|
||
self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
|
||
self._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._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 load(cls, filename: str | Path) -> "StepModel":
|
||
path = Path(filename)
|
||
if not path.exists():
|
||
raise FileNotFoundError(path)
|
||
|
||
product_names = _parse_product_names(path)
|
||
parts, whole_shape = _load_with_xcaf(path, product_names)
|
||
if not parts or whole_shape.IsNull():
|
||
whole_shape = _load_plain_step(path)
|
||
fallback_name = product_names[0] if product_names else path.stem
|
||
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
|
||
return cls(path, parts, whole_shape)
|
||
|
||
def display_parts(self) -> list[PartNode]:
|
||
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
|
||
if leaf_parts:
|
||
return leaf_parts
|
||
return [p for p in self.parts if not p.shape.IsNull()]
|
||
|
||
def stats(self) -> TopologyStats:
|
||
topo = TopologyExplorer(self.shape, ignore_orientation=True)
|
||
return TopologyStats(
|
||
parts=len(self.display_parts()),
|
||
solids=len(list(topo.solids())),
|
||
faces=len(list(topo.faces())),
|
||
edges=len(list(topo.edges())),
|
||
vertices=len(list(topo.vertices())),
|
||
)
|
||
|
||
def part_topology_stats(self, part_id: int) -> TopologyStats:
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
topo = TopologyExplorer(part.shape, ignore_orientation=True)
|
||
return TopologyStats(
|
||
parts=1,
|
||
solids=len(list(topo.solids())),
|
||
faces=len(list(topo.faces())),
|
||
edges=len(list(topo.edges())),
|
||
vertices=len(list(topo.vertices())),
|
||
)
|
||
|
||
def geometry_stats(self) -> dict[str, object]:
|
||
surface_props = GProp_GProps()
|
||
brepgprop.SurfaceProperties(self.shape, surface_props)
|
||
info: dict[str, object] = {
|
||
"surface_area": surface_props.Mass(),
|
||
"surface_center": _point_tuple(surface_props.CentreOfMass()),
|
||
}
|
||
info.update(_shape_bounds_info(self.shape))
|
||
info.update(_shape_volume_info(self.shape))
|
||
return info
|
||
|
||
def refresh_topology(self) -> None:
|
||
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
|
||
self.faces.clear()
|
||
self.face_logical_ids.clear()
|
||
self.face_part_ids.clear()
|
||
self.face_solid_ids.clear()
|
||
self.edges.clear()
|
||
self.edge_part_ids.clear()
|
||
self.edge_solid_ids.clear()
|
||
self.solids.clear()
|
||
self._quick_face_info_cache.clear()
|
||
self._face_info_cache.clear()
|
||
self._feature_info_cache.clear()
|
||
self._edge_info_cache.clear()
|
||
self._face_edge_ids_cache.clear()
|
||
self._edge_face_ids_cache.clear()
|
||
self._same_domain_face_ids_cache.clear()
|
||
self._face_first_level_topology_cache.clear()
|
||
self._cylindrical_first_level_topology_cache.clear()
|
||
self._face_first_level_fact_cache.clear()
|
||
self._edge_vertex_points_cache.clear()
|
||
self._edge_vertex_key_edge_ids_cache = None
|
||
self._edge_first_level_topology_cache.clear()
|
||
self._edge_first_level_fact_cache.clear()
|
||
self._local_face_deform_readiness_cache.clear()
|
||
self._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._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)
|
||
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)
|
||
if str(info.get("surface") or "") == "cylinder" and 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"] = "拉伸/切除平面"
|
||
if bool(info.get("has_inner_boundaries")):
|
||
info["local_face_deform_ready"] = False
|
||
info["local_face_deform_face_count"] = 1
|
||
info["local_face_deform_blocker"] = "当前 Face 有内孔/内边界;请优先使用拉伸/切除、孔或槽的专门修改入口。"
|
||
else:
|
||
info["local_face_deform_ready"] = True
|
||
info["local_face_deform_face_count"] = 1
|
||
info["local_face_deform_blocker"] = ""
|
||
info["local_face_deform_status"] = "deferred"
|
||
info.update(
|
||
self._local_face_plane_size_info(
|
||
face_id,
|
||
face=face,
|
||
surf=surf,
|
||
center=_tuple_or_none(info.get("area_center")),
|
||
)
|
||
)
|
||
elif surface_type == GeomAbs_Cylinder:
|
||
cyl = surf.Cylinder()
|
||
axis = cyl.Axis()
|
||
radius = cyl.Radius()
|
||
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
|
||
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
|
||
info["radius"] = radius
|
||
info["diameter"] = radius * 2.0
|
||
info["axis_point"] = _point_tuple(axis.Location())
|
||
info["axis"] = _dir_tuple(axis.Direction())
|
||
info["angular_span"] = u_span
|
||
info["is_full_cylinder"] = u_span >= math.tau * 0.98
|
||
info["height_estimate"] = props.Mass() / swept_area
|
||
info["feature_guess"] = "cylindrical face"
|
||
info["confidence"] = "unchecked"
|
||
info["material_toward_axis"] = "not sampled"
|
||
info["material_away_axis"] = "not sampled"
|
||
info["material_vote_summary"] = "quick selection skips material sampling"
|
||
info["material_sample_count"] = 0
|
||
info["note"] = "快速选择阶段不判断孔/槽/凸台;需要语义识别时切换特征探测级别。"
|
||
info["feature_source_face_id"] = face_id
|
||
info["feature_highlight_face_ids"] = (face_id,)
|
||
if u_span < math.tau * 0.92:
|
||
info["slot_kind"] = "partial-cylindrical-groove"
|
||
info["slot_angular_span"] = u_span
|
||
info["slot_open_angle"] = max(math.tau - u_span, 0.0)
|
||
info["slot_chord_width_estimate"] = 2.0 * radius * math.sin(min(u_span, math.tau) * 0.5)
|
||
info["slot_sagitta_depth_estimate"] = radius * (1.0 - math.cos(min(u_span, math.tau) * 0.5))
|
||
info["slot_arc_length_estimate"] = radius * u_span
|
||
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._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 _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, so 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_same_domain_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),
|
||
"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(_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")),
|
||
)
|
||
)
|
||
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._recognition_summary_fields(info))
|
||
self._face_info_cache[face_id] = dict(info)
|
||
return dict(info)
|
||
|
||
def _freeform_face_limit_fields(self, surface: str) -> dict[str, object]:
|
||
blocker = (
|
||
"当前 Face 是自由曲面/非基础解析曲面;STEP 里通常没有可直接修改的历史参数,"
|
||
"当前一级阶段不开放面积、尺寸、半径或偏移类参数化修改。"
|
||
"请先保留为只读诊断,后续需要专门的曲面控制点或曲面替换语义。"
|
||
)
|
||
return {
|
||
"feature_type": "自由曲面 Face(暂不支持参数化编辑)",
|
||
"feature_edit_actions": "只读诊断;不开放参数化修改",
|
||
"freeform_face_status": "blocked",
|
||
"freeform_face_risk": "blocked",
|
||
"freeform_face_blockers": blocker,
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": 1,
|
||
"local_face_deform_blocker": blocker,
|
||
"recognition_confidence": "low",
|
||
"recognition_risk": "blocked",
|
||
"recognition_blockers": blocker,
|
||
"note": f"{surface} 当前按自由曲面处理,不会伪装成平面、圆柱、圆锥、球面或环面参数。",
|
||
}
|
||
|
||
def face_surface_kind(self, face_id: int) -> str:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return ""
|
||
cached = self._face_info_cache.get(face_id)
|
||
if cached is not None and cached.get("surface") is not None:
|
||
return str(cached.get("surface"))
|
||
try:
|
||
surf = BRepAdaptor_Surface(self.faces[face_id])
|
||
surface_type = surf.GetType()
|
||
except Exception:
|
||
return ""
|
||
return SURFACE_TYPES.get(surface_type, f"type {surface_type}")
|
||
|
||
def face_cylinder_diameter(self, face_id: int) -> float | None:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return None
|
||
cached = self._face_info_cache.get(face_id)
|
||
if cached is not None and cached.get("diameter") is not None and cached.get("surface") == "cylinder":
|
||
try:
|
||
return float(cached["diameter"])
|
||
except (TypeError, ValueError):
|
||
pass
|
||
try:
|
||
surf = BRepAdaptor_Surface(self.faces[face_id])
|
||
if surf.GetType() != GeomAbs_Cylinder:
|
||
return None
|
||
return float(surf.Cylinder().Radius()) * 2.0
|
||
except Exception:
|
||
return None
|
||
|
||
def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]:
|
||
surface = str(info.get("surface") or "")
|
||
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"
|
||
|
||
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("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 -= 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_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"],
|
||
}
|
||
if extrusion_candidate:
|
||
extrusion = _float_or_none(shell_info.get("shell_thickness_estimate"))
|
||
extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium"
|
||
result.update(
|
||
{
|
||
"prismatic_extrusion_status": "candidate",
|
||
"prismatic_extrusion_estimate": extrusion if extrusion is not None else "",
|
||
"prismatic_reference_face_id": opposite_face_id,
|
||
"prismatic_extrusion_confidence": extrusion_confidence,
|
||
"confidence": extrusion_confidence,
|
||
"prismatic_reference_source": "side-wall-topology" if topology_reference else "overlapping-plane",
|
||
"prismatic_highlight_face_ids": tuple(sorted({face_id, *reference_face_ids, *connected_side_ids})),
|
||
"prismatic_extrusion_note": "相对平面通过至少两个侧壁与当前矩形面相连。",
|
||
}
|
||
)
|
||
return result
|
||
|
||
def _planar_shell_region_info(
|
||
self,
|
||
face_id: int,
|
||
coplanar_face_ids: Iterable[int],
|
||
info: dict[str, object],
|
||
) -> dict[str, object]:
|
||
try:
|
||
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
||
except Exception:
|
||
return {
|
||
"shell_region_status": "not-detected",
|
||
"shell_region_note": "无法读取当前平面,不能估算壳体区域。",
|
||
}
|
||
if source_surf.GetType() != GeomAbs_Plane:
|
||
return {}
|
||
|
||
plane = source_surf.Plane()
|
||
normal = plane.Axis().Direction()
|
||
u_dir, v_dir = _plane_basis_dirs(normal)
|
||
valid_region_ids = sorted({int(item) for item in coplanar_face_ids if 0 <= int(item) < len(self.faces)})
|
||
if not valid_region_ids:
|
||
valid_region_ids = [face_id]
|
||
try:
|
||
region_shape = _compound_from_shapes(self.faces[item] for item in valid_region_ids)
|
||
source_interval = _shape_plane_interval(region_shape, plane.Location(), u_dir, v_dir)
|
||
except Exception:
|
||
source_interval = _shape_plane_interval(self.faces[face_id], plane.Location(), u_dir, v_dir)
|
||
if source_interval is None:
|
||
return {
|
||
"shell_region_status": "not-detected",
|
||
"shell_region_note": "当前平面区域缺少稳定投影范围,不能估算壳体厚度。",
|
||
}
|
||
|
||
def interval_length(interval: tuple[float, float]) -> float:
|
||
return max(float(max(interval) - min(interval)), 0.0)
|
||
|
||
def overlap_length(left: tuple[float, float], right: tuple[float, float]) -> float:
|
||
left_min, left_max = min(left), max(left)
|
||
right_min, right_max = min(right), max(right)
|
||
return max(min(left_max, right_max) - max(left_min, right_min), 0.0)
|
||
|
||
source_area = max(interval_length(source_interval[0]) * interval_length(source_interval[1]), 1e-12)
|
||
diagonal = max(_shape_diagonal(self.shape), 1.0)
|
||
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
||
source_solid_id = self.face_solid_ids[face_id]
|
||
region_id_set = set(valid_region_ids)
|
||
best: dict[str, object] | None = None
|
||
best_score: tuple[float, float] | None = None
|
||
|
||
for candidate_id, face in enumerate(self.faces):
|
||
if candidate_id in region_id_set:
|
||
continue
|
||
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
||
continue
|
||
try:
|
||
candidate_surf = BRepAdaptor_Surface(face)
|
||
except Exception:
|
||
continue
|
||
if candidate_surf.GetType() != GeomAbs_Plane:
|
||
continue
|
||
candidate_plane = candidate_surf.Plane()
|
||
normal_dot = _direction_dot(normal, candidate_plane.Axis().Direction())
|
||
if abs(normal_dot) < 0.985:
|
||
continue
|
||
thickness = abs(_axis_parameter(plane.Location(), normal, candidate_plane.Location()))
|
||
if thickness <= tolerance:
|
||
continue
|
||
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
|
||
if interval is None:
|
||
continue
|
||
overlap_area = overlap_length(source_interval[0], interval[0]) * overlap_length(source_interval[1], interval[1])
|
||
overlap_ratio = overlap_area / source_area
|
||
if overlap_ratio <= 0.02:
|
||
continue
|
||
score = (thickness, -overlap_ratio)
|
||
if best_score is None or score < best_score:
|
||
best_score = score
|
||
best = {
|
||
"shell_opposite_face_id": candidate_id,
|
||
"shell_thickness_estimate": thickness,
|
||
"shell_signed_thickness": _axis_parameter(plane.Location(), normal, candidate_plane.Location()),
|
||
"shell_overlap_ratio_estimate": overlap_ratio,
|
||
"shell_opposite_normal_dot": normal_dot,
|
||
}
|
||
|
||
if best is None:
|
||
return {
|
||
"shell_region_status": "not-detected",
|
||
"shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体厚度。",
|
||
}
|
||
|
||
thickness = float(best["shell_thickness_estimate"])
|
||
overlap_ratio = float(best["shell_overlap_ratio_estimate"])
|
||
local_width = _float_or_none(info.get("local_face_width"))
|
||
local_height = _float_or_none(info.get("local_face_height"))
|
||
local_spans = [value for value in (local_width, local_height) if value is not None and value > tolerance]
|
||
if local_spans and thickness > min(local_spans) * 1.5:
|
||
return {
|
||
"shell_region_status": "not-detected",
|
||
"shell_opposite_face_id": best["shell_opposite_face_id"],
|
||
"shell_thickness_estimate": thickness,
|
||
"shell_overlap_ratio_estimate": overlap_ratio,
|
||
"shell_region_note": "相对平面距离明显大于当前面的局部短边,不按壳体厚度处理。",
|
||
}
|
||
thin_ratio = thickness / diagonal
|
||
if overlap_ratio >= 0.55 and thin_ratio <= 0.08:
|
||
confidence = "high"
|
||
elif overlap_ratio >= 0.25 and thin_ratio <= 0.18:
|
||
confidence = "medium"
|
||
else:
|
||
confidence = "low"
|
||
kind = "thin-wall-opposite-plane-candidate" if thin_ratio <= 0.18 else "opposite-plane-region-candidate"
|
||
return {
|
||
"shell_region_kind": kind,
|
||
"shell_region_status": "candidate",
|
||
"shell_confidence": confidence,
|
||
"shell_source_face_ids": tuple(valid_region_ids),
|
||
**best,
|
||
"shell_note": (
|
||
"通过同一 solid 内投影重叠的相对平面估算壳体厚度;"
|
||
"这是 B-Rep 几何近似,不等同于原 CAD 壳命令参数。"
|
||
),
|
||
}
|
||
|
||
def _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,
|
||
}
|
||
)
|
||
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 = feature.get("slot_angular_span", feature.get("angular_span", info.get("angular_span")))
|
||
topology = {
|
||
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
|
||
"topology_relation_depth": 1,
|
||
"topology_relation_scope": "selected cylindrical same-domain side region + direct shared-edge adjacent Faces",
|
||
"topology_relation_boundary": "shared-edge",
|
||
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
|
||
"topology_ignored_relation_note": (
|
||
"Only direct shared-edge neighbors of the cylindrical side region are treated as first-level topology. "
|
||
"Faces reached through those neighbors are recorded later as second-level or deeper relationships."
|
||
),
|
||
"source_face_id": face_id,
|
||
"source_part_id": source_part_id,
|
||
"source_solid_id": source_solid_id,
|
||
"feature_type": feature.get("feature_type"),
|
||
"feature_guess": feature.get("feature_guess", info.get("feature_guess")),
|
||
"slot_kind": feature.get("slot_kind", ""),
|
||
"cylinder_end_type": feature.get("cylinder_end_type", info.get("cylinder_end_type")),
|
||
"is_full_cylinder": bool(feature.get("is_full_cylinder", info.get("is_full_cylinder", False))),
|
||
"angular_span": angular_span,
|
||
"cylindrical_feature_side_face_ids": side_face_ids,
|
||
"cylindrical_feature_side_face_count": len(side_face_ids),
|
||
"cylindrical_feature_selected_boundary_edge_ids": selected_boundary_edge_ids,
|
||
"cylindrical_feature_selected_boundary_edge_count": len(selected_boundary_edge_ids),
|
||
"cylindrical_feature_boundary_edge_ids": region_boundary_edge_ids,
|
||
"cylindrical_feature_boundary_edge_count": len(region_boundary_edge_ids),
|
||
"cylindrical_feature_boundary_vertex_points": boundary_vertex_points,
|
||
"cylindrical_feature_boundary_vertex_count": len(boundary_vertex_points),
|
||
"cylindrical_feature_adjacent_face_ids": adjacent_face_ids,
|
||
"cylindrical_feature_adjacent_face_count": len(adjacent_face_ids),
|
||
"cylindrical_feature_adjacent_surface_types": adjacent_surface_types,
|
||
"cylindrical_feature_shared_edges_by_face": shared_edge_refs,
|
||
"cylindrical_feature_first_level_face_ids": first_level_face_ids,
|
||
"cylindrical_feature_first_level_face_count": len(first_level_face_ids),
|
||
"cylindrical_feature_end_face_ids": end_face_ids,
|
||
"cylindrical_feature_end_face_count": len(end_face_ids),
|
||
"cylindrical_feature_bottom_face_ids": bottom_face_ids,
|
||
"cylindrical_feature_bottom_face_count": len(bottom_face_ids),
|
||
"cylindrical_feature_opening_face_ids": opening_face_ids,
|
||
"cylindrical_feature_opening_face_count": len(opening_face_ids),
|
||
"cylindrical_feature_slot_boundary_face_ids": slot_boundary_face_ids,
|
||
"cylindrical_feature_slot_boundary_face_count": len(slot_boundary_face_ids),
|
||
**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_deform_readiness(self, face_id: int) -> dict[str, object]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return {
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_blocker": "Face ID 不存在,不能做局部 Face 变形。",
|
||
}
|
||
solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
|
||
cache_key = solid_id if solid_id >= 0 else -(face_id + 1)
|
||
cached = self._local_face_deform_readiness_cache.get(cache_key)
|
||
if cached is not None:
|
||
return dict(cached)
|
||
|
||
def remember(info: dict[str, object]) -> dict[str, object]:
|
||
self._local_face_deform_readiness_cache[cache_key] = dict(info)
|
||
return dict(info)
|
||
|
||
if solid_id < 0 or solid_id >= len(self.solids):
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": 1,
|
||
"local_face_deform_blocker": "找不到当前 Face 所属 Solid,不能做局部重建。",
|
||
}
|
||
)
|
||
solid_face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id]
|
||
if not solid_face_ids:
|
||
solid_face_ids = [face_id]
|
||
face_count = len(solid_face_ids)
|
||
if face_count > 128:
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": face_count,
|
||
"local_face_deform_blocker": "所属 Solid 的 Face 数量超过 128,当前版本不开放局部重建。",
|
||
}
|
||
)
|
||
|
||
source_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.faces[face_id]
|
||
tolerance = max(_shape_diagonal(source_shape) * 1e-7, 1e-6)
|
||
for item in solid_face_ids:
|
||
face = self.faces[item]
|
||
try:
|
||
surface = BRepAdaptor_Surface(face)
|
||
except Exception:
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": face_count,
|
||
"local_face_deform_blocker": "所属 Solid 里有 Face 不能稳定读取曲面类型,不能做局部 Face 变形。",
|
||
}
|
||
)
|
||
if surface.GetType() != GeomAbs_Plane:
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": face_count,
|
||
"local_face_deform_blocker": "所属 Solid 含有曲面,当前版本只对简单全平面 Solid 开放局部重建。",
|
||
}
|
||
)
|
||
wire_info = self._face_boundary_wire_info(face)
|
||
if bool(wire_info.get("has_inner_boundaries")):
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": face_count,
|
||
"local_face_deform_blocker": "所属 Solid 里有带内孔/内边界的 Face,请优先使用孔、槽或拉伸/切除等专门修改方式。",
|
||
}
|
||
)
|
||
try:
|
||
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": face_count,
|
||
"local_face_deform_blocker": "所属 Solid 里有 Face 顶点环不能稳定读取,不能做局部 Face 变形。",
|
||
}
|
||
)
|
||
except Exception:
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": False,
|
||
"local_face_deform_face_count": face_count,
|
||
"local_face_deform_blocker": "所属 Solid 里有 Face 顶点环读取失败,不能做局部 Face 变形。",
|
||
}
|
||
)
|
||
|
||
return remember(
|
||
{
|
||
"local_face_deform_ready": True,
|
||
"local_face_deform_face_count": face_count,
|
||
"local_face_deform_blocker": "",
|
||
}
|
||
)
|
||
|
||
def face_boundary_edge_ids(self, face_id: int) -> list[int]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return []
|
||
return self._face_boundary_edge_ids(face_id)
|
||
|
||
def face_logical_id(self, face_id: int) -> int:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
if face_id < len(self.face_logical_ids):
|
||
return int(self.face_logical_ids[face_id])
|
||
return face_id
|
||
|
||
def face_region_logical_id(self, face_id: int) -> int:
|
||
face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
|
||
return min(self.face_logical_id(item) for item in face_ids if 0 <= item < len(self.faces))
|
||
|
||
def face_ids_for_logical_id(self, logical_id: int) -> list[int]:
|
||
logical_id = int(logical_id)
|
||
return [face_id for face_id in range(len(self.faces)) if self.face_logical_id(face_id) == logical_id]
|
||
|
||
def resolve_face_selection_id(self, face_or_logical_id: int) -> int | None:
|
||
logical_matches = self.face_ids_for_logical_id(int(face_or_logical_id))
|
||
if logical_matches:
|
||
return logical_matches[0]
|
||
if 0 <= int(face_or_logical_id) < len(self.faces):
|
||
return int(face_or_logical_id)
|
||
return None
|
||
|
||
def assign_logical_face_region(self, logical_id: int, face_ids: Iterable[int]) -> None:
|
||
valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)})
|
||
if not valid_face_ids:
|
||
return
|
||
for face_id in valid_face_ids:
|
||
self.face_logical_ids[face_id] = int(logical_id)
|
||
self._quick_face_info_cache.pop(face_id, None)
|
||
self._face_info_cache.pop(face_id, None)
|
||
self._feature_info_cache.pop(face_id, None)
|
||
|
||
def assign_logical_face_region_exclusive(self, logical_id: int, face_ids: Iterable[int]) -> None:
|
||
valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)})
|
||
if not valid_face_ids:
|
||
return
|
||
logical_id = int(logical_id)
|
||
replacement_id = max([logical_id, len(self.faces), *[int(item) for item in self.face_logical_ids]], default=logical_id) + 1
|
||
for face_id, current_logical_id in enumerate(list(self.face_logical_ids)):
|
||
if int(current_logical_id) != logical_id or face_id in valid_face_ids:
|
||
continue
|
||
self.face_logical_ids[face_id] = replacement_id
|
||
replacement_id += 1
|
||
self._quick_face_info_cache.pop(face_id, None)
|
||
self._face_info_cache.pop(face_id, None)
|
||
self._feature_info_cache.pop(face_id, None)
|
||
self.assign_logical_face_region(logical_id, valid_face_ids)
|
||
|
||
def nearest_edge_id_to_point(
|
||
self,
|
||
edge_ids: Iterable[int],
|
||
point: tuple[float, float, float] | None,
|
||
) -> int | None:
|
||
valid_edge_ids = [int(edge_id) for edge_id in edge_ids if 0 <= int(edge_id) < len(self.edges)]
|
||
if not valid_edge_ids:
|
||
return None
|
||
if point is None:
|
||
return valid_edge_ids[0]
|
||
|
||
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
|
||
best_edge_id: int | None = None
|
||
best_distance = math.inf
|
||
for edge_id in valid_edge_ids:
|
||
try:
|
||
samples = discretize_edge(self.edges[edge_id], 0.35)
|
||
except Exception:
|
||
samples = []
|
||
if len(samples) < 2:
|
||
try:
|
||
curve = BRepAdaptor_Curve(self.edges[edge_id])
|
||
samples = [
|
||
_point_tuple(curve.Value(curve.FirstParameter())),
|
||
_point_tuple(curve.Value(curve.LastParameter())),
|
||
]
|
||
except Exception:
|
||
samples = []
|
||
if not samples:
|
||
continue
|
||
sample_points = [(float(coords[0]), float(coords[1]), float(coords[2])) for coords in samples]
|
||
if len(sample_points) == 1:
|
||
distance = _point_distance_sq((px, py, pz), sample_points[0])
|
||
else:
|
||
distance = min(
|
||
_point_segment_distance_sq((px, py, pz), start, end)
|
||
for start, end in zip(sample_points, sample_points[1:])
|
||
)
|
||
if distance < best_distance:
|
||
best_distance = distance
|
||
best_edge_id = edge_id
|
||
return best_edge_id if best_edge_id is not None else valid_edge_ids[0]
|
||
|
||
def nearest_face_id_to_point(
|
||
self,
|
||
face_ids: Iterable[int],
|
||
point: tuple[float, float, float] | None,
|
||
) -> int | None:
|
||
valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)]
|
||
if not valid_face_ids:
|
||
return None
|
||
if point is None:
|
||
return valid_face_ids[0]
|
||
|
||
point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex()
|
||
exact_face_ids = valid_face_ids
|
||
if len(valid_face_ids) > 48:
|
||
scored_face_ids: list[tuple[float, int]] = []
|
||
for face_id in valid_face_ids:
|
||
scored_face_ids.append((self._face_bounds_distance_sq(face_id, point), face_id))
|
||
scored_face_ids.sort(key=lambda item: item[0])
|
||
exact_face_ids = [face_id for _distance, face_id in scored_face_ids[:48]]
|
||
|
||
best_face_id: int | None = None
|
||
best_distance = math.inf
|
||
for face_id in exact_face_ids:
|
||
try:
|
||
extrema = BRepExtrema_DistShapeShape(point_vertex, self.faces[face_id])
|
||
extrema.Perform()
|
||
if not extrema.IsDone():
|
||
continue
|
||
distance = float(extrema.Value())
|
||
except Exception:
|
||
center = _surface_center(self.faces[face_id])
|
||
distance = math.sqrt(
|
||
_point_distance_sq(
|
||
(float(point[0]), float(point[1]), float(point[2])),
|
||
_point_tuple(center),
|
||
)
|
||
)
|
||
if distance < best_distance:
|
||
best_distance = distance
|
||
best_face_id = face_id
|
||
return best_face_id if best_face_id is not None else exact_face_ids[0]
|
||
|
||
def _face_bounds_distance_sq(self, face_id: int, point: tuple[float, float, float]) -> float:
|
||
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
|
||
try:
|
||
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(self.faces[face_id])
|
||
dx = max(xmin - px, 0.0, px - xmax)
|
||
dy = max(ymin - py, 0.0, py - ymax)
|
||
dz = max(zmin - pz, 0.0, pz - zmax)
|
||
return dx * dx + dy * dy + dz * dz
|
||
except Exception:
|
||
try:
|
||
center = _surface_center(self.faces[face_id])
|
||
return _point_distance_sq((px, py, pz), _point_tuple(center))
|
||
except Exception:
|
||
return math.inf
|
||
|
||
def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
|
||
if not edge_ids:
|
||
return []
|
||
source_solid_id = self.face_solid_ids[face_id]
|
||
adjacent: set[int] = set()
|
||
for edge_id in edge_ids:
|
||
if edge_id < 0 or edge_id >= len(self.edges):
|
||
continue
|
||
for candidate_id in self._edge_adjacent_face_ids(edge_id):
|
||
if candidate_id == face_id:
|
||
continue
|
||
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
||
continue
|
||
adjacent.add(candidate_id)
|
||
return sorted(adjacent)
|
||
|
||
def _connected_coplanar_planar_face_ids(self, face_id: int) -> list[int]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return []
|
||
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
||
if source_surf.GetType() != GeomAbs_Plane:
|
||
return [face_id]
|
||
|
||
source_solid_id = self.face_solid_ids[face_id]
|
||
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
||
visited = {face_id}
|
||
queue = [face_id]
|
||
while queue:
|
||
current_id = queue.pop(0)
|
||
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
|
||
if adjacent_id in visited:
|
||
continue
|
||
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
|
||
continue
|
||
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
|
||
if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
|
||
visited.add(adjacent_id)
|
||
queue.append(adjacent_id)
|
||
shared_edge_result = sorted(visited)
|
||
if len(self.faces) > 600 or len(shared_edge_result) > 1:
|
||
return shared_edge_result
|
||
|
||
interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4)
|
||
plane = source_surf.Plane()
|
||
axis_point = plane.Location()
|
||
u_dir, v_dir = _plane_basis_dirs(plane.Axis().Direction())
|
||
candidates: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {}
|
||
for candidate_id, face in enumerate(self.faces):
|
||
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
||
continue
|
||
candidate_surf = BRepAdaptor_Surface(face)
|
||
if not _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
|
||
continue
|
||
interval = _shape_plane_interval(face, axis_point, u_dir, v_dir)
|
||
if interval is not None:
|
||
candidates[candidate_id] = interval
|
||
|
||
if face_id in candidates:
|
||
visited = {face_id}
|
||
queue = [face_id]
|
||
while queue:
|
||
current_id = queue.pop(0)
|
||
current_interval = candidates[current_id]
|
||
for candidate_id, candidate_interval in candidates.items():
|
||
if candidate_id in visited:
|
||
continue
|
||
if _plane_intervals_touch_or_overlap(
|
||
current_interval,
|
||
candidate_interval,
|
||
interval_tolerance,
|
||
):
|
||
visited.add(candidate_id)
|
||
queue.append(candidate_id)
|
||
return sorted(visited)
|
||
return sorted(visited)
|
||
|
||
def connected_same_domain_face_ids(self, face_id: int) -> list[int]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return []
|
||
if face_id in self._same_domain_face_ids_cache:
|
||
return list(self._same_domain_face_ids_cache[face_id])
|
||
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
||
surface_type = source_surf.GetType()
|
||
if surface_type == GeomAbs_Plane:
|
||
face_ids = self._connected_coplanar_planar_face_ids(face_id)
|
||
elif surface_type == GeomAbs_Cylinder:
|
||
face_ids = self._connected_cocylindrical_face_ids(face_id)
|
||
else:
|
||
face_ids = [face_id]
|
||
face_ids = sorted(set(face_ids or [face_id]))
|
||
for item in face_ids:
|
||
self._same_domain_face_ids_cache[item] = list(face_ids)
|
||
return list(face_ids)
|
||
|
||
def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return []
|
||
source_solid_id = self.face_solid_ids[face_id]
|
||
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
||
if source_surf.GetType() != GeomAbs_Cylinder:
|
||
return [face_id]
|
||
cylinder = source_surf.Cylinder()
|
||
axis = cylinder.Axis()
|
||
axis_point = axis.Location()
|
||
axis_dir = axis.Direction()
|
||
radius = max(float(cylinder.Radius()), 0.0)
|
||
diagonal = _shape_diagonal(self.shape)
|
||
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
||
visited = {face_id}
|
||
queue = [face_id]
|
||
while queue:
|
||
current_id = queue.pop(0)
|
||
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
|
||
if adjacent_id in visited:
|
||
continue
|
||
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
|
||
continue
|
||
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
|
||
if _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
|
||
visited.add(adjacent_id)
|
||
queue.append(adjacent_id)
|
||
shared_edge_result = sorted(visited)
|
||
if len(self.faces) > 600 or len(shared_edge_result) > 1:
|
||
return shared_edge_result
|
||
|
||
interval_tolerance = max(tolerance * 50.0, diagonal * 1e-5, radius * 1e-4, 1e-3)
|
||
source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir)
|
||
candidates: dict[int, tuple[float, float]] = {}
|
||
for candidate_id, face in enumerate(self.faces):
|
||
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
||
continue
|
||
candidate_surf = BRepAdaptor_Surface(face)
|
||
if not _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
|
||
continue
|
||
interval = _shape_axis_interval(face, axis_point, axis_dir)
|
||
if interval is not None:
|
||
candidates[candidate_id] = interval
|
||
|
||
if source_interval is not None and face_id in candidates:
|
||
visited = {face_id}
|
||
queue = [face_id]
|
||
while queue:
|
||
current_id = queue.pop(0)
|
||
current_interval = candidates[current_id]
|
||
for candidate_id, candidate_interval in candidates.items():
|
||
if candidate_id in visited:
|
||
continue
|
||
if _intervals_touch_or_overlap(
|
||
current_interval,
|
||
candidate_interval,
|
||
interval_tolerance,
|
||
):
|
||
visited.add(candidate_id)
|
||
queue.append(candidate_id)
|
||
return sorted(visited)
|
||
return sorted(visited)
|
||
|
||
def _cylindrical_axis_range(
|
||
self,
|
||
face_id: int,
|
||
surf: BRepAdaptor_Surface | None = None,
|
||
face_ids: Iterable[int] | None = None,
|
||
) -> dict[str, object]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
surf = surf or BRepAdaptor_Surface(self.faces[face_id])
|
||
if surf.GetType() != GeomAbs_Cylinder:
|
||
raise ValueError("Selected face is not cylindrical.")
|
||
|
||
cyl = surf.Cylinder()
|
||
axis = cyl.Axis()
|
||
axis_point = axis.Location()
|
||
axis_dir = axis.Direction()
|
||
source_v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter()))
|
||
source_v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter()))
|
||
|
||
if face_ids is None:
|
||
domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
|
||
else:
|
||
domain_face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)})
|
||
if face_id not in domain_face_ids:
|
||
domain_face_ids.append(face_id)
|
||
domain_face_ids.sort()
|
||
|
||
intervals: list[tuple[float, float]] = []
|
||
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
||
for item in domain_face_ids:
|
||
item_surf = BRepAdaptor_Surface(self.faces[item])
|
||
if item_surf.GetType() != GeomAbs_Cylinder:
|
||
continue
|
||
if not _surfaces_are_cocylindrical(item_surf, surf, tolerance):
|
||
continue
|
||
interval = _shape_axis_interval(self.faces[item], axis_point, axis_dir)
|
||
if interval is not None:
|
||
intervals.append(interval)
|
||
|
||
if intervals and len(domain_face_ids) > 1:
|
||
v_min = min(interval[0] for interval in intervals)
|
||
v_max = max(interval[1] for interval in intervals)
|
||
range_source = "same-domain-cylinder-faces"
|
||
else:
|
||
v_min = source_v_min
|
||
v_max = source_v_max
|
||
range_source = "selected-face-v-range"
|
||
|
||
return {
|
||
"axis_point": axis_point,
|
||
"axis_direction": axis_dir,
|
||
"v_min": v_min,
|
||
"v_max": v_max,
|
||
"span": max(v_max - v_min, 0.0),
|
||
"source_v_min": source_v_min,
|
||
"source_v_max": source_v_max,
|
||
"same_domain_face_ids": tuple(domain_face_ids),
|
||
"same_domain_face_count": len(domain_face_ids),
|
||
"range_source": range_source,
|
||
}
|
||
|
||
def _region_boundary_edge_ids(self, face_ids: Iterable[int]) -> list[int]:
|
||
counts: dict[int, int] = {}
|
||
for face_id in face_ids:
|
||
for edge_id in self._face_boundary_edge_ids(face_id):
|
||
counts[edge_id] = counts.get(edge_id, 0) + 1
|
||
return sorted(edge_id for edge_id, count in counts.items() if count == 1)
|
||
|
||
def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape:
|
||
valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)]
|
||
if not valid_face_ids:
|
||
raise ValueError("No planar faces were found for push/pull.")
|
||
if len(valid_face_ids) > 1:
|
||
boundary_edge_ids = self._region_boundary_edge_ids(valid_face_ids)
|
||
if boundary_edge_ids:
|
||
try:
|
||
wire = BRepBuilderAPI_MakeWire()
|
||
for edge_id in boundary_edge_ids:
|
||
wire.Add(topods.Edge(self.edges[edge_id]))
|
||
if not hasattr(wire, "IsDone") or wire.IsDone():
|
||
face_builder = BRepBuilderAPI_MakeFace(wire.Wire())
|
||
if not hasattr(face_builder, "IsDone") or face_builder.IsDone():
|
||
profile = face_builder.Face()
|
||
if not profile.IsNull():
|
||
_ensure_valid_shape(profile)
|
||
return profile
|
||
except Exception:
|
||
pass
|
||
profile_faces = [self.faces[face_id] for face_id in valid_face_ids]
|
||
if not profile_faces:
|
||
raise ValueError("No planar faces were found for push/pull.")
|
||
return _unify_same_domain_shape(_compound_from_shapes(profile_faces))
|
||
|
||
def _face_region_mapping_specs(self, face_ids: Iterable[int]) -> list[dict[str, object]]:
|
||
specs: list[dict[str, object]] = []
|
||
seen_logical_ids: set[int] = set()
|
||
diagonal = _shape_diagonal(self.shape)
|
||
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
||
for face_id in sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}):
|
||
logical_id = self.face_region_logical_id(face_id)
|
||
if logical_id in seen_logical_ids:
|
||
continue
|
||
seen_logical_ids.add(logical_id)
|
||
face = self.faces[face_id]
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() == GeomAbs_Plane:
|
||
plane = surf.Plane()
|
||
normal = plane.Axis().Direction()
|
||
u_dir, v_dir = _plane_basis_dirs(normal)
|
||
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
|
||
if interval is None:
|
||
continue
|
||
specs.append(
|
||
{
|
||
"surface": "plane",
|
||
"logical_id": logical_id,
|
||
"part_id": self.face_part_ids[face_id],
|
||
"point": _point_tuple(plane.Location()),
|
||
"normal": _dir_tuple(normal),
|
||
"u_dir": _dir_tuple(u_dir),
|
||
"v_dir": _dir_tuple(v_dir),
|
||
"interval": interval,
|
||
"tolerance": tolerance,
|
||
}
|
||
)
|
||
elif surf.GetType() == GeomAbs_Cylinder:
|
||
cylinder = surf.Cylinder()
|
||
axis = cylinder.Axis()
|
||
interval = _shape_axis_interval(face, axis.Location(), axis.Direction())
|
||
if interval is None:
|
||
continue
|
||
specs.append(
|
||
{
|
||
"surface": "cylinder",
|
||
"logical_id": logical_id,
|
||
"part_id": self.face_part_ids[face_id],
|
||
"axis_point": _point_tuple(axis.Location()),
|
||
"axis_direction": _dir_tuple(axis.Direction()),
|
||
"radius": float(cylinder.Radius()),
|
||
"interval": interval,
|
||
"tolerance": tolerance,
|
||
}
|
||
)
|
||
return specs
|
||
|
||
def _apply_face_region_mapping_specs(self, specs: Iterable[dict[str, object]]) -> None:
|
||
for spec in specs:
|
||
logical_id = int(spec.get("logical_id", -1))
|
||
if logical_id < 0:
|
||
continue
|
||
seed_face_ids = self._matching_face_ids_for_region_spec(spec)
|
||
if not seed_face_ids:
|
||
continue
|
||
region_ids: set[int] = set()
|
||
for seed_face_id in seed_face_ids:
|
||
region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id])
|
||
self.assign_logical_face_region(logical_id, region_ids)
|
||
|
||
def _matching_face_ids_for_region_spec(self, spec: dict[str, object]) -> list[int]:
|
||
surface = str(spec.get("surface", ""))
|
||
part_id = int(spec.get("part_id", -1))
|
||
tolerance_value = spec.get("tolerance")
|
||
if tolerance_value is None:
|
||
tolerance_value = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
||
tolerance = float(tolerance_value)
|
||
matches: list[int] = []
|
||
for face_id, face in enumerate(self.faces):
|
||
if part_id >= 0 and self.face_part_ids[face_id] != part_id:
|
||
continue
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surface == "plane":
|
||
if not _surface_matches_plane_spec(surf, spec, tolerance):
|
||
continue
|
||
plane = surf.Plane()
|
||
u_dir = gp_Dir(*spec["u_dir"])
|
||
v_dir = gp_Dir(*spec["v_dir"])
|
||
interval = _shape_plane_interval(face, gp_Pnt(*spec["point"]), u_dir, v_dir)
|
||
if interval is not None and _plane_intervals_touch_or_overlap(interval, spec["interval"], max(tolerance * 20.0, 1e-4)):
|
||
matches.append(face_id)
|
||
elif surface == "cylinder":
|
||
if not _surface_matches_cylinder_spec(surf, spec, tolerance):
|
||
continue
|
||
interval = _shape_axis_interval(face, gp_Pnt(*spec["axis_point"]), gp_Dir(*spec["axis_direction"]))
|
||
interval_tolerance = max(tolerance * 50.0, _shape_diagonal(self.shape) * 1e-5, float(spec.get("radius", 0.0)) * 1e-4, 1e-3)
|
||
if interval is not None and _intervals_touch_or_overlap(interval, spec["interval"], interval_tolerance):
|
||
matches.append(face_id)
|
||
return matches
|
||
|
||
def _edge_vertex_points(self, edge_id: int) -> tuple[tuple[float, float, float], ...]:
|
||
if edge_id < 0 or edge_id >= len(self.edges):
|
||
return ()
|
||
cached = self._edge_vertex_points_cache.get(edge_id)
|
||
if cached is not None:
|
||
return tuple(cached)
|
||
|
||
diagonal = _shape_diagonal(self.shape)
|
||
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
||
points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
|
||
explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX)
|
||
while explorer.More():
|
||
vertex = topods.Vertex(explorer.Current())
|
||
point = _point_tuple(BRep_Tool.Pnt(vertex))
|
||
points_by_key[self._local_point_key(point, tolerance)] = point
|
||
explorer.Next()
|
||
points = tuple(points_by_key[key] for key in sorted(points_by_key))
|
||
self._edge_vertex_points_cache[edge_id] = points
|
||
return points
|
||
|
||
def _edge_vertex_key_edge_ids(self) -> dict[tuple[int, int, int, int, int], set[int]]:
|
||
cached = self._edge_vertex_key_edge_ids_cache
|
||
if cached is not None:
|
||
return cached
|
||
|
||
diagonal = _shape_diagonal(self.shape)
|
||
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
||
mapping: dict[tuple[int, int, int, int, int], set[int]] = {}
|
||
for edge_id in range(len(self.edges)):
|
||
part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
|
||
solid_id = self._edge_solid_id(edge_id)
|
||
for point in self._edge_vertex_points(edge_id):
|
||
key = (int(part_id), int(solid_id), *self._local_point_key(point, tolerance))
|
||
mapping.setdefault(key, set()).add(edge_id)
|
||
self._edge_vertex_key_edge_ids_cache = mapping
|
||
return mapping
|
||
|
||
def _edge_shared_vertex_adjacent_edge_ids(self, edge_id: int) -> tuple[int, ...]:
|
||
if edge_id < 0 or edge_id >= len(self.edges):
|
||
return ()
|
||
diagonal = _shape_diagonal(self.shape)
|
||
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
||
part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
|
||
solid_id = self._edge_solid_id(edge_id)
|
||
mapping = self._edge_vertex_key_edge_ids()
|
||
adjacent: set[int] = set()
|
||
for point in self._edge_vertex_points(edge_id):
|
||
key = (int(part_id), int(solid_id), *self._local_point_key(point, tolerance))
|
||
adjacent.update(mapping.get(key, set()))
|
||
adjacent.discard(edge_id)
|
||
return tuple(sorted(item for item in adjacent if 0 <= item < len(self.edges)))
|
||
|
||
def edge_first_level_topology(self, edge_id: int) -> dict[str, object]:
|
||
"""Return the explicit first-level B-Rep neighborhood for an Edge."""
|
||
if edge_id < 0 or edge_id >= len(self.edges):
|
||
raise ValueError(f"Unknown edge id {edge_id}")
|
||
cached = self._edge_first_level_topology_cache.get(edge_id)
|
||
if cached is not None:
|
||
return dict(cached)
|
||
|
||
source_part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
|
||
source_solid_id = self._edge_solid_id(edge_id)
|
||
endpoint_points = self._edge_vertex_points(edge_id)
|
||
adjacent_edge_ids = self._edge_shared_vertex_adjacent_edge_ids(edge_id)
|
||
adjacent_face_ids = tuple(self._edge_adjacent_face_ids(edge_id))
|
||
adjacent_surface_types = tuple((face_id, self.face_surface_kind(face_id)) for face_id in adjacent_face_ids)
|
||
|
||
adjacent_face_boundary_edges: set[int] = set()
|
||
shared_edges_by_face: list[dict[str, object]] = []
|
||
for face_id in adjacent_face_ids:
|
||
boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
|
||
adjacent_face_boundary_edges.update(boundary_edge_ids)
|
||
shared_edges_by_face.append(
|
||
{
|
||
"face_id": face_id,
|
||
"edge_ids": (edge_id,),
|
||
"edge_count": 1,
|
||
"surface": self.face_surface_kind(face_id),
|
||
"face_boundary_edge_ids": boundary_edge_ids,
|
||
}
|
||
)
|
||
|
||
first_level_edge_ids = tuple(sorted({edge_id, *adjacent_edge_ids}))
|
||
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
|