feat: 完善一级关系编辑 UI 与视图体验

This commit is contained in:
2026-08-06 18:15:14 +08:00
parent 57d75541c3
commit eef9efcc1e
29 changed files with 3907 additions and 178 deletions
+521 -107
View File
@@ -1678,6 +1678,23 @@ class OperationMixin:
notes.append(f"相邻圆柱Face {face_id}{mode_label}不可用:{axis_plan.get('message', '')}")
continue
axis_topology_fields = {
f"move_axis_{key}": axis_plan.get(key)
for key in (
"topology_relation_depth",
"topology_relation_status",
"topology_relation_boundary",
"topology_ignored_relation_depths",
"first_level_topology_status",
"first_level_topology_guard_note",
"first_level_boundary_edge_count",
"first_level_adjacent_face_count",
"cylindrical_feature_side_face_count",
"cylindrical_feature_boundary_edge_count",
"cylindrical_feature_adjacent_face_count",
)
if key in axis_plan
}
candidate = {
"circular_edge_cylinder_face_id": face_id,
"circular_edge_cylinder_mode": mode,
@@ -1703,6 +1720,7 @@ class OperationMixin:
"move_axis_blockers": axis_plan.get("blockers", ""),
"move_axis_feature_guess": axis_plan.get("feature_guess", feature_guess),
"move_axis_confidence": axis_plan.get("confidence", face_info.get("confidence", "")),
**axis_topology_fields,
"resize_strategy": "move-adjacent-cylinder-from-circular-edge-center",
"edit_strategy_label": f"通过{mode_label}移动圆Edge",
"edit_semantics": (
@@ -2347,11 +2365,53 @@ class OperationMixin:
feature_guess = str(info.get("feature_guess", ""))
current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"]))
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
warnings: list[str] = []
blockers: list[str] = []
risk = "medium"
status = "caution"
topology_status = str(topology_fields.get("topology_relation_status") or "")
side_count = int(topology_fields.get("cylindrical_feature_side_face_count", 0) or 0)
boundary_edge_count = int(topology_fields.get("cylindrical_feature_boundary_edge_count", 0) or 0)
adjacent_face_ids = tuple(_int_values(topology_fields.get("cylindrical_feature_adjacent_face_ids")))
adjacent_count = int(topology_fields.get("cylindrical_feature_adjacent_face_count", 0) or 0)
missing_support_faces = tuple(sorted(set(support_face_ids) - set(adjacent_face_ids)))
topology_blockers: list[str] = []
topology_warnings: list[str] = []
topology_risk = "low"
if topology_status != "ready":
topology_blockers.append("当前圆角的一级拓扑关系无法确认,不能稳定执行移除旧圆角并重建。")
if side_count <= 0:
topology_blockers.append("没有识别到当前圆角面区域。")
if boundary_edge_count <= 0:
topology_blockers.append("没有识别到当前圆角面的边界 Edge,不能确认重建影响范围。")
if adjacent_count < 2:
topology_blockers.append("没有识别到当前圆角直接连接的两个支撑 Face。")
if missing_support_faces:
topology_blockers.append(f"已识别支撑 Face {missing_support_faces} 不在当前圆角一级相邻 Face 中。")
if adjacent_count > 8 or boundary_edge_count > 16:
topology_risk = _max_risk(topology_risk, "high")
topology_warnings.append("当前圆角一级邻域较复杂,重建后需要重点检查相邻面。")
elif adjacent_count > 4 or boundary_edge_count > 8:
topology_risk = _max_risk(topology_risk, "medium")
topology_warnings.append("当前圆角一级邻域包含较多相邻面或边界边。")
topology_fields.update(
{
"first_level_topology_status": "blocked" if topology_blockers else "ready",
"first_level_topology_risk": "blocked" if topology_blockers else topology_risk,
"first_level_topology_blockers": "".join(topology_blockers),
"first_level_topology_warnings": "".join(topology_warnings),
"first_level_topology_guard_note": (
f"圆角一级关系检查:圆角 Face {side_count} 个,边界 Edge {boundary_edge_count} 条,"
f"直接相邻 Face {adjacent_count} 个,支撑 Face {len(support_face_ids)} 个。"
),
}
)
blockers.extend(topology_blockers)
warnings.extend(topology_warnings)
risk = _max_risk(risk, str(topology_fields.get("first_level_topology_risk") or "low"))
if feature_guess != "round/fillet candidate":
blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。")
if target_radius <= 0:
@@ -2412,6 +2472,7 @@ class OperationMixin:
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
**topology_fields,
"feature_type": feature.get("feature_type"),
"feature_guess": feature_guess,
"confidence": info.get("confidence"),
@@ -4105,6 +4166,7 @@ class OperationMixin:
blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}")
if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate":
blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。")
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
cylinder_identity = {**info, **feature}
angular_span = _effective_cylinder_angular_span(cylinder_identity) or 0.0
if not _is_effectively_full_cylinder(cylinder_identity):
@@ -4239,6 +4301,7 @@ class OperationMixin:
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
**topology_fields,
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
@@ -4320,7 +4383,8 @@ class OperationMixin:
f"target_height={float(plan['target_height']):g}, "
f"delta={float(plan['delta_height']):g}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}. {push_result}"
f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}, "
f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}. {push_result}"
)
def shell_thickness_preview_polydata(
@@ -7664,6 +7728,7 @@ class OperationMixin:
check = self._edge_length_result_check(plan)
if check is None:
return "Result check unavailable."
topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=False)
return (
"Result check: "
f"match={check['match_method']}, "
@@ -7672,7 +7737,7 @@ class OperationMixin:
f"target_error={float(check['target_error']):g}, "
f"relative_error={float(check['relative_error']):g}, "
f"endpoint_error={float(check['endpoint_error']):g}, "
f"scope={check['scope']}."
f"scope={check['scope']}. {topology_check}"
)
def _edge_length_result_summary_or_raise(self, plan: dict[str, object]) -> str:
@@ -7699,6 +7764,7 @@ class OperationMixin:
f"允许误差 {endpoint_tolerance:g}"
)
topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=True)
return (
"Result check: "
f"match={check['match_method']}, "
@@ -7707,7 +7773,173 @@ class OperationMixin:
f"target_error={float(check['target_error']):g}, "
f"relative_error={float(check['relative_error']):g}, "
f"endpoint_error={float(check['endpoint_error']):g}, "
f"scope={check['scope']}."
f"scope={check['scope']}. {topology_check}"
)
def _edge_first_level_result_summary(
self,
plan: dict[str, object],
result_edge_id: int,
*,
enforce: bool,
) -> str:
strategy = str(plan.get("resize_strategy") or "")
enforce_strategy = strategy in {
"local-edge-only-deform",
"local-edge-endpoint-deform",
"local-edge-center-deform",
"move-edge-end-plane-by-push-pull",
}
expected_vertices = _int_or_none(plan.get("first_level_fact_boundary_vertex_count"))
if expected_vertices is None:
expected_vertices = _int_or_none(plan.get("first_level_vertex_count"))
expected_adjacent_edges = _int_or_none(plan.get("first_level_fact_adjacent_edge_count"))
if expected_adjacent_edges is None:
expected_adjacent_edges = _int_or_none(plan.get("first_level_adjacent_edge_count"))
expected_adjacent_faces = _int_or_none(plan.get("first_level_fact_adjacent_face_count"))
if expected_adjacent_faces is None:
expected_adjacent_faces = _int_or_none(plan.get("first_level_adjacent_face_count"))
expected_included_edges = _int_or_none(plan.get("first_level_fact_included_edge_count"))
if expected_included_edges is None:
expected_included_edges = _int_or_none(plan.get("first_level_edge_count"))
if not any(
value is not None and value > 0
for value in (expected_vertices, expected_adjacent_edges, expected_adjacent_faces, expected_included_edges)
):
return "First-level topology check: unavailable."
try:
topology = self.edge_first_level_topology(int(result_edge_id))
except Exception as exc:
if enforce and enforce_strategy:
raise RuntimeError(
"Edge编辑后无法重新识别目标Edge的一级关系,模型已恢复到修改前状态。"
f"目标Edge {result_edge_id},原因:{exc}"
) from exc
return f"First-level topology check: unavailable ({exc})."
actual_vertices = int(topology.get("first_level_vertex_count", 0) or 0)
actual_adjacent_edges = int(topology.get("first_level_adjacent_edge_count", 0) or 0)
actual_adjacent_faces = int(topology.get("first_level_adjacent_face_count", 0) or 0)
actual_included_edges = int(topology.get("first_level_edge_count", 0) or 0)
failures: list[str] = []
if expected_vertices is not None and expected_vertices > 0 and actual_vertices != expected_vertices:
failures.append(f"端点 Vertex {actual_vertices}/{expected_vertices}")
if (
expected_adjacent_edges is not None
and expected_adjacent_edges > 0
and actual_adjacent_edges < expected_adjacent_edges
):
failures.append(f"共享端点相邻 Edge {actual_adjacent_edges}/{expected_adjacent_edges}")
if (
expected_adjacent_faces is not None
and expected_adjacent_faces > 0
and actual_adjacent_faces < expected_adjacent_faces
):
failures.append(f"直接相邻 Face {actual_adjacent_faces}/{expected_adjacent_faces}")
if (
expected_included_edges is not None
and expected_included_edges > 0
and actual_included_edges < expected_included_edges
):
failures.append(f"一级范围 Edge {actual_included_edges}/{expected_included_edges}")
if failures and enforce and enforce_strategy:
raise RuntimeError(
"Edge编辑后一级关系不完整,模型已恢复到修改前状态。"
f"目标Edge {result_edge_id},未通过:{'; '.join(failures)}"
)
status = "ok" if not failures else f"warning({'; '.join(failures)})"
return (
"First-level topology check: "
f"{status}, edge={result_edge_id}, endpoint_vertices={actual_vertices}, "
f"adjacent_edges={actual_adjacent_edges}, adjacent_faces={actual_adjacent_faces}, "
f"included_edges={actual_included_edges}."
)
def _edge_blend_result_summary_or_raise(
self,
plan: dict[str, object],
before_stats,
*,
mode: str,
) -> str:
part_id = _int_or_none(plan.get("part_id"))
if part_id is None:
raise RuntimeError("Edge blend result check failed: the source part could not be resolved.")
part = self.part_by_id(part_id)
if part is None:
raise RuntimeError(f"Edge blend result check failed: unknown part id {part_id}.")
_ensure_valid_shape(part.shape)
after_stats = self.part_topology_stats(part_id)
before_solids = int(getattr(before_stats, "solids", 0))
after_solids = int(getattr(after_stats, "solids", 0))
if before_solids > 0 and after_solids != before_solids:
raise RuntimeError(
"Edge blend result changed the solid count; the model has been restored to the previous state. "
f"Solids {before_solids}->{after_solids}."
)
face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0))
edge_delta = int(getattr(after_stats, "edges", 0)) - int(getattr(before_stats, "edges", 0))
if mode == "fillet":
target_radius = _float_or_none(plan.get("target_radius"))
if target_radius is None or target_radius <= 0.0:
raise RuntimeError("Edge fillet result check failed: target radius is invalid.")
tolerance = max(_shape_diagonal(part.shape) * 1e-5, target_radius * 1e-3, 1e-5)
matched_faces: list[int] = []
for face_id in range(len(self.faces)):
if int(self.face_part_ids[face_id]) != part_id:
continue
try:
info = self.face_info(face_id)
except Exception:
continue
if info.get("surface") != "cylinder":
continue
radius = _float_or_none(info.get("radius"))
if radius is None:
continue
if abs(radius - target_radius) <= tolerance:
matched_faces.append(face_id)
if not matched_faces:
raise RuntimeError(
"Edge fillet result did not contain a cylindrical face near the target radius; "
"the model has been restored to the previous state. "
f"Target radius {target_radius:g}, tolerance {tolerance:g}."
)
if face_delta < 0:
raise RuntimeError(
"Edge fillet result lost faces unexpectedly; the model has been restored to the previous state. "
f"Face delta {face_delta}."
)
return (
"Edge blend result check: "
f"mode=fillet, solids={before_solids}->{after_solids}, "
f"face_delta={face_delta}, edge_delta={edge_delta}, "
f"target_radius={target_radius:g}, matched_faces={tuple(matched_faces[:8])}."
)
if face_delta <= 0:
raise RuntimeError(
"Edge chamfer result did not add the expected chamfer face; "
"the model has been restored to the previous state. "
f"Face delta {face_delta}."
)
if edge_delta <= 0:
raise RuntimeError(
"Edge chamfer result did not add the expected boundary edges; "
"the model has been restored to the previous state. "
f"Edge delta {edge_delta}."
)
return (
"Edge blend result check: "
f"mode={mode}, solids={before_solids}->{after_solids}, "
f"face_delta={face_delta}, edge_delta={edge_delta}."
)
def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None:
@@ -10166,6 +10398,119 @@ class OperationMixin:
return False
return bool(saw_outer_radius and saw_inner_radius)
def _existing_fillet_result_summary_or_raise(
self,
plan: dict[str, object],
before_stats,
) -> str:
part_id = _int_or_none(plan.get("part_id"))
if part_id is None:
raise RuntimeError("Existing fillet result check failed: the source part could not be resolved.")
part = self.part_by_id(part_id)
if part is None:
raise RuntimeError(f"Existing fillet result check failed: unknown part id {part_id}.")
_ensure_valid_shape(part.shape)
after_stats = self.part_topology_stats(part_id)
before_solids = int(getattr(before_stats, "solids", 0))
after_solids = int(getattr(after_stats, "solids", 0))
if before_solids > 0 and after_solids != before_solids:
raise RuntimeError(
"Existing fillet result changed the solid count; the model has been restored to the previous state. "
f"Solids {before_solids}->{after_solids}."
)
target_radius = _float_or_none(plan.get("target_radius"))
if target_radius is None or target_radius <= 0.0:
raise RuntimeError("Existing fillet result check failed: target radius is invalid.")
diagonal = max(_shape_diagonal(part.shape), target_radius, 1.0)
radius_tolerance = max(target_radius * 0.01, diagonal * 1e-5, 1e-5)
required_adjacent = max(2, min(int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0), 4))
required_boundary = max(1, min(int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0), 4))
matched: list[dict[str, object]] = []
closest: dict[str, object] | None = None
for candidate_face_id in range(len(self.faces)):
if int(self.face_part_ids[candidate_face_id]) != part_id:
continue
try:
info = self.face_info(candidate_face_id)
except Exception:
continue
if info.get("surface") != "cylinder":
continue
radius = _float_or_none(info.get("radius"))
if radius is None:
continue
radius_error = abs(radius - target_radius)
candidate = {
"face_id": candidate_face_id,
"radius": radius,
"radius_error": radius_error,
"feature_guess": info.get("feature_guess", ""),
"angular_span": info.get("angular_span", ""),
}
if closest is None or radius_error < float(closest["radius_error"]):
closest = candidate
if radius_error > radius_tolerance:
continue
try:
topology = self.cylindrical_feature_first_level_topology(candidate_face_id)
side_count = int(topology.get("cylindrical_feature_side_face_count", 0) or 0)
boundary_count = int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0)
adjacent_count = int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0)
except Exception as exc:
raise RuntimeError(
"Existing fillet result matched the target radius but first-level topology failed; "
f"Face {candidate_face_id}, error={exc}."
) from exc
if side_count <= 0 or boundary_count < required_boundary or adjacent_count < required_adjacent:
raise RuntimeError(
"Existing fillet result matched the target radius but lost first-level topology; "
f"Face {candidate_face_id}, side={side_count}, boundary_edges={boundary_count}, "
f"adjacent_faces={adjacent_count}, required_boundary={required_boundary}, "
f"required_adjacent={required_adjacent}."
)
matched.append(
{
**candidate,
"side_face_count": side_count,
"boundary_edge_count": boundary_count,
"adjacent_face_count": adjacent_count,
}
)
if not matched:
if closest is None:
detail = "no cylindrical Face remained in the source part"
else:
detail = (
f"closest Face {closest['face_id']} radius {float(closest['radius']):.6g}, "
f"target {target_radius:.6g}, error {float(closest['radius_error']):.6g}"
)
raise RuntimeError(
"Existing fillet result did not contain a target-radius cylindrical round Face; "
f"the model has been restored to the previous state. {detail}."
)
face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0))
edge_delta = int(getattr(after_stats, "edges", 0)) - int(getattr(before_stats, "edges", 0))
matched_face_ids = tuple(int(item["face_id"]) for item in matched[:8])
topology_summaries = tuple(
{
"face_id": int(item["face_id"]),
"boundary_edge_count": int(item["boundary_edge_count"]),
"adjacent_face_count": int(item["adjacent_face_count"]),
}
for item in matched[:8]
)
return (
"Existing fillet result check: "
f"solids={before_solids}->{after_solids}, face_delta={face_delta}, edge_delta={edge_delta}, "
f"target_radius={target_radius:g}, matched_faces={matched_face_ids}, "
f"first_level_topology_matched=True, topology={topology_summaries}."
)
def resize_existing_fillet(self, face_id: int, target_radius: float) -> str:
plan = self.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
@@ -10176,49 +10521,58 @@ class OperationMixin:
if part is None:
raise ValueError(f"Unknown part id {part_id}")
source_face = topods.Face(self.faces[face_id])
defeatured = _defeature_faces(part.shape, [source_face])
axis_point = gp_Pnt(*plan["axis_point"])
axis_dir = gp_Dir(*plan["axis"])
root_edges = _axis_aligned_edge_candidates(
defeatured,
axis_point,
axis_dir,
expected_length=float(plan.get("height_estimate") or 0.0),
reference_radius=float(plan["current_radius"]),
)
if not root_edges:
raise RuntimeError(
"已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;"
"该圆角可能不是简单直线边圆角。"
old_part_shape = part.shape
before_stats = self.part_topology_stats(part_id)
try:
source_face = topods.Face(self.faces[face_id])
defeatured = _defeature_faces(part.shape, [source_face])
axis_point = gp_Pnt(*plan["axis_point"])
axis_dir = gp_Dir(*plan["axis"])
root_edges = _axis_aligned_edge_candidates(
defeatured,
axis_point,
axis_dir,
expected_length=float(plan.get("height_estimate") or 0.0),
reference_radius=float(plan["current_radius"]),
)
if not root_edges:
raise RuntimeError(
"Existing fillet face was removed, but no axis-aligned sharp Edge was found for refillet; "
"the selected round is probably not a simple straight-edge fillet."
)
result = None
failures: list[str] = []
for index, root_edge in enumerate(root_edges[:16], start=1):
try:
maker = BRepFilletAPI_MakeFillet(defeatured)
maker.Add(float(target_radius), topods.Edge(root_edge))
result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}")
break
except Exception as exc:
failures.append(str(exc))
if result is None:
detail = failures[-1] if failures else "没有可用的候选边。"
raise RuntimeError(
"已移除已有圆角面,但所有候选锐边都无法重新倒圆;"
f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}"
)
result = None
failures: list[str] = []
for index, root_edge in enumerate(root_edges[:16], start=1):
try:
maker = BRepFilletAPI_MakeFillet(defeatured)
maker.Add(float(target_radius), topods.Edge(root_edge))
result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}")
break
except Exception as exc:
failures.append(str(exc))
if result is None:
detail = failures[-1] if failures else "no candidate Edge was available"
raise RuntimeError(
"Existing fillet face was removed, but every candidate sharp Edge failed to refillet; "
f"the selected round may be a complex blend. Last error: {detail}"
)
part.shape = result
self.refresh_topology()
result_check = self._existing_fillet_result_summary_or_raise(plan, before_stats)
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
part.shape = result
self.refresh_topology()
return (
"Existing fillet radius resize completed: "
f"face {face_id}, current_radius={float(plan['current_radius']):g}, "
f"target_radius={target_radius:g}, "
f"delta_radius={float(plan['delta_radius']):g}, "
f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, "
f"risk={plan['risk']}."
f"risk={plan['risk']}. {result_check}"
)
def fillet_edge(self, edge_id: int, radius: float) -> str:
@@ -10231,16 +10585,24 @@ class OperationMixin:
if part is None:
raise ValueError(f"Unknown part id {part_id}")
maker = BRepFilletAPI_MakeFillet(part.shape)
maker.Add(radius, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge fillet")
part.shape = result
self.refresh_topology()
old_part_shape = part.shape
before_stats = self.part_topology_stats(part_id)
try:
maker = BRepFilletAPI_MakeFillet(part.shape)
maker.Add(radius, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge fillet")
part.shape = result
self.refresh_topology()
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="fillet")
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
f"Edge fillet completed: edge {edge_id}, radius={radius:g}, "
f"edge_length={float(plan['edge_length']):g}, "
f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, "
f"risk={plan['risk']}."
f"risk={plan['risk']}. {result_check}"
)
def chamfer_edge(self, edge_id: int, distance: float) -> str:
@@ -10253,16 +10615,24 @@ class OperationMixin:
if part is None:
raise ValueError(f"Unknown part id {part_id}")
maker = BRepFilletAPI_MakeChamfer(part.shape)
maker.Add(distance, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge chamfer")
part.shape = result
self.refresh_topology()
old_part_shape = part.shape
before_stats = self.part_topology_stats(part_id)
try:
maker = BRepFilletAPI_MakeChamfer(part.shape)
maker.Add(distance, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge chamfer")
part.shape = result
self.refresh_topology()
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="chamfer")
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, "
f"edge_length={float(plan['edge_length']):g}, "
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
f"risk={plan['risk']}."
f"risk={plan['risk']}. {result_check}"
)
def chamfer_edge_asymmetric(
@@ -10282,19 +10652,29 @@ class OperationMixin:
raise ValueError(f"Unknown part id {part_id}")
resolved_reference_face_id = int(plan["reference_face_id"])
maker = BRepFilletAPI_MakeChamfer(part.shape)
old_part_shape = part.shape
before_stats = self.part_topology_stats(part_id)
try:
maker.Add(
float(distance1),
float(distance2),
topods.Edge(self.edges[edge_id]),
topods.Face(self.faces[resolved_reference_face_id]),
)
except TypeError as exc:
raise RuntimeError("The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face).") from exc
result = _finalize_builder_result(maker, "asymmetric edge chamfer")
part.shape = result
self.refresh_topology()
maker = BRepFilletAPI_MakeChamfer(part.shape)
try:
maker.Add(
float(distance1),
float(distance2),
topods.Edge(self.edges[edge_id]),
topods.Face(self.faces[resolved_reference_face_id]),
)
except TypeError as exc:
raise RuntimeError(
"The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face)."
) from exc
result = _finalize_builder_result(maker, "asymmetric edge chamfer")
part.shape = result
self.refresh_topology()
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="asymmetric_chamfer")
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
f"Asymmetric Edge chamfer completed: edge {edge_id}, "
f"distance1={float(distance1):g}, distance2={float(distance2):g}, "
@@ -10302,7 +10682,7 @@ class OperationMixin:
f"edge_length={float(plan['edge_length']):g}, "
f"distance1_to_length_ratio={float(plan['distance1_to_length_ratio']):g}, "
f"distance2_to_length_ratio={float(plan['distance2_to_length_ratio']):g}, "
f"risk={plan['risk']}."
f"risk={plan['risk']}. {result_check}"
)
def chamfer_edge_distance_angle(
@@ -10322,28 +10702,40 @@ class OperationMixin:
raise ValueError(f"Unknown part id {part_id}")
resolved_reference_face_id = int(plan["reference_face_id"])
maker = BRepFilletAPI_MakeChamfer(part.shape)
old_part_shape = part.shape
before_stats = self.part_topology_stats(part_id)
try:
maker.AddDA(
float(distance),
float(plan["target_angle_radians"]),
topods.Edge(self.edges[edge_id]),
topods.Face(self.faces[resolved_reference_face_id]),
)
except AttributeError as exc:
raise RuntimeError("The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
except TypeError as exc:
raise RuntimeError("The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
result = _finalize_builder_result(maker, "distance-angle edge chamfer")
part.shape = result
self.refresh_topology()
maker = BRepFilletAPI_MakeChamfer(part.shape)
try:
maker.AddDA(
float(distance),
float(plan["target_angle_radians"]),
topods.Edge(self.edges[edge_id]),
topods.Face(self.faces[resolved_reference_face_id]),
)
except AttributeError as exc:
raise RuntimeError(
"The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face)."
) from exc
except TypeError as exc:
raise RuntimeError(
"The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face)."
) from exc
result = _finalize_builder_result(maker, "distance-angle edge chamfer")
part.shape = result
self.refresh_topology()
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="distance_angle_chamfer")
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
f"Distance-angle Edge chamfer completed: edge {edge_id}, "
f"distance={float(distance):g}, angle_degrees={float(angle_degrees):g}, "
f"reference_face={resolved_reference_face_id}, "
f"edge_length={float(plan['edge_length']):g}, "
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
f"risk={plan['risk']}."
f"risk={plan['risk']}. {result_check}"
)
def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
@@ -11892,41 +12284,62 @@ class OperationMixin:
if part is None:
raise ValueError(f"Unknown part id {part_id}")
start = gp_Pnt(*plan["boss_tool_start_point"])
direction = gp_Dir(*plan["boss_tool_axis_direction"])
axis = gp_Ax2(start, direction)
height = float(plan["boss_tool_height"])
if plan["resize_mode"] == "enlarge":
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
op = BRepAlgoAPI_Fuse(part.shape, tool)
result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False)
action = "enlarged by bounded fuse"
else:
removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
remove_op = BRepAlgoAPI_Cut(part.shape, removal)
removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False)
if _topology_shape_count(removed, TopAbs_FACE) == 0:
exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"])
exact_axis = gp_Ax2(exact_start, direction)
exact_replacement = BRepPrimAPI_MakeCylinder(
exact_axis,
float(plan["boss_tool_inner_radius"]),
float(plan["boss_tool_exact_height"]),
).Shape()
result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement")
old_part_shape = part.shape
verification: dict[str, object] = {}
try:
start = gp_Pnt(*plan["boss_tool_start_point"])
direction = gp_Dir(*plan["boss_tool_axis_direction"])
axis = gp_Ax2(start, direction)
height = float(plan["boss_tool_height"])
if plan["resize_mode"] == "enlarge":
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
op = BRepAlgoAPI_Fuse(part.shape, tool)
result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False)
action = "enlarged by bounded fuse"
else:
fuse_op = BRepAlgoAPI_Fuse(removed, replacement)
result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False)
action = "shrunk by removing old envelope and fusing target cylinder"
removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
remove_op = BRepAlgoAPI_Cut(part.shape, removal)
removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False)
if _topology_shape_count(removed, TopAbs_FACE) == 0:
exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"])
exact_axis = gp_Ax2(exact_start, direction)
exact_replacement = BRepPrimAPI_MakeCylinder(
exact_axis,
float(plan["boss_tool_inner_radius"]),
float(plan["boss_tool_exact_height"]),
).Shape()
result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement")
else:
fuse_op = BRepAlgoAPI_Fuse(removed, replacement)
result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False)
action = "shrunk by removing old envelope and fusing target cylinder"
part.shape = result
self.refresh_topology()
verification_plan = dict(plan)
verification_plan["cutter_axis_point"] = plan["boss_tool_axis_point"]
verification_plan["cutter_axis_direction"] = plan["boss_tool_axis_direction"]
verification = self._verify_cylindrical_resize_result(verification_plan, part_id)
if not verification["matched"]:
detail = str(verification.get("detail", "cylindrical boss resize result verification failed"))
raise RuntimeError(
"Cylindrical boss resize returned a result, but no target-diameter boss with "
f"valid first-level topology was found; rolled back to the previous model.{detail}"
)
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
part.shape = result
self.refresh_topology()
return (
f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, "
f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, "
f"height={float(plan['boss_tool_height']):g}."
f"height={float(plan['boss_tool_height']):g}, "
f"verified_face={verification.get('face_id', '')}, "
f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}."
)
def move_cylindrical_boss_axis(
@@ -12011,7 +12424,8 @@ class OperationMixin:
f"diameter={float(plan['target_diameter']):g}, "
f"height={float(plan['boss_tool_height']):g}, "
f"risk={plan['risk']}, action={action}, "
f"verified_face={verification.get('face_id', '')}."
f"verified_face={verification.get('face_id', '')}, "
f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}."
)
def suppress_cylindrical_hole(self, face_id: int) -> str: