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

2598 lines
121 KiB
Python
Raw Normal View History

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"Unknown part id {part_id}")
if part.shape.IsNull():
raise RuntimeError(f"Part {part_id} has a null shape and cannot be repaired.")
before_stats = self.part_topology_stats(part_id)
repaired = _prepare_shape_for_step_export(part.shape)
if repaired.IsNull():
raise RuntimeError(f"Part {part_id} repair returned a null shape.")
part.shape = repaired
self.refresh_topology()
after_stats = self.part_topology_stats(part_id)
return (
f"Part repair completed: part {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倒圆会作用在整个Part 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倒角会作用在整个Part 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 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, "局部边形变不可用:找不到所属Part。"
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_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。"
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 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 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 _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 {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 == "local-edge-only-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)
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]),
"endpoint-local",
)
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
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]))
best: tuple[float, dict[str, object]] | None = None
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,
}
if best is None or score < best[0]:
best = (score, candidate)
return best[1] if best is not None else None
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 enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
return self.resize_cylindrical_hole(face_id, new_diameter)
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()
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()
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}."
)
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 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}."
)