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

4599 lines
215 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
from pathlib import Path
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_GTransform,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeSolid,
BRepBuilderAPI_Sewing,
BRepBuilderAPI_Transform,
)
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.ShapeFix import ShapeFix_Shape
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCC.Core.TopAbs import (
TopAbs_EDGE,
TopAbs_EXTERNAL,
TopAbs_FACE,
TopAbs_FORWARD,
TopAbs_IN,
TopAbs_INTERNAL,
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SHELL,
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.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .geometry_utils import * # noqa: F403
from .step_io import _prepare_shape_for_step_export
class OperationMixin:
def repair_model(self) -> str:
before_stats = self.stats()
repaired_parts = 0
skipped_parts = 0
for part in self.display_parts():
if part.shape.IsNull():
skipped_parts += 1
continue
repaired = _prepare_shape_for_step_export(part.shape)
if repaired.IsNull():
skipped_parts += 1
continue
part.shape = repaired
repaired_parts += 1
if repaired_parts == 0:
raise RuntimeError("No valid part shape was available for repair.")
self.refresh_topology()
after_stats = self.stats()
return (
"Model repair completed: "
f"parts repaired={repaired_parts}, skipped={skipped_parts}, "
f"solids {before_stats.solids}->{after_stats.solids}, "
f"faces {before_stats.faces}->{after_stats.faces}, "
f"edges {before_stats.edges}->{after_stats.edges}."
)
def repair_part(self, part_id: int) -> str:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"未知零件 ID {part_id}")
if part.shape.IsNull():
raise RuntimeError(f"零件 {part_id} 为空 shape,无法修复。")
before_stats = self.part_topology_stats(part_id)
repaired = _prepare_shape_for_step_export(part.shape)
if repaired.IsNull():
raise RuntimeError(f"零件 {part_id} 修复结果为空 shape。")
part.shape = repaired
self.refresh_topology()
after_stats = self.part_topology_stats(part_id)
return (
f"零件修复完成: 零件 {part_id}, "
f"solids {before_stats.solids}->{after_stats.solids}, "
f"faces {before_stats.faces}->{after_stats.faces}, "
f"edges {before_stats.edges}->{after_stats.edges}."
)
def repair_solid(self, solid_id: int) -> str:
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]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
before_part_stats = self.part_topology_stats(part_id)
repaired = _prepare_shape_for_step_export(solid)
if repaired.IsNull():
raise RuntimeError(f"Solid {solid_id} repair returned a null shape.")
part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1:
part.shape = repaired
else:
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and _same_shape(item, solid):
shapes.append(repaired)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
after_part_stats = self.part_topology_stats(part_id)
return (
f"Solid repair completed: solid {solid_id}, part {part_id}, "
f"part solids {before_part_stats.solids}->{after_part_stats.solids}, "
f"faces {before_part_stats.faces}->{after_part_stats.faces}, "
f"edges {before_part_stats.edges}->{after_part_stats.edges}."
)
def edge_fillet_plan(self, edge_id: int, radius: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
readiness = _edge_fillet_readiness(info, radius)
part_id = int(info["part_id"])
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
readiness = dict(readiness)
if readiness["fillet_status"] != "blocked":
readiness["fillet_status"] = "caution"
readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high")
readiness["fillet_warnings"] = _join_nonempty(
readiness["fillet_warnings"],
f"当前零件包含 {part_stats.solids} 个SolidEdge倒圆会作用在整个零件 shape 上,请导出前检查结果。",
)
readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"])
length = float(info.get("length", 0.0))
radius_to_length_ratio = radius / max(length, 1e-9)
return {
"status": readiness["fillet_status"],
"risk": readiness["fillet_risk"],
"message": readiness["fillet_note"],
"warnings": readiness["fillet_warnings"],
"blockers": readiness["fillet_blockers"],
"edge_id": edge_id,
"part_id": info["part_id"],
"solid_id": info.get("solid_id", -1),
"curve": info.get("curve"),
"edge_length": length,
"target_radius": radius,
"radius_to_length_ratio": radius_to_length_ratio,
"adjacent_face_ids": info.get("adjacent_face_ids", ()),
"adjacent_face_count": info.get("adjacent_face_count", 0),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"direction": info.get("direction"),
}
def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
readiness = _edge_chamfer_readiness(info, distance)
part_id = int(info["part_id"])
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
readiness = dict(readiness)
if readiness["chamfer_status"] != "blocked":
readiness["chamfer_status"] = "caution"
readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high")
readiness["chamfer_warnings"] = _join_nonempty(
readiness["chamfer_warnings"],
f"当前零件包含 {part_stats.solids} 个SolidEdge倒角会作用在整个零件 shape 上,请导出前检查结果。",
)
readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"])
length = float(info.get("length", 0.0))
distance_to_length_ratio = distance / max(length, 1e-9)
return {
"status": readiness["chamfer_status"],
"risk": readiness["chamfer_risk"],
"message": readiness["chamfer_note"],
"warnings": readiness["chamfer_warnings"],
"blockers": readiness["chamfer_blockers"],
"edge_id": edge_id,
"part_id": info["part_id"],
"solid_id": info.get("solid_id", -1),
"curve": info.get("curve"),
"edge_length": length,
"target_distance": distance,
"distance_to_length_ratio": distance_to_length_ratio,
"adjacent_face_ids": info.get("adjacent_face_ids", ()),
"adjacent_face_count": info.get("adjacent_face_count", 0),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"direction": info.get("direction"),
}
def edge_asymmetric_chamfer_plan(
self,
edge_id: int,
distance1: float,
distance2: float,
reference_face_id: int | None = None,
) -> dict[str, object]:
distance1 = float(distance1)
distance2 = float(distance2)
max_distance = max(distance1, distance2)
plan = self.edge_chamfer_plan(edge_id, max_distance)
info = self.edge_info(edge_id)
length = float(info.get("length", 0.0))
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else []
warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else []
risk = str(plan.get("risk", "medium"))
if distance1 <= 0 or distance2 <= 0:
blockers.append("Asymmetric chamfer distances D1 and D2 must both be greater than 0.")
if length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if len(adjacent_face_ids) < 2:
blockers.append("Asymmetric chamfer needs at least two adjacent Faces on the selected Edge.")
resolved_reference_face_id: int | None
if reference_face_id is None:
resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None
else:
try:
resolved_reference_face_id = int(reference_face_id)
except (TypeError, ValueError):
resolved_reference_face_id = None
blockers.append("Reference Face ID must be an integer.")
if resolved_reference_face_id is None:
blockers.append("Could not resolve an adjacent reference Face for asymmetric chamfer.")
elif resolved_reference_face_id not in adjacent_face_ids:
blockers.append(
f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; "
f"available adjacent Faces: {tuple(adjacent_face_ids)}."
)
elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces):
blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.")
ratio1 = distance1 / max(length, 1e-9)
ratio2 = distance2 / max(length, 1e-9)
if length > 1e-9:
if max(ratio1, ratio2) >= 0.45:
blockers.append("D1 or D2 is close to half of the Edge length; asymmetric chamfer is blocked.")
elif max(ratio1, ratio2) > 0.25:
risk = _max_risk(risk, "high")
warnings.append("D1 or D2 is larger than 25% of the Edge length; OCCT chamfer failure is more likely.")
elif max(ratio1, ratio2) > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("D1 or D2 is relatively large compared with the Edge length.")
if abs(distance1 - distance2) <= max(max_distance * 1e-6, 1e-7):
warnings.append("D1 and D2 are almost equal; the result will be close to a symmetric chamfer.")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings) if warnings else "Asymmetric chamfer can be attempted, but it depends on OCCT."
else:
status = "ready"
message = "Asymmetric chamfer can be attempted on this straight Edge."
plan.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
"chamfer_mode": "asymmetric-distances",
"target_distance": max_distance,
"target_distance1": distance1,
"target_distance2": distance2,
"distance1_to_length_ratio": ratio1,
"distance2_to_length_ratio": ratio2,
"reference_face_id": resolved_reference_face_id,
"reference_face_candidates": tuple(adjacent_face_ids),
}
)
return plan
def edge_distance_angle_chamfer_plan(
self,
edge_id: int,
distance: float,
angle_degrees: float,
reference_face_id: int | None = None,
) -> dict[str, object]:
distance = float(distance)
angle_degrees = float(angle_degrees)
angle_radians = math.radians(angle_degrees)
plan = self.edge_chamfer_plan(edge_id, distance)
info = self.edge_info(edge_id)
length = float(info.get("length", 0.0))
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else []
warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else []
risk = str(plan.get("risk", "medium"))
if distance <= 0:
blockers.append("Distance-angle chamfer distance must be greater than 0.")
if angle_degrees <= 0 or angle_degrees >= 89.0:
blockers.append("Distance-angle chamfer angle must be greater than 0 and less than 89 degrees.")
elif angle_degrees < 10.0 or angle_degrees > 80.0:
risk = _max_risk(risk, "high")
warnings.append("Chamfer angle is near an extreme value; OCCT failure is more likely.")
elif angle_degrees < 20.0 or angle_degrees > 70.0:
risk = _max_risk(risk, "medium")
warnings.append("Chamfer angle is relatively steep; please check the result carefully.")
if length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if len(adjacent_face_ids) < 2:
blockers.append("Distance-angle chamfer needs at least two adjacent Faces on the selected Edge.")
resolved_reference_face_id: int | None
if reference_face_id is None:
resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None
else:
try:
resolved_reference_face_id = int(reference_face_id)
except (TypeError, ValueError):
resolved_reference_face_id = None
blockers.append("Reference Face ID must be an integer.")
if resolved_reference_face_id is None:
blockers.append("Could not resolve an adjacent reference Face for distance-angle chamfer.")
elif resolved_reference_face_id not in adjacent_face_ids:
blockers.append(
f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; "
f"available adjacent Faces: {tuple(adjacent_face_ids)}."
)
elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces):
blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.")
distance_ratio = distance / max(length, 1e-9)
if length > 1e-9:
if distance_ratio >= 0.45:
blockers.append("Chamfer distance is close to half of the Edge length; distance-angle chamfer is blocked.")
elif distance_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("Chamfer distance is larger than 25% of the Edge length.")
elif distance_ratio > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("Chamfer distance is relatively large compared with the Edge length.")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings) if warnings else "Distance-angle chamfer can be attempted, but it depends on OCCT."
else:
status = "ready"
message = "Distance-angle chamfer can be attempted on this straight Edge."
plan.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
"chamfer_mode": "distance-angle",
"target_distance": distance,
"target_angle_degrees": angle_degrees,
"target_angle_radians": angle_radians,
"distance_to_length_ratio": distance_ratio,
"reference_face_id": resolved_reference_face_id,
"reference_face_candidates": tuple(adjacent_face_ids),
}
)
return plan
def general_edge_length_plan(
self,
edge_id: int,
target_length: float,
anchor_mode: str = "auto",
) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
current_length = float(info.get("length", 0.0))
target_length = float(target_length)
delta_length = target_length - current_length
curve = str(info.get("curve", ""))
anchor_mode = self._edge_length_anchor_mode(anchor_mode)
base: dict[str, object] = {
"edge_id": edge_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id", -1),
"curve": curve,
"edge_length_anchor_mode": anchor_mode,
"edge_length_anchor_label": self._edge_length_anchor_label(anchor_mode),
"current_length": current_length,
"target_length": target_length,
"delta_length": delta_length,
"length_change_ratio": abs(delta_length) / max(current_length, 1e-9),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"length_center": info.get("length_center"),
}
warnings: list[str] = [
"Edge长度修改基于当前 STEP/B-Rep 结果几何,不是 CAD 建模历史里的参数编辑。"
]
blockers: list[str] = []
risk = "low"
status = "ready"
if current_length <= 1e-9:
blockers.append("当前Edge长度无效。")
if target_length <= 1e-9:
blockers.append("Edge目标长度必须大于 0。")
if abs(delta_length) <= max(current_length * 1e-7, 1e-7):
blockers.append("Edge目标长度与当前Edge长度几乎相同,不需要修改。")
if not blockers:
ratio = abs(delta_length) / max(current_length, 1e-9)
if ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("长度变化超过当前Edge长度的 50%,形状异常或修复失败的概率较高。")
elif ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append("长度变化超过当前Edge长度的 25%,请确认预览范围。")
if not blockers and curve == "line":
local_candidate, local_skip_note = self._local_edge_length_deform_candidate(
info,
target_length,
anchor_mode=anchor_mode,
)
if local_candidate is not None:
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
status = "caution" if risk != "low" else status
warnings.append(
"将优先只移动当前 Edge 的端点并重建相邻平面;非共面的四边面会拆成三角面。"
)
base.update(local_candidate)
elif local_skip_note:
warnings.append(local_skip_note)
if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center":
candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode)
if candidate is not None:
push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"]))
if push_plan["status"] != "blocked":
risk = _max_risk(risk, str(push_plan["risk"]))
push_warnings = str(push_plan.get("warnings", ""))
if push_warnings:
warnings.append(push_warnings)
base.update(candidate)
base.update(
{
"resize_strategy": "move-edge-end-plane-by-push-pull",
"push_pull_status": push_plan.get("status"),
"push_pull_risk": push_plan.get("risk"),
"push_pull_message": push_plan.get("message"),
"push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", ()),
"push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1),
"push_pull_scope_note": push_plan.get("push_pull_scope_note", ""),
}
)
else:
warnings.append(f"端面推拉路径不可用,将尝试通用仿射缩放:{push_plan['message']}")
elif anchor_mode in {"keep-start", "keep-end"}:
warnings.append(
f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面推拉路径,将尝试按该基准缩放所属对象。"
)
elif not blockers and curve == "line" and anchor_mode == "center":
warnings.append("Edge长度基准为固定中心;将使用轴向仿射缩放,让Edge中心尽量保持不动。")
if not blockers and "resize_strategy" not in base and curve == "circle":
cylinder_candidate, cylinder_notes = self._circular_edge_length_cylinder_candidate(info, target_length)
if cylinder_candidate is not None:
risk = _max_risk(risk, str(cylinder_candidate["cylinder_resize_risk"]))
status = "caution" if risk != "low" else status
warnings.append("识别到相邻圆柱面;将优先把Edge目标长度换算成圆柱直径做局部编辑。")
cylinder_warnings = str(cylinder_candidate.get("cylinder_resize_warnings", ""))
if cylinder_warnings:
warnings.append(cylinder_warnings)
base.update(cylinder_candidate)
elif cylinder_notes:
warnings.extend(cylinder_notes[:3])
if not blockers and "resize_strategy" not in base and curve != "line":
planar_candidate, planar_note = self._planar_edge_length_scale_candidate(info, target_length)
if planar_candidate is not None:
risk = _max_risk(risk, str(planar_candidate.get("affine_transform_risk", "medium")))
status = "caution"
warnings.append(str(planar_candidate.get("affine_transform_warning", "")))
note = str(planar_candidate.get("affine_transform_note", ""))
if note:
warnings.append(note)
base.update(planar_candidate)
elif planar_note:
warnings.append(planar_note)
if not blockers and "resize_strategy" not in base:
axis = self._edge_length_affine_axis(info, anchor_mode=anchor_mode)
if axis is None:
blockers.append("无法为当前 Edge 推断可靠的缩放方向。")
else:
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
scale = target_length / max(current_length, 1e-9)
transform_kind = "axis-affine" if curve == "line" else "uniform"
transform_label = "沿Edge方向仿射缩放" if transform_kind == "axis-affine" else "以Edge中心整体缩放"
risk = _max_risk(risk, "medium")
if curve != "line" or abs(scale - 1.0) > 0.25:
risk = _max_risk(risk, "high")
status = "caution"
if transform_kind == "axis-affine":
warnings.append(
"未找到可推拉端面;将沿该Edge的几何方向对所属 "
f"{target_kind} 做仿射缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。"
)
else:
warnings.append(
"当前 Edge 不是直线;将以 Edge 中心为基准对所属 "
f"{target_kind} 做均匀缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。"
)
if curve != "line":
warnings.append("非直线Edge的目标长度通过整体比例缩放实现,执行后请复查周边尺寸。")
base.update(
{
"resize_strategy": "scale-owning-shape-from-edge",
"affine_scale": scale,
"affine_transform_kind": transform_kind,
"affine_transform_label": transform_label,
"affine_transform_note": "",
"affine_axis_point": axis["axis_point"],
"affine_axis_direction": axis["axis_direction"],
"affine_axis_source": axis["axis_source"],
"affine_anchor_source": axis["anchor_source"],
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
)
if not blockers and base.get("resize_strategy") == "scale-owning-shape-from-edge":
refine_note = self._refine_affine_edge_length_scale(base)
if refine_note:
warnings.append(refine_note)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
strategy = str(base.get("resize_strategy", ""))
if strategy == "local-edge-only-deform":
message = "可以通过局部边形变只调整这条直线Edge,并重建周边平面。"
elif strategy == "move-edge-end-plane-by-push-pull":
message = "可以通过端面推拉调整这条直线Edge长度。"
elif strategy == "resize-adjacent-cylinder-from-circular-edge-length":
message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧Edge长度。"
elif strategy == "scale-owning-shape-from-edge":
message = "可以通过几何缩放 fallback 尝试调整该Edge长度。"
else:
message = "可以尝试直接修改该Edge长度。"
base.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
}
)
return base
def _local_edge_length_deform_candidate(
self,
edge_info: dict[str, object],
target_length: float,
anchor_mode: str = "auto",
) -> tuple[dict[str, object] | None, str]:
if str(edge_info.get("curve", "")) != "line":
return None, ""
start = _tuple_or_none(edge_info.get("start_point"))
end = _tuple_or_none(edge_info.get("end_point"))
if start is None or end is None:
return None, "局部边形变不可用:当前 Edge 缺少稳定起点或终点。"
current_length = float(edge_info.get("length", 0.0))
if current_length <= 1e-9:
return None, "局部边形变不可用:当前Edge长度无效。"
axis = _tuple_normalized(_tuple_sub(end, start))
if axis is None:
return None, "局部边形变不可用:当前 Edge 方向无效。"
solid_id = int(edge_info.get("solid_id", -1))
if solid_id < 0 or solid_id >= len(self.solids):
return None, "局部边形变不可用:当前 Edge 没有关联到稳定 Solid。"
part_id = int(edge_info.get("part_id", -1))
part = self.part_by_id(part_id)
if part is None:
return None, "局部边形变不可用:找不到所属零件。"
solid = self.solids[solid_id][1]
solid_faces = _explore(solid, TopAbs_FACE)
if not solid_faces:
return None, "局部边形变不可用:所属Solid没有可重建Face。"
if len(solid_faces) > 128:
return None, "局部边形变暂只对较简单的平面多面体开放,复杂模型将使用后备策略。"
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
return None, "局部边形变暂只支持全平面多面体;含曲面的模型将使用后备策略。"
if len(_explore(face, TopAbs_WIRE)) != 1:
return None, "局部边形变暂不处理带内孔的Face;将使用后备策略。"
if len(self._local_deform_face_vertex_points(face, max(_shape_diagonal(solid) * 1e-7, 1e-6))) < 3:
return None, "局部边形变不可用:部分Face顶点环无法稳定读取。"
anchor_mode = self._edge_length_anchor_mode(anchor_mode)
target_length = float(target_length)
delta_length = target_length - current_length
if anchor_mode == "keep-end":
start_move = _tuple_scale(axis, -delta_length)
end_move = (0.0, 0.0, 0.0)
moved_label = "移动起点,固定终点"
elif anchor_mode == "center":
start_move = _tuple_scale(axis, -delta_length * 0.5)
end_move = _tuple_scale(axis, delta_length * 0.5)
moved_label = "两端各移动一半,保持中心"
else:
start_move = (0.0, 0.0, 0.0)
end_move = _tuple_scale(axis, delta_length)
moved_label = "固定起点,移动终点"
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if part_solid_count > 1 else "part"
ratio = abs(delta_length) / max(current_length, 1e-9)
local_risk = "high" if ratio > 0.5 else "medium"
return (
{
"resize_strategy": "local-edge-only-deform",
"local_edge_deform_target_kind": target_kind,
"local_edge_deform_face_count": len(solid_faces),
"local_edge_deform_anchor": moved_label,
"local_edge_deform_start_move": start_move,
"local_edge_deform_end_move": end_move,
"local_edge_deform_moved_endpoint_count": 2 if anchor_mode == "center" else 1,
"local_edge_deform_risk": local_risk,
"local_edge_deform_note": (
"只移动当前 Edge 的端点并重建所属平面多面体;非共面 Face 会被拆成三角面。"
),
"part_solid_count": part_solid_count,
},
"",
)
def edge_endpoint_move_plan(
self,
edge_id: int,
endpoint_role: str,
target_point: tuple[float, float, float],
) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
role = str(endpoint_role or "").strip().lower()
role_aliases = {
"start": "start",
"edge-start": "start",
"begin": "start",
"起点": "start",
"end": "end",
"edge-end": "end",
"finish": "end",
"终点": "end",
}
role = role_aliases.get(role, role)
if role not in {"start", "end"}:
raise ValueError("endpoint_role must be 'start' or 'end'.")
info = self.edge_info(edge_id)
current_length = float(info.get("length", 0.0))
curve = str(info.get("curve", ""))
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
target = _tuple_or_none(target_point)
base: dict[str, object] = {
"edge_id": edge_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id", -1),
"curve": curve,
"current_length": current_length,
"target_length": current_length,
"delta_length": 0.0,
"length_change_ratio": 0.0,
"edge_endpoint_role": role,
"edge_endpoint_label": "start point" if role == "start" else "end point",
"start_point": start,
"end_point": end,
"target_endpoint_point": target,
"length_center": info.get("length_center"),
"resize_strategy": "local-edge-endpoint-deform",
}
warnings = [
"Edge endpoint coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history."
]
blockers: list[str] = []
risk = "low"
status = "ready"
if curve != "line":
blockers.append("Only straight line Edge endpoints can be moved directly.")
if current_length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if start is None or end is None:
blockers.append("Current Edge does not have stable start/end coordinates.")
if target is None:
blockers.append("Target endpoint coordinate must be a valid X/Y/Z tuple.")
if not blockers and start is not None and end is not None and target is not None:
current_endpoint = start if role == "start" else end
fixed_endpoint = end if role == "start" else start
move = _tuple_sub(target, current_endpoint)
move_distance = _vector_length(move)
target_length = _vector_length(_tuple_sub(target, fixed_endpoint))
delta_length = target_length - current_length
length_change_ratio = abs(delta_length) / max(current_length, 1e-9)
move_ratio = move_distance / max(current_length, 1e-9)
base.update(
{
"current_endpoint_point": current_endpoint,
"fixed_endpoint_point": fixed_endpoint,
"target_length": target_length,
"delta_length": delta_length,
"length_change_ratio": length_change_ratio,
"moved_endpoint_delta": move,
"moved_endpoint_distance": move_distance,
"moved_endpoint_ratio": move_ratio,
"edge_length_anchor_mode": "keep-end" if role == "start" else "keep-start",
"edge_length_anchor_label": "fixed end point" if role == "start" else "fixed start point",
}
)
if move_distance <= max(current_length * 1e-7, 1e-7):
blockers.append("Target endpoint coordinate is almost identical to the current coordinate.")
if target_length <= 1e-9:
blockers.append("Moving this endpoint would collapse the Edge length to zero.")
if move_ratio > 0.5 or length_change_ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("Endpoint movement or resulting length change is larger than 50% of the current Edge length.")
elif move_ratio > 0.25 or length_change_ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append("Endpoint movement or resulting length change is larger than 25% of the current Edge length.")
if not blockers and start is not None and end is not None and target is not None:
anchor_mode = "keep-end" if role == "start" else "keep-start"
local_candidate, local_skip_note = self._local_edge_length_deform_candidate(
info,
float(base["target_length"]),
anchor_mode=anchor_mode,
)
if local_candidate is None:
blockers.append(local_skip_note or "Local endpoint deformation is not available for this Edge.")
else:
start_move = _tuple_sub(target, start) if role == "start" else (0.0, 0.0, 0.0)
end_move = _tuple_sub(target, end) if role == "end" else (0.0, 0.0, 0.0)
expected_start = _tuple_add(start, start_move)
expected_end = _tuple_add(end, end_move)
base.update(local_candidate)
base.update(
{
"resize_strategy": "local-edge-endpoint-deform",
"local_edge_deform_anchor": "move start point, keep end point"
if role == "start"
else "move end point, keep start point",
"local_edge_deform_start_move": start_move,
"local_edge_deform_end_move": end_move,
"local_edge_deform_moved_endpoint_count": 1,
"local_edge_deform_note": (
"Move one endpoint of the selected straight Edge and rebuild the surrounding planar solid."
),
"expected_start_point": expected_start,
"expected_end_point": expected_end,
}
)
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
warnings.append(
"The selected Edge endpoint will be moved and the surrounding planar faces will be rebuilt; non-planar faces may be split into triangles."
)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "The straight Edge endpoint can be moved by local planar-solid deformation."
base.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
}
)
return base
def edge_center_move_plan(
self,
edge_id: int,
target_center: tuple[float, float, float],
) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
current_length = float(info.get("length", 0.0))
curve = str(info.get("curve", ""))
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
current_center = _tuple_or_none(info.get("length_center"))
target = _tuple_or_none(target_center)
base: dict[str, object] = {
"edge_id": edge_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id", -1),
"curve": curve,
"current_length": current_length,
"target_length": current_length,
"delta_length": 0.0,
"length_change_ratio": 0.0,
"start_point": start,
"end_point": end,
"length_center": current_center,
"target_edge_center": target,
"resize_strategy": "local-edge-center-deform",
}
warnings = [
"Edge center coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history."
]
blockers: list[str] = []
risk = "low"
status = "ready"
if curve != "line":
blockers.append("Only straight line Edge centers can be moved directly.")
if current_length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if start is None or end is None or current_center is None:
blockers.append("Current Edge does not have stable start/end/center coordinates.")
if target is None:
blockers.append("Target Edge center coordinate must be a valid X/Y/Z tuple.")
if not blockers and start is not None and end is not None and current_center is not None and target is not None:
move = _tuple_sub(target, current_center)
move_distance = _vector_length(move)
move_ratio = move_distance / max(current_length, 1e-9)
base.update(
{
"current_edge_center": current_center,
"target_edge_center": target,
"moved_edge_center_delta": move,
"moved_edge_center_distance": move_distance,
"moved_edge_center_ratio": move_ratio,
"edge_length_anchor_mode": "center",
"edge_length_anchor_label": "move whole edge center",
}
)
if move_distance <= max(current_length * 1e-7, 1e-7):
blockers.append("Target Edge center coordinate is almost identical to the current coordinate.")
if move_ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("Edge center movement is larger than 50% of the current Edge length.")
elif move_ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append("Edge center movement is larger than 25% of the current Edge length.")
if not blockers and start is not None and end is not None and current_center is not None and target is not None:
local_candidate, local_skip_note = self._local_edge_length_deform_candidate(
info,
current_length,
anchor_mode="center",
)
if local_candidate is None:
blockers.append(local_skip_note or "Local Edge center deformation is not available for this Edge.")
else:
move = _tuple_sub(target, current_center)
expected_start = _tuple_add(start, move)
expected_end = _tuple_add(end, move)
base.update(local_candidate)
base.update(
{
"resize_strategy": "local-edge-center-deform",
"local_edge_deform_anchor": "move both endpoints equally",
"local_edge_deform_start_move": move,
"local_edge_deform_end_move": move,
"local_edge_deform_moved_endpoint_count": 2,
"local_edge_deform_note": (
"Move both endpoints of the selected straight Edge and rebuild the surrounding planar solid."
),
"expected_start_point": expected_start,
"expected_end_point": expected_end,
}
)
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
warnings.append(
"Both endpoints of the selected Edge will be moved and the surrounding planar faces will be rebuilt; non-planar faces may be split into triangles."
)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "The straight Edge center can be moved by local planar-solid deformation."
base.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
}
)
return base
def _edge_length_anchor_mode(self, anchor_mode: str | None) -> str:
normalized = str(anchor_mode or "auto").strip().lower()
aliases = {
"自动": "auto",
"auto": "auto",
"center": "center",
"centre": "center",
"固定中心": "center",
"keep-center": "center",
"start": "keep-start",
"起点": "keep-start",
"固定起点": "keep-start",
"keep-start": "keep-start",
"end": "keep-end",
"终点": "keep-end",
"固定终点": "keep-end",
"keep-end": "keep-end",
}
return aliases.get(normalized, "auto")
def _edge_length_anchor_label(self, anchor_mode: str) -> str:
return {
"auto": "自动选择局部端面",
"center": "固定中心",
"keep-start": "固定起点",
"keep-end": "固定终点",
}.get(anchor_mode, "自动选择局部端面")
def _circular_edge_length_cylinder_candidate(
self,
edge_info: dict[str, object],
target_length: float,
) -> tuple[dict[str, object] | None, list[str]]:
current_length = float(edge_info.get("length", 0.0))
current_radius = float(edge_info.get("radius", 0.0))
if current_length <= 1e-9 or current_radius <= 1e-9:
return None, []
length_scale = float(target_length) / current_length
target_radius = current_radius * length_scale
target_diameter = target_radius * 2.0
if target_diameter <= 1e-9:
return None, []
notes: list[str] = []
candidates: list[tuple[tuple[int, int, int, int], dict[str, object]]] = []
risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
status_rank = {"ready": 0, "caution": 1, "blocked": 2}
adjacent_face_ids = _int_values(edge_info.get("adjacent_face_ids"))
if not adjacent_face_ids:
return None, []
for face_id in adjacent_face_ids:
if face_id < 0 or face_id >= len(self.faces):
continue
face_info = self.face_info(face_id)
if face_info.get("surface") != "cylinder" or "diameter" not in face_info:
continue
face_radius = float(face_info.get("radius", 0.0))
if face_radius <= 1e-9:
continue
radius_tolerance = max(current_radius * 0.06, face_radius * 0.06, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4)
if abs(face_radius - current_radius) > radius_tolerance:
continue
feature_guess = str(face_info.get("feature_guess", "cylindrical face"))
if feature_guess == "round/fillet candidate":
notes.append(f"相邻圆柱Face {face_id} 更像已有圆角,未自动按孔/凸台直径改边长。")
continue
if feature_guess == "hole/groove candidate":
mode_order = ("hole",)
elif feature_guess == "boss/outer-round candidate":
mode_order = ("boss",)
else:
notes.append(f"相邻圆柱Face {face_id} 尚未明确识别为孔/槽或凸台,未自动按圆柱直径改边长。")
continue
for mode_index, mode in enumerate(mode_order):
mode_label = "圆柱凸台直径" if mode == "boss" else "圆柱孔/槽直径"
try:
cylinder_plan = (
self.cylindrical_boss_resize_plan(face_id, target_diameter)
if mode == "boss"
else self.cylindrical_resize_plan(face_id, target_diameter)
)
except Exception as exc:
notes.append(f"相邻圆柱Face {face_id}{mode_label}计划生成失败:{exc}")
continue
plan_status = str(cylinder_plan.get("status", "blocked"))
plan_risk = str(cylinder_plan.get("risk", "blocked"))
if plan_status == "blocked":
notes.append(f"相邻圆柱Face {face_id}{mode_label}不可用:{cylinder_plan.get('message', '')}")
continue
candidate = {
"resize_strategy": "resize-adjacent-cylinder-from-circular-edge-length",
"circular_edge_current_radius": current_radius,
"circular_edge_target_radius": target_radius,
"circular_edge_length_scale": length_scale,
"circular_edge_cylinder_face_id": face_id,
"circular_edge_cylinder_mode": mode,
"circular_edge_cylinder_mode_label": mode_label,
"cylinder_resize_face_id": face_id,
"cylinder_resize_operation": "resize_cylindrical_boss" if mode == "boss" else "resize_cylindrical_hole",
"cylinder_resize_current_diameter": cylinder_plan.get("current_diameter"),
"cylinder_resize_target_diameter": target_diameter,
"cylinder_resize_delta_diameter": cylinder_plan.get("delta_diameter"),
"cylinder_resize_delta_ratio": cylinder_plan.get("diameter_delta_ratio"),
"cylinder_resize_status": plan_status,
"cylinder_resize_risk": plan_risk,
"cylinder_resize_message": cylinder_plan.get("message"),
"cylinder_resize_warnings": cylinder_plan.get("warnings", ""),
"cylinder_resize_blockers": cylinder_plan.get("blockers", ""),
"cylinder_resize_feature_guess": cylinder_plan.get("feature_guess", feature_guess),
"cylinder_resize_confidence": cylinder_plan.get("confidence", face_info.get("confidence", "")),
"cylinder_resize_same_domain_face_ids": cylinder_plan.get("same_domain_face_ids", ()),
"cylinder_resize_same_domain_face_count": cylinder_plan.get("same_domain_face_count", ""),
}
score = (
status_rank.get(plan_status, 9),
risk_rank.get(plan_risk, 9),
mode_index,
face_id,
)
candidates.append((score, candidate))
if not candidates:
if notes:
notes.insert(0, "圆边没有找到可直接复用的相邻圆柱直径编辑路径,将回退到几何缩放。")
return None, notes
candidates.sort(key=lambda item: item[0])
return candidates[0][1], notes
def _edge_length_affine_axis(
self,
edge_info: dict[str, object],
anchor_mode: str = "auto",
) -> dict[str, object] | None:
start = _tuple_or_none(edge_info.get("start_point"))
end = _tuple_or_none(edge_info.get("end_point"))
center = _tuple_or_none(edge_info.get("length_center"))
anchor_mode = self._edge_length_anchor_mode(anchor_mode)
if start is not None and end is not None:
direction = _tuple_normalized(_tuple_sub(end, start))
if direction is not None:
midpoint = (
(start[0] + end[0]) * 0.5,
(start[1] + end[1]) * 0.5,
(start[2] + end[2]) * 0.5,
)
if anchor_mode == "keep-start":
axis_point = start
anchor_source = "edge start point"
elif anchor_mode == "keep-end":
axis_point = end
anchor_source = "edge end point"
else:
axis_point = center or midpoint
anchor_source = "edge center"
return {
"axis_point": axis_point,
"axis_direction": direction,
"axis_source": "edge start/end chord",
"anchor_source": anchor_source,
}
bbox_min = _tuple_or_none(edge_info.get("bbox_min"))
bbox_max = _tuple_or_none(edge_info.get("bbox_max"))
if bbox_min is not None and bbox_max is not None:
sizes = [abs(bbox_max[index] - bbox_min[index]) for index in range(3)]
axis_index = max(range(3), key=lambda index: sizes[index])
if sizes[axis_index] > 1e-9:
direction = [0.0, 0.0, 0.0]
direction[axis_index] = 1.0
return {
"axis_point": center or (
(bbox_min[0] + bbox_max[0]) * 0.5,
(bbox_min[1] + bbox_max[1]) * 0.5,
(bbox_min[2] + bbox_max[2]) * 0.5,
),
"axis_direction": tuple(direction),
"axis_source": "edge bounding-box longest axis",
"anchor_source": "edge bounding-box center",
}
return None
def _planar_edge_length_scale_candidate(
self,
edge_info: dict[str, object],
target_length: float,
) -> tuple[dict[str, object] | None, str]:
curve = str(edge_info.get("curve", ""))
if curve == "line":
return None, ""
current_length = float(edge_info.get("length", 0.0))
if current_length <= 1e-9:
return None, "平面曲线径向缩放不可用:当前Edge长度无效。"
center = _tuple_or_none(edge_info.get("center"))
axis = _tuple_normalized(_tuple_or_none(edge_info.get("axis")))
axis_source = ""
anchor_source = ""
label = "围绕平面曲线法向径向缩放"
sample_count: int | str = ""
plane_deviation: float | str = ""
if center is not None and axis is not None and curve in {"circle", "ellipse"}:
axis_source = f"{curve} center/axis"
anchor_source = f"{curve} center"
label = "围绕圆边轴线径向缩放" if curve == "circle" else "围绕椭圆边法向径向缩放"
else:
frame, note = self._sampled_planar_edge_frame(edge_info)
if frame is None:
return None, note
center = frame["center"]
axis = frame["axis"]
axis_source = "sampled edge best-fit plane"
anchor_source = "sampled edge center"
sample_count = int(frame["sample_count"])
plane_deviation = float(frame["plane_deviation"])
if center is None or axis is None:
return None, "平面曲线径向缩放不可用:无法确定曲线中心或平面法向。"
part_id = int(edge_info.get("part_id", -1))
solid_id = int(edge_info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
return None, "平面曲线径向缩放不可用:找不到所属零件。"
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
scale = float(target_length) / max(current_length, 1e-9)
transform_risk = "high" if abs(scale - 1.0) > 0.25 or axis_source.startswith("sampled") else "medium"
if curve == "circle":
warning = (
"圆边没有可复用的相邻圆柱直径编辑路径;将围绕圆边轴线对所属 "
f"{target_kind} 做径向缩放,尽量保留轴向尺寸,但仍会影响同一 {target_kind} 上的其他径向尺寸。"
)
elif curve == "ellipse":
warning = (
"椭圆边将围绕自身平面法向对所属 "
f"{target_kind} 做径向缩放,尽量保留法向尺寸,但会影响同一 {target_kind} 上的其他平面内尺寸。"
)
else:
warning = (
"当前非直线Edge可近似为平面曲线;将按采样平面法向对所属 "
f"{target_kind} 做径向缩放。该路径依赖采样估算,执行后请复查周边尺寸。"
)
return (
{
"resize_strategy": "scale-owning-shape-from-edge",
"affine_scale": scale,
"affine_transform_kind": "radial-affine",
"affine_transform_label": label,
"affine_transform_note": "可能把解析圆/圆锥/圆柱/椭圆边面转换为 B-spline 几何。",
"affine_transform_warning": warning,
"affine_transform_risk": transform_risk,
"affine_axis_point": center,
"affine_axis_direction": axis,
"affine_axis_source": axis_source,
"affine_anchor_source": anchor_source,
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
"planar_edge_scale_sample_count": sample_count,
"planar_edge_scale_plane_deviation": plane_deviation,
},
"",
)
def _sampled_planar_edge_frame(self, edge_info: dict[str, object]) -> tuple[dict[str, object] | None, str]:
edge_id = int(edge_info.get("edge_id", -1))
if edge_id < 0 or edge_id >= len(self.edges):
return None, "平面曲线径向缩放不可用:Edge ID无效。"
curve = BRepAdaptor_Curve(self.edges[edge_id])
first = float(curve.FirstParameter())
last = float(curve.LastParameter())
if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12:
return None, "平面曲线径向缩放不可用:Edge参数范围无效。"
sample_count = 17
points = [
_point_tuple(curve.Value(first + (last - first) * index / (sample_count - 1)))
for index in range(sample_count)
]
distinct: list[tuple[float, float, float]] = []
tolerance = max(float(edge_info.get("length", 0.0)) * 1e-7, _shape_diagonal(self.edges[edge_id]) * 1e-7, 1e-7)
for point in points:
if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in distinct):
distinct.append(point)
if len(distinct) < 3:
return None, "平面曲线径向缩放不可用:采样点不足以确定平面。"
center = _tuple_or_none(edge_info.get("length_center"))
if center is None:
center = (
sum(point[0] for point in distinct) / len(distinct),
sum(point[1] for point in distinct) / len(distinct),
sum(point[2] for point in distinct) / len(distinct),
)
normal = self._sampled_edge_plane_normal(distinct, center)
if normal is None:
return None, "平面曲线径向缩放不可用:采样点近似共线,无法确定平面法向。"
plane_deviation = max(abs(_tuple_dot(_tuple_sub(point, center), normal)) for point in distinct)
max_radius = max(_vector_length(_tuple_sub(point, center)) for point in distinct)
allowed_deviation = max(max_radius * 1e-4, float(edge_info.get("length", 0.0)) * 1e-5, 1e-6)
if plane_deviation > allowed_deviation:
return None, (
"平面曲线径向缩放不可用:Edge采样点不在稳定平面内,将使用更保守的缩放 fallback。"
)
return (
{
"center": center,
"axis": normal,
"sample_count": len(distinct),
"plane_deviation": plane_deviation,
},
"",
)
def _sampled_edge_plane_normal(
self,
points: list[tuple[float, float, float]],
center: tuple[float, float, float],
) -> tuple[float, float, float] | None:
normal = (0.0, 0.0, 0.0)
for index, point in enumerate(points):
next_point = points[(index + 1) % len(points)]
cross = _tuple_cross(_tuple_sub(point, center), _tuple_sub(next_point, center))
normal = (
normal[0] + cross[0],
normal[1] + cross[1],
normal[2] + cross[2],
)
normalized = _tuple_normalized(normal)
if normalized is not None:
return normalized
best: tuple[float, float, float] | None = None
best_length = 0.0
count = len(points)
for first_index in range(count - 2):
for second_index in range(first_index + 1, count - 1):
for third_index in range(second_index + 1, count):
candidate = _tuple_cross(
_tuple_sub(points[second_index], points[first_index]),
_tuple_sub(points[third_index], points[first_index]),
)
candidate_length = _vector_length(candidate)
if candidate_length > best_length:
best = candidate
best_length = candidate_length
return _tuple_normalized(best)
def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]:
return self.general_edge_length_plan(edge_id, target_length, anchor_mode="auto")
def _straight_edge_length_end_face_candidate(
self,
edge_info: dict[str, object],
delta_length: float,
anchor_mode: str = "auto",
) -> dict[str, object] | None:
start = _tuple_or_none(edge_info.get("start_point"))
end = _tuple_or_none(edge_info.get("end_point"))
if start is None or end is None:
return None
axis = _tuple_normalized(_tuple_sub(end, start))
if axis is None:
return None
solid_id = int(edge_info.get("solid_id", -1))
if solid_id < 0 or solid_id >= len(self.solids):
return None
solid = self.solids[solid_id][1]
tolerance = max(_shape_diagonal(solid) * 1e-5, abs(delta_length) * 1e-5, 1e-4)
candidates: list[tuple[float, dict[str, object]]] = []
anchor_mode = self._edge_length_anchor_mode(anchor_mode)
if anchor_mode == "keep-start":
endpoint_specs = [("end", "终点端", end, _tuple_scale(axis, delta_length))]
elif anchor_mode == "keep-end":
endpoint_specs = [("start", "起点端", start, _tuple_scale(axis, -delta_length))]
elif anchor_mode == "center":
endpoint_specs = []
else:
endpoint_specs = [
("start", "起点端", start, _tuple_scale(axis, -delta_length)),
("end", "终点端", end, _tuple_scale(axis, delta_length)),
]
for endpoint_role, endpoint_label, endpoint, desired_vector in endpoint_specs:
desired_unit = _tuple_normalized(desired_vector)
if desired_unit is None:
continue
for face_id, face in enumerate(self.faces):
if self.face_solid_ids[face_id] != solid_id:
continue
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
plane = surf.Plane()
plane_origin = _point_tuple(plane.Location())
plane_normal = _tuple_normalized(_dir_tuple(plane.Axis().Direction()))
if plane_normal is None:
continue
plane_distance = abs(_tuple_dot(_tuple_sub(endpoint, plane_origin), plane_normal))
if plane_distance > tolerance:
continue
axis_alignment = abs(_tuple_dot(plane_normal, axis))
if axis_alignment < 0.82:
continue
face_info = self.face_info(face_id)
outward = _tuple_normalized(_tuple_or_none(face_info.get("push_pull_outward_direction")))
if outward is None:
continue
movement_alignment = abs(_tuple_dot(outward, desired_unit))
if movement_alignment < 0.82:
continue
push_pull_distance = _tuple_dot(desired_vector, outward)
if abs(push_pull_distance) <= 1e-9:
continue
confidence_bonus = 0.0 if face_info.get("push_pull_confidence") == "high" else 0.2
score = plane_distance / max(tolerance, 1e-9) + (1.0 - movement_alignment) + confidence_bonus
candidates.append(
(
score,
{
"end_face_id": face_id,
"end_face_endpoint_role": endpoint_role,
"end_face_label": endpoint_label,
"end_face_plane_distance": plane_distance,
"end_face_axis_alignment": axis_alignment,
"end_face_movement_alignment": movement_alignment,
"end_face_outward_direction": outward,
"end_face_push_pull_confidence": face_info.get("push_pull_confidence", ""),
"push_pull_distance": push_pull_distance,
"desired_movement_vector": desired_vector,
},
)
)
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
return candidates[0][1]
def existing_fillet_resize_plan(self, face_id: int, target_radius: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "radius" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 Face 不是圆柱圆角面,不能修改已有圆角半径。",
"blockers": "当前选中的 Face 不是圆柱圆角面。",
"warnings": "",
"face_id": face_id,
}
feature = self.feature_info(face_id)
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", ()))
warnings: list[str] = []
blockers: list[str] = []
risk = "medium"
status = "caution"
if feature_guess != "round/fillet candidate":
blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。")
if target_radius <= 0:
blockers.append("目标圆角半径必须大于 0。")
if current_radius <= 0:
blockers.append("当前圆角半径估算无效。")
if current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-5, 1e-6):
blockers.append("目标圆角半径与当前估算半径几乎相同,不需要修改。")
if len(support_face_ids) < 2:
blockers.append("当前版本只对识别到至少两个支撑Face的已有圆角候选开放。")
part_id = int(info.get("part_id", -1))
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
blockers.append(
f"当前零件包含 {part_stats.solids} 个Solid;已有圆角半径修改当前版本只对单Solid零件开放。"
)
height_estimate = float(info.get("height_estimate", 0.0))
angular_span = float(info.get("angular_span", 0.0))
radius_delta = target_radius - current_radius
radius_delta_ratio = abs(radius_delta) / max(current_radius, 1e-9)
if not blockers:
if radius_delta_ratio > 1.0:
risk = "high"
warnings.append("目标半径变化超过当前半径的 100%defeature/refillet 很可能失败。")
elif radius_delta_ratio > 0.35:
risk = _max_risk(risk, "high")
warnings.append("目标半径变化超过当前半径的 35%,请谨慎检查结果。")
if height_estimate > 0 and target_radius > height_estimate * 0.5:
risk = _max_risk(risk, "high")
warnings.append("目标半径超过圆角长度估算的一半,几何比例异常。")
if angular_span > math.pi * 1.25:
risk = _max_risk(risk, "high")
warnings.append("当前圆角圆弧跨度较大,可能不是普通边圆角。")
if str(info.get("confidence", "low")) != "high":
warnings.append("已有圆角识别置信度不是 high,执行结果需要重点检查。")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers + warnings)
else:
message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。"
if warnings:
message += " " + " ".join(warnings)
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"feature_type": feature.get("feature_type"),
"feature_guess": feature_guess,
"confidence": info.get("confidence"),
"current_radius": current_radius,
"target_radius": target_radius,
"delta_radius": radius_delta,
"radius_delta_ratio": radius_delta_ratio,
"height_estimate": info.get("height_estimate"),
"angular_span": info.get("angular_span"),
"axis_point": info.get("axis_point"),
"axis": info.get("axis"),
"feature_existing_fillet_support_face_ids": support_face_ids,
"feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"),
"resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge",
"resize_note": (
"当前版本的已有圆角半径修改只支持由圆柱面表示的直线边圆角。"
"执行后 Face/Edge ID 会重建,请重新选择对象确认结果。"
),
}
def push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 Face 不是平面,不能执行推拉平面。",
"blockers": "当前选中的 Face 不是平面。",
"warnings": "",
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"distance": distance,
}
info = self.face_info(face_id)
scope_face_ids = self._connected_coplanar_planar_face_ids(face_id)
if len(scope_face_ids) > 1:
scope_note = f"将一起推拉 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。"
else:
scope_note = "只推拉当前Face。"
direction_confidence = str(info.get("push_pull_confidence", "low"))
bbox_diagonal = float(info.get("bbox_diagonal", 0.0))
distance_abs = abs(distance)
warnings: list[str] = []
blockers: list[str] = []
risk = "low"
status = "ready"
if distance_abs <= 1e-9:
status = "blocked"
risk = "blocked"
blockers.append("推拉距离为 0,不需要修改。")
if direction_confidence != "high":
risk = _max_risk(risk, "medium")
warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。")
if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2:
risk = _max_risk(risk, "high")
warnings.append("推拉距离超过当前Face包围盒对角线的 20%,容易导致布尔失败或大范围变形。")
elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08:
risk = _max_risk(risk, "medium")
warnings.append("推拉距离相对当前Face尺寸偏大,请确认预览范围。")
if risk in {"medium", "high"} and status != "blocked":
status = "caution"
if blockers:
message = " ".join(blockers + warnings)
elif warnings:
message = " ".join(warnings)
else:
message = "可以尝试推拉该平面。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info["part_id"],
"solid_id": info["solid_id"],
"distance": distance,
"surface": info.get("surface"),
"area": info.get("area"),
"bbox_diagonal": info.get("bbox_diagonal"),
"outward_direction": info.get("push_pull_outward_direction"),
"direction_confidence": direction_confidence,
"direction_note": info.get("push_pull_note"),
"push_pull_scope_face_ids": tuple(scope_face_ids),
"push_pull_scope_face_count": len(scope_face_ids),
"push_pull_scope_note": scope_note,
}
def shell_thickness_plan(self, face_id: int, target_thickness: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.feature_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"薄壁/壳体厚度调整基于相对平面几何估算,会移动当前选中平面区域,保留相对平面不动。"
]
risk = "low"
status = "ready"
target_thickness = float(target_thickness)
current_thickness = float(info.get("shell_thickness_estimate", 0.0))
signed_thickness = float(info.get("shell_signed_thickness", 0.0))
delta_thickness = target_thickness - current_thickness
confidence = str(info.get("shell_confidence", "low"))
overlap_ratio = float(info.get("shell_overlap_ratio_estimate", 0.0))
source_face_ids = tuple(_int_values(info.get("shell_source_face_ids")) or [face_id])
opposite_face_id = int(info.get("shell_opposite_face_id", -1))
if info.get("surface") != "plane":
blockers.append("当前选中 Face 不是平面,不能调整薄壁/壳体厚度。")
if info.get("shell_region_status") != "candidate":
blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对薄壁/壳体平面。")))
if current_thickness <= 1e-9 or abs(signed_thickness) <= 1e-9:
blockers.append("当前薄壁厚度估算无效。")
if target_thickness <= 1e-9:
blockers.append("目标薄壁厚度必须大于 0。")
if abs(delta_thickness) <= max(current_thickness * 1e-5, 1e-6):
blockers.append("目标厚度与当前估算厚度几乎相同,不需要修改。")
normal = _tuple_normalized(_tuple_or_none(info.get("normal")))
outward = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction")))
desired_movement = None
push_pull_distance = 0.0
movement_alignment = 0.0
if not blockers:
if normal is None or outward is None:
blockers.append("当前平面缺少稳定法向或推拉方向,不能换算厚度修改。")
else:
sign = 1.0 if signed_thickness >= 0.0 else -1.0
toward_opposite = _tuple_scale(normal, sign)
desired_movement = _tuple_scale(toward_opposite, -delta_thickness)
desired_unit = _tuple_normalized(desired_movement)
if desired_unit is None:
blockers.append("目标厚度变化量无效。")
else:
movement_alignment = abs(_tuple_dot(desired_unit, outward))
if movement_alignment < 0.82:
blockers.append("当前平面推拉方向与薄壁厚度方向不匹配,暂不执行自动厚度修改。")
else:
push_pull_distance = _tuple_dot(desired_movement, outward)
if not blockers:
delta_ratio = abs(delta_thickness) / max(current_thickness, 1e-9)
if confidence == "low":
risk = _max_risk(risk, "high")
warnings.append("薄壁/壳体相对面识别置信度较低。")
elif confidence == "medium":
risk = _max_risk(risk, "medium")
warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。")
if overlap_ratio < 0.25:
risk = _max_risk(risk, "high")
warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。")
elif overlap_ratio < 0.55:
risk = _max_risk(risk, "medium")
warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。")
if delta_ratio > 0.8:
risk = _max_risk(risk, "high")
warnings.append("目标厚度变化超过当前厚度的 80%,容易导致布尔失败或周边变形。")
elif delta_ratio > 0.35:
risk = _max_risk(risk, "medium")
warnings.append("目标厚度变化超过当前厚度的 35%,请确认预览。")
push_plan = self.push_pull_plan(face_id, push_pull_distance)
if push_plan["status"] == "blocked":
blockers.append(str(push_plan["message"]))
else:
risk = _max_risk(risk, str(push_plan["risk"]))
push_warnings = str(push_plan.get("warnings", ""))
if push_warnings:
warnings.append(push_warnings)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
push_plan = {}
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "可以通过推拉当前平面区域调整薄壁/壳体厚度。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"surface": info.get("surface"),
"shell_region_kind": info.get("shell_region_kind"),
"shell_confidence": confidence,
"shell_source_face_ids": source_face_ids,
"shell_opposite_face_id": opposite_face_id,
"shell_current_thickness": current_thickness,
"shell_target_thickness": target_thickness,
"shell_delta_thickness": delta_thickness,
"shell_delta_ratio": abs(delta_thickness) / max(current_thickness, 1e-9),
"shell_signed_thickness": signed_thickness,
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"),
"shell_desired_movement_vector": desired_movement,
"shell_movement_alignment": movement_alignment,
"push_pull_distance": push_pull_distance,
"outward_direction": info.get("push_pull_outward_direction"),
"push_pull_status": push_plan.get("status"),
"push_pull_risk": push_plan.get("risk"),
"push_pull_message": push_plan.get("message"),
"push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", source_face_ids),
"push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", len(source_face_ids)),
"push_pull_scope_note": push_plan.get("push_pull_scope_note", ""),
"resize_strategy": "push-pull-shell-source-plane-to-target-thickness",
}
def cylindrical_boss_height_plan(
self,
face_id: int,
target_height: float,
*,
require_boss: bool = True,
) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
feature = self.feature_info(face_id)
blockers: list[str] = []
edit_label = "凸台高度" if require_boss else "圆柱高度"
warnings: list[str] = [
f"{edit_label}调整会推拉识别到的圆柱端盖 Face;这是 B-Rep 几何编辑,不是 CAD 历史特征参数。"
]
risk = "low"
if info.get("surface") != "cylinder":
blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}。")
if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate":
blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。")
angular_span = float(info.get("angular_span", 0.0))
if angular_span < math.tau * 0.92:
blockers.append(f"{edit_label}调整当前版本只支持接近完整圆柱的圆柱面。")
if target_height <= 1e-9:
blockers.append(f"目标{edit_label}必须大于 0。")
axis_range: dict[str, object] = {
"span": 0.0,
"same_domain_face_ids": (),
"same_domain_face_count": 0,
}
if info.get("surface") == "cylinder":
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
_int_values(feature.get("feature_side_face_ids")),
)
current_height = float(axis_range.get("span", 0.0))
delta_height = float(target_height) - current_height
if current_height <= 1e-9:
blockers.append(f"当前{edit_label}估算无效。")
elif abs(delta_height) <= max(current_height * 1e-5, 1e-6):
blockers.append(f"目标{edit_label}与当前估算高度几乎相同,不需要修改。")
if str(info.get("confidence", "low")) != "high":
risk = _max_risk(risk, "medium")
warnings.append(f"{edit_label}识别置信度不是 high,修改后请重点检查结果。")
if current_height > 1e-9:
delta_ratio = abs(delta_height) / current_height
if delta_ratio > 0.8:
risk = _max_risk(risk, "high")
warnings.append("目标高度变化超过当前高度的 80%,可能导致周边几何异常。")
elif delta_ratio > 0.3:
risk = _max_risk(risk, "medium")
warnings.append("目标高度变化超过当前高度的 30%,请确认预览方向和范围。")
cap_candidates: list[tuple[tuple[int, int, float, int], dict[str, object]]] = []
if not blockers:
endpoint_groups = [
("start", "起点端盖", _int_values(feature.get("feature_start_end_face_ids")), -1.0, bool(info.get("start_end_open"))),
("end", "终点端盖", _int_values(feature.get("feature_end_end_face_ids")), 1.0, bool(info.get("end_end_open"))),
]
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if axis_direction is None:
blockers.append(f"当前{edit_label}缺少稳定轴线方向,不能换算高度修改。")
else:
for endpoint_role, endpoint_label, cap_face_ids, axis_sign, is_open_end in endpoint_groups:
desired_movement = _tuple_scale(axis_direction, axis_sign * delta_height)
desired_unit = _tuple_normalized(desired_movement)
if desired_unit is None:
continue
for cap_face_id in cap_face_ids:
if cap_face_id < 0 or cap_face_id >= len(self.faces):
continue
try:
cap_info = self.face_info(cap_face_id)
except Exception:
continue
if cap_info.get("surface") != "plane":
continue
outward = _tuple_normalized(_tuple_or_none(cap_info.get("push_pull_outward_direction")))
if outward is None:
continue
movement_alignment = abs(_tuple_dot(desired_unit, outward))
if movement_alignment < 0.82:
continue
push_pull_distance = _tuple_dot(desired_movement, outward)
if abs(push_pull_distance) <= 1e-9:
continue
push_plan = self.push_pull_plan(cap_face_id, push_pull_distance)
if push_plan["status"] == "blocked":
warnings.append(f"{endpoint_label} Face {cap_face_id} 不能推拉:{push_plan.get('message', '')}")
continue
candidate_risk = str(push_plan.get("risk", "medium"))
score = (
0 if is_open_end else 1,
{"low": 0, "medium": 1, "high": 2}.get(candidate_risk, 3),
-movement_alignment,
cap_face_id,
)
cap_candidates.append(
(
score,
{
"boss_height_cap_face_id": cap_face_id,
"boss_height_endpoint_role": endpoint_role,
"boss_height_endpoint_label": endpoint_label,
"boss_height_open_end": is_open_end,
"boss_height_movement_alignment": movement_alignment,
"boss_height_desired_movement_vector": desired_movement,
"push_pull_distance": push_pull_distance,
"push_pull_status": push_plan.get("status"),
"push_pull_risk": push_plan.get("risk"),
"push_pull_message": push_plan.get("message"),
"push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", (cap_face_id,)),
"push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1),
"push_pull_scope_note": push_plan.get("push_pull_scope_note", ""),
},
)
)
selected_cap: dict[str, object] = {}
if not blockers:
if not cap_candidates:
blockers.append("没有找到可推拉的圆柱端盖 Face,暂不能直接调整凸台高度。")
else:
cap_candidates.sort(key=lambda item: item[0])
selected_cap = cap_candidates[0][1]
risk = _max_risk(risk, str(selected_cap.get("push_pull_risk", "medium")))
if not selected_cap.get("boss_height_open_end"):
risk = _max_risk(risk, "medium")
warnings.append("未能确认所选端盖是凸台外端,执行后请重点检查是否移动了正确端面。")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
status = "ready"
message = f"可以通过推拉圆柱端盖调整{edit_label}。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"current_height": current_height,
"target_height": float(target_height),
"delta_height": delta_height,
"height_delta_ratio": abs(delta_height) / max(current_height, 1e-9),
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"axis": info.get("axis"),
"same_domain_face_ids": axis_range.get("same_domain_face_ids"),
"same_domain_face_count": axis_range.get("same_domain_face_count"),
"feature_start_end_face_ids": feature.get("feature_start_end_face_ids"),
"feature_end_end_face_ids": feature.get("feature_end_end_face_ids"),
"resize_strategy": (
"push-pull-cylindrical-boss-end-cap-to-target-height"
if require_boss
else "push-pull-cylindrical-end-cap-to-target-height"
),
**selected_cap,
}
def cylindrical_height_plan(self, face_id: int, target_height: float) -> dict[str, object]:
return self.cylindrical_boss_height_plan(face_id, target_height, require_boss=False)
def cylindrical_boss_height_preview_polydata(
self,
face_id: int,
target_height: float,
deflection: float = 0.8,
):
plan = self.cylindrical_boss_height_plan(face_id, target_height)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self.push_pull_preview_polydata(
int(plan["boss_height_cap_face_id"]),
float(plan["push_pull_distance"]),
deflection,
)
def resize_cylindrical_boss_height(self, face_id: int, target_height: float) -> str:
plan = self.cylindrical_boss_height_plan(face_id, target_height)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
push_result = self.push_pull_face(
int(plan["boss_height_cap_face_id"]),
float(plan["push_pull_distance"]),
)
return (
"Cylindrical boss height resize completed by end-cap push/pull: "
f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, "
f"current_height={float(plan['current_height']):g}, "
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']}. {push_result}"
)
def shell_thickness_preview_polydata(
self,
face_id: int,
target_thickness: float,
deflection: float = 0.8,
):
plan = self.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self.push_pull_preview_polydata(face_id, float(plan["push_pull_distance"]), deflection)
def resize_shell_thickness(self, face_id: int, target_thickness: float) -> str:
plan = self.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
push_result = self.push_pull_face(face_id, float(plan["push_pull_distance"]))
return (
"Shell thickness resize completed by planar push/pull: "
f"face {face_id}, current_thickness={float(plan['shell_current_thickness']):g}, "
f"target_thickness={float(plan['shell_target_thickness']):g}, "
f"delta={float(plan['shell_delta_thickness']):g}, "
f"opposite_face={plan.get('shell_opposite_face_id')}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"risk={plan['risk']}. {push_result}"
)
def resize_cylindrical_height(self, face_id: int, target_height: float) -> str:
plan = self.cylindrical_height_plan(face_id, target_height)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
push_result = self.push_pull_face(
int(plan["boss_height_cap_face_id"]),
float(plan["push_pull_distance"]),
)
return (
"Cylindrical height resize completed by end-cap push/pull: "
f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, "
f"current_height={float(plan['current_height']):g}, "
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']}. {push_result}"
)
def conical_reference_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"圆锥面参考半径修改会围绕圆锥轴径向缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。"
]
risk = "medium"
try:
target_radius = float(target_radius)
except (TypeError, ValueError):
target_radius = 0.0
blockers.append("目标圆锥参考半径必须是数字。")
current_radius = _float_or_none(info.get("reference_radius"))
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if info.get("surface") != "cone":
blockers.append("当前选中 Face 不是圆锥面。")
if current_radius is None or current_radius <= 1e-9:
blockers.append("当前圆锥面缺少有效参考半径。")
if axis_point is None or axis_direction is None:
blockers.append("当前圆锥面缺少稳定轴线,不能做径向缩放。")
if target_radius <= 1e-9:
blockers.append("目标圆锥参考半径必须大于 0。")
scale = target_radius / max(current_radius or 1.0, 1e-9)
delta_radius = target_radius - float(current_radius or 0.0)
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
blockers.append("目标圆锥参考半径与当前值几乎相同,不需要修改。")
if delta_ratio > 0.6:
risk = _max_risk(risk, "high")
warnings.append("圆锥参考半径变化超过 60%,可能明显影响周边几何。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("圆锥参考半径变化超过 25%,修改后请重点检查相邻面。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前圆锥面所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
if target_kind == "part":
warnings.append("当前会缩放所属零件 shape,可能影响同一零件上的其它尺寸。")
else:
warnings.append("当前会缩放所属 Solid,可能影响同一 Solid 上的其它尺寸。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"current_reference_radius": current_radius,
"target_reference_radius": target_radius,
"current_reference_diameter": None if current_radius is None else current_radius * 2.0,
"target_reference_diameter": target_radius * 2.0,
"delta_reference_radius": delta_radius,
"reference_radius_delta_ratio": delta_ratio,
"semi_angle": info.get("semi_angle"),
"axis_point": axis_point,
"axis": axis_direction,
"resize_strategy": "radial-affine-scale-cone-reference-radius",
"affine_scale": scale,
"affine_transform_kind": "radial-affine",
"affine_transform_label": "围绕圆锥轴径向缩放",
"affine_axis_point": axis_point,
"affine_axis_direction": axis_direction,
"affine_axis_source": "cone axis",
"affine_anchor_source": "cone axis point",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_conical_reference_radius(self, face_id: int, target_radius: float) -> str:
plan = self.conical_reference_radius_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
return (
"Conical face reference radius resize completed by radial affine scaling: "
f"face {face_id}, "
f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}."
)
def spherical_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"球面半径修改会围绕球心缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。"
]
risk = "medium"
try:
target_radius = float(target_radius)
except (TypeError, ValueError):
target_radius = 0.0
blockers.append("目标球面半径必须是数字。")
current_radius = _float_or_none(info.get("radius"))
center = _tuple_or_none(info.get("center"))
if info.get("surface") != "sphere":
blockers.append("当前选中 Face 不是球面。")
if current_radius is None or current_radius <= 1e-9:
blockers.append("当前球面缺少有效半径。")
if center is None:
blockers.append("当前球面缺少稳定球心,不能缩放。")
if target_radius <= 1e-9:
blockers.append("目标球面半径必须大于 0。")
scale = target_radius / max(current_radius or 1.0, 1e-9)
delta_radius = target_radius - float(current_radius or 0.0)
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
blockers.append("目标球面半径与当前值几乎相同,不需要修改。")
if delta_ratio > 0.6:
risk = _max_risk(risk, "high")
warnings.append("球面半径变化超过 60%,可能明显影响周边几何。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("球面半径变化超过 25%,修改后请重点检查相邻面。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前球面所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"current_radius": current_radius,
"target_radius": target_radius,
"current_diameter": None if current_radius is None else current_radius * 2.0,
"target_diameter": target_radius * 2.0,
"delta_radius": delta_radius,
"radius_delta_ratio": delta_ratio,
"center": center,
"resize_strategy": "uniform-scale-sphere-radius",
"affine_scale": scale,
"affine_transform_kind": "uniform",
"affine_transform_label": "围绕球心均匀缩放",
"affine_axis_point": center,
"affine_axis_direction": (0.0, 0.0, 1.0),
"affine_axis_source": "sphere center",
"affine_anchor_source": "sphere center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_spherical_radius(self, face_id: int, target_radius: float) -> str:
plan = self.spherical_radius_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
return (
"Spherical face radius resize completed by uniform scaling: "
f"face {face_id}, "
f"radius={float(plan['current_radius']):g}->{float(plan['target_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}."
)
def toroidal_radius_plan(self, face_id: int, target_radius: float, mode: str = "minor") -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
mode_key = "major" if str(mode).lower() in {"major", "main", "major_radius"} else "minor"
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"环面半径修改会围绕环面中心均匀缩放所属零件/Solid;主半径和小半径会等比例变化。"
]
risk = "medium"
try:
target_radius = float(target_radius)
except (TypeError, ValueError):
target_radius = 0.0
blockers.append("目标环面半径必须是数字。")
current_major = _float_or_none(info.get("major_radius"))
current_minor = _float_or_none(info.get("minor_radius"))
current_radius = current_major if mode_key == "major" else current_minor
center = _tuple_or_none(info.get("center"))
axis = _tuple_normalized(_tuple_or_none(info.get("axis"))) or (0.0, 0.0, 1.0)
if info.get("surface") != "torus":
blockers.append("当前选中 Face 不是环面。")
if current_major is None or current_major <= 1e-9 or current_minor is None or current_minor <= 1e-9:
blockers.append("当前环面缺少有效主半径或小半径。")
if center is None:
blockers.append("当前环面缺少稳定中心,不能缩放。")
if target_radius <= 1e-9:
blockers.append("目标环面半径必须大于 0。")
scale = target_radius / max(current_radius or 1.0, 1e-9)
target_major = None if current_major is None else current_major * scale
target_minor = None if current_minor is None else current_minor * scale
delta_radius = target_radius - float(current_radius or 0.0)
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
blockers.append("目标环面半径与当前值几乎相同,不需要修改。")
if delta_ratio > 0.6:
risk = _max_risk(risk, "high")
warnings.append("环面半径变化超过 60%,可能明显影响周边几何。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("环面半径变化超过 25%,修改后请重点检查相邻面。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前环面所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"torus_radius_mode": mode_key,
"current_major_radius": current_major,
"target_major_radius": target_major,
"current_minor_radius": current_minor,
"target_minor_radius": target_minor,
"target_radius": target_radius,
"delta_radius": delta_radius,
"radius_delta_ratio": delta_ratio,
"center": center,
"axis": axis,
"resize_strategy": "uniform-scale-torus-radius",
"affine_scale": scale,
"affine_transform_kind": "uniform",
"affine_transform_label": "围绕环面中心均匀缩放",
"affine_axis_point": center,
"affine_axis_direction": axis,
"affine_axis_source": "torus center",
"affine_anchor_source": "torus center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_toroidal_radius(self, face_id: int, target_radius: float, mode: str = "minor") -> str:
plan = self.toroidal_radius_plan(face_id, target_radius, mode)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
mode_label = "major" if plan.get("torus_radius_mode") == "major" else "minor"
current = plan.get("current_major_radius") if mode_label == "major" else plan.get("current_minor_radius")
target = plan.get("target_major_radius") if mode_label == "major" else plan.get("target_minor_radius")
return (
"Toroidal face radius resize completed by uniform scaling: "
f"face {face_id}, {mode_label}_radius={float(current):g}->{float(target):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}."
)
def face_area_scale_plan(self, face_id: int, target_area: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"Face面积修改是复杂曲面的兜底几何缩放;会围绕Face面积中心均匀缩放所属零件/Solid。"
]
risk = "high"
try:
target_area = float(target_area)
except (TypeError, ValueError):
target_area = 0.0
blockers.append("目标Face面积必须是数字。")
current_area = _float_or_none(info.get("area"))
center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if current_area is None or current_area <= 1e-9:
blockers.append("当前Face缺少有效面积。")
if center is None:
blockers.append("当前Face缺少稳定面积中心,不能缩放。")
if target_area <= 1e-9:
blockers.append("目标Face面积必须大于 0。")
if current_area is not None and abs(target_area - current_area) <= max(current_area * 1e-6, 1e-6):
blockers.append("目标Face面积与当前值几乎相同,不需要修改。")
scale = math.sqrt(target_area / max(current_area or 1.0, 1e-9))
area_delta = target_area - float(current_area or 0.0)
area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9)
if area_delta_ratio <= 0.15:
risk = "medium"
elif area_delta_ratio > 0.8:
warnings.append("目标面积变化超过 80%,很可能明显影响周边几何。")
else:
warnings.append("目标面积变化较大,修改后请重点检查周边尺寸。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前Face所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"current_area": current_area,
"target_area": target_area,
"area_delta": area_delta,
"area_delta_ratio": area_delta_ratio,
"area_center": center,
"resize_strategy": "uniform-scale-face-area-fallback",
"affine_scale": scale,
"affine_transform_kind": "uniform",
"affine_transform_label": "围绕Face面积中心均匀缩放",
"affine_axis_point": center,
"affine_axis_direction": (0.0, 0.0, 1.0),
"affine_axis_source": "face area center",
"affine_anchor_source": "face area center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_face_area(self, face_id: int, target_area: float) -> str:
plan = self.face_area_scale_plan(face_id, target_area)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
return (
"Face area resize completed by uniform scaling fallback: "
f"face {face_id}, "
f"area={float(plan['current_area']):g}->{float(plan['target_area']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}."
)
def cylindrical_resize_preview_polydata(
self,
face_id: int,
new_diameter: float,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Cylinder resize preview currently supports cylindrical faces only.")
direction = surf.Cylinder().Axis().Direction()
previews: list[dict[str, object]] = []
if plan["resize_mode"] == "shrink" and "fill_start_point" in plan:
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
BRepMesh_IncrementalMesh(filler, deflection)
previews.append(
{
"role": "fill",
"label": "补料预览",
"polydata": _shape_faces_polydata(filler),
}
)
cutter_start = gp_Pnt(*plan["cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
cutter = BRepPrimAPI_MakeCylinder(
cutter_axis,
float(plan["cutter_radius"]),
float(plan["cutter_height"]),
).Shape()
BRepMesh_IncrementalMesh(cutter, deflection)
previews.append(
{
"role": "cutter",
"label": "切削预览",
"polydata": _shape_faces_polydata(cutter),
}
)
return previews
def cylindrical_boss_resize_preview_polydata(
self,
face_id: int,
new_diameter: float,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
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()
BRepMesh_IncrementalMesh(tool, deflection)
return [
{
"role": "fill",
"label": "凸台扩大补料预览",
"polydata": _shape_faces_polydata(tool),
}
]
removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
BRepMesh_IncrementalMesh(removal, deflection)
BRepMesh_IncrementalMesh(replacement, deflection)
return [
{
"role": "cutter",
"label": "凸台缩小移除范围预览",
"polydata": _shape_faces_polydata(removal),
},
{
"role": "fill",
"label": "凸台缩小重建目标预览",
"polydata": _shape_faces_polydata(replacement),
},
]
def cylindrical_suppress_preview_polydata(
self,
face_id: int,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_suppress_plan(face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Cylinder suppress preview currently supports cylindrical faces only.")
direction = surf.Cylinder().Axis().Direction()
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
BRepMesh_IncrementalMesh(filler, deflection)
return [
{
"role": "fill",
"label": "封堵补料预览",
"polydata": _shape_faces_polydata(filler),
}
]
def cylindrical_depth_preview_polydata(
self,
face_id: int,
target_depth: float,
bottom_face_id: int | None = None,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_depth_plan(
face_id,
target_depth,
bottom_face_id=bottom_face_id,
)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
start = gp_Pnt(*plan["depth_tool_start_point"])
direction = gp_Dir(*plan["depth_axis_direction"])
axis = gp_Ax2(start, direction)
tool = BRepPrimAPI_MakeCylinder(
axis,
float(plan["depth_tool_radius"]),
float(plan["depth_tool_height"]),
).Shape()
BRepMesh_IncrementalMesh(tool, deflection)
role = str(plan["depth_tool_role"])
return [
{
"role": role,
"label": "切削预览" if role == "cutter" else "补料预览",
"polydata": _shape_faces_polydata(tool),
}
]
def existing_fillet_resize_preview_polydata(
self,
face_id: int,
target_radius: float,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return [
{
"role": "remove",
"label": "将移除并重建的已有圆角面",
"polydata": self.build_face_polydata(face_ids=[face_id], deflection=deflection),
}
]
def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8):
plan = self.push_pull_plan(face_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
profile_shape = self._push_pull_profile_shape(scope_face_ids)
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
raise ValueError("Push/pull preview currently supports planar faces only.")
outward = plan["outward_direction"]
vec = gp_Vec(
float(outward[0]) * distance,
float(outward[1]) * distance,
float(outward[2]) * distance,
)
preview_shape = BRepPrimAPI_MakePrism(profile_shape, vec).Shape()
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def straight_edge_length_preview_polydata(
self,
edge_id: int,
target_length: float,
deflection: float = 0.8,
anchor_mode: str = "auto",
):
plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("resize_strategy") == "local-edge-only-deform":
preview_shape = self._local_edge_deform_shape(plan)
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull":
return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection)
if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length":
face_id = int(plan["cylinder_resize_face_id"])
target_diameter = float(plan["cylinder_resize_target_diameter"])
if plan.get("circular_edge_cylinder_mode") == "boss":
return self.cylindrical_boss_resize_preview_polydata(face_id, target_diameter, deflection)
return self.cylindrical_resize_preview_polydata(face_id, target_diameter, deflection)
preview_shape = self._edge_length_affine_preview_shape(plan)
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def resize_straight_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str:
return self.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode)
def resize_general_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str:
plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("resize_strategy") == "local-edge-only-deform":
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by local edge-only deformation: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"anchor={plan.get('local_edge_deform_anchor')}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull":
push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"]))
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by end-face push/pull: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"end_face={int(plan['end_face_id'])}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"risk={plan['risk']}. {result_check} {push_result}"
)
if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length":
face_id = int(plan["cylinder_resize_face_id"])
target_diameter = float(plan["cylinder_resize_target_diameter"])
if plan.get("circular_edge_cylinder_mode") == "boss":
resize_result = self.resize_cylindrical_boss(face_id, target_diameter)
else:
resize_result = self.resize_cylindrical_hole(face_id, target_diameter)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by adjacent cylinder diameter edit: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"cylinder_face={face_id}, "
f"target_diameter={target_diameter:g}, "
f"mode={plan.get('circular_edge_cylinder_mode_label')}, "
f"risk={plan['risk']}. {result_check} {resize_result}"
)
self._apply_edge_length_affine_transform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by geometric scale fallback: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"transform={plan.get('affine_transform_label') or plan.get('affine_transform_kind')}, "
f"predicted_edge_length={float(plan.get('affine_predicted_edge_length') or 0.0):g}, "
f"axis_source={plan.get('affine_axis_source')}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def move_edge_endpoint(
self,
edge_id: int,
endpoint_role: str,
target_point: tuple[float, float, float],
) -> str:
plan = self.edge_endpoint_move_plan(edge_id, endpoint_role, target_point)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("resize_strategy") != "local-edge-endpoint-deform":
raise ValueError("Unsupported Edge endpoint move strategy.")
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge endpoint move completed by local edge deformation: "
f"edge {edge_id}, "
f"endpoint={plan.get('edge_endpoint_role')}, "
f"current_endpoint={plan.get('current_endpoint_point')}, "
f"target_endpoint={plan.get('target_endpoint_point')}, "
f"move={plan.get('moved_endpoint_delta')}, "
f"current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def move_edge_center(
self,
edge_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.edge_center_move_plan(edge_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("resize_strategy") != "local-edge-center-deform":
raise ValueError("Unsupported Edge center move strategy.")
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge center move completed by local edge deformation: "
f"edge {edge_id}, "
f"current_center={plan.get('current_edge_center')}, "
f"target_center={plan.get('target_edge_center')}, "
f"move={plan.get('moved_edge_center_delta')}, "
f"edge_length={float(plan['current_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def _edge_length_result_summary(self, plan: dict[str, object]) -> str:
check = self._edge_length_result_check(plan)
if check is None:
return "Result check unavailable."
return (
"Result check: "
f"match={check['match_method']}, "
f"nearest_edge={check['edge_id']}, "
f"nearest_length={float(check['nearest_length']):g}, "
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']}."
)
def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None:
try:
target_length = float(plan.get("target_length", 0.0))
except (TypeError, ValueError):
return None
if target_length <= 1e-9 or not self.edges:
return None
try:
part_id = int(plan.get("part_id", -1))
except (TypeError, ValueError):
part_id = -1
try:
solid_id = int(plan.get("solid_id", -1))
except (TypeError, ValueError):
solid_id = -1
target_kind = str(
plan.get("local_edge_deform_target_kind")
or plan.get("affine_target_kind")
or ("solid" if solid_id >= 0 else "part")
)
edge_ids = [edge_id for edge_id in range(len(self.edges)) if part_id < 0 or self.edge_part_ids[edge_id] == part_id]
scope = f"零件 {part_id}" if part_id >= 0 else "model"
if target_kind == "solid" and solid_id >= 0:
solid_edge_ids = [edge_id for edge_id in edge_ids if self.edge_solid_ids[edge_id] == solid_id]
if solid_edge_ids:
edge_ids = solid_edge_ids
scope = f"solid {solid_id}"
if not edge_ids:
edge_ids = list(range(len(self.edges)))
scope = "model"
expected = self._edge_length_expected_endpoints(plan)
best_length: tuple[float, int, float, float | None] | None = None
best_endpoint: tuple[float, float, int, float] | None = None
for edge_id in edge_ids:
try:
length = float(self.edge_info(edge_id).get("length", 0.0))
except Exception:
continue
if length <= 1e-9:
continue
error = abs(length - target_length)
endpoint_error = None
if expected is not None:
endpoint_error = self._edge_endpoint_pair_error(edge_id, expected[0], expected[1])
if endpoint_error is not None and (
best_endpoint is None
or endpoint_error < best_endpoint[0]
or (
abs(endpoint_error - best_endpoint[0]) <= 1e-9
and (error < best_endpoint[1] or (abs(error - best_endpoint[1]) <= 1e-9 and edge_id < best_endpoint[2]))
)
):
best_endpoint = (endpoint_error, error, edge_id, length)
if best_length is None or error < best_length[0] or (
abs(error - best_length[0]) <= 1e-9 and edge_id < best_length[1]
):
best_length = (error, edge_id, length, endpoint_error)
if best_length is None:
return None
match_method = "length"
endpoint_error_value = best_length[3]
error, edge_id, length = best_length[0], best_length[1], best_length[2]
if expected is not None and best_endpoint is not None:
endpoint_tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4)
if best_endpoint[0] <= endpoint_tolerance:
endpoint_error_value, error, edge_id, length = best_endpoint
match_method = str(expected[2])
return {
"edge_id": edge_id,
"nearest_length": length,
"target_error": error,
"relative_error": error / max(target_length, 1e-9),
"endpoint_error": endpoint_error_value if endpoint_error_value is not None else -1.0,
"match_method": match_method,
"scope": scope,
}
def _edge_length_expected_endpoints(
self,
plan: dict[str, object],
) -> tuple[tuple[float, float, float], tuple[float, float, float], str] | None:
start = _tuple_or_none(plan.get("start_point"))
end = _tuple_or_none(plan.get("end_point"))
if start is None or end is None:
return None
strategy = str(plan.get("resize_strategy", ""))
if strategy in {"local-edge-only-deform", "local-edge-endpoint-deform", "local-edge-center-deform"}:
start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0)
end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0)
if strategy == "local-edge-endpoint-deform":
match_label = "endpoint-local-coordinate"
elif strategy == "local-edge-center-deform":
match_label = "endpoint-local-center"
else:
match_label = "endpoint-local"
return (
(start[0] + start_move[0], start[1] + start_move[1], start[2] + start_move[2]),
(end[0] + end_move[0], end[1] + end_move[1], end[2] + end_move[2]),
match_label,
)
if strategy == "move-edge-end-plane-by-push-pull":
movement = _tuple_or_none(plan.get("desired_movement_vector"))
endpoint_role = str(plan.get("end_face_endpoint_role", ""))
if movement is None or endpoint_role not in {"start", "end"}:
return None
if endpoint_role == "start":
start = (start[0] + movement[0], start[1] + movement[1], start[2] + movement[2])
else:
end = (end[0] + movement[0], end[1] + movement[1], end[2] + movement[2])
return start, end, "endpoint-push-pull"
if strategy == "scale-owning-shape-from-edge":
transformed_start = self._edge_length_affine_point(start, plan)
transformed_end = self._edge_length_affine_point(end, plan)
if transformed_start is None or transformed_end is None:
return None
return transformed_start, transformed_end, "endpoint-affine"
return None
def _edge_endpoint_pair_error(
self,
edge_id: int,
expected_start: tuple[float, float, float],
expected_end: tuple[float, float, float],
) -> float | None:
info = self.edge_info(edge_id)
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
if start is None or end is None:
return None
direct = max(_vector_length(_tuple_sub(start, expected_start)), _vector_length(_tuple_sub(end, expected_end)))
reversed_order = max(
_vector_length(_tuple_sub(start, expected_end)),
_vector_length(_tuple_sub(end, expected_start)),
)
return min(direct, reversed_order)
def _edge_length_affine_point(
self,
point: tuple[float, float, float],
plan: dict[str, object],
) -> tuple[float, float, float] | None:
axis_point = _tuple_or_none(plan.get("affine_axis_point"))
if axis_point is None:
return None
try:
scale = float(plan.get("affine_scale", 1.0))
except (TypeError, ValueError):
return None
relative = _tuple_sub(point, axis_point)
transform_kind = str(plan.get("affine_transform_kind", "axis-affine"))
if transform_kind == "uniform":
moved = _tuple_scale(relative, scale)
else:
axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction")))
if axis_direction is None:
return None
axial = _tuple_scale(axis_direction, _tuple_dot(relative, axis_direction))
radial = _tuple_sub(relative, axial)
if transform_kind == "radial-affine":
moved = (
axial[0] + radial[0] * scale,
axial[1] + radial[1] * scale,
axial[2] + radial[2] * scale,
)
else:
moved = (
radial[0] + axial[0] * scale,
radial[1] + axial[1] * scale,
radial[2] + axial[2] * scale,
)
return (axis_point[0] + moved[0], axis_point[1] + moved[1], axis_point[2] + moved[2])
def _local_edge_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape:
_target_kind, solid, _part, _source_solid = self._local_edge_deform_target(plan)
tolerance = max(_shape_diagonal(solid) * 1e-7, abs(float(plan.get("delta_length", 0.0))) * 1e-7, 1e-6)
faces = _explore(solid, TopAbs_FACE)
moved_faces: list[TopoDS_Shape] = []
moved_points: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for face in faces:
points = self._local_deform_face_vertex_points(face, tolerance)
if len(points) < 3:
raise RuntimeError("Local edge deformation could not read a stable face vertex loop.")
for point in points:
moved = self._local_edge_deform_moved_point(point, plan, tolerance)
moved_points[self._local_point_key(moved, tolerance)] = moved
if not moved_points:
raise RuntimeError("Local edge deformation produced no moved vertices.")
center = (
sum(point[0] for point in moved_points.values()) / len(moved_points),
sum(point[1] for point in moved_points.values()) / len(moved_points),
sum(point[2] for point in moved_points.values()) / len(moved_points),
)
for face in faces:
points = [
self._local_edge_deform_moved_point(point, plan, tolerance)
for point in self._local_deform_face_vertex_points(face, tolerance)
]
points = self._dedupe_local_points(points, tolerance)
if len(points) < 3:
raise RuntimeError("Local edge deformation collapsed a face.")
points = self._orient_local_polygon_outward(points, center)
if self._local_points_are_planar(points, tolerance):
moved_faces.append(self._make_local_polygon_face(points))
else:
for index in range(1, len(points) - 1):
triangle = [points[0], points[index], points[index + 1]]
triangle = self._orient_local_polygon_outward(triangle, center)
moved_faces.append(self._make_local_polygon_face(triangle))
sewing = BRepBuilderAPI_Sewing(tolerance)
for face in moved_faces:
sewing.Add(face)
sewing.Perform()
sewed = sewing.SewedShape()
if sewed.IsNull():
raise RuntimeError("Local edge deformation sewing produced an empty shape.")
if sewed.ShapeType() == TopAbs_SHELL:
shell = topods.Shell(sewed)
else:
shells = _explore(sewed, TopAbs_SHELL)
if not shells:
raise RuntimeError("Local edge deformation did not produce a sewable shell.")
shell = topods.Shell(shells[0])
solid_builder = BRepBuilderAPI_MakeSolid(shell)
solid = solid_builder.Solid()
if solid.IsNull():
raise RuntimeError("Local edge deformation could not create a solid from the rebuilt shell.")
return _ensure_valid_or_repaired_shape(solid, "local edge deformation")
def _apply_local_edge_deform(self, plan: dict[str, object]) -> None:
target_kind, _source_shape, part, source_solid = self._local_edge_deform_target(plan)
transformed = self._local_edge_deform_shape(plan)
if target_kind == "part":
part.shape = transformed
else:
part_solids = _explore(part.shape, TopAbs_SOLID)
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and source_solid is not None and _same_shape(item, source_solid):
shapes.append(transformed)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
def _local_edge_deform_target(
self,
plan: dict[str, object],
) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]:
part_id = int(plan.get("part_id", -1))
solid_id = int(plan.get("solid_id", -1))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
target_kind = str(plan.get("local_edge_deform_target_kind", "part"))
solid = self.solids[solid_id][1]
return ("solid" if target_kind == "solid" else "part"), solid, part, solid
def _local_deform_face_vertex_points(
self,
face: TopoDS_Shape,
tolerance: float,
) -> list[tuple[float, float, float]]:
points: list[tuple[float, float, float]] = []
explorer = TopExp_Explorer(face, TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in points):
points.append(point)
explorer.Next()
if len(points) < 3:
return points
surf = BRepAdaptor_Surface(face)
normal = _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction()))
if normal is None:
return points
if face.Orientation() == TopAbs_REVERSED:
normal = _tuple_scale(normal, -1.0)
return self._order_local_polygon_points(points, normal)
def _order_local_polygon_points(
self,
points: list[tuple[float, float, float]],
normal: tuple[float, float, float],
) -> list[tuple[float, float, float]]:
center = (
sum(point[0] for point in points) / len(points),
sum(point[1] for point in points) / len(points),
sum(point[2] for point in points) / len(points),
)
reference = (1.0, 0.0, 0.0) if abs(normal[0]) < 0.9 else (0.0, 1.0, 0.0)
u_axis = _tuple_normalized(_tuple_cross(normal, reference))
if u_axis is None:
return points
v_axis = _tuple_normalized(_tuple_cross(normal, u_axis))
if v_axis is None:
return points
ordered = sorted(
points,
key=lambda point: math.atan2(
_tuple_dot(_tuple_sub(point, center), v_axis),
_tuple_dot(_tuple_sub(point, center), u_axis),
),
)
polygon_normal = self._local_polygon_normal(ordered)
if polygon_normal is not None and _tuple_dot(polygon_normal, normal) < 0:
ordered.reverse()
return ordered
def _local_edge_deform_moved_point(
self,
point: tuple[float, float, float],
plan: dict[str, object],
tolerance: float,
) -> tuple[float, float, float]:
start = _tuple_or_none(plan.get("start_point"))
end = _tuple_or_none(plan.get("end_point"))
start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0)
end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0)
if start is not None and _vector_length(_tuple_sub(point, start)) <= tolerance:
return (point[0] + start_move[0], point[1] + start_move[1], point[2] + start_move[2])
if end is not None and _vector_length(_tuple_sub(point, end)) <= tolerance:
return (point[0] + end_move[0], point[1] + end_move[1], point[2] + end_move[2])
return point
def _make_local_polygon_face(self, points: list[tuple[float, float, float]]) -> TopoDS_Shape:
polygon = BRepBuilderAPI_MakePolygon()
for point in points:
polygon.Add(gp_Pnt(*point))
polygon.Close()
if hasattr(polygon, "IsDone") and not polygon.IsDone():
raise RuntimeError("Local edge deformation could not create a polygon wire.")
maker = BRepBuilderAPI_MakeFace(polygon.Wire())
if hasattr(maker, "IsDone") and not maker.IsDone():
raise RuntimeError("Local edge deformation could not create a face from a polygon wire.")
face = maker.Face()
if face.IsNull():
raise RuntimeError("Local edge deformation created an empty face.")
return face
def _dedupe_local_points(
self,
points: list[tuple[float, float, float]],
tolerance: float,
) -> list[tuple[float, float, float]]:
result: list[tuple[float, float, float]] = []
for point in points:
if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in result):
result.append(point)
if len(result) > 1 and _vector_length(_tuple_sub(result[0], result[-1])) <= tolerance:
result.pop()
return result
def _local_points_are_planar(
self,
points: list[tuple[float, float, float]],
tolerance: float,
) -> bool:
if len(points) <= 3:
return True
normal = self._local_polygon_normal(points)
if normal is None:
return False
origin = points[0]
return all(abs(_tuple_dot(_tuple_sub(point, origin), normal)) <= tolerance * 20.0 for point in points[3:])
def _local_polygon_normal(
self,
points: list[tuple[float, float, float]],
) -> tuple[float, float, float] | None:
normal = (0.0, 0.0, 0.0)
count = len(points)
for index, point in enumerate(points):
next_point = points[(index + 1) % count]
normal = (
normal[0] + (point[1] - next_point[1]) * (point[2] + next_point[2]),
normal[1] + (point[2] - next_point[2]) * (point[0] + next_point[0]),
normal[2] + (point[0] - next_point[0]) * (point[1] + next_point[1]),
)
return _tuple_normalized(normal)
def _orient_local_polygon_outward(
self,
points: list[tuple[float, float, float]],
shape_center: tuple[float, float, float],
) -> list[tuple[float, float, float]]:
normal = self._local_polygon_normal(points)
if normal is None:
return points
center = (
sum(point[0] for point in points) / len(points),
sum(point[1] for point in points) / len(points),
sum(point[2] for point in points) / len(points),
)
if _tuple_dot(normal, _tuple_sub(center, shape_center)) < 0:
return list(reversed(points))
return points
def _local_point_key(self, point: tuple[float, float, float], tolerance: float) -> tuple[int, int, int]:
scale = max(float(tolerance), 1e-9)
return (round(point[0] / scale), round(point[1] / scale), round(point[2] / scale))
def _edge_length_affine_preview_shape(self, plan: dict[str, object]) -> TopoDS_Shape:
target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan)
return self._affine_scaled_shape_along_edge(source_shape, plan)
def _refine_affine_edge_length_scale(self, plan: dict[str, object]) -> str:
edge_id = int(plan.get("edge_id", -1))
if edge_id < 0 or edge_id >= len(self.edges):
return ""
target_length = float(plan.get("target_length", 0.0))
if target_length <= 1e-9:
return ""
initial_scale = float(plan.get("affine_scale", 1.0))
plan["affine_initial_scale"] = initial_scale
refined_scale = initial_scale
measured_length = 0.0
iterations = 0
tolerance = max(target_length * 5e-4, 1e-5)
for _index in range(3):
iterations += 1
plan["affine_scale"] = refined_scale
try:
transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan)
props = GProp_GProps()
brepgprop.LinearProperties(transformed_edge, props)
measured_length = float(props.Mass())
except Exception:
plan["affine_scale"] = initial_scale
return "几何缩放比例预校正失败,将使用原始目标比例。"
if measured_length <= 1e-9:
plan["affine_scale"] = initial_scale
return "几何缩放比例预校正失败:预估Edge长度无效。"
if abs(measured_length - target_length) <= tolerance:
break
refined_scale *= target_length / measured_length
plan["affine_scale"] = refined_scale
try:
transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan)
props = GProp_GProps()
brepgprop.LinearProperties(transformed_edge, props)
final_measured_length = float(props.Mass())
if final_measured_length > 1e-9:
measured_length = final_measured_length
except Exception:
pass
plan["affine_predicted_edge_length"] = measured_length
plan["affine_scale_correction"] = refined_scale / initial_scale if abs(initial_scale) > 1e-12 else 1.0
plan["affine_scale_refine_iterations"] = iterations
if abs(refined_scale - initial_scale) > max(abs(initial_scale) * 1e-4, 1e-6):
plan["affine_scale_refine_note"] = "已用预变换Edge长度微调几何缩放比例。"
return "已用预变换Edge长度微调几何缩放比例,使目标Edge长度更接近输入值。"
plan["affine_scale_refine_note"] = ""
return ""
def _apply_edge_length_affine_transform(self, plan: dict[str, object]) -> None:
target_kind, source_shape, part, solid = self._edge_length_affine_target(plan)
transformed = self._affine_scaled_shape_along_edge(source_shape, plan)
_ensure_valid_shape(transformed)
if target_kind == "part":
part.shape = transformed
else:
part_solids = _explore(part.shape, TopAbs_SOLID)
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and _same_shape(item, solid):
shapes.append(transformed)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
def _edge_length_affine_target(
self,
plan: dict[str, object],
) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]:
part_id = int(plan.get("part_id", -1))
solid_id = int(plan.get("solid_id", -1))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
target_kind = str(plan.get("affine_target_kind", "part"))
if target_kind == "solid" and 0 <= solid_id < len(self.solids):
return target_kind, self.solids[solid_id][1], part, self.solids[solid_id][1]
return "part", part.shape, part, None
def _affine_scaled_shape_along_edge(self, shape: TopoDS_Shape, plan: dict[str, object]) -> TopoDS_Shape:
axis_point = _tuple_or_none(plan.get("affine_axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction")))
scale = float(plan.get("affine_scale", 1.0))
transform_kind = str(plan.get("affine_transform_kind", "axis-affine"))
if axis_point is None:
raise ValueError("Missing affine edge-length axis.")
if transform_kind == "uniform":
transform = gp_Trsf()
transform.SetScale(gp_Pnt(*axis_point), scale)
builder = BRepBuilderAPI_Transform(shape, transform, True)
builder.Build()
if not builder.IsDone():
raise RuntimeError("Uniform edge-length scale transform failed.")
result = builder.Shape()
if result.IsNull():
raise RuntimeError("Uniform edge-length scale transform produced an empty shape.")
return result
if axis_direction is None:
raise ValueError("Missing affine edge-length axis direction.")
ux, uy, uz = axis_direction
if transform_kind == "radial-affine":
matrix = [
[scale + (1.0 - scale) * ux * ux, (1.0 - scale) * ux * uy, (1.0 - scale) * ux * uz],
[(1.0 - scale) * uy * ux, scale + (1.0 - scale) * uy * uy, (1.0 - scale) * uy * uz],
[(1.0 - scale) * uz * ux, (1.0 - scale) * uz * uy, scale + (1.0 - scale) * uz * uz],
]
else:
matrix = [
[1.0 + (scale - 1.0) * ux * ux, (scale - 1.0) * ux * uy, (scale - 1.0) * ux * uz],
[(scale - 1.0) * uy * ux, 1.0 + (scale - 1.0) * uy * uy, (scale - 1.0) * uy * uz],
[(scale - 1.0) * uz * ux, (scale - 1.0) * uz * uy, 1.0 + (scale - 1.0) * uz * uz],
]
cx, cy, cz = axis_point
moved_center = (
matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz,
matrix[1][0] * cx + matrix[1][1] * cy + matrix[1][2] * cz,
matrix[2][0] * cx + matrix[2][1] * cy + matrix[2][2] * cz,
)
translation = (cx - moved_center[0], cy - moved_center[1], cz - moved_center[2])
transform = gp_GTrsf()
for row in range(3):
for column in range(3):
transform.SetValue(row + 1, column + 1, matrix[row][column])
transform.SetTranslationPart(gp_XYZ(*translation))
builder = BRepBuilderAPI_GTransform(shape, transform, True)
builder.Build()
if not builder.IsDone():
raise RuntimeError(f"{transform_kind} edge-length transform failed.")
result = builder.Shape()
if result.IsNull():
raise RuntimeError(f"{transform_kind} edge-length transform produced an empty shape.")
return result
def push_pull_face(self, face_id: int, distance: float) -> str:
plan = self.push_pull_plan(face_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
raise ValueError("Push/pull currently supports planar faces only.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
outward = plan["outward_direction"]
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
profile_shape = self._push_pull_profile_shape(scope_face_ids)
boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids)
side_face_ids = sorted(
set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(scope_face_ids)
)
side_region_mapping_specs = self._face_region_mapping_specs(side_face_ids)
cap_extension = self._cylindrical_cap_extension_plan(face_id, distance, outward)
if cap_extension is not None:
op = BRepAlgoAPI_Fuse(part.shape, cap_extension["tool_shape"])
result = _finalize_boolean_result(op, "cylindrical cap push/pull")
result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance)
part.shape = result
self.refresh_topology()
self._apply_face_region_mapping_specs(side_region_mapping_specs)
return (
"Planar face push/pull completed: cylindrical cap extension, "
f"semantic_distance={distance:g}, "
f"radius={float(cap_extension['radius']):g}, "
f"old_height={float(cap_extension['old_height']):g}, "
f"new_height={float(cap_extension['new_height']):g}, "
f"side_faces={cap_extension['side_face_ids']}, "
f"outward_direction={_format_tuple(outward)}, "
f"direction_confidence={plan['direction_confidence']}, "
f"risk={plan['risk']}."
)
overlap = _boolean_overlap_distance(part.shape, distance)
start_offset = -overlap if distance >= 0 else overlap
tool_distance = distance + overlap if distance >= 0 else distance - overlap
tool_face = _translated_shape(profile_shape, outward, start_offset)
vec = gp_Vec(
float(outward[0]) * tool_distance,
float(outward[1]) * tool_distance,
float(outward[2]) * tool_distance,
)
tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape()
op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape)
result = _finalize_boolean_result(op, "push/pull")
result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance)
part.shape = result
self.refresh_topology()
self._apply_face_region_mapping_specs(side_region_mapping_specs)
action = "fused outward prism" if distance >= 0 else "cut inward prism"
return (
"Planar face push/pull completed: "
f"{action}, semantic_distance={distance:g}, "
f"tool_overlap={overlap:g}, "
f"scope_faces={len(scope_face_ids)}, "
f"outward_direction={_format_tuple(outward)}, "
f"direction_confidence={plan['direction_confidence']}, "
f"risk={plan['risk']}."
)
def _cylindrical_cap_extension_plan(
self,
face_id: int,
distance: float,
outward: tuple[float, float, float],
) -> dict[str, object] | None:
if distance <= 0:
return None
if face_id < 0 or face_id >= len(self.faces):
return None
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1:
return None
cap_center = _surface_center(self.faces[face_id])
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)
if not adjacent_face_ids:
return None
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2]))
candidates: list[tuple[float, dict[str, object]]] = []
for adjacent_id in adjacent_face_ids:
side_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
if side_surf.GetType() != GeomAbs_Cylinder:
continue
angular_span = abs(side_surf.LastUParameter() - side_surf.FirstUParameter())
if angular_span < math.tau * 0.92:
continue
cylinder = side_surf.Cylinder()
radius = float(cylinder.Radius())
axis = cylinder.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
axis_alignment = _direction_dot(outward_dir, axis_dir)
if abs(axis_alignment) < 0.92:
continue
if _point_axis_distance(axis_point, axis_dir, cap_center) > max(radius * 0.08, tolerance * 10.0):
continue
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
old_height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_center)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if axis_alignment > 0 and end_distance <= end_tolerance:
new_min = v_min
new_max = v_max + distance
end_score = end_distance
elif axis_alignment < 0 and start_distance <= end_tolerance:
new_min = v_min - distance
new_max = v_max
end_score = start_distance
else:
continue
height = max(new_max - new_min, 1e-6)
start = _point_on_axis(axis_point, axis_dir, new_min)
tool_shape = BRepPrimAPI_MakeCylinder(gp_Ax2(start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, height).Shape()
score = end_score + _point_axis_distance(axis_point, axis_dir, cap_center)
candidate = {
"tool_shape": tool_shape,
"side_face_ids": axis_range["same_domain_face_ids"],
"radius": radius,
"old_height": old_height,
"new_height": height,
"axis_alignment": axis_alignment,
"cap_axis_parameter": cap_parameter,
"start_parameter": new_min,
"end_parameter": new_max,
}
candidates.append((score, candidate))
if not candidates:
return None
distinct_radii: list[float] = []
for _score, candidate in candidates:
radius = float(candidate["radius"])
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii):
distinct_radii.append(radius)
if len(distinct_radii) > 1:
return None
return min(candidates, key=lambda item: item[0])[1]
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":
raise ValueError(str(plan["message"]))
part_id = int(plan["part_id"])
part = self.part_by_id(part_id)
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(
"已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;"
"该圆角可能不是简单直线边圆角。"
)
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}"
)
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']}."
)
def fillet_edge(self, edge_id: int, radius: float) -> str:
plan = self.edge_fillet_plan(edge_id, radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
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()
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']}."
)
def chamfer_edge(self, edge_id: int, distance: float) -> str:
plan = self.edge_chamfer_plan(edge_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
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()
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']}."
)
def chamfer_edge_asymmetric(
self,
edge_id: int,
distance1: float,
distance2: float,
reference_face_id: int | None = None,
) -> str:
plan = self.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
resolved_reference_face_id = int(plan["reference_face_id"])
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()
return (
f"Asymmetric Edge chamfer completed: edge {edge_id}, "
f"distance1={float(distance1):g}, distance2={float(distance2):g}, "
f"reference_face={resolved_reference_face_id}, "
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']}."
)
def chamfer_edge_distance_angle(
self,
edge_id: int,
distance: float,
angle_degrees: float,
reference_face_id: int | None = None,
) -> str:
plan = self.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
resolved_reference_face_id = int(plan["reference_face_id"])
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()
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']}."
)
def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
return self.resize_cylindrical_hole(face_id, new_diameter)
def resize_cylindrical_slot_width(
self,
face_id: int,
target_width: float,
pair_face_id: int | None = None,
) -> str:
return self._resize_cylindrical_slot_parameter(face_id, target_width, "width", pair_face_id=pair_face_id)
def resize_cylindrical_slot_depth(
self,
face_id: int,
target_depth: float,
pair_face_id: int | None = None,
) -> str:
return self._resize_cylindrical_slot_parameter(face_id, target_depth, "depth", pair_face_id=pair_face_id)
def resize_cylindrical_slot_arc_length(
self,
face_id: int,
target_arc_length: float,
pair_face_id: int | None = None,
) -> str:
return self._resize_cylindrical_slot_parameter(face_id, target_arc_length, "arc_length", pair_face_id=pair_face_id)
def resize_cylindrical_slot_angular_span(self, face_id: int, target_angular_span: float) -> str:
plan = self.cylindrical_slot_angular_span_plan(face_id, target_angular_span)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self._resize_cylindrical_slot_angular_span_with_sector_tool(plan)
def resize_cylindrical_slot_total_length(
self,
face_id: int,
target_total_length: float,
pair_face_id: int | None = None,
) -> str:
plan = self.cylindrical_slot_total_length_plan(face_id, target_total_length, pair_face_id=pair_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self._resize_cylindrical_slot_length_with_capsule_tool(plan)
def _resize_cylindrical_slot_parameter(
self,
face_id: int,
target_value: float,
mode: str,
pair_face_id: int | None = None,
) -> str:
plan = self.cylindrical_slot_resize_plan(face_id, target_value, mode, pair_face_id=pair_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
target_diameter = float(plan["slot_target_diameter"])
if plan.get("slot_resize_strategy") == "paired-obround-slot-prism":
resize_result = self._resize_cylindrical_slot_with_capsule_tool(plan)
tool_path = "paired-obround-slot-prism"
else:
resize_result = self._resize_cylindrical_slot_with_sector_tool(plan)
tool_path = "slot-sector"
mode_key = str(plan.get("slot_resize_mode") or mode)
if mode_key == "depth":
current_value = plan.get("slot_current_depth")
final_value = plan.get("slot_target_depth")
label = "depth"
elif mode_key == "arc_length":
current_value = plan.get("slot_current_arc_length")
final_value = plan.get("slot_target_arc_length")
label = "arc_length"
else:
current_value = plan.get("slot_current_width")
final_value = plan.get("slot_target_width")
label = "width"
def fmt(value: object) -> str:
try:
return f"{float(value):g}"
except (TypeError, ValueError):
return ""
return (
f"Cylindrical slot {label} resize completed: face {face_id}, "
f"{label}={fmt(current_value)}->{fmt(final_value)}, "
f"derived_diameter={target_diameter:g}, "
f"angular_span={fmt(plan.get('slot_angular_span'))}, "
f"strategy={plan.get('slot_resize_strategy')}, "
f"tool_path={tool_path}, "
f"risk={plan['risk']}. {resize_result}"
)
def _resize_cylindrical_slot_length_with_capsule_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
target_plan = dict(plan)
target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1")
target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2")
try:
filler = self._slot_capsule_prism_tool(plan, float(plan["fill_radius"]))
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "obround slot length fill/fuse")
cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) / 2.0)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "obround slot length cut")
part.shape = result
self.refresh_topology()
verification = self._verify_obround_slot_length_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "obround slot length result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Obround cylindrical slot total length resize completed: "
f"length {float(plan.get('slot_current_total_length', 0.0)):g} -> {float(plan['slot_target_total_length']):g}, "
f"center_distance={float(plan['slot_current_center_distance']):g}->{float(plan['slot_target_center_distance']):g}, "
f"diameter={float(plan['slot_target_diameter']):g}, "
f"paired_face={plan.get('slot_pair_face_id', '')}, "
f"mode={plan['resize_mode']}, action=capsule fill and recut, "
f"verified_face={verification.get('face_id', '')}."
)
def _resize_cylindrical_slot_angular_span_with_sector_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
try:
filler = self._slot_sector_prism_tool(
face_id,
float(plan["fill_radius"]),
float(plan["fill_start_parameter"]),
float(plan["fill_end_parameter"]),
)
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "slot angular-span fill/fuse")
cutter = self._slot_sector_prism_tool(
face_id,
float(plan["slot_target_diameter"]) / 2.0,
float(plan["cutter_start_parameter"]),
float(plan["cutter_end_parameter"]),
u_first_override=float(plan["slot_target_u_first"]),
u_last_override=float(plan["slot_target_u_last"]),
)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "slot angular-span cut")
part.shape = result
self.refresh_topology()
verification = self._verify_slot_angular_span_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "slot angular-span result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Cylindrical slot angular-span resize completed: "
f"span {float(plan['slot_current_angular_span']):g} -> {float(plan['slot_target_angular_span']):g}, "
f"degrees={math.degrees(float(plan['slot_current_angular_span'])):g}->{math.degrees(float(plan['slot_target_angular_span'])):g}, "
f"diameter={float(plan['slot_target_diameter']):g}, "
f"mode={plan['resize_mode']}, action=sector fill and recut, "
f"height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def _verify_slot_angular_span_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_span = _float_or_none(plan.get("slot_target_angular_span"))
target_diameter = _float_or_none(plan.get("slot_target_diameter"))
if target_span is None or target_span <= 1e-9 or target_diameter is None or target_diameter <= 1e-9:
return {"matched": False, "detail": " Missing target slot angular-span verification data."}
try:
axis_point = gp_Pnt(*plan["cutter_axis_point"])
axis_direction = gp_Dir(*plan["cutter_axis_direction"])
except Exception:
return {"matched": False, "detail": " Missing original slot axis data."}
target_radius = target_diameter * 0.5
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5)
axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4)
span_tolerance = max(target_span * 0.08, 0.02)
best: dict[str, object] | None = None
best_score = math.inf
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
candidate_radius = float(cyl.Radius())
radius_error = abs(candidate_radius - target_radius)
axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction()))
if axis_dot < 1.0 - 1e-5:
continue
axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location())
angular_span = abs(float(surf.LastUParameter()) - float(surf.FirstUParameter()))
span_error = abs(angular_span - target_span)
except Exception:
continue
score = (
radius_error / max(radius_tolerance, 1e-9)
+ axis_distance / max(axis_tolerance, 1e-9)
+ span_error / max(span_tolerance, 1e-9)
)
candidate = {
"matched": (
radius_error <= radius_tolerance
and axis_distance <= axis_tolerance
and span_error <= span_tolerance
),
"face_id": face_id,
"angular_span": angular_span,
"span_error": span_error,
"diameter": candidate_radius * 2.0,
"radius_error": radius_error,
"axis_distance": axis_distance,
"span_tolerance": span_tolerance,
}
if candidate["matched"]:
return candidate
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " No cylindrical slot face on the original axis was found after angular-span edit."}
return {
"matched": False,
"detail": (
f" Closest slot Face {best['face_id']} span {float(best['angular_span']):.6g}, "
f"target {target_span:.6g}, span error {float(best['span_error']):.6g}, "
f"diameter {float(best['diameter']):.6g}."
),
**best,
}
def _verify_obround_slot_length_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_distance = _float_or_none(plan.get("slot_target_center_distance"))
target_diameter = _float_or_none(plan.get("slot_target_diameter"))
axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction")))
length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction")))
if target_distance is None or target_diameter is None or axis_dir is None or length_dir is None:
return {"matched": False, "detail": " Missing obround slot length verification data."}
target_radius = target_diameter * 0.5
if target_radius <= 1e-9 or target_distance <= 1e-9:
return {"matched": False, "detail": " Invalid target slot length or diameter."}
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5)
distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4)
candidates: list[dict[str, object]] = []
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
radius = float(cyl.Radius())
if abs(radius - target_radius) > radius_tolerance:
continue
candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction()))
if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4:
continue
axis_range = self._cylindrical_axis_range(face_id, surf)
mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5
mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter))
candidates.append({"face_id": face_id, "mid": mid, "radius": radius})
except Exception:
continue
best: dict[str, object] | None = None
best_score = math.inf
for index, first in enumerate(candidates):
for second in candidates[index + 1 :]:
raw_offset = _tuple_sub(second["mid"], first["mid"])
axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir))
section_offset = _tuple_sub(raw_offset, axis_offset)
distance = _vector_length(section_offset)
if distance <= 1e-9:
continue
direction = _tuple_normalized(section_offset)
if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96:
continue
error = abs(distance - target_distance)
score = error / max(distance_tolerance, 1e-9)
candidate = {
"matched": error <= distance_tolerance,
"face_id": first["face_id"],
"paired_face_id": second["face_id"],
"center_distance": distance,
"target_center_distance": target_distance,
"center_distance_error": error,
"center_distance_tolerance": distance_tolerance,
}
if candidate["matched"]:
return candidate
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " No paired cylindrical slot ends matching the target radius were found."}
return {
"matched": False,
"detail": (
f" Closest paired slot center distance {float(best['center_distance']):.6g}, "
f"target {target_distance:.6g}, error {float(best['center_distance_error']):.6g}."
),
**best,
}
def _resize_cylindrical_slot_with_capsule_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Obround slot resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = float(cyl.Radius())
new_radius = float(plan["slot_target_diameter"]) / 2.0
if new_radius <= 0:
raise ValueError("Target slot diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
try:
if plan["resize_mode"] == "shrink":
filler = self._slot_capsule_prism_tool(plan, float(plan["fill_radius"]))
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "obround slot fill/fuse")
cutter = self._slot_capsule_prism_tool(plan, new_radius)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "obround slot cut")
part.shape = result
self.refresh_topology()
verification = self._verify_cylindrical_resize_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "obround slot result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
action = "capsule cut" if plan["resize_mode"] == "enlarge" else "capsule fill and recut"
return (
"Obround cylindrical slot resize completed: "
f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, "
f"paired_face={plan.get('slot_pair_face_id', '')}, "
f"axis_distance={float(plan.get('slot_pair_axis_distance', 0.0)):g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def _slot_capsule_prism_tool(self, plan: dict[str, object], radius: float) -> TopoDS_Shape:
if radius <= 1e-9:
raise ValueError("Slot capsule radius must be greater than 0.")
center_1 = _tuple_or_none(plan.get("slot_capsule_start_center_1"))
center_2 = _tuple_or_none(plan.get("slot_capsule_start_center_2"))
axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction")))
length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction")))
side_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_side_direction")))
if center_1 is None or center_2 is None or axis_dir is None or length_dir is None or side_dir is None:
raise ValueError("Obround slot tool is missing capsule frame information.")
center_distance = _vector_length(_tuple_sub(center_2, center_1))
if center_distance <= 1e-9:
raise ValueError("Obround slot tool requires two distinct slot centers.")
if abs(_tuple_dot(axis_dir, length_dir)) > 1e-4 or abs(_tuple_dot(axis_dir, side_dir)) > 1e-4:
raise ValueError("Obround slot capsule frame is not perpendicular to the extrusion axis.")
if abs(_tuple_dot(length_dir, side_dir)) > 1e-4:
raise ValueError("Obround slot capsule frame length/side axes are not perpendicular.")
sample_count = max(12, min(96, int(math.pi * max(radius, 1.0) / max(radius * 0.12, 0.02))))
points: list[tuple[float, float, float]] = [
_tuple_add(center_1, _tuple_scale(side_dir, radius)),
_tuple_add(center_2, _tuple_scale(side_dir, radius)),
]
for index in range(1, sample_count + 1):
theta = math.pi / 2.0 - math.pi * index / sample_count
radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius))
points.append(_tuple_add(center_2, radial))
points.append(_tuple_add(center_1, _tuple_scale(side_dir, -radius)))
for index in range(1, sample_count + 1):
theta = -math.pi / 2.0 - math.pi * index / sample_count
radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius))
points.append(_tuple_add(center_1, radial))
tool_face = self._make_local_polygon_face(self._dedupe_local_points(points, max(radius * 1e-7, 1e-7)))
height = max(float(plan.get("cutter_height", 0.0)), 1e-6)
vec = gp_Vec(axis_dir[0] * height, axis_dir[1] * height, axis_dir[2] * height)
return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, vec), "obround slot capsule prism")
def _resize_cylindrical_slot_with_sector_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Slot resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = float(plan["slot_target_diameter"]) / 2.0
if new_radius <= 0:
raise ValueError("Target slot diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
try:
if plan["resize_mode"] == "shrink":
filler = self._slot_sector_prism_tool(
face_id,
float(plan["fill_radius"]),
float(plan["fill_start_parameter"]),
float(plan["fill_end_parameter"]),
)
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "slot sector fill/fuse")
cutter = self._slot_sector_prism_tool(
face_id,
new_radius,
float(plan["cutter_start_parameter"]),
float(plan["cutter_end_parameter"]),
)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "slot sector cut")
part.shape = result
self.refresh_topology()
verification = self._verify_cylindrical_resize_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "slot sector result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
action = "sector cut" if plan["resize_mode"] == "enlarge" else "sector fill and recut"
return (
"Cylindrical slot sector resize completed: "
f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def _slot_sector_prism_tool(
self,
face_id: int,
radius: float,
start_parameter: float,
end_parameter: float,
*,
u_first_override: float | None = None,
u_last_override: float | None = None,
) -> TopoDS_Shape:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Slot sector tool requires a cylindrical face.")
if radius <= 1e-9:
raise ValueError("Slot sector tool radius must be greater than 0.")
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
direction = axis.Direction()
u_first = float(surf.FirstUParameter()) if u_first_override is None else float(u_first_override)
u_last = float(surf.LastUParameter()) if u_last_override is None else float(u_last_override)
span = abs(u_last - u_first)
if span <= 1e-6 or span >= math.tau * 0.98:
raise ValueError("Slot sector tool requires a stable partial-cylinder U span.")
if end_parameter < start_parameter:
start_parameter, end_parameter = end_parameter, start_parameter
height = max(float(end_parameter) - float(start_parameter), 1e-6)
pad = min(max(1e-4, 0.001 / max(float(radius), 1e-6)), max(span * 0.02, 1e-4))
if u_last >= u_first:
u_start = u_first - pad
u_end = u_last + pad
else:
u_start = u_first + pad
u_end = u_last - pad
sample_count = max(8, min(96, int(abs(u_end - u_start) / (math.pi / 36.0)) + 2))
v_mid = (float(surf.FirstVParameter()) + float(surf.LastVParameter())) / 2.0
axis_start = _point_on_axis(axis_point, direction, float(start_parameter))
axis_start_tuple = _point_tuple(axis_start)
points: list[tuple[float, float, float]] = [axis_start_tuple]
for index in range(sample_count):
u = u_start + (u_end - u_start) * index / max(sample_count - 1, 1)
source = surf.Value(u, v_mid)
source_parameter = _axis_parameter(axis_point, direction, source)
source_axis = _point_on_axis(axis_point, direction, source_parameter)
radial = _tuple_sub(_point_tuple(source), _point_tuple(source_axis))
unit = _tuple_normalized(radial)
if unit is None:
raise ValueError("Could not derive slot sector radial direction.")
points.append(
(
axis_start_tuple[0] + unit[0] * float(radius),
axis_start_tuple[1] + unit[1] * float(radius),
axis_start_tuple[2] + unit[2] * float(radius),
)
)
tool_face = self._make_local_polygon_face(points)
vec = gp_Vec(direction.X() * height, direction.Y() * height, direction.Z() * height)
return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, vec), "slot sector prism")
def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Hole resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
if new_radius <= 0:
raise ValueError("Target diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
direction = cyl.Axis().Direction()
old_part_shape = part.shape
source_shape = part.shape
if plan["resize_mode"] == "shrink":
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse")
cutter_start = gp_Pnt(*plan["cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape()
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "cylinder cut")
part.shape = result
self.refresh_topology()
verification = self._verify_cylindrical_resize_result(plan, part_id)
if not verification["matched"]:
part.shape = old_part_shape
self.refresh_topology()
detail = str(verification.get("detail", ""))
raise RuntimeError(
"孔/圆柱直径布尔计算返回了结果,但结果里没有检测到目标直径的圆柱面,"
"已回滚到修改前状态。"
f"{detail}"
)
action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut"
return (
f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def move_cylindrical_hole_axis(
self,
face_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.cylindrical_axis_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
direction = gp_Dir(*plan["cutter_axis_direction"])
old_part_shape = part.shape
try:
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, direction)
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
filled_shape = _finalize_boolean_result(fuse, "cylinder axis move old-hole fill/fuse")
cutter_start = gp_Pnt(*plan["target_cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, direction)
cutter = BRepPrimAPI_MakeCylinder(
cutter_axis,
float(plan["target_radius"]),
float(plan["cutter_height"]),
).Shape()
op = BRepAlgoAPI_Cut(filled_shape, cutter)
result = _finalize_boolean_result(op, "cylinder axis move target-hole cut")
part.shape = result
self.refresh_topology()
verification_plan = dict(plan)
verification_plan["cutter_axis_point"] = plan["target_cutter_axis_point"]
verification_plan["cutter_start_point"] = plan["target_cutter_start_point"]
verification = self._verify_cylindrical_resize_result(verification_plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "cylinder axis move result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Cylindrical hole axis move completed: "
f"face {face_id}, "
f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, "
f"move={plan.get('axis_move_vector')}, "
f"diameter={float(plan['target_diameter']):g}, "
f"height={float(plan['cutter_height']):g}, "
f"risk={plan['risk']}, "
f"verified_face={verification.get('face_id', '')}."
)
def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_diameter = float(plan.get("target_diameter", 0.0))
target_radius = target_diameter / 2.0
if target_radius <= 1e-9:
return {"matched": False, "detail": " 目标直径无效。"}
try:
axis_point = gp_Pnt(*plan["cutter_axis_point"])
axis_direction = gp_Dir(*plan["cutter_axis_direction"])
except Exception:
return {"matched": False, "detail": " 缺少原孔轴信息,无法确认结果。"}
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5)
axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4)
best: dict[str, object] | None = None
best_score = math.inf
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
candidate_radius = float(cyl.Radius())
radius_error = abs(candidate_radius - target_radius)
axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction()))
if axis_dot < 1.0 - 1e-5:
continue
axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location())
except Exception:
continue
score = radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9)
candidate = {
"matched": radius_error <= radius_tolerance and axis_distance <= axis_tolerance,
"face_id": face_id,
"diameter": candidate_radius * 2.0,
"radius_error": radius_error,
"axis_distance": axis_distance,
"radius_tolerance": radius_tolerance,
"axis_tolerance": axis_tolerance,
}
if candidate["matched"]:
return candidate
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " 未找到同零件内与原孔轴平行的圆柱面。"}
return {
"matched": False,
"detail": (
f" 最近候选 Face {best['face_id']} 的直径约 {float(best['diameter']):.6g}"
f"目标直径 {target_diameter:.6g}"
f"半径误差 {float(best['radius_error']):.6g}"
f"轴距 {float(best['axis_distance']):.6g}。"
),
**best,
}
def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
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")
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")
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")
action = "shrunk by removing old envelope and fusing target cylinder"
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}."
)
def move_cylindrical_boss_axis(
self,
face_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.cylindrical_boss_axis_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
verification: dict[str, object] = {}
try:
direction = gp_Dir(*plan["boss_tool_axis_direction"])
height = float(plan["boss_tool_height"])
removal_start = gp_Pnt(*plan["boss_tool_start_point"])
removal_axis = gp_Ax2(removal_start, direction)
removal_radius = float(
plan.get("boss_tool_outer_radius")
or plan.get("boss_tool_old_radius")
or plan.get("target_radius")
)
removal = BRepPrimAPI_MakeCylinder(removal_axis, removal_radius, height).Shape()
remove_op = BRepAlgoAPI_Cut(part.shape, removal)
removed = _finalize_boolean_result(remove_op, "cylindrical boss axis move remove old envelope")
target_start_values = _tuple_or_none(plan.get("target_boss_tool_start_point"))
target_height = height
if _topology_shape_count(removed, TopAbs_FACE) == 0:
exact_start = _tuple_or_none(plan.get("target_boss_tool_exact_start_point"))
if exact_start is not None:
target_start_values = exact_start
target_height = float(plan.get("boss_tool_exact_height") or height)
if target_start_values is None:
raise ValueError("Could not derive target boss tool start point.")
target_start = gp_Pnt(*target_start_values)
target_axis = gp_Ax2(target_start, direction)
replacement = BRepPrimAPI_MakeCylinder(
target_axis,
float(plan["target_boss_tool_radius"]),
max(target_height, 1e-6),
).Shape()
if _topology_shape_count(removed, TopAbs_FACE) == 0:
result = _ensure_valid_or_repaired_shape(replacement, "cylindrical boss axis move replacement")
action = "rebuilt moved cylinder"
else:
fuse_op = BRepAlgoAPI_Fuse(removed, replacement)
result = _finalize_boolean_result(fuse_op, "cylindrical boss axis move fuse target cylinder")
action = "removed old envelope and fused moved cylinder"
part.shape = result
self.refresh_topology()
verification_plan = dict(plan)
verification_plan["cutter_axis_point"] = plan["target_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 axis move result verification failed"))
raise RuntimeError(
"圆柱凸台轴心坐标布尔计算返回了结果,但结果里没有检测到目标轴心位置的圆柱凸台,"
"已回滚到修改前状态。"
f"{detail}"
)
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Cylindrical boss axis move completed: "
f"face {face_id}, "
f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, "
f"move={plan.get('axis_move_vector')}, "
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', '')}."
)
def suppress_cylindrical_hole(self, face_id: int) -> str:
plan = self.cylindrical_suppress_plan(face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Cylinder suppress currently supports cylindrical faces only.")
direction = surf.Cylinder().Axis().Direction()
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
result = _finalize_boolean_result(fuse, "cylinder suppress/fill")
part.shape = result
self.refresh_topology()
return (
f"Cylindrical hole suppress completed: face {face_id}, "
f"diameter={float(plan['diameter']):g}, "
f"height={float(plan['fill_height']):g}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}."
)
def resize_cylindrical_depth(
self,
face_id: int,
target_depth: float,
bottom_face_id: int | None = None,
) -> str:
plan = self.cylindrical_depth_plan(
face_id,
target_depth,
bottom_face_id=bottom_face_id,
)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
start = gp_Pnt(*plan["depth_tool_start_point"])
direction = gp_Dir(*plan["depth_axis_direction"])
axis = gp_Ax2(start, direction)
tool = BRepPrimAPI_MakeCylinder(
axis,
float(plan["depth_tool_radius"]),
float(plan["depth_tool_height"]),
).Shape()
if plan["depth_mode"] == "deepen":
op = BRepAlgoAPI_Cut(part.shape, tool)
result = _finalize_boolean_result(op, "blind depth cut")
action = "deepened by bounded cut"
else:
op = BRepAlgoAPI_Fuse(part.shape, tool)
result = _finalize_boolean_result(op, "blind depth fill/fuse")
action = "made shallower by bounded fill"
part.shape = result
self.refresh_topology()
return (
f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, "
f"mode={plan['depth_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}."
)