feat: 完善 Face 一级关系编辑和稳定性

This commit is contained in:
2026-08-04 09:35:39 +08:00
parent bb44e3920d
commit 5799d5d813
29 changed files with 6295 additions and 189 deletions
+702 -19
View File
@@ -63,6 +63,7 @@ from OCC.Core.TopAbs import (
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SOLID,
TopAbs_VERTEX,
TopAbs_WIRE,
)
from OCC.Core.TopExp import TopExp_Explorer, topexp
@@ -110,6 +111,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
@@ -191,6 +194,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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._local_face_deform_readiness_cache.clear()
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
@@ -283,8 +288,24 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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._local_face_deform_readiness_cache.clear()
def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool:
previous = tuple(int(item) for item in logical_ids)
if len(previous) != len(self.faces):
return False
self.face_logical_ids = list(previous)
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._local_face_deform_readiness_cache.clear()
return True
def quick_face_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
@@ -323,16 +344,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"]
info["push_pull_confidence"] = "unchecked"
info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。"
info["push_pull_status"] = "candidate"
info["feature_type"] = "可推拉平面候选"
info["feature_source_face_id"] = face_id
@@ -353,7 +369,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=False)
info["radius"] = radius
info["diameter"] = radius * 2.0
info["axis_point"] = _point_tuple(axis.Location())
@@ -361,13 +376,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"]
info["confidence"] = classification["confidence"]
info["material_toward_axis"] = classification["toward_axis"]
info["material_away_axis"] = classification["away_axis"]
info["material_vote_summary"] = classification["vote_summary"]
info["material_sample_count"] = classification["sample_count"]
info["note"] = classification["note"]
info["feature_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:
@@ -572,6 +587,112 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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,
) -> 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}")
source_info = 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:
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:
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 candidate_id in source_feature_faces:
continue
try:
info = 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 "")
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)
related.update(
{
"association_source_face_id": candidate_id,
"association_hop_count": hop_count,
"association_relation": "shared-edge-topology",
"association_priority": (
0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2)
),
}
)
results.append(related)
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:max_features]
def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
major_radius = float(info.get("major_radius", 0.0) or 0.0)
@@ -581,9 +702,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
{
"kind": "feature",
"feature_type": "环面候选",
"feature_type": prismatic_info.get("feature_type", "可推拉平面候选"),
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_highlight_face_ids": tuple(sorted(highlight_face_ids)),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改环面主半径/小半径",
"feature_mode": (
@@ -622,6 +745,36 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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(
{
@@ -633,11 +786,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"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_edit_actions": "修改圆锥参考半径/直径/半角",
"feature_mode": (
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放"
"不是 CAD 历史里的锥孔或倒角参数。"
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;简单圆锥会解析重建"
"嵌入式锥孔/沉孔会优先局部重切,不是 CAD 历史里的锥孔或倒角参数。"
),
**boundary_info,
}
)
return result
@@ -646,6 +800,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info)
prismatic_info = self._planar_rectangular_profile_info(face_id, coplanar_face_ids, info, shell_info)
if len(coplanar_face_ids) > 1:
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。"
else:
@@ -653,6 +808,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
edit_actions = "推拉平面"
if shell_info.get("shell_region_status") == "candidate":
edit_actions += ";调整薄壁/壳体厚度"
if prismatic_info.get("prismatic_profile_status") == "candidate":
edit_actions = "调整规则矩形轮廓长度/宽度"
if prismatic_info.get("prismatic_extrusion_status") == "candidate":
edit_actions += ";调整棱柱高度/凹槽深度"
else:
edit_actions += ";沿法向推拉"
highlight_face_ids = set(coplanar_face_ids)
highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids")))
result = dict(info)
result.update(
{
@@ -671,10 +834,247 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_edit_actions": edit_actions,
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
**shell_info,
**prismatic_info,
}
)
return result
def _planar_rectangular_profile_info(
self,
face_id: int,
coplanar_face_ids: Iterable[int],
info: dict[str, object],
shell_info: dict[str, object],
) -> dict[str, object]:
region_ids = sorted({int(item) for item in coplanar_face_ids})
if region_ids != [face_id]:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "共面区域包含多个 Face,暂不把整体包围盒当作规则矩形特征尺寸。",
}
edge_ids = self._face_boundary_edge_ids(face_id)
if len(edge_ids) != 4:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "规则矩形轮廓需要恰好四条边。",
}
direction_groups: list[dict[str, object]] = []
for edge_id in edge_ids:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
if curve.GetType() != GeomAbs_Line:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓含非直线边,不按规则矩形特征处理。",
}
start = _point_tuple(curve.Value(curve.FirstParameter()))
end = _point_tuple(curve.Value(curve.LastParameter()))
vector = _tuple_sub(end, start)
length = math.sqrt(_tuple_dot(vector, vector))
direction = _tuple_normalized(vector)
except Exception:
direction = None
length = 0.0
if direction is None or length <= 1e-9:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形轮廓存在退化边或无法读取的直线边。",
}
matched_group = None
for group in direction_groups:
group_direction = _tuple_or_none(group.get("direction"))
if group_direction is not None and abs(_tuple_dot(direction, group_direction)) >= 0.999:
matched_group = group
break
if matched_group is None:
matched_group = {"direction": direction, "lengths": [], "edge_ids": []}
direction_groups.append(matched_group)
matched_group["lengths"].append(length)
matched_group["edge_ids"].append(edge_id)
if len(direction_groups) != 2 or any(len(group["lengths"]) != 2 for group in direction_groups):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "四条边没有形成两组稳定的平行对边。",
}
first_direction = _tuple_or_none(direction_groups[0].get("direction"))
second_direction = _tuple_or_none(direction_groups[1].get("direction"))
if first_direction is None or second_direction is None or abs(_tuple_dot(first_direction, second_direction)) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "两组对边不垂直,不按规则矩形特征处理。",
}
for group in direction_groups:
lengths = [float(item) for item in group["lengths"]]
average = sum(lengths) / len(lengths)
if max(abs(item - average) for item in lengths) > max(average * 1e-4, 1e-7):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形候选的相对边长度不一致。",
}
group["average_length"] = average
direction_groups.sort(key=lambda group: float(group["average_length"]), reverse=True)
length = float(direction_groups[0]["average_length"])
width = float(direction_groups[1]["average_length"])
area = _float_or_none(info.get("area"))
area_ratio = area / max(length * width, 1e-12) if area is not None else 0.0
if area is None or abs(area_ratio - 1.0) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓面积与长乘宽不一致,可能存在内孔或非矩形裁剪。",
"prismatic_profile_area_ratio": area_ratio,
}
adjacent_side_ids = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, face_id)) - {face_id})
try:
opposite_face_id = int(shell_info["shell_opposite_face_id"])
except (KeyError, TypeError, ValueError):
opposite_face_id = None
connected_side_ids: list[int] = []
if opposite_face_id is not None:
for side_id in adjacent_side_ids:
side_neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
if opposite_face_id in side_neighbors:
connected_side_ids.append(side_id)
reference_face_ids = [opposite_face_id] if opposite_face_id is not None else []
topology_reference = False
signed_extrusion = _float_or_none(shell_info.get("shell_signed_thickness"))
if len(connected_side_ids) < 2:
source_plane = BRepAdaptor_Surface(self.faces[face_id]).Plane()
source_normal = source_plane.Axis().Direction()
solid_id = self.face_solid_ids[face_id]
tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, 1e-6)
groups: list[dict[str, object]] = []
for side_id in adjacent_side_ids:
neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
for candidate_id in neighbors:
if candidate_id == face_id or candidate_id in region_ids:
continue
if solid_id >= 0 and self.face_solid_ids[candidate_id] != solid_id:
continue
try:
candidate_surface = BRepAdaptor_Surface(self.faces[candidate_id])
if candidate_surface.GetType() != GeomAbs_Plane:
continue
candidate_plane = candidate_surface.Plane()
normal_dot = _direction_dot(source_normal, candidate_plane.Axis().Direction())
if abs(normal_dot) < 0.995:
continue
signed_distance = _axis_parameter(
source_plane.Location(),
source_normal,
candidate_plane.Location(),
)
except Exception:
continue
if abs(signed_distance) <= tolerance:
continue
matched_group = None
for group in groups:
if abs(float(group["signed_distance"]) - signed_distance) <= tolerance * 20.0:
matched_group = group
break
if matched_group is None:
matched_group = {
"signed_distance": signed_distance,
"face_ids": set(),
"side_ids": set(),
"normal_dot": normal_dot,
}
groups.append(matched_group)
matched_group["face_ids"].add(candidate_id)
matched_group["side_ids"].add(side_id)
eligible_groups = [group for group in groups if len(group["side_ids"]) >= 2]
if eligible_groups:
eligible_groups.sort(key=lambda group: (-len(group["side_ids"]), abs(float(group["signed_distance"]))))
best_group = eligible_groups[0]
reference_face_ids = sorted(int(item) for item in best_group["face_ids"])
connected_side_ids = sorted(int(item) for item in best_group["side_ids"])
opposite_face_id = reference_face_ids[0]
signed_extrusion = float(best_group["signed_distance"])
topology_reference = True
support_ratio = len(connected_side_ids) / max(len(adjacent_side_ids), 1)
shell_info.update(
{
"shell_region_status": "candidate",
"shell_region_kind": "prismatic-topology-reference",
"shell_source_face_ids": tuple(region_ids),
"shell_opposite_face_id": opposite_face_id,
"shell_thickness_estimate": abs(signed_extrusion),
"shell_signed_thickness": signed_extrusion,
"shell_overlap_ratio_estimate": support_ratio,
"shell_opposite_normal_dot": best_group["normal_dot"],
"shell_confidence": "high" if support_ratio >= 0.99 else "medium",
"shell_note": "通过矩形轮廓的相邻侧壁找到高度/深度基准。",
}
)
extrusion_candidate = opposite_face_id is not None and len(connected_side_ids) >= 2
profile_confidence = "high" if len(adjacent_side_ids) == 4 and area_ratio >= 0.999 else "medium"
feature_type = "规则矩形棱柱候选" if extrusion_candidate else "规则矩形平面候选"
feature_semantics = "generic-prismatic"
if extrusion_candidate and reference_face_ids:
reference_area = 0.0
for reference_id in reference_face_ids:
props = GProp_GProps()
brepgprop.SurfaceProperties(self.faces[reference_id], props)
reference_area += float(props.Mass())
oriented_normal = _tuple_normalized(_tuple_or_none(info.get("oriented_normal")))
source_normal_tuple = _tuple_normalized(
_dir_tuple(BRepAdaptor_Surface(self.faces[face_id]).Plane().Axis().Direction())
)
if reference_area > area * 1.2 and oriented_normal is not None and source_normal_tuple is not None:
outward_offset = float(signed_extrusion or 0.0) * _tuple_dot(source_normal_tuple, oriented_normal)
if outward_offset > 0:
feature_type = "矩形口袋候选"
feature_semantics = "subtractive-pocket"
else:
feature_type = "矩形凸台候选"
feature_semantics = "additive-boss"
result: dict[str, object] = {
"prismatic_profile_status": "candidate",
"prismatic_profile_kind": "rectangular-planar-profile",
"prismatic_profile_confidence": profile_confidence,
"confidence": profile_confidence,
"prismatic_length": length,
"prismatic_width": width,
"prismatic_length_direction": direction_groups[0]["direction"],
"prismatic_width_direction": direction_groups[1]["direction"],
"prismatic_profile_area_ratio": area_ratio,
"prismatic_side_face_ids": tuple(adjacent_side_ids),
"prismatic_connected_side_face_ids": tuple(connected_side_ids),
"prismatic_reference_face_ids": tuple(reference_face_ids),
"prismatic_feature_semantics": feature_semantics,
"prismatic_profile_note": "四条直线边形成两组等长平行对边,面积与长乘宽一致。",
"feature_type": feature_type,
"local_face_width": length,
"local_face_height": width,
"local_face_width_direction": direction_groups[0]["direction"],
"local_face_height_direction": direction_groups[1]["direction"],
}
if extrusion_candidate:
extrusion = _float_or_none(shell_info.get("shell_thickness_estimate"))
extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium"
result.update(
{
"prismatic_extrusion_status": "candidate",
"prismatic_extrusion_estimate": extrusion if extrusion is not None else "",
"prismatic_reference_face_id": opposite_face_id,
"prismatic_extrusion_confidence": extrusion_confidence,
"confidence": extrusion_confidence,
"prismatic_reference_source": "side-wall-topology" if topology_reference else "overlapping-plane",
"prismatic_highlight_face_ids": tuple(sorted({face_id, *reference_face_ids, *connected_side_ids})),
"prismatic_extrusion_note": "相对平面通过至少两个侧壁与当前矩形面相连。",
}
)
return result
def _planar_shell_region_info(
self,
face_id: int,
@@ -768,6 +1168,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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"
@@ -798,15 +1209,39 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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"])
material_toward = str(info.get("material_toward_axis", "") or "")
material_away = str(info.get("material_away_axis", "") or "")
if (
guess == "round/fillet candidate"
and has_two_axial_caps
and material_toward == "inside"
and "outside" in material_away
):
info = dict(info)
info["feature_guess"] = "boss/outer-round candidate"
info["confidence"] = "medium"
info["note"] = "partial cylinder 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 = float(info.get("angular_span", 0.0))
angular_span = combined_angular_span
if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选"
@@ -856,6 +1291,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"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),
@@ -1141,6 +1579,251 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache[face_id] = list(edge_ids)
return edge_ids
def face_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the explicit first-level B-Rep neighborhood for a Face.
The current project defines first-level Face topology as the selected
Face region plus Faces that share a boundary Edge with that region.
Vertex-only contacts are reported through boundary vertex counts, but
they are not used as propagation edges at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._face_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
try:
same_domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
except Exception:
same_domain_face_ids = [face_id]
same_domain_face_ids = tuple(sorted({int(item) for item in same_domain_face_ids if 0 <= int(item) < len(self.faces)}))
if not same_domain_face_ids:
same_domain_face_ids = (face_id,)
same_domain_set = set(same_domain_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(same_domain_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in same_domain_set:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
first_level_face_ids = tuple(sorted({*same_domain_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for item in same_domain_face_ids:
explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types: list[tuple[int, str]] = []
for adjacent_id in adjacent_face_ids:
adjacent_surface_types.append((adjacent_id, self.face_surface_kind(adjacent_id)))
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
topology = {
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected same-domain region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"当前阶段只传播一级关系;相邻 Face 再连接出去的二级、三级拓扑只作为后续目标,不自动递归编辑。"
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"same_domain_face_ids": same_domain_face_ids,
"same_domain_face_count": len(same_domain_face_ids),
"same_domain_region_kind": "same-domain-region" if len(same_domain_face_ids) > 1 else "single-face",
"selected_boundary_edge_ids": selected_boundary_edge_ids,
"selected_boundary_edge_count": len(selected_boundary_edge_ids),
"first_level_boundary_edge_ids": region_boundary_edge_ids,
"first_level_boundary_edge_count": len(region_boundary_edge_ids),
"first_level_boundary_vertex_points": boundary_vertex_points,
"first_level_boundary_vertex_count": len(boundary_vertex_points),
"first_level_adjacent_face_ids": adjacent_face_ids,
"first_level_adjacent_face_count": len(adjacent_face_ids),
"first_level_adjacent_surface_types": tuple(adjacent_surface_types),
"first_level_shared_edges_by_face": shared_edge_refs,
"first_level_face_ids": first_level_face_ids,
"first_level_face_count": len(first_level_face_ids),
"first_level_topology_note": (
f"已识别当前 Face 区域 {len(same_domain_face_ids)} 个 Face、"
f"边界 Edge {len(region_boundary_edge_ids)} 条、"
f"边界 Vertex {len(boundary_vertex_points)} 个、"
f"共享边一级相邻 Face {len(adjacent_face_ids)} 个;"
"当前编辑计划只处理这些一级关系。"
),
}
for item in same_domain_face_ids:
self._face_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def cylindrical_feature_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the first-level B-Rep neighborhood for a cylindrical feature.
For holes and slots, first-level topology means the selected cylindrical
side region and Faces that directly share one of its boundary Edges.
Paired slot ends reached through another planar wall are deliberately
not promoted to first-level topology at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._cylindrical_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
info = self.face_info(face_id)
if info.get("surface") != "cylinder":
raise ValueError(f"Face {face_id} is not a cylindrical feature face")
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
feature = self.feature_info(face_id)
def valid_face_ids(values: object) -> tuple[int, ...]:
result: list[int] = []
for item in _int_values(values):
if item < 0 or item >= len(self.faces):
continue
if source_part_id >= 0 and self.face_part_ids[item] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[item] != source_solid_id:
continue
result.append(item)
return tuple(sorted(set(result)))
side_face_ids = valid_face_ids(feature.get("feature_side_face_ids"))
if not side_face_ids:
side_face_ids = valid_face_ids(feature.get("feature_face_ids"))
if not side_face_ids:
try:
side_face_ids = valid_face_ids(self.connected_same_domain_face_ids(face_id))
except Exception:
side_face_ids = ()
if not side_face_ids:
side_face_ids = (face_id,)
side_face_set = set(side_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(side_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in side_face_set:
continue
if source_part_id >= 0 and self.face_part_ids[candidate_id] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
end_face_ids = valid_face_ids(feature.get("feature_end_face_ids"))
bottom_face_ids = valid_face_ids(feature.get("feature_bottom_face_ids"))
opening_face_ids = valid_face_ids(feature.get("feature_opening_face_ids"))
slot_boundary_face_ids = valid_face_ids(feature.get("feature_slot_boundary_face_ids"))
first_level_face_ids = tuple(sorted({*side_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for edge_id in region_boundary_edge_ids:
if edge_id < 0 or edge_id >= len(self.edges):
continue
explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types = tuple((item, self.face_surface_kind(item)) for item in adjacent_face_ids)
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
angular_span = feature.get("slot_angular_span", feature.get("angular_span", info.get("angular_span")))
topology = {
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected cylindrical same-domain side region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"Only direct shared-edge neighbors of the cylindrical side region are treated as first-level topology. "
"Faces reached through those neighbors are recorded later as second-level or deeper relationships."
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"feature_type": feature.get("feature_type"),
"feature_guess": feature.get("feature_guess", info.get("feature_guess")),
"slot_kind": feature.get("slot_kind", ""),
"cylinder_end_type": feature.get("cylinder_end_type", info.get("cylinder_end_type")),
"is_full_cylinder": bool(feature.get("is_full_cylinder", info.get("is_full_cylinder", False))),
"angular_span": angular_span,
"cylindrical_feature_side_face_ids": side_face_ids,
"cylindrical_feature_side_face_count": len(side_face_ids),
"cylindrical_feature_selected_boundary_edge_ids": selected_boundary_edge_ids,
"cylindrical_feature_selected_boundary_edge_count": len(selected_boundary_edge_ids),
"cylindrical_feature_boundary_edge_ids": region_boundary_edge_ids,
"cylindrical_feature_boundary_edge_count": len(region_boundary_edge_ids),
"cylindrical_feature_boundary_vertex_points": boundary_vertex_points,
"cylindrical_feature_boundary_vertex_count": len(boundary_vertex_points),
"cylindrical_feature_adjacent_face_ids": adjacent_face_ids,
"cylindrical_feature_adjacent_face_count": len(adjacent_face_ids),
"cylindrical_feature_adjacent_surface_types": adjacent_surface_types,
"cylindrical_feature_shared_edges_by_face": shared_edge_refs,
"cylindrical_feature_first_level_face_ids": first_level_face_ids,
"cylindrical_feature_first_level_face_count": len(first_level_face_ids),
"cylindrical_feature_end_face_ids": end_face_ids,
"cylindrical_feature_end_face_count": len(end_face_ids),
"cylindrical_feature_bottom_face_ids": bottom_face_ids,
"cylindrical_feature_bottom_face_count": len(bottom_face_ids),
"cylindrical_feature_opening_face_ids": opening_face_ids,
"cylindrical_feature_opening_face_count": len(opening_face_ids),
"cylindrical_feature_slot_boundary_face_ids": slot_boundary_face_ids,
"cylindrical_feature_slot_boundary_face_count": len(slot_boundary_face_ids),
"first_level_topology_note": (
f"Cylindrical side Faces={len(side_face_ids)}, boundary Edges={len(region_boundary_edge_ids)}, "
f"boundary Vertices={len(boundary_vertex_points)}, direct adjacent Faces={len(adjacent_face_ids)}. "
"Second-level and deeper propagation is not automatic in this stage."
),
}
for item in side_face_ids:
self._cylindrical_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]:
try:
boundary_wires = len(_explore(face, TopAbs_WIRE))