feat: 完善 STEP/B-Rep 一级关系参数化编辑

This commit is contained in:
2026-08-07 18:08:32 +08:00
parent eef9efcc1e
commit 12250603dd
26 changed files with 4254 additions and 1270 deletions
+345 -15
View File
@@ -80,6 +80,12 @@ 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
@@ -326,7 +332,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
raise ValueError(f"Unknown face id {face_id}")
full_info = self._face_info_cache.get(face_id)
if full_info is not None:
return dict(full_info)
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)
@@ -419,6 +429,39 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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())
@@ -449,6 +492,189 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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])
@@ -525,6 +751,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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())
@@ -601,6 +828,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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()
@@ -637,12 +867,29 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
("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", "已有圆角半径"),
("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 capability_specs
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 "")
@@ -678,7 +925,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
risk = value
if surface in FREEFORM_FACE_SURFACES:
risk = "blocked"
for status_key, risk_key, _blocker_key, _label in capability_specs:
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
@@ -743,6 +990,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
add("slot_geometry", "部分圆柱槽/半孔几何")
if info.get("shell_region_status") == "candidate":
add("shell_opposite_face", "找到相对平面/壳体候选")
add("user_operation_priority", f"常用操作优先级={user_priority_label}")
ready_actions: list[str] = []
limited_actions: list[str] = []
@@ -757,7 +1005,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if text and text not in items:
items.append(text)
for status_key, _risk_key, blocker_key, label in capability_specs:
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"}:
@@ -768,7 +1016,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if has_planar_push_pull_candidate:
add_action(ready_actions, "平面拉伸/切除")
if has_local_face_deform:
add_action(ready_actions, "当前面面积/U向尺寸/V向尺寸/中心/偏移")
add_action(ready_actions, "当前面面内长度/面内宽度/中心/偏移")
elif str(info.get("local_face_deform_blocker") or "").strip():
add_action(limited_actions, "局部重建尺寸/中心/偏移")
if has_shell_candidate:
@@ -790,7 +1038,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
add_unique(limitations, text)
else:
add_unique(blockers, text)
for status_key, _risk_key, blocker_key, _label in capability_specs:
for status_key, _risk_key, blocker_key, _label in relevant_capability_specs:
text = str(info.get(blocker_key) or "").strip()
if not text:
continue
@@ -830,7 +1078,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
else:
decision = "不建议自动修改"
summary_parts = [candidate, f"置信度={confidence}", f"风险={risk}", f"评分={score}", f"结论={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:
@@ -856,6 +1111,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"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),
@@ -1077,15 +1335,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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": (
0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2)
),
"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)
@@ -1722,6 +1982,35 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
**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": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。",
}
)
return result
def _cylindrical_slot_info(
@@ -1769,19 +2058,60 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
radius = max(float(info.get("radius", 0.0)), 0.0)
angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau)
support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids))
adjacent_round_face_ids: list[int] = []
radius_tolerance = max(radius * 0.12, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4)
for adjacent_id in adjacent_face_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"
and adjacent_info.get("feature_guess") == "round/fillet candidate"
and adjacent_radius is not None
and abs(adjacent_radius - radius) <= radius_tolerance
):
adjacent_round_face_ids.append(adjacent_id)
adjacent_round_face_ids = sorted(set(adjacent_round_face_ids))
support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids) - set(adjacent_round_face_ids))
chain_face_ids = tuple(sorted({face_id, *adjacent_round_face_ids}))
chain_blocker = (
"当前圆角与其它圆角面直接相连,属于复杂圆角链或角部 blend;"
"当前版本暂未实现稳定圆角链重建。"
if adjacent_round_face_ids
else ""
)
return {
"existing_fillet_kind": "cylindrical-round-face",
"existing_fillet_status": "candidate",
"existing_fillet_status": "blocked" if chain_blocker else "candidate",
"existing_fillet_risk": "blocked" if chain_blocker else "medium",
"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),
"existing_fillet_chain_face_count": len(chain_face_ids),
"existing_fillet_chain_status": "chain-candidate" if adjacent_round_face_ids else "single-face",
"existing_fillet_chain_note": (
"Detected directly connected round/fillet Faces with a matching radius; "
"this is treated as a fillet chain or corner blend, not a single isolated fillet."
if adjacent_round_face_ids
else "No directly connected same-radius round/fillet Face was detected."
),
"existing_fillet_note": (
"这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;"
"当前版本可尝试使用 defeature + 重新倒圆修改半径;"
"复杂 blend 或支撑面不明确时可能失败并回滚。"
chain_blocker
if chain_blocker
else (
"这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;"
"当前版本可尝试使用 defeature + 重新倒圆修改半径;"
"复杂 blend 或支撑面不明确时可能失败并回滚。"
)
),
}