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

2598 lines
121 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"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}."
)