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

9251 lines
448 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_MakeEdge,
BRepBuilderAPI_GTransform,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeWire,
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_MakeCone, 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.GC import GC_MakeArcOfCircle
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
def _int_or_none(value: object) -> int | None:
if value in {"", None}:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _angle_degrees_or_none(value: object) -> float | None:
radians = _float_or_none(value)
if radians is None:
return None
return abs(math.degrees(radians))
def _result_value_error(actual: object, target: object) -> float:
if isinstance(actual, tuple) and isinstance(target, tuple):
if len(actual) != len(target):
return math.inf
return max(abs(float(actual[index]) - float(target[index])) for index in range(len(actual)))
try:
return abs(float(actual) - float(target))
except (TypeError, ValueError):
return math.inf
def _format_result_number(value: object) -> str:
try:
number = float(value)
except (TypeError, ValueError):
return str(value)
if not math.isfinite(number):
return str(number)
return f"{number:g}"
class OperationMixin:
def _face_first_level_plan_fields(self, face_id: int) -> dict[str, object]:
try:
topology = self.face_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"first_level_adjacent_face_ids": (),
"first_level_adjacent_face_count": 0,
"first_level_boundary_edge_ids": (),
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"same_domain_face_ids": (face_id,),
"same_domain_face_count": 1,
}
fields = {
"topology_relation_depth": topology.get("topology_relation_depth", 1),
"topology_relation_model": topology.get("topology_relation_model"),
"topology_relation_scope": topology.get("topology_relation_scope"),
"topology_relation_boundary": topology.get("topology_relation_boundary"),
"topology_relation_status": "ready",
"topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()),
"topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"same_domain_face_ids": topology.get("same_domain_face_ids", (face_id,)),
"same_domain_face_count": topology.get("same_domain_face_count", 1),
"same_domain_region_kind": topology.get("same_domain_region_kind", "single-face"),
"selected_boundary_edge_ids": topology.get("selected_boundary_edge_ids", ()),
"selected_boundary_edge_count": topology.get("selected_boundary_edge_count", 0),
"first_level_boundary_edge_ids": topology.get("first_level_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("first_level_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("first_level_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("first_level_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("first_level_adjacent_face_count", 0),
"first_level_adjacent_surface_types": topology.get("first_level_adjacent_surface_types", ()),
"first_level_shared_edges_by_face": topology.get("first_level_shared_edges_by_face", ()),
"first_level_face_ids": topology.get("first_level_face_ids", ()),
"first_level_face_count": topology.get("first_level_face_count", 0),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
}
fields["first_level_edit_semantics"] = (
"当前 Face 阶段只使用一级共享边拓扑:当前同域 Face 区域会作为编辑对象,"
"直接相邻 Face 会跟随重建或作为推拉侧壁参与结果校验;二级/三级关系暂不递归传播。"
)
return fields
def repair_model(self) -> str:
before_stats = self.stats()
repaired_parts = 0
skipped_parts = 0
for part in self.display_parts():
if part.shape.IsNull():
skipped_parts += 1
continue
repaired = _prepare_shape_for_step_export(part.shape)
if repaired.IsNull():
skipped_parts += 1
continue
part.shape = repaired
repaired_parts += 1
if repaired_parts == 0:
raise RuntimeError("No valid part shape was available for repair.")
self.refresh_topology()
after_stats = self.stats()
return (
"Model repair completed: "
f"parts repaired={repaired_parts}, skipped={skipped_parts}, "
f"solids {before_stats.solids}->{after_stats.solids}, "
f"faces {before_stats.faces}->{after_stats.faces}, "
f"edges {before_stats.edges}->{after_stats.edges}."
)
def repair_part(self, part_id: int) -> str:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"未知零件 ID {part_id}")
if part.shape.IsNull():
raise RuntimeError(f"零件 {part_id} 为空 shape,无法修复。")
before_stats = self.part_topology_stats(part_id)
repaired = _prepare_shape_for_step_export(part.shape)
if repaired.IsNull():
raise RuntimeError(f"零件 {part_id} 修复结果为空 shape。")
part.shape = repaired
self.refresh_topology()
after_stats = self.part_topology_stats(part_id)
return (
f"零件修复完成: 零件 {part_id}, "
f"solids {before_stats.solids}->{after_stats.solids}, "
f"faces {before_stats.faces}->{after_stats.faces}, "
f"edges {before_stats.edges}->{after_stats.edges}."
)
def repair_solid(self, solid_id: int) -> str:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
before_part_stats = self.part_topology_stats(part_id)
repaired = _prepare_shape_for_step_export(solid)
if repaired.IsNull():
raise RuntimeError(f"Solid {solid_id} repair returned a null shape.")
part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1:
part.shape = repaired
else:
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and _same_shape(item, solid):
shapes.append(repaired)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
after_part_stats = self.part_topology_stats(part_id)
return (
f"Solid repair completed: solid {solid_id}, part {part_id}, "
f"part solids {before_part_stats.solids}->{after_part_stats.solids}, "
f"faces {before_part_stats.faces}->{after_part_stats.faces}, "
f"edges {before_part_stats.edges}->{after_part_stats.edges}."
)
def edge_fillet_plan(self, edge_id: int, radius: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
readiness = _edge_fillet_readiness(info, radius)
part_id = int(info["part_id"])
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
readiness = dict(readiness)
if readiness["fillet_status"] != "blocked":
readiness["fillet_status"] = "caution"
readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high")
readiness["fillet_warnings"] = _join_nonempty(
readiness["fillet_warnings"],
f"当前零件包含 {part_stats.solids} 个SolidEdge倒圆会作用在整个零件 shape 上,请导出前检查结果。",
)
readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"])
length = float(info.get("length", 0.0))
radius_to_length_ratio = radius / max(length, 1e-9)
return {
"status": readiness["fillet_status"],
"risk": readiness["fillet_risk"],
"message": readiness["fillet_note"],
"warnings": readiness["fillet_warnings"],
"blockers": readiness["fillet_blockers"],
"edge_id": edge_id,
"part_id": info["part_id"],
"solid_id": info.get("solid_id", -1),
"curve": info.get("curve"),
"edge_length": length,
"target_radius": radius,
"radius_to_length_ratio": radius_to_length_ratio,
"adjacent_face_ids": info.get("adjacent_face_ids", ()),
"adjacent_face_count": info.get("adjacent_face_count", 0),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"direction": info.get("direction"),
"resize_strategy": "add-edge-fillet",
"edit_strategy_label": "给Edge添加新圆角",
"edit_semantics": (
"在当前直线 Edge 及其相邻 Face 上调用 OCCT 倒圆;会替换这条边附近的局部拓扑,"
"不是修改已有圆角面。"
),
}
def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
readiness = _edge_chamfer_readiness(info, distance)
part_id = int(info["part_id"])
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
readiness = dict(readiness)
if readiness["chamfer_status"] != "blocked":
readiness["chamfer_status"] = "caution"
readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high")
readiness["chamfer_warnings"] = _join_nonempty(
readiness["chamfer_warnings"],
f"当前零件包含 {part_stats.solids} 个SolidEdge倒角会作用在整个零件 shape 上,请导出前检查结果。",
)
readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"])
length = float(info.get("length", 0.0))
distance_to_length_ratio = distance / max(length, 1e-9)
return {
"status": readiness["chamfer_status"],
"risk": readiness["chamfer_risk"],
"message": readiness["chamfer_note"],
"warnings": readiness["chamfer_warnings"],
"blockers": readiness["chamfer_blockers"],
"edge_id": edge_id,
"part_id": info["part_id"],
"solid_id": info.get("solid_id", -1),
"curve": info.get("curve"),
"edge_length": length,
"target_distance": distance,
"distance_to_length_ratio": distance_to_length_ratio,
"adjacent_face_ids": info.get("adjacent_face_ids", ()),
"adjacent_face_count": info.get("adjacent_face_count", 0),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"direction": info.get("direction"),
"resize_strategy": "add-edge-symmetric-chamfer",
"edit_strategy_label": "给Edge添加对称倒角",
"edit_semantics": (
"在当前直线 Edge 及其相邻 Face 上调用 OCCT 对称倒角;会替换这条边附近的局部拓扑。"
),
}
def edge_asymmetric_chamfer_plan(
self,
edge_id: int,
distance1: float,
distance2: float,
reference_face_id: int | None = None,
) -> dict[str, object]:
distance1 = float(distance1)
distance2 = float(distance2)
max_distance = max(distance1, distance2)
plan = self.edge_chamfer_plan(edge_id, max_distance)
info = self.edge_info(edge_id)
length = float(info.get("length", 0.0))
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else []
warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else []
risk = str(plan.get("risk", "medium"))
if distance1 <= 0 or distance2 <= 0:
blockers.append("Asymmetric chamfer distances D1 and D2 must both be greater than 0.")
if length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if len(adjacent_face_ids) < 2:
blockers.append("Asymmetric chamfer needs at least two adjacent Faces on the selected Edge.")
resolved_reference_face_id: int | None
if reference_face_id is None:
resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None
else:
try:
resolved_reference_face_id = int(reference_face_id)
except (TypeError, ValueError):
resolved_reference_face_id = None
blockers.append("Reference Face ID must be an integer.")
if resolved_reference_face_id is None:
blockers.append("Could not resolve an adjacent reference Face for asymmetric chamfer.")
elif resolved_reference_face_id not in adjacent_face_ids:
blockers.append(
f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; "
f"available adjacent Faces: {tuple(adjacent_face_ids)}."
)
elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces):
blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.")
ratio1 = distance1 / max(length, 1e-9)
ratio2 = distance2 / max(length, 1e-9)
if length > 1e-9:
if max(ratio1, ratio2) >= 0.45:
blockers.append("D1 or D2 is close to half of the Edge length; asymmetric chamfer is blocked.")
elif max(ratio1, ratio2) > 0.25:
risk = _max_risk(risk, "high")
warnings.append("D1 or D2 is larger than 25% of the Edge length; OCCT chamfer failure is more likely.")
elif max(ratio1, ratio2) > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("D1 or D2 is relatively large compared with the Edge length.")
if abs(distance1 - distance2) <= max(max_distance * 1e-6, 1e-7):
warnings.append("D1 and D2 are almost equal; the result will be close to a symmetric chamfer.")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings) if warnings else "Asymmetric chamfer can be attempted, but it depends on OCCT."
else:
status = "ready"
message = "Asymmetric chamfer can be attempted on this straight Edge."
plan.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
"chamfer_mode": "asymmetric-distances",
"target_distance": max_distance,
"target_distance1": distance1,
"target_distance2": distance2,
"distance1_to_length_ratio": ratio1,
"distance2_to_length_ratio": ratio2,
"reference_face_id": resolved_reference_face_id,
"reference_face_candidates": tuple(adjacent_face_ids),
"resize_strategy": "add-edge-asymmetric-chamfer",
"edit_strategy_label": "给Edge添加不等距倒角",
"edit_semantics": (
"按 D1/D2 两个距离在当前 Edge 两侧生成不等距倒角;参考 Face 决定 D1/D2 的方向。"
),
}
)
return plan
def edge_distance_angle_chamfer_plan(
self,
edge_id: int,
distance: float,
angle_degrees: float,
reference_face_id: int | None = None,
) -> dict[str, object]:
distance = float(distance)
angle_degrees = float(angle_degrees)
angle_radians = math.radians(angle_degrees)
plan = self.edge_chamfer_plan(edge_id, distance)
info = self.edge_info(edge_id)
length = float(info.get("length", 0.0))
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else []
warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else []
risk = str(plan.get("risk", "medium"))
if distance <= 0:
blockers.append("Distance-angle chamfer distance must be greater than 0.")
if angle_degrees <= 0 or angle_degrees >= 89.0:
blockers.append("Distance-angle chamfer angle must be greater than 0 and less than 89 degrees.")
elif angle_degrees < 10.0 or angle_degrees > 80.0:
risk = _max_risk(risk, "high")
warnings.append("Chamfer angle is near an extreme value; OCCT failure is more likely.")
elif angle_degrees < 20.0 or angle_degrees > 70.0:
risk = _max_risk(risk, "medium")
warnings.append("Chamfer angle is relatively steep; please check the result carefully.")
if length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if len(adjacent_face_ids) < 2:
blockers.append("Distance-angle chamfer needs at least two adjacent Faces on the selected Edge.")
resolved_reference_face_id: int | None
if reference_face_id is None:
resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None
else:
try:
resolved_reference_face_id = int(reference_face_id)
except (TypeError, ValueError):
resolved_reference_face_id = None
blockers.append("Reference Face ID must be an integer.")
if resolved_reference_face_id is None:
blockers.append("Could not resolve an adjacent reference Face for distance-angle chamfer.")
elif resolved_reference_face_id not in adjacent_face_ids:
blockers.append(
f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; "
f"available adjacent Faces: {tuple(adjacent_face_ids)}."
)
elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces):
blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.")
distance_ratio = distance / max(length, 1e-9)
if length > 1e-9:
if distance_ratio >= 0.45:
blockers.append("Chamfer distance is close to half of the Edge length; distance-angle chamfer is blocked.")
elif distance_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("Chamfer distance is larger than 25% of the Edge length.")
elif distance_ratio > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("Chamfer distance is relatively large compared with the Edge length.")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings) if warnings else "Distance-angle chamfer can be attempted, but it depends on OCCT."
else:
status = "ready"
message = "Distance-angle chamfer can be attempted on this straight Edge."
plan.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
"chamfer_mode": "distance-angle",
"target_distance": distance,
"target_angle_degrees": angle_degrees,
"target_angle_radians": angle_radians,
"distance_to_length_ratio": distance_ratio,
"reference_face_id": resolved_reference_face_id,
"reference_face_candidates": tuple(adjacent_face_ids),
"resize_strategy": "add-edge-distance-angle-chamfer",
"edit_strategy_label": "给Edge添加距离+角度倒角",
"edit_semantics": (
"按距离 D 和角度在当前 Edge 上生成倒角;参考 Face 决定距离和角度的方向。"
),
}
)
return plan
def general_edge_length_plan(
self,
edge_id: int,
target_length: float,
anchor_mode: str = "auto",
strategy_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)
strategy_mode = self._edge_length_strategy_mode(strategy_mode)
force_local = strategy_mode == "local-edge-only-deform"
force_end_face = strategy_mode == "move-edge-end-plane-by-push-pull"
force_cylinder = strategy_mode == "resize-adjacent-cylinder-from-circular-edge-length"
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),
"edge_length_strategy_mode": strategy_mode,
"edge_length_strategy_label": self._edge_length_strategy_label(strategy_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 force_local and curve != "line":
blockers.append("“只变当前Edge”策略当前只支持直线Edge。")
if not blockers and force_end_face and curve != "line":
blockers.append("“移动端面/整体尺寸”策略当前只支持直线Edge。")
if not blockers and force_cylinder and curve != "circle":
blockers.append("“相邻圆柱直径”策略当前只支持圆形/圆弧Edge。")
if not blockers and force_end_face and anchor_mode == "center":
blockers.append("“移动端面/整体尺寸”需要固定起点、固定终点或自动基准,不能使用固定中心。")
if not blockers and curve == "line" and strategy_mode in {"auto", "local-edge-only-deform"}:
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:
if force_local:
blockers.append(local_skip_note)
else:
warnings.append(local_skip_note)
if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center" and strategy_mode in {"auto", "move-edge-end-plane-by-push-pull"}:
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:
message = f"端面推拉路径不可用:{push_plan['message']}"
if force_end_face:
blockers.append(message)
else:
warnings.append(f"{message} 将尝试通用仿射缩放。")
elif anchor_mode in {"keep-start", "keep-end"}:
message = f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面推拉路径。"
if force_end_face:
blockers.append(message)
else:
warnings.append(f"{message} 将尝试按该基准缩放所属对象。")
elif force_end_face:
blockers.append("未找到可用于当前Edge的端面推拉路径。")
elif not blockers and curve == "line" and anchor_mode == "center" and strategy_mode in {"auto", "scale-owning-shape-from-edge"}:
warnings.append("Edge长度基准为固定中心;将使用轴向仿射缩放,让Edge中心尽量保持不动。")
if not blockers and "resize_strategy" not in base and curve == "circle" and strategy_mode in {"auto", "resize-adjacent-cylinder-from-circular-edge-length"}:
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:
if force_cylinder:
blockers.append("未找到可复用的相邻圆柱直径编辑路径:" + " ".join(cylinder_notes[:3]))
else:
warnings.extend(cylinder_notes[:3])
if not blockers and "resize_strategy" not in base and curve != "line" and strategy_mode in {"auto", "scale-owning-shape-from-edge"}:
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 and strategy_mode in {"auto", "scale-owning-shape-from-edge"}:
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,
}
)
elif not blockers and "resize_strategy" not in base and strategy_mode != "auto":
blockers.append(f"当前Edge不满足所选策略“{self._edge_length_strategy_label(strategy_mode)}”的执行条件。")
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(
{
"edge_length_constraint_summary": self._edge_length_constraint_summary(anchor_mode),
"edge_length_impact_summary": self._edge_length_impact_summary(base),
"edit_strategy_label": self._edge_length_strategy_label(str(base.get("resize_strategy") or strategy_mode)),
"edit_semantics": self._edge_length_impact_summary(base),
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
}
)
return base
def _local_edge_length_deform_candidate(
self,
edge_info: dict[str, object],
target_length: float,
anchor_mode: str = "auto",
) -> tuple[dict[str, object] | None, str]:
if str(edge_info.get("curve", "")) != "line":
return None, ""
start = _tuple_or_none(edge_info.get("start_point"))
end = _tuple_or_none(edge_info.get("end_point"))
if start is None or end is None:
return None, "局部边形变不可用:当前 Edge 缺少稳定起点或终点。"
current_length = float(edge_info.get("length", 0.0))
if current_length <= 1e-9:
return None, "局部边形变不可用:当前Edge长度无效。"
axis = _tuple_normalized(_tuple_sub(end, start))
if axis is None:
return None, "局部边形变不可用:当前 Edge 方向无效。"
solid_id = int(edge_info.get("solid_id", -1))
if solid_id < 0 or solid_id >= len(self.solids):
return None, "局部边形变不可用:当前 Edge 没有关联到稳定 Solid。"
part_id = int(edge_info.get("part_id", -1))
part = self.part_by_id(part_id)
if part is None:
return None, "局部边形变不可用:找不到所属零件。"
solid = self.solids[solid_id][1]
solid_faces = _explore(solid, TopAbs_FACE)
if not solid_faces:
return None, "局部边形变不可用:所属Solid没有可重建Face。"
if len(solid_faces) > 128:
return None, "局部边形变暂只对较简单的平面多面体开放,复杂模型将使用后备策略。"
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
return None, "局部边形变暂只支持全平面多面体;含曲面的模型将使用后备策略。"
if len(_explore(face, TopAbs_WIRE)) != 1:
return None, "局部边形变暂不处理带内孔的Face;将使用后备策略。"
if len(self._local_deform_face_vertex_points(face, max(_shape_diagonal(solid) * 1e-7, 1e-6))) < 3:
return None, "局部边形变不可用:部分Face顶点环无法稳定读取。"
anchor_mode = self._edge_length_anchor_mode(anchor_mode)
target_length = float(target_length)
delta_length = target_length - current_length
if anchor_mode == "keep-end":
start_move = _tuple_scale(axis, -delta_length)
end_move = (0.0, 0.0, 0.0)
moved_label = "移动起点,固定终点"
elif anchor_mode == "center":
start_move = _tuple_scale(axis, -delta_length * 0.5)
end_move = _tuple_scale(axis, delta_length * 0.5)
moved_label = "两端各移动一半,保持中心"
else:
start_move = (0.0, 0.0, 0.0)
end_move = _tuple_scale(axis, delta_length)
moved_label = "固定起点,移动终点"
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if part_solid_count > 1 else "part"
ratio = abs(delta_length) / max(current_length, 1e-9)
local_risk = "high" if ratio > 0.5 else "medium"
return (
{
"resize_strategy": "local-edge-only-deform",
"local_edge_deform_target_kind": target_kind,
"local_edge_deform_face_count": len(solid_faces),
"local_edge_deform_anchor": moved_label,
"local_edge_deform_start_move": start_move,
"local_edge_deform_end_move": end_move,
"local_edge_deform_moved_endpoint_count": 2 if anchor_mode == "center" else 1,
"local_edge_deform_risk": local_risk,
"local_edge_deform_note": (
"只移动当前 Edge 的端点并重建所属平面多面体;非共面 Face 会被拆成三角面。"
),
"part_solid_count": part_solid_count,
},
"",
)
def edge_endpoint_move_plan(
self,
edge_id: int,
endpoint_role: str,
target_point: tuple[float, float, float],
) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
role = str(endpoint_role or "").strip().lower()
role_aliases = {
"start": "start",
"edge-start": "start",
"begin": "start",
"起点": "start",
"end": "end",
"edge-end": "end",
"finish": "end",
"终点": "end",
}
role = role_aliases.get(role, role)
if role not in {"start", "end"}:
raise ValueError("endpoint_role must be 'start' or 'end'.")
info = self.edge_info(edge_id)
current_length = float(info.get("length", 0.0))
curve = str(info.get("curve", ""))
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
target = _tuple_or_none(target_point)
base: dict[str, object] = {
"edge_id": edge_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id", -1),
"curve": curve,
"current_length": current_length,
"target_length": current_length,
"delta_length": 0.0,
"length_change_ratio": 0.0,
"edge_endpoint_role": role,
"edge_endpoint_label": "start point" if role == "start" else "end point",
"start_point": start,
"end_point": end,
"target_endpoint_point": target,
"length_center": info.get("length_center"),
"resize_strategy": "local-edge-endpoint-deform",
}
warnings = [
"Edge endpoint coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history."
]
blockers: list[str] = []
risk = "low"
status = "ready"
if curve != "line":
blockers.append("Only straight line Edge endpoints can be moved directly.")
if current_length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if start is None or end is None:
blockers.append("Current Edge does not have stable start/end coordinates.")
if target is None:
blockers.append("Target endpoint coordinate must be a valid X/Y/Z tuple.")
if not blockers and start is not None and end is not None and target is not None:
current_endpoint = start if role == "start" else end
fixed_endpoint = end if role == "start" else start
move = _tuple_sub(target, current_endpoint)
move_distance = _vector_length(move)
target_length = _vector_length(_tuple_sub(target, fixed_endpoint))
delta_length = target_length - current_length
length_change_ratio = abs(delta_length) / max(current_length, 1e-9)
move_ratio = move_distance / max(current_length, 1e-9)
base.update(
{
"current_endpoint_point": current_endpoint,
"fixed_endpoint_point": fixed_endpoint,
"target_length": target_length,
"delta_length": delta_length,
"length_change_ratio": length_change_ratio,
"moved_endpoint_delta": move,
"moved_endpoint_distance": move_distance,
"moved_endpoint_ratio": move_ratio,
"edge_length_anchor_mode": "keep-end" if role == "start" else "keep-start",
"edge_length_anchor_label": "fixed end point" if role == "start" else "fixed start point",
}
)
if move_distance <= max(current_length * 1e-7, 1e-7):
blockers.append("Target endpoint coordinate is almost identical to the current coordinate.")
if target_length <= 1e-9:
blockers.append("Moving this endpoint would collapse the Edge length to zero.")
if move_ratio > 0.5 or length_change_ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("Endpoint movement or resulting length change is larger than 50% of the current Edge length.")
elif move_ratio > 0.25 or length_change_ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append("Endpoint movement or resulting length change is larger than 25% of the current Edge length.")
if not blockers and start is not None and end is not None and target is not None:
anchor_mode = "keep-end" if role == "start" else "keep-start"
local_candidate, local_skip_note = self._local_edge_length_deform_candidate(
info,
float(base["target_length"]),
anchor_mode=anchor_mode,
)
if local_candidate is None:
blockers.append(local_skip_note or "Local endpoint deformation is not available for this Edge.")
else:
start_move = _tuple_sub(target, start) if role == "start" else (0.0, 0.0, 0.0)
end_move = _tuple_sub(target, end) if role == "end" else (0.0, 0.0, 0.0)
expected_start = _tuple_add(start, start_move)
expected_end = _tuple_add(end, end_move)
base.update(local_candidate)
base.update(
{
"resize_strategy": "local-edge-endpoint-deform",
"local_edge_deform_anchor": "move start point, keep end point"
if role == "start"
else "move end point, keep start point",
"local_edge_deform_start_move": start_move,
"local_edge_deform_end_move": end_move,
"local_edge_deform_moved_endpoint_count": 1,
"local_edge_deform_note": (
"Move one endpoint of the selected straight Edge and rebuild the surrounding planar solid."
),
"expected_start_point": expected_start,
"expected_end_point": expected_end,
}
)
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
warnings.append(
"The selected Edge endpoint will be moved and the surrounding planar faces will be rebuilt; non-planar faces may be split into triangles."
)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "The straight Edge endpoint can be moved by local planar-solid deformation."
base.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
}
)
return base
def edge_center_move_plan(
self,
edge_id: int,
target_center: tuple[float, float, float],
) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
current_length = float(info.get("length", 0.0))
curve = str(info.get("curve", ""))
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
current_center = _tuple_or_none(info.get("length_center"))
target = _tuple_or_none(target_center)
base: dict[str, object] = {
"edge_id": edge_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id", -1),
"curve": curve,
"current_length": current_length,
"target_length": current_length,
"delta_length": 0.0,
"length_change_ratio": 0.0,
"start_point": start,
"end_point": end,
"length_center": current_center,
"target_edge_center": target,
"resize_strategy": "local-edge-center-deform",
}
warnings = [
"Edge center coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history."
]
blockers: list[str] = []
risk = "low"
status = "ready"
if curve != "line":
blockers.append("Only straight line Edge centers can be moved directly.")
if current_length <= 1e-9:
blockers.append("Current Edge length is invalid.")
if start is None or end is None or current_center is None:
blockers.append("Current Edge does not have stable start/end/center coordinates.")
if target is None:
blockers.append("Target Edge center coordinate must be a valid X/Y/Z tuple.")
if not blockers and start is not None and end is not None and current_center is not None and target is not None:
move = _tuple_sub(target, current_center)
move_distance = _vector_length(move)
move_ratio = move_distance / max(current_length, 1e-9)
base.update(
{
"current_edge_center": current_center,
"target_edge_center": target,
"moved_edge_center_delta": move,
"moved_edge_center_distance": move_distance,
"moved_edge_center_ratio": move_ratio,
"edge_length_anchor_mode": "center",
"edge_length_anchor_label": "move whole edge center",
}
)
if move_distance <= max(current_length * 1e-7, 1e-7):
blockers.append("Target Edge center coordinate is almost identical to the current coordinate.")
if move_ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("Edge center movement is larger than 50% of the current Edge length.")
elif move_ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append("Edge center movement is larger than 25% of the current Edge length.")
if not blockers and start is not None and end is not None and current_center is not None and target is not None:
local_candidate, local_skip_note = self._local_edge_length_deform_candidate(
info,
current_length,
anchor_mode="center",
)
if local_candidate is None:
blockers.append(local_skip_note or "Local Edge center deformation is not available for this Edge.")
else:
move = _tuple_sub(target, current_center)
expected_start = _tuple_add(start, move)
expected_end = _tuple_add(end, move)
base.update(local_candidate)
base.update(
{
"resize_strategy": "local-edge-center-deform",
"local_edge_deform_anchor": "move both endpoints equally",
"local_edge_deform_start_move": move,
"local_edge_deform_end_move": move,
"local_edge_deform_moved_endpoint_count": 2,
"local_edge_deform_note": (
"Move both endpoints of the selected straight Edge and rebuild the surrounding planar solid."
),
"expected_start_point": expected_start,
"expected_end_point": expected_end,
}
)
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
warnings.append(
"Both endpoints of the selected Edge will be moved and the surrounding planar faces will be rebuilt; non-planar faces may be split into triangles."
)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "The straight Edge center can be moved by local planar-solid deformation."
base.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(warnings),
"blockers": "; ".join(blockers),
}
)
return base
def _edge_length_anchor_mode(self, anchor_mode: str | None) -> str:
normalized = str(anchor_mode or "auto").strip().lower()
aliases = {
"自动": "auto",
"auto": "auto",
"center": "center",
"centre": "center",
"固定中心": "center",
"keep-center": "center",
"start": "keep-start",
"起点": "keep-start",
"固定起点": "keep-start",
"keep-start": "keep-start",
"end": "keep-end",
"终点": "keep-end",
"固定终点": "keep-end",
"keep-end": "keep-end",
}
return aliases.get(normalized, "auto")
def _edge_length_anchor_label(self, anchor_mode: str) -> str:
return {
"auto": "自动选择局部端面",
"center": "固定中心",
"keep-start": "固定起点",
"keep-end": "固定终点",
}.get(anchor_mode, "自动选择局部端面")
def _edge_length_strategy_mode(self, strategy_mode: str | None) -> str:
normalized = str(strategy_mode or "auto").strip().lower()
aliases = {
"自动": "auto",
"auto": "auto",
"local": "local-edge-only-deform",
"local-edge": "local-edge-only-deform",
"local-edge-only": "local-edge-only-deform",
"local-edge-only-deform": "local-edge-only-deform",
"只改当前edge": "local-edge-only-deform",
"只改这条edge": "local-edge-only-deform",
"只改当前边": "local-edge-only-deform",
"只改这条边": "local-edge-only-deform",
"只变当前edge": "local-edge-only-deform",
"只变这条edge": "local-edge-only-deform",
"只变当前边": "local-edge-only-deform",
"只变这条边": "local-edge-only-deform",
"局部边形变": "local-edge-only-deform",
"end-face": "move-edge-end-plane-by-push-pull",
"push-pull": "move-edge-end-plane-by-push-pull",
"move-edge-end-plane": "move-edge-end-plane-by-push-pull",
"move-edge-end-plane-by-push-pull": "move-edge-end-plane-by-push-pull",
"移动端面": "move-edge-end-plane-by-push-pull",
"移动端面/保持垂直": "move-edge-end-plane-by-push-pull",
"保持垂直": "move-edge-end-plane-by-push-pull",
"保持面垂直": "move-edge-end-plane-by-push-pull",
"保持相邻面垂直": "move-edge-end-plane-by-push-pull",
"端面推拉": "move-edge-end-plane-by-push-pull",
"整体尺寸变化": "move-edge-end-plane-by-push-pull",
"变成长方体": "move-edge-end-plane-by-push-pull",
"cylinder": "resize-adjacent-cylinder-from-circular-edge-length",
"adjacent-cylinder": "resize-adjacent-cylinder-from-circular-edge-length",
"resize-adjacent-cylinder-from-circular-edge-length": "resize-adjacent-cylinder-from-circular-edge-length",
"相邻圆柱": "resize-adjacent-cylinder-from-circular-edge-length",
"相邻圆柱直径": "resize-adjacent-cylinder-from-circular-edge-length",
"scale": "scale-owning-shape-from-edge",
"scale-owning": "scale-owning-shape-from-edge",
"scale-owning-shape-from-edge": "scale-owning-shape-from-edge",
"缩放所属对象": "scale-owning-shape-from-edge",
"整体缩放": "scale-owning-shape-from-edge",
}
return aliases.get(normalized, "auto")
def _edge_length_strategy_label(self, strategy_mode: str) -> str:
return {
"auto": "自动选择",
"local-edge-only-deform": "只改当前Edge",
"move-edge-end-plane-by-push-pull": "移动端面/保持垂直",
"resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径",
"scale-owning-shape-from-edge": "缩放所属对象",
}.get(strategy_mode, "自动选择")
def _edge_length_constraint_summary(self, anchor_mode: str) -> str:
anchor_mode = self._edge_length_anchor_mode(anchor_mode)
return {
"auto": "自动基准:默认尽量固定起点,移动终点;如果策略需要,会按可用端面调整。",
"center": "固定中心:Edge中心尽量不动,两端或所属对象围绕中心变化。",
"keep-start": "固定起点:起点尽量不动,终点或相关端面承担长度变化。",
"keep-end": "固定终点:终点尽量不动,起点或相关端面承担长度变化。",
}.get(anchor_mode, "自动基准:程序会选择更稳定的一端作为固定约束。")
def _edge_length_impact_summary(self, plan: dict[str, object]) -> str:
strategy = str(plan.get("resize_strategy") or plan.get("edge_length_strategy_mode") or "auto")
target_kind = str(plan.get("affine_target_kind") or plan.get("local_edge_deform_target_kind") or "所属对象")
if strategy == "local-edge-only-deform":
return (
"只改当前Edge:只移动被选Edge的端点并重建相邻平面;相邻面会自然变斜,"
"必要时非共面四边面会拆成三角面,整体端面不会一起平移。"
)
if strategy == "move-edge-end-plane-by-push-pull":
end_face = plan.get("end_face_id")
face_text = f" Face {end_face}" if end_face not in {None, ""} else ""
return (
f"移动端面:把长度变化转换为端面{face_text}推拉;端面和同一端面区域上的相关边会跟随,"
"相邻平面会尽量保持垂直,正方体这类模型会更像变成长方体。"
)
if strategy == "resize-adjacent-cylinder-from-circular-edge-length":
return (
"相邻圆柱直径:把圆形/圆弧Edge目标长度换算成相邻圆柱直径,"
"优先重切孔/槽或重建凸台,不会把整个模型按Edge长度缩放。"
)
if strategy == "scale-owning-shape-from-edge":
return (
f"缩放所属对象:对所属 {target_kind} 做轴向、径向或整体缩放;"
"同一对象上的其它尺寸会跟随变化,适合作为明确选择的高风险兜底语义。"
)
return (
"自动选择:程序会按局部Edge形变、端面移动、相邻圆柱编辑、缩放所属对象的顺序寻找可用路径;"
"确认窗口会显示最终采用的实际策略。"
)
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 circular_edge_axis_move_plan(
self,
edge_id: int,
target_center: tuple[float, float, float],
) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
blockers: list[str] = []
warnings: list[str] = [
"圆Edge圆心/轴心移动会优先寻找相邻孔、槽或凸台圆柱面,再复用对应轴心移动路线。"
]
risk = "medium"
try:
target = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
except (TypeError, ValueError, IndexError):
target = (0.0, 0.0, 0.0)
blockers.append("圆Edge目标圆心必须是 X, Y, Z 三个数字。")
current_center = _tuple_or_none(info.get("center"))
current_radius = _float_or_none(info.get("radius"))
current_length = _float_or_none(info.get("length"))
curve = str(info.get("curve", ""))
if curve != "circle":
blockers.append("只有圆形或圆弧 Edge 才能按相邻圆柱轴心移动。")
if current_center is None:
blockers.append("当前圆Edge缺少稳定圆心,不能换算轴心移动。")
if current_radius is None or current_radius <= 1e-9:
blockers.append("当前圆Edge缺少稳定半径,不能匹配相邻圆柱。")
movement = (0.0, 0.0, 0.0)
move_distance = 0.0
if current_center is not None:
movement = _tuple_sub(target, current_center)
move_distance = _vector_length(movement)
diagonal = max(_shape_diagonal(self.edges[edge_id]), current_radius or 0.0, 1.0)
if move_distance <= max(diagonal * 1e-7, 1e-6):
blockers.append("目标圆心和当前圆心几乎相同,无需移动。")
elif current_radius is not None and current_radius > 0:
ratio = move_distance / current_radius
if ratio > 8.0:
risk = _max_risk(risk, "high")
warnings.append("圆心移动超过 8 个圆边半径,布尔操作可能影响无关几何。")
elif ratio > 2.0:
risk = _max_risk(risk, "high")
warnings.append("圆心移动超过 2 个圆边半径,建议执行后重点检查周边壁厚。")
candidate: dict[str, object] | None = None
candidate_notes: list[str] = []
if not blockers:
candidate, candidate_notes = self._circular_edge_axis_move_cylinder_candidate(info, movement)
if candidate is None:
blockers.append(
"未找到可复用的相邻孔、槽或凸台圆柱轴心移动路径。"
+ (" " + " ".join(candidate_notes[:3]) if candidate_notes else "")
)
else:
risk = _max_risk(risk, str(candidate.get("move_axis_risk", "medium")))
warnings.append(str(candidate.get("move_axis_warnings", "")))
if candidate_notes:
warnings.extend(candidate_notes[:3])
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
else:
status = "caution" if risk in {"medium", "high"} else "ready"
mode_label = str(candidate.get("circular_edge_cylinder_mode_label", "相邻圆柱轴心")) if candidate else "相邻圆柱轴心"
message = (
f"可以通过{mode_label}移动来调整圆Edge圆心;"
"圆边所在截面的圆心移动量会同步应用到相邻圆柱的中间轴心。 "
+ " ".join(part for part in warnings if part)
)
result = {
"status": status,
"risk": risk,
"message": message,
"warnings": "; ".join(part for part in warnings if part),
"blockers": "; ".join(blockers),
"edge_id": edge_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"curve": curve,
"current_edge_center": current_center,
"target_edge_center": target,
"circular_edge_center_move_vector": movement,
"circular_edge_center_move_distance": move_distance,
"circular_edge_current_radius": current_radius,
"circular_edge_current_diameter": None if current_radius is None else current_radius * 2.0,
"current_length": current_length,
"resize_strategy": "move-adjacent-cylinder-from-circular-edge-center",
"edit_strategy_label": "圆Edge相邻圆柱轴心移动",
"edit_semantics": (
"把圆Edge圆心的移动量应用到相邻孔、槽或凸台的圆柱轴心;"
"这是移动局部圆柱特征,不是整体平移零件。"
),
}
if candidate:
result.update(candidate)
return result
def _circular_edge_axis_move_cylinder_candidate(
self,
edge_info: dict[str, object],
movement: tuple[float, float, float],
) -> tuple[dict[str, object] | None, list[str]]:
current_radius = _float_or_none(edge_info.get("radius"))
if current_radius is None or current_radius <= 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, ["圆Edge没有相邻Face信息,无法判断它属于哪个孔/槽/凸台。"]
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_or_none(face_info.get("radius"))
if face_radius is None or 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
feature: dict[str, object] = {}
try:
feature = self.feature_info(face_id)
except Exception:
feature = {}
axis_data = self._cylindrical_face_axis_mid_center(face_id, feature)
if axis_data is None:
notes.append(f"相邻圆柱Face {face_id} 缺少稳定中间轴心。")
continue
target_axis_center = _tuple_add(axis_data["current_axis_center"], movement)
angular_span = _float_or_none(face_info.get("angular_span"))
slot_kind = str(feature.get("slot_kind") or face_info.get("slot_kind") or "")
if feature_guess == "hole/groove candidate":
if slot_kind == "partial-cylindrical-groove" or (angular_span is not None and angular_span < math.tau * 0.92):
mode_order = ("slot", "hole")
else:
mode_order = ("hole", "slot")
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 = {
"hole": "圆柱孔轴心",
"slot": "槽/半孔轴心",
"boss": "圆柱凸台轴心",
}.get(mode, "相邻圆柱轴心")
try:
axis_plan = (
self.cylindrical_slot_axis_move_plan(face_id, target_axis_center)
if mode == "slot"
else self.cylindrical_boss_axis_move_plan(face_id, target_axis_center)
if mode == "boss"
else self.cylindrical_axis_move_plan(face_id, target_axis_center)
)
except Exception as exc:
notes.append(f"相邻圆柱Face {face_id}{mode_label}计划生成失败:{exc}")
continue
plan_status = str(axis_plan.get("status", "blocked"))
plan_risk = str(axis_plan.get("risk", "blocked"))
if plan_status == "blocked":
notes.append(f"相邻圆柱Face {face_id}{mode_label}不可用:{axis_plan.get('message', '')}")
continue
candidate = {
"circular_edge_cylinder_face_id": face_id,
"circular_edge_cylinder_mode": mode,
"circular_edge_cylinder_mode_label": mode_label,
"circular_edge_axis_operation": {
"hole": "move_cylindrical_hole_axis",
"slot": "move_cylindrical_slot_axis",
"boss": "move_cylindrical_boss_axis",
}[mode],
"circular_edge_target_axis_center": target_axis_center,
"current_axis_center": axis_plan.get("current_axis_center"),
"target_axis_center": axis_plan.get("target_axis_center"),
"axis_move_vector": axis_plan.get("axis_move_vector"),
"axis_move_distance": axis_plan.get("axis_move_distance"),
"axis_move_radial_distance": axis_plan.get("axis_move_radial_distance"),
"axis_move_axial_delta": axis_plan.get("axis_move_axial_delta"),
"target_diameter": axis_plan.get("target_diameter"),
"target_radius": axis_plan.get("target_radius"),
"move_axis_status": plan_status,
"move_axis_risk": plan_risk,
"move_axis_message": axis_plan.get("message"),
"move_axis_warnings": axis_plan.get("warnings", ""),
"move_axis_blockers": axis_plan.get("blockers", ""),
"move_axis_feature_guess": axis_plan.get("feature_guess", feature_guess),
"move_axis_confidence": axis_plan.get("confidence", face_info.get("confidence", "")),
"resize_strategy": "move-adjacent-cylinder-from-circular-edge-center",
"edit_strategy_label": f"通过{mode_label}移动圆Edge",
"edit_semantics": (
f"圆Edge圆心的目标移动量会转成相邻Face {face_id}{mode_label}移动;"
"这会重建局部孔/槽/凸台,不会整体平移零件。"
),
}
score = (
status_rank.get(plan_status, 9),
risk_rank.get(plan_risk, 9),
mode_index,
face_id,
)
candidates.append((score, candidate))
if not candidates:
return None, notes
candidates.sort(key=lambda item: item[0])
return candidates[0][1], notes
def _cylindrical_face_axis_mid_center(
self,
face_id: int,
feature: dict[str, object] | None = None,
) -> dict[str, object] | None:
if face_id < 0 or face_id >= len(self.faces):
return None
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Cylinder:
return None
if feature is None:
feature = self.feature_info(face_id)
cyl = surf.Cylinder()
axis_range = self._cylindrical_axis_range(
face_id,
surf,
_int_values((feature or {}).get("feature_side_face_ids")),
)
mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5
return {
"current_axis_center": _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter)),
"axis_direction": _dir_tuple(cyl.Axis().Direction()),
"same_domain_face_ids": axis_range.get("same_domain_face_ids", ()),
"same_domain_face_count": axis_range.get("same_domain_face_count", 0),
"same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")),
"same_domain_range_source": axis_range.get("range_source", ""),
}
except Exception:
return None
def move_circular_edge_axis_center(
self,
edge_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.circular_edge_axis_move_plan(edge_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face_id = int(plan.get("circular_edge_cylinder_face_id", -1))
target_axis_center = _tuple_or_none(plan.get("target_axis_center"))
mode = str(plan.get("circular_edge_cylinder_mode", ""))
if face_id < 0 or target_axis_center is None:
raise ValueError("圆Edge轴心移动缺少可执行的相邻圆柱Face或目标轴心。")
if mode == "slot":
delegated = self.move_cylindrical_slot_axis(face_id, target_axis_center)
elif mode == "boss":
delegated = self.move_cylindrical_boss_axis(face_id, target_axis_center)
else:
delegated = self.move_cylindrical_hole_axis(face_id, target_axis_center)
return (
"Circular Edge adjacent-cylinder axis move completed: "
f"edge {edge_id}, face {face_id}, mode={plan.get('circular_edge_cylinder_mode_label')}, "
f"edge_center={plan.get('current_edge_center')}->{plan.get('target_edge_center')}, "
f"axis_center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}. "
f"{delegated}"
)
def _edge_length_affine_axis(
self,
edge_info: dict[str, object],
anchor_mode: str = "auto",
) -> dict[str, object] | None:
start = _tuple_or_none(edge_info.get("start_point"))
end = _tuple_or_none(edge_info.get("end_point"))
center = _tuple_or_none(edge_info.get("length_center"))
anchor_mode = self._edge_length_anchor_mode(anchor_mode)
if start is not None and end is not None:
direction = _tuple_normalized(_tuple_sub(end, start))
if direction is not None:
midpoint = (
(start[0] + end[0]) * 0.5,
(start[1] + end[1]) * 0.5,
(start[2] + end[2]) * 0.5,
)
if anchor_mode == "keep-start":
axis_point = start
anchor_source = "edge start point"
elif anchor_mode == "keep-end":
axis_point = end
anchor_source = "edge end point"
else:
axis_point = center or midpoint
anchor_source = "edge center"
return {
"axis_point": axis_point,
"axis_direction": direction,
"axis_source": "edge start/end chord",
"anchor_source": anchor_source,
}
bbox_min = _tuple_or_none(edge_info.get("bbox_min"))
bbox_max = _tuple_or_none(edge_info.get("bbox_max"))
if bbox_min is not None and bbox_max is not None:
sizes = [abs(bbox_max[index] - bbox_min[index]) for index in range(3)]
axis_index = max(range(3), key=lambda index: sizes[index])
if sizes[axis_index] > 1e-9:
direction = [0.0, 0.0, 0.0]
direction[axis_index] = 1.0
return {
"axis_point": center or (
(bbox_min[0] + bbox_max[0]) * 0.5,
(bbox_min[1] + bbox_max[1]) * 0.5,
(bbox_min[2] + bbox_max[2]) * 0.5,
),
"axis_direction": tuple(direction),
"axis_source": "edge bounding-box longest axis",
"anchor_source": "edge bounding-box center",
}
return None
def _planar_edge_length_scale_candidate(
self,
edge_info: dict[str, object],
target_length: float,
) -> tuple[dict[str, object] | None, str]:
curve = str(edge_info.get("curve", ""))
if curve == "line":
return None, ""
current_length = float(edge_info.get("length", 0.0))
if current_length <= 1e-9:
return None, "平面曲线径向缩放不可用:当前Edge长度无效。"
center = _tuple_or_none(edge_info.get("center"))
axis = _tuple_normalized(_tuple_or_none(edge_info.get("axis")))
axis_source = ""
anchor_source = ""
label = "围绕平面曲线法向径向缩放"
sample_count: int | str = ""
plane_deviation: float | str = ""
if center is not None and axis is not None and curve in {"circle", "ellipse"}:
axis_source = f"{curve} center/axis"
anchor_source = f"{curve} center"
label = "围绕圆边轴线径向缩放" if curve == "circle" else "围绕椭圆边法向径向缩放"
else:
frame, note = self._sampled_planar_edge_frame(edge_info)
if frame is None:
return None, note
center = frame["center"]
axis = frame["axis"]
axis_source = "sampled edge best-fit plane"
anchor_source = "sampled edge center"
sample_count = int(frame["sample_count"])
plane_deviation = float(frame["plane_deviation"])
if center is None or axis is None:
return None, "平面曲线径向缩放不可用:无法确定曲线中心或平面法向。"
part_id = int(edge_info.get("part_id", -1))
solid_id = int(edge_info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
return None, "平面曲线径向缩放不可用:找不到所属零件。"
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
scale = float(target_length) / max(current_length, 1e-9)
transform_risk = "high" if abs(scale - 1.0) > 0.25 or axis_source.startswith("sampled") else "medium"
if curve == "circle":
warning = (
"圆边没有可复用的相邻圆柱直径编辑路径;将围绕圆边轴线对所属 "
f"{target_kind} 做径向缩放,尽量保留轴向尺寸,但仍会影响同一 {target_kind} 上的其他径向尺寸。"
)
elif curve == "ellipse":
warning = (
"椭圆边将围绕自身平面法向对所属 "
f"{target_kind} 做径向缩放,尽量保留法向尺寸,但会影响同一 {target_kind} 上的平面内其它尺寸。"
)
else:
warning = (
"当前非直线Edge可近似为平面曲线;将按采样平面法向对所属 "
f"{target_kind} 做径向缩放。该路径依赖采样估算,执行后请复查周边尺寸。"
)
return (
{
"resize_strategy": "scale-owning-shape-from-edge",
"affine_scale": scale,
"affine_transform_kind": "radial-affine",
"affine_transform_label": label,
"affine_transform_note": "可能把解析圆/圆锥/圆柱/椭圆边面转换为 B-spline 几何。",
"affine_transform_warning": warning,
"affine_transform_risk": transform_risk,
"affine_axis_point": center,
"affine_axis_direction": axis,
"affine_axis_source": axis_source,
"affine_anchor_source": anchor_source,
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
"planar_edge_scale_sample_count": sample_count,
"planar_edge_scale_plane_deviation": plane_deviation,
},
"",
)
def _sampled_planar_edge_frame(self, edge_info: dict[str, object]) -> tuple[dict[str, object] | None, str]:
edge_id = int(edge_info.get("edge_id", -1))
if edge_id < 0 or edge_id >= len(self.edges):
return None, "平面曲线径向缩放不可用:Edge ID无效。"
curve = BRepAdaptor_Curve(self.edges[edge_id])
first = float(curve.FirstParameter())
last = float(curve.LastParameter())
if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12:
return None, "平面曲线径向缩放不可用:Edge参数范围无效。"
sample_count = 17
points = [
_point_tuple(curve.Value(first + (last - first) * index / (sample_count - 1)))
for index in range(sample_count)
]
distinct: list[tuple[float, float, float]] = []
tolerance = max(float(edge_info.get("length", 0.0)) * 1e-7, _shape_diagonal(self.edges[edge_id]) * 1e-7, 1e-7)
for point in points:
if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in distinct):
distinct.append(point)
if len(distinct) < 3:
return None, "平面曲线径向缩放不可用:采样点不足以确定平面。"
center = _tuple_or_none(edge_info.get("length_center"))
if center is None:
center = (
sum(point[0] for point in distinct) / len(distinct),
sum(point[1] for point in distinct) / len(distinct),
sum(point[2] for point in distinct) / len(distinct),
)
normal = self._sampled_edge_plane_normal(distinct, center)
if normal is None:
return None, "平面曲线径向缩放不可用:采样点近似共线,无法确定平面法向。"
plane_deviation = max(abs(_tuple_dot(_tuple_sub(point, center), normal)) for point in distinct)
max_radius = max(_vector_length(_tuple_sub(point, center)) for point in distinct)
allowed_deviation = max(max_radius * 1e-4, float(edge_info.get("length", 0.0)) * 1e-5, 1e-6)
if plane_deviation > allowed_deviation:
return None, (
"平面曲线径向缩放不可用:Edge采样点不在稳定平面内,将使用更保守的缩放 fallback。"
)
return (
{
"center": center,
"axis": normal,
"sample_count": len(distinct),
"plane_deviation": plane_deviation,
},
"",
)
def _sampled_edge_plane_normal(
self,
points: list[tuple[float, float, float]],
center: tuple[float, float, float],
) -> tuple[float, float, float] | None:
normal = (0.0, 0.0, 0.0)
for index, point in enumerate(points):
next_point = points[(index + 1) % len(points)]
cross = _tuple_cross(_tuple_sub(point, center), _tuple_sub(next_point, center))
normal = (
normal[0] + cross[0],
normal[1] + cross[1],
normal[2] + cross[2],
)
normalized = _tuple_normalized(normal)
if normalized is not None:
return normalized
best: tuple[float, float, float] | None = None
best_length = 0.0
count = len(points)
for first_index in range(count - 2):
for second_index in range(first_index + 1, count - 1):
for third_index in range(second_index + 1, count):
candidate = _tuple_cross(
_tuple_sub(points[second_index], points[first_index]),
_tuple_sub(points[third_index], points[first_index]),
)
candidate_length = _vector_length(candidate)
if candidate_length > best_length:
best = candidate
best_length = candidate_length
return _tuple_normalized(best)
def ellipse_edge_axis_radius_plan(
self,
edge_id: int,
target_radius: float,
axis_kind: str = "major",
) -> 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)
axis_kind = "minor" if str(axis_kind).lower() in {"minor", "small", "y", "minor_radius"} else "major"
radius_key = "minor_radius" if axis_kind == "minor" else "major_radius"
direction_key = "minor_axis" if axis_kind == "minor" else "major_axis"
axis_label = "小半径" if axis_kind == "minor" else "主半径"
axis_direction_label = "小轴" if axis_kind == "minor" else "主轴"
current_radius = _float_or_none(info.get(radius_key))
other_radius = _float_or_none(info.get("major_radius" if axis_kind == "minor" else "minor_radius"))
center = _tuple_or_none(info.get("center"))
direction = _tuple_normalized(_tuple_or_none(info.get(direction_key)))
other_direction = _tuple_normalized(
_tuple_or_none(info.get("major_axis" if axis_kind == "minor" else "minor_axis"))
)
target_radius = float(target_radius)
blockers: list[str] = []
warnings: list[str] = [
"椭圆Edge半径修改基于当前 STEP/B-Rep 结果几何;会对所属对象做单轴仿射缩放,不是恢复 CAD 草图约束。"
]
risk = "medium"
if info.get("curve") != "ellipse":
blockers.append("当前Edge不是椭圆Edge。")
if current_radius is None or current_radius <= 1e-9:
blockers.append(f"当前椭圆Edge缺少稳定{axis_label}。")
if other_radius is None or other_radius <= 1e-9:
blockers.append("当前椭圆Edge缺少另一个半径,不能稳定校验结果。")
if center is None:
blockers.append("当前椭圆Edge缺少稳定中心。")
if direction is None:
blockers.append(f"当前椭圆Edge缺少稳定{axis_direction_label}方向。")
if other_direction is None:
blockers.append("当前椭圆Edge缺少另一个轴方向,不能稳定校验结果。")
if target_radius <= 1e-9:
blockers.append(f"椭圆Edge目标{axis_label}必须大于 0。")
if current_radius is not None and current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-7, 1e-7):
blockers.append(f"椭圆Edge目标{axis_label}与当前值几乎相同,不需要修改。")
scale = target_radius / max(current_radius or 1.0, 1e-9)
delta = None if current_radius is None else target_radius - current_radius
delta_ratio = None if current_radius is None or current_radius <= 0 else abs(delta or 0.0) / current_radius
if delta_ratio is not None:
if delta_ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append(f"目标{axis_label}变化超过当前值的 50%,周边几何变形或修复失败的概率较高。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append(f"目标{axis_label}变化超过当前值的 25%,请确认影响范围。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到椭圆Edge所属零件。")
part_solid_count = 0
target_kind = "part"
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
else:
status = "caution" if risk != "low" else "ready"
message = " ".join(warnings)
target_major = target_radius if axis_kind == "major" else info.get("major_radius")
target_minor = target_radius if axis_kind == "minor" else info.get("minor_radius")
return {
"edge_id": edge_id,
"part_id": part_id,
"solid_id": solid_id,
"curve": info.get("curve"),
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"resize_strategy": f"ellipse-edge-{axis_kind}-axis-affine",
"edit_strategy_label": f"椭圆Edge{axis_label}单轴缩放",
"edit_semantics": (
f"沿椭圆{axis_direction_label}方向缩放所属 {target_kind},让{axis_label}接近目标值;"
"另一个半径方向尽量不动,但同一对象上的其它几何会受这个单轴缩放影响。"
),
"ellipse_axis_kind": axis_kind,
"ellipse_axis_label": axis_label,
"ellipse_axis_direction_label": axis_direction_label,
"ellipse_current_radius": current_radius,
"ellipse_target_radius": target_radius,
"ellipse_radius_delta": delta,
"ellipse_radius_delta_ratio": delta_ratio,
"ellipse_current_major_radius": info.get("major_radius"),
"ellipse_target_major_radius": target_major,
"ellipse_current_minor_radius": info.get("minor_radius"),
"ellipse_target_minor_radius": target_minor,
"affine_scale": scale,
"affine_transform_kind": "axis-affine",
"affine_transform_label": f"沿椭圆{axis_direction_label}单轴缩放",
"affine_transform_note": "这会改变所属对象在该方向上的尺寸,可能把部分解析几何转换为 B-spline。",
"affine_axis_point": center,
"affine_axis_direction": direction,
"ellipse_other_axis_direction": other_direction,
"affine_axis_source": f"ellipse {axis_direction_label}",
"affine_anchor_source": "ellipse center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_ellipse_edge_axis_radius(
self,
edge_id: int,
target_radius: float,
axis_kind: str = "major",
) -> str:
plan = self.ellipse_edge_axis_radius_plan(edge_id, target_radius, axis_kind=axis_kind)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
result_check = self._ellipse_edge_axis_radius_result_summary(plan)
return (
f"Ellipse Edge {plan['ellipse_axis_kind']} radius resize completed: "
f"edge {edge_id}, "
f"current_radius={float(plan['ellipse_current_radius']):g}, "
f"target_radius={float(plan['ellipse_target_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def _ellipse_edge_axis_radius_result_summary(self, plan: dict[str, object]) -> str:
axis_kind = str(plan.get("ellipse_axis_kind") or "major")
radius_key = "minor_radius" if axis_kind == "minor" else "major_radius"
target_radius = _float_or_none(plan.get("ellipse_target_radius"))
if target_radius is None:
return "Result check: target radius was unavailable."
part_id = int(plan.get("part_id", -1))
solid_id = int(plan.get("solid_id", -1))
best: tuple[float, int, float, dict[str, object]] | None = None
for candidate_edge_id in range(len(self.edges)):
if part_id >= 0 and self.edge_part_ids[candidate_edge_id] != part_id:
continue
if solid_id >= 0 and self.edge_solid_ids[candidate_edge_id] != solid_id:
continue
info = self.edge_info(candidate_edge_id)
if info.get("curve") != "ellipse":
continue
value = _float_or_none(info.get(radius_key))
if value is None:
continue
error = abs(value - target_radius)
if best is None or error < best[0]:
best = (error, candidate_edge_id, value, info)
if best is None:
sampled = self._ellipse_edge_axis_radius_sampled_result(plan)
if sampled is not None:
candidate_edge_id, value, other_value, error = sampled
return (
f"Result check: nearest_edge={candidate_edge_id}, sampled_{axis_kind}_radius={value:.6g}, "
f"target_error={error:.6g}, sampled_other_radius={other_value:.6g}; "
"the refreshed edge is no longer an analytic ellipse."
)
return "Result check: no ellipse-like Edge was recognized after the edit; inspect the refreshed B-Rep."
error, candidate_edge_id, value, info = best
other_key = "major_radius" if axis_kind == "minor" else "minor_radius"
other_value = _float_or_none(info.get(other_key))
return (
f"Result check: nearest_edge={candidate_edge_id}, "
f"nearest_{axis_kind}_radius={value:.6g}, "
f"target_error={error:.6g}, "
f"other_radius={other_value:.6g}."
if other_value is not None
else (
f"Result check: nearest_edge={candidate_edge_id}, "
f"nearest_{axis_kind}_radius={value:.6g}, target_error={error:.6g}."
)
)
def _ellipse_edge_axis_radius_sampled_result(
self,
plan: dict[str, object],
) -> tuple[int, float, float, float] | None:
center = _tuple_or_none(plan.get("affine_axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction")))
other_direction = _tuple_normalized(_tuple_or_none(plan.get("ellipse_other_axis_direction")))
target_radius = _float_or_none(plan.get("ellipse_target_radius"))
if center is None or axis_direction is None or other_direction is None or target_radius is None:
return None
part_id = int(plan.get("part_id", -1))
solid_id = int(plan.get("solid_id", -1))
best: tuple[int, float, float, float] | None = None
for edge_id in range(len(self.edges)):
if part_id >= 0 and self.edge_part_ids[edge_id] != part_id:
continue
if solid_id >= 0 and self.edge_solid_ids[edge_id] != solid_id:
continue
extents = self._sample_edge_axis_extents(edge_id, center, axis_direction, other_direction)
if extents is None:
continue
axis_radius, other_radius = extents
error = abs(axis_radius - target_radius)
if best is None or error < best[3]:
best = (edge_id, axis_radius, other_radius, error)
return best
def _sample_edge_axis_extents(
self,
edge_id: int,
center: tuple[float, float, float],
axis_direction: tuple[float, float, float],
other_direction: tuple[float, float, float],
) -> tuple[float, float] | None:
if edge_id < 0 or edge_id >= len(self.edges):
return None
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
sample_count = 1025
axis_extent = 0.0
other_extent = 0.0
for index in range(sample_count):
parameter = first + (last - first) * index / (sample_count - 1)
point = _point_tuple(curve.Value(parameter))
relative = _tuple_sub(point, center)
axis_extent = max(axis_extent, abs(_tuple_dot(relative, axis_direction)))
other_extent = max(other_extent, abs(_tuple_dot(relative, other_direction)))
if axis_extent <= 1e-9 and other_extent <= 1e-9:
return None
return axis_extent, other_extent
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",
"edit_strategy_label": "移除旧圆角并重新倒圆",
"edit_semantics": "先移除当前已有圆角面,再在恢复出的锐边上按目标半径重新倒圆;复杂 blend 可能失败并回滚。",
"resize_note": (
"当前版本的已有圆角半径修改只支持由圆柱面表示的直线边圆角。"
"执行后 Face/Edge ID 会重建,请重新选择对象确认结果。"
),
}
def _push_pull_inward_material_depth(
self,
face_id: int,
outward: tuple[float, float, float] | None,
) -> float | None:
if face_id < 0 or face_id >= len(self.faces):
return None
direction = _tuple_normalized(outward)
if direction is None:
return None
solid_id = self.face_solid_ids[face_id]
if solid_id < 0 or solid_id >= len(self.solids):
return None
try:
props = GProp_GProps()
brepgprop.SurfaceProperties(self.faces[face_id], props)
interval = _shape_axis_interval(self.solids[solid_id][1], props.CentreOfMass(), gp_Dir(*direction))
except Exception:
return None
if interval is None:
return None
inward_depth = max(-float(interval[0]), 0.0)
return inward_depth if inward_depth > 1e-9 else None
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"
inward_material_depth = self._push_pull_inward_material_depth(
face_id,
_tuple_or_none(info.get("push_pull_outward_direction")),
)
inward_cut_ratio = (
distance_abs / inward_material_depth
if distance < 0 and inward_material_depth is not None and inward_material_depth > 1e-9
else None
)
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 * 5.0:
risk = "blocked"
status = "blocked"
blockers.append("推拉距离超过当前 Face 尺寸的 5 倍,容易生成过大布尔体或影响无关几何。")
elif 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 inward_cut_ratio is not None and inward_material_depth is not None:
depth_tolerance = max(
inward_material_depth * 1e-5,
bbox_diagonal * 1e-7 if bbox_diagonal > 0 else 0.0,
1e-6,
)
if distance_abs >= inward_material_depth - depth_tolerance:
status = "blocked"
risk = "blocked"
blockers.append(
"向内切削距离达到或超过当前面背后的材料厚度;继续执行很可能把实体切空或生成无效几何。"
)
elif inward_cut_ratio >= 0.85:
risk = _max_risk(risk, "high")
warnings.append("向内切削距离已经接近当前面背后的材料厚度,剩余壁厚很薄,请谨慎确认。")
elif inward_cut_ratio >= 0.6:
risk = _max_risk(risk, "medium")
warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。")
cap_extension_info: dict[str, object] | None = None
if distance > 0:
outward_for_cap = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction")))
if outward_for_cap is not None:
try:
cap_extension_info = self._cylindrical_cap_extension_plan(face_id, distance, outward_for_cap)
except Exception:
cap_extension_info = None
if cap_extension_info is not None:
old_height = _float_or_none(cap_extension_info.get("old_height"))
new_height = _float_or_none(cap_extension_info.get("new_height"))
radius = _float_or_none(cap_extension_info.get("radius"))
growth_ratio = (
new_height / old_height
if old_height is not None and old_height > 1e-9 and new_height is not None
else None
)
distance_to_height_ratio = (
distance_abs / old_height if old_height is not None and old_height > 1e-9 else None
)
if growth_ratio is not None and growth_ratio > 3.0:
status = "blocked"
risk = "blocked"
blockers.append(
"当前操作会把圆柱高度一次性放大到原来的 "
f"{_format_result_number(growth_ratio)} 倍;当前版本会先阻止这种大跨度圆柱端面推拉,"
"避免 OCCT 布尔长时间计算、卡死或返回无效 B-Rep。请先分多次小幅修改,或改用整体高度/所属对象缩放语义。"
)
elif distance_to_height_ratio is not None and distance_to_height_ratio > 1.0:
risk = _max_risk(risk, "high")
warnings.append(
"当前圆柱端面推拉距离已经超过圆柱原高度,布尔延长可能较慢;建议优先小幅修改。"
)
elif radius is not None and radius > 0 and distance_abs > radius * 4.0:
risk = _max_risk(risk, "high")
warnings.append(
"当前圆柱端面推拉距离明显大于圆柱半径,可能生成很长的补料体;建议优先小幅修改。"
)
if risk in {"medium", "high"} and status != "blocked":
status = "caution"
if blockers:
message = " ".join(blockers + warnings)
elif warnings:
message = " ".join(warnings)
else:
message = "可以尝试推拉该平面。"
plane_origin = _tuple_or_none(info.get("plane_origin"))
outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction")))
current_plane_position = None
target_plane_position = None
if plane_origin is not None and outward_direction is not None:
current_plane_position = _tuple_dot(plane_origin, outward_direction)
target_plane_position = current_plane_position + float(distance)
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"],
**self._face_first_level_plan_fields(face_id),
"distance": distance,
"surface": info.get("surface"),
"area": info.get("area"),
"bbox_diagonal": info.get("bbox_diagonal"),
"plane_origin": plane_origin,
"plane_direction": outward_direction,
"current_plane_position": current_plane_position,
"target_plane_position": target_plane_position,
"push_pull_inward_material_depth": inward_material_depth,
"push_pull_inward_cut_ratio": inward_cut_ratio,
"cylindrical_cap_extension_old_height": (
cap_extension_info.get("old_height") if cap_extension_info is not None else None
),
"cylindrical_cap_extension_new_height": (
cap_extension_info.get("new_height") if cap_extension_info is not None else None
),
"cylindrical_cap_extension_radius": (
cap_extension_info.get("radius") if cap_extension_info is not None else None
),
"outward_direction": 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 face_plane_offset_frame(
self,
face_id: int,
) -> tuple[tuple[float, float, float], tuple[float, float, float], float] | None:
if face_id < 0 or face_id >= len(self.faces):
return None
info = self.quick_face_info(face_id)
if str(info.get("surface", "")) != "plane":
return None
origin = _tuple_or_none(info.get("plane_origin"))
direction = (
_tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction")))
or _tuple_normalized(_tuple_or_none(info.get("oriented_normal")))
or _tuple_normalized(_tuple_or_none(info.get("normal")))
)
if origin is None or direction is None:
return None
current_position = _tuple_dot(origin, direction)
return origin, direction, current_position
def face_plane_offset_local_plan(self, face_id: int, distance: float) -> dict[str, object]:
try:
distance = float(distance)
except (TypeError, ValueError):
distance = 0.0
frame = None
blockers = ["目标面偏移必须是数字。"]
else:
frame = self.face_plane_offset_frame(face_id)
blockers = []
if face_id < 0 or face_id >= len(self.faces):
blockers.append(f"Unknown face id {face_id}")
if frame is None:
blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按面偏移只移动当前 Face。")
current_center = None
target_center = None
if 0 <= face_id < len(self.faces):
info = self.face_info(face_id)
current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if current_center is None:
blockers.append("当前 Face 缺少稳定中心坐标。")
plane_origin = None
plane_direction = None
current_position = None
target_position = None
if frame is not None:
plane_origin, plane_direction, current_position = frame
target_position = current_position + distance
if current_center is not None and plane_direction is not None:
move_vector = _tuple_scale(plane_direction, distance)
target_center = _tuple_add(current_center, move_vector)
else:
move_vector = (0.0, 0.0, 0.0)
if blockers:
return {
"status": "blocked",
"risk": "blocked",
"message": " ".join(blockers),
"warnings": "",
"blockers": "".join(blockers),
"face_id": face_id,
"current_plane_position": current_position,
"target_plane_position": target_position,
"plane_origin": plane_origin,
"plane_direction": plane_direction,
"plane_offset_distance": distance,
"current_face_center": current_center,
"target_face_center": target_center,
"face_center_move_vector": move_vector,
"resize_strategy": "local-face-plane-offset-deform",
"edit_strategy_label": "按面偏移只移动当前Face",
"edit_semantics": "把目标面偏移换算成沿当前面垂直方向的移动量,只移动当前 Face 并重建相邻平面。",
}
plan = self.face_center_local_move_plan(face_id, target_center)
plan.update(
{
"current_plane_position": current_position,
"target_plane_position": target_position,
"plane_origin": plane_origin,
"plane_direction": plane_direction,
"plane_offset_distance": distance,
"resize_strategy": "local-face-plane-offset-deform",
"edit_strategy_label": "按面偏移只移动当前Face",
"edit_semantics": (
"把目标面偏移换算成沿当前面垂直方向的移动量,只移动当前 Face 的顶点并重建相邻平面;"
"不推拉加料/切削,也不平移所属对象。"
),
}
)
return plan
def face_plane_offset_owning_translation_plan(self, face_id: int, distance: float) -> dict[str, object]:
try:
distance = float(distance)
except (TypeError, ValueError):
distance = 0.0
frame = None
blockers = ["目标面偏移必须是数字。"]
else:
frame = self.face_plane_offset_frame(face_id)
blockers = []
if face_id < 0 or face_id >= len(self.faces):
blockers.append(f"Unknown face id {face_id}")
info: dict[str, object] = {}
else:
info = self.face_info(face_id)
if frame is None:
blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按面偏移平移所属对象。")
plane_origin = None
plane_direction = None
current_position = None
target_position = None
vector = (0.0, 0.0, 0.0)
if frame is not None:
plane_origin, plane_direction, current_position = frame
target_position = current_position + distance
vector = _tuple_scale(plane_direction, distance)
part_id = int(info.get("part_id", -1)) if info else -1
solid_id = int(info.get("solid_id", -1)) if info else -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"
if part is None:
blockers.append("找不到当前 Face 所属特征。")
if target_kind == "solid" and not (0 <= solid_id < len(self.solids)):
blockers.append("找不到当前 Face 所属 Solid。")
if blockers:
return {
"status": "blocked",
"risk": "blocked",
"message": " ".join(blockers),
"warnings": "",
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"target_kind": target_kind,
"current_plane_position": current_position,
"target_plane_position": target_position,
"plane_origin": plane_origin,
"plane_direction": plane_direction,
"plane_offset_distance": distance,
"translation_vector": vector,
"resize_strategy": "translate-owning-shape-from-plane-offset",
"translate_strategy": f"translate-{target_kind}",
"edit_strategy_label": "按面偏移平移所属对象",
"edit_semantics": "把目标面偏移换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。",
}
plan = self.translate_solid_plan(solid_id, vector) if target_kind == "solid" else self.translate_part_plan(part_id, vector)
distance_ratio = 0.0
diagonal = float(plan.get("bbox_diagonal") or 0.0)
if diagonal > 1e-9:
distance_ratio = abs(distance) / diagonal
if distance_ratio > 5.0:
blocker = "目标面偏移需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。"
blockers = [part for part in str(plan.get("blockers") or "").split("") if part]
blockers.append(blocker)
plan["status"] = "blocked"
plan["risk"] = "blocked"
plan["blockers"] = "".join(blockers)
plan["message"] = " ".join(blockers)
plan.update(
{
"face_id": face_id,
"current_plane_position": current_position,
"target_plane_position": target_position,
"plane_origin": plane_origin,
"plane_direction": plane_direction,
"plane_offset_distance": distance,
"face_offset_distance_ratio": distance_ratio,
"resize_strategy": "translate-owning-shape-from-plane-offset",
"translate_strategy": f"translate-{target_kind}",
"edit_strategy_label": "按面偏移平移所属对象",
"edit_semantics": "把目标面偏移换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。",
}
)
return plan
def face_center_owning_translation_plan(
self,
face_id: int,
target_center: tuple[float, float, 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)
target = _tuple_or_none(target_center)
current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
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"
blockers: list[str] = []
if current_center is None:
blockers.append("当前 Face 缺少稳定中心坐标,不能按中心平移所属对象。")
if target is None:
blockers.append("目标 Face 中心必须是有效的 X/Y/Z 坐标。")
if part is None:
blockers.append("找不到当前 Face 所属特征。")
if target_kind == "solid" and not (0 <= solid_id < len(self.solids)):
blockers.append("找不到当前 Face 所属 Solid。")
vector = (0.0, 0.0, 0.0)
move_distance = 0.0
move_ratio = 0.0
diagonal = 0.0
if current_center is not None and target is not None:
vector = _tuple_sub(target, current_center)
move_distance = _vector_length(vector)
source_shape = (
self.solids[solid_id][1]
if target_kind == "solid" and 0 <= solid_id < len(self.solids)
else (part.shape if part is not None else None)
)
diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0
if diagonal > 1e-9:
move_ratio = move_distance / diagonal
if move_distance <= max(diagonal * 1e-7, 1e-7):
blockers.append("目标 Face 中心与当前中心几乎相同,不需要移动。")
elif move_ratio > 5.0:
blockers.append("目标 Face 中心移动距离超过所属对象尺寸的 5 倍,容易生成极端变形或把特征移到远离模型的位置。")
if blockers:
return {
"status": "blocked",
"risk": "blocked",
"message": " ".join(blockers),
"warnings": "",
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"target_kind": target_kind,
"current_face_center": current_center,
"target_face_center": target,
"translation_vector": vector,
"translation_distance": move_distance,
"face_center_move_vector": vector,
"face_center_move_distance": move_distance,
"face_center_move_ratio": move_ratio,
"bbox_diagonal": diagonal,
"resize_strategy": "translate-owning-shape-from-face-center",
"translate_strategy": f"translate-{target_kind}",
"edit_strategy_label": "平移 Face 所属对象",
"edit_semantics": "把目标 Face 的中心坐标换算成平移量,并平移所属特征或 Solid;不做单面局部扭曲。",
}
plan = self.translate_solid_plan(solid_id, vector) if target_kind == "solid" else self.translate_part_plan(part_id, vector)
diagonal = float(plan.get("bbox_diagonal") or diagonal or 0.0)
if diagonal > 1e-9:
move_ratio = move_distance / diagonal
plan.update(
{
"face_id": face_id,
"current_face_center": current_center,
"target_face_center": target,
"face_center_move_vector": vector,
"face_center_move_distance": move_distance,
"face_center_move_ratio": move_ratio,
"resize_strategy": "translate-owning-shape-from-face-center",
"translate_strategy": f"translate-{target_kind}",
"edit_strategy_label": "平移 Face 所属对象",
"edit_semantics": "把目标 Face 的中心坐标换算成平移量,并平移所属特征或 Solid;不做单面局部扭曲。",
}
)
return plan
def move_face_center_owning(
self,
face_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.face_center_owning_translation_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
vector = tuple(plan["translation_vector"])
if plan.get("target_kind") == "solid":
result = self.translate_solid(int(plan["solid_id"]), vector)
else:
result = self.translate_part(int(plan["part_id"]), vector)
result_check = self._face_edit_result_summary(plan)
return (
"Face center move completed by owning-shape translation: "
f"face {face_id}, "
f"current_center={plan.get('current_face_center')}, "
f"target_center={plan.get('target_face_center')}, "
f"move={plan.get('face_center_move_vector')}, "
f"target={plan.get('target_kind')}, "
f"risk={plan['risk']}. {result_check} {result}"
)
def move_face_plane_offset_local(self, face_id: int, distance: float) -> str:
plan = self.face_plane_offset_local_plan(face_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
self._apply_local_face_deform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Face plane offset completed by local face-only deformation: "
f"face {face_id}, "
f"current_position={float(plan['current_plane_position']):g}, "
f"target_position={float(plan['target_plane_position']):g}, "
f"distance={float(plan['plane_offset_distance']):g}, "
f"moved_points={plan.get('local_face_deform_moved_point_count')}, "
f"rebuilt_faces={plan.get('local_face_deform_face_count')}, "
f"target={plan.get('local_face_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def translate_face_plane_offset_owning(self, face_id: int, distance: float) -> str:
plan = self.face_plane_offset_owning_translation_plan(face_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
if plan.get("target_kind") == "solid":
result = self.translate_solid(int(plan["solid_id"]), tuple(plan["translation_vector"]))
else:
result = self.translate_part(int(plan["part_id"]), tuple(plan["translation_vector"]))
result_check = self._face_edit_result_summary(plan)
return (
"Face plane offset completed by owning-shape translation: "
f"face {face_id}, "
f"current_position={float(plan['current_plane_position']):g}, "
f"target_position={float(plan['target_plane_position']):g}, "
f"distance={float(plan['plane_offset_distance']):g}, "
f"target={plan.get('target_kind')}, "
f"risk={plan['risk']}. "
f"{result_check} "
f"{result}"
)
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"
try:
target_thickness = float(target_thickness)
except (TypeError, ValueError):
target_thickness = 0.0
blockers.append("目标薄壁厚度必须是数字。")
current_thickness = float(info.get("shell_thickness_estimate") or 0.0)
signed_thickness = float(info.get("shell_signed_thickness") or 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("目标厚度与当前估算厚度几乎相同,不需要修改。")
if current_thickness > 1e-9:
thickness_scale = target_thickness / current_thickness
if thickness_scale < 0.05:
blockers.append("目标薄壁厚度会把当前厚度缩到 5% 以下,容易生成退化薄壁或无效几何。")
elif thickness_scale > 5.0:
blockers.append("目标薄壁厚度会把当前厚度放大到 5 倍以上,容易导致推拉布尔失败或大范围变形。")
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 _local_face_plane_size_info(
self,
face_id: int,
*,
face: TopoDS_Shape | None = None,
surf: BRepAdaptor_Surface | None = None,
center: tuple[float, float, float] | None = None,
tolerance: float | None = None,
) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
return {}
face_shape = face or self.faces[face_id]
surface = surf or BRepAdaptor_Surface(face_shape)
if surface.GetType() != GeomAbs_Plane:
return {}
if center is None:
try:
center = _point_tuple(_surface_center(face_shape))
except Exception:
center = None
if center is None:
return {}
if tolerance is None:
solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else face_shape
tolerance = max(_shape_diagonal(source_shape) * 1e-7, 1e-6)
points = self._local_deform_face_vertex_points(face_shape, tolerance)
if len(points) < 3:
return {}
normal = _tuple_normalized(_dir_tuple(surface.Plane().Axis().Direction()))
if normal is None:
return {}
if face_shape.Orientation() == TopAbs_REVERSED:
normal = _tuple_scale(normal, -1.0)
u_dir, v_dir = _plane_basis_dirs(gp_Dir(*normal))
width_dir = _tuple_normalized(_dir_tuple(u_dir))
height_dir = _tuple_normalized(_dir_tuple(v_dir))
if width_dir is None:
return {}
if height_dir is None:
return {}
def span_for(axis: tuple[float, float, float]) -> float:
values = [_tuple_dot(_tuple_sub(point, center), axis) for point in points]
return max(values) - min(values)
width = span_for(width_dir)
height = span_for(height_dir)
if width <= max(tolerance, 1e-9) or height <= max(tolerance, 1e-9):
return {}
return {
"local_face_width": width,
"local_face_height": height,
"local_face_width_direction": width_dir,
"local_face_height_direction": height_dir,
"local_face_size_center": center,
"local_face_size_source_point_count": len(points),
}
def face_center_local_move_plan(
self,
face_id: int,
target_center: tuple[float, float, 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)
target = _tuple_or_none(target_center)
current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
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
solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None
source_shape = solid or (part.shape if part is not None else None)
diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0
tolerance = max(diagonal * 1e-7, 1e-6)
blockers: list[str] = []
warnings: list[str] = [
"只移动当前 Face 会移动这个 Face 的顶点并重建周边平面;相邻面可能变斜或被拆成三角面。"
]
risk = "low"
status = "ready"
if info.get("surface") != "plane":
blockers.append("当前 Face 不是平面,不能执行“只移动当前Face”。")
if current_center is None:
blockers.append("当前 Face 缺少稳定中心坐标。")
if target is None:
blockers.append("目标 Face 中心必须是有效的 X/Y/Z 坐标。")
if part is None:
blockers.append("找不到当前 Face 所属特征。")
if solid is None:
blockers.append("找不到当前 Face 所属 Solid。")
move_vector = (0.0, 0.0, 0.0)
move_distance = 0.0
move_ratio = 0.0
if current_center is not None and target is not None:
move_vector = _tuple_sub(target, current_center)
move_distance = _vector_length(move_vector)
move_ratio = move_distance / max(diagonal, 1e-9)
if move_distance <= max(diagonal * 1e-7, 1e-7):
blockers.append("目标 Face 中心与当前中心几乎相同,不需要修改。")
elif move_ratio > 5.0:
blockers.append("目标 Face 中心移动距离超过所属对象尺寸的 5 倍,容易生成极端变形或无效几何。")
elif move_ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("Face 中心移动距离超过所属对象尺寸的 50%,局部形变风险很高。")
elif move_ratio > 0.2:
risk = _max_risk(risk, "medium")
warnings.append("Face 中心移动距离超过所属对象尺寸的 20%,请确认相邻面变化是否符合预期。")
source_points: tuple[tuple[float, float, float], ...] = ()
face_count = 0
part_solid_count = 0
if part is not None:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
if not blockers and solid is not None:
solid_faces = _explore(solid, TopAbs_FACE)
face_count = len(solid_faces)
if not solid_faces:
blockers.append("局部 Face 移动不可用:所属 Solid 没有可重建 Face。")
if face_count > 128:
blockers.append("局部 Face 移动暂只对较简单的平面实体开放,复杂模型请使用推拉或整体平移。")
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
blockers.append("局部 Face 移动暂只支持全平面实体;含曲面的模型请使用其它编辑方式。")
break
if len(_explore(face, TopAbs_WIRE)) != 1:
blockers.append("局部 Face 移动暂不处理带内孔的 Face;请使用孔/槽专门入口。")
break
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
blockers.append("局部 Face 移动不可用:部分 Face 顶点环无法稳定读取。")
break
if not blockers:
points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance)
if len(points) < 3:
blockers.append("局部 Face 移动不可用:当前 Face 顶点环无法稳定读取。")
else:
source_points = tuple(points)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "可以只移动当前平面 Face,并让相邻平面按新的顶点位置重建。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
**self._face_first_level_plan_fields(face_id),
"surface": info.get("surface"),
"current_face_center": current_center,
"target_face_center": target,
"face_center_move_vector": move_vector,
"face_center_move_distance": move_distance,
"face_center_move_ratio": move_ratio,
"bbox_diagonal": diagonal,
"local_face_deform_target_kind": target_kind,
"local_face_deform_face_count": face_count,
"local_face_deform_source_points": source_points,
"local_face_deform_moved_point_count": len(source_points),
"part_solid_count": part_solid_count,
"resize_strategy": "local-face-only-deform",
"edit_strategy_label": "只移动当前Face",
"edit_semantics": (
"只移动当前 Face 的顶点,周边相邻面按新顶点重建;这不是平移所属对象,也不是面面积缩放。"
),
}
def face_area_local_resize_plan(self, face_id: int, target_area: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
current_area = _float_or_none(info.get("area"))
center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
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
solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None
source_shape = solid or (part.shape if part is not None else None)
diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0
tolerance = max(diagonal * 1e-7, 1e-6)
blockers: list[str] = []
warnings: list[str] = [
"只缩放当前 Face 面积会在该平面内移动当前 Face 的顶点,并重建相邻平面。"
]
risk = "low"
status = "ready"
try:
target_area = float(target_area)
except (TypeError, ValueError):
target_area = 0.0
blockers.append("目标面面积必须是数字。")
if info.get("surface") != "plane":
blockers.append("当前 Face 不是平面,不能执行“只缩放当前Face面积”。")
if current_area is None or current_area <= 1e-9:
blockers.append("当前 Face 缺少稳定面积。")
if center is None:
blockers.append("当前 Face 缺少稳定中心坐标。")
if target_area <= 1e-9:
blockers.append("目标面面积必须大于 0。")
if part is None:
blockers.append("找不到当前 Face 所属特征。")
if solid is None:
blockers.append("找不到当前 Face 所属 Solid。")
scale = (
math.sqrt(target_area / max(float(current_area), 1e-9))
if target_area > 1e-9 and current_area is not None and current_area > 1e-9
else 1.0
)
area_delta = target_area - float(current_area or 0.0)
area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9)
if scale < 0.05:
blockers.append("目标面面积会把当前 Face 缩放到当前尺寸的 5% 以下,容易生成退化面或无效几何。")
elif scale > 5.0:
blockers.append("目标面面积会把当前 Face 放大到当前尺寸的 5 倍以上,容易穿过相邻几何或导致重建失败。")
if current_area is not None and abs(area_delta) <= max(current_area * 1e-6, 1e-6):
blockers.append("目标面面积与当前面积几乎相同,不需要修改。")
if area_delta_ratio > 1.0:
risk = _max_risk(risk, "high")
warnings.append("目标面面积变化超过当前面积的 100%,相邻面形变风险很高。")
elif area_delta_ratio > 0.35:
risk = _max_risk(risk, "medium")
warnings.append("目标面面积变化超过当前面积的 35%,请确认相邻面变化是否符合预期。")
if scale < 0.2:
risk = _max_risk(risk, "high")
warnings.append("当前 Face 会被缩得很小,可能生成薄小面或退化边。")
elif scale > 2.5:
risk = _max_risk(risk, "high")
warnings.append("当前 Face 会被放大很多,可能穿过相邻几何。")
point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = ()
face_count = 0
part_solid_count = 0
if part is not None:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
if not blockers and solid is not None and center is not None:
solid_faces = _explore(solid, TopAbs_FACE)
face_count = len(solid_faces)
if not solid_faces:
blockers.append("局部 Face 面积缩放不可用:所属 Solid 没有可重建 Face。")
if face_count > 128:
blockers.append("局部 Face 面积缩放暂只对较简单的平面实体开放。")
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
blockers.append("局部 Face 面积缩放暂只支持全平面实体;含曲面的模型请使用所属对象缩放。")
break
if len(_explore(face, TopAbs_WIRE)) != 1:
blockers.append("局部 Face 面积缩放暂不处理带内孔的 Face。")
break
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
blockers.append("局部 Face 面积缩放不可用:部分 Face 顶点环无法稳定读取。")
break
if not blockers:
points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance)
if len(points) < 3:
blockers.append("局部 Face 面积缩放不可用:当前 Face 顶点环无法稳定读取。")
else:
target_items: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for point in points:
relative = _tuple_sub(point, center)
target_point = (
center[0] + relative[0] * scale,
center[1] + relative[1] * scale,
center[2] + relative[2] * scale,
)
target_items.append((point, target_point))
point_targets = tuple(target_items)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "可以只缩放当前平面 Face 的面积,并让相邻平面按新的顶点位置重建。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
**self._face_first_level_plan_fields(face_id),
"surface": info.get("surface"),
"current_area": current_area,
"target_area": target_area,
"area_delta": area_delta,
"area_delta_ratio": area_delta_ratio,
"area_center": center,
"local_face_area_scale": scale,
"bbox_diagonal": diagonal,
"local_face_deform_target_kind": target_kind,
"local_face_deform_face_count": face_count,
"local_face_deform_source_point_targets": point_targets,
"local_face_deform_moved_point_count": len(point_targets),
"part_solid_count": part_solid_count,
"resize_strategy": "local-face-area-only-deform",
"edit_strategy_label": "只缩放当前Face面积",
"edit_semantics": (
"围绕当前 Face 中心在该平面内缩放这个 Face 的顶点,周边相邻面按新顶点重建;"
"这不是缩放所属对象。"
),
}
def face_size_local_resize_plan(self, face_id: int, target_size: float, axis: str = "width") -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width"
axis_label = "面高" if axis_key == "height" else "面宽"
info = self.face_info(face_id)
center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
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
solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None
source_shape = solid or (part.shape if part is not None else None)
diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0
tolerance = max(diagonal * 1e-7, 1e-6)
blockers: list[str] = []
warnings: list[str] = [
f"{axis_label}(当前面)会只沿选中 Face 自身平面内的一个方向缩放当前 Face 顶点,并重建相邻平面。"
]
risk = "low"
status = "ready"
try:
target_size = float(target_size)
except (TypeError, ValueError):
target_size = 0.0
blockers.append(f"目标{axis_label}必须是数字。")
if info.get("surface") != "plane":
blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(当前面)”。")
if center is None:
blockers.append("当前 Face 缺少稳定中心坐标。")
if target_size <= 1e-9:
blockers.append(f"目标{axis_label}必须大于 0。")
if part is None:
blockers.append("找不到当前 Face 所属特征。")
if solid is None:
blockers.append("找不到当前 Face 所属 Solid。")
size_info = self._local_face_plane_size_info(
face_id,
center=center,
tolerance=tolerance,
)
current_width = _float_or_none(size_info.get("local_face_width"))
current_height = _float_or_none(size_info.get("local_face_height"))
width_dir = _tuple_or_none(size_info.get("local_face_width_direction"))
height_dir = _tuple_or_none(size_info.get("local_face_height_direction"))
current_size = current_height if axis_key == "height" else current_width
axis_dir = height_dir if axis_key == "height" else width_dir
if current_size is None or current_size <= 1e-9 or axis_dir is None:
blockers.append(f"当前 Face 缺少稳定{axis_label}方向或尺寸。")
scale = target_size / max(float(current_size or 1.0), 1e-9)
delta_size = target_size - float(current_size or 0.0)
delta_ratio = abs(delta_size) / max(float(current_size or 0.0), 1e-9)
if scale < 0.05:
blockers.append(f"目标{axis_label}会把当前 Face 沿该方向缩放到当前值的 5% 以下,容易生成退化边或无效几何。")
elif scale > 5.0:
blockers.append(f"目标{axis_label}会把当前 Face 沿该方向放大到当前值的 5 倍以上,容易穿过相邻几何或导致重建失败。")
if current_size is not None and abs(delta_size) <= max(current_size * 1e-6, 1e-6):
blockers.append(f"目标{axis_label}与当前值几乎相同,不需要修改。")
if delta_ratio > 1.0:
risk = _max_risk(risk, "high")
warnings.append(f"目标{axis_label}变化超过当前值的 100%,相邻面形变风险很高。")
elif delta_ratio > 0.35:
risk = _max_risk(risk, "medium")
warnings.append(f"目标{axis_label}变化超过当前值的 35%,请确认相邻面变化是否符合预期。")
if scale < 0.2:
risk = _max_risk(risk, "high")
warnings.append("当前 Face 会沿一个方向被缩得很窄,可能生成薄小面或退化边。")
elif scale > 2.5:
risk = _max_risk(risk, "high")
warnings.append("当前 Face 会沿一个方向被拉得很长,可能穿过相邻几何。")
point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = ()
face_count = 0
part_solid_count = 0
if part is not None:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
if not blockers and solid is not None and center is not None and axis_dir is not None:
solid_faces = _explore(solid, TopAbs_FACE)
face_count = len(solid_faces)
if not solid_faces:
blockers.append(f"局部 Face {axis_label}修改不可用:所属 Solid 没有可重建 Face。")
if face_count > 128:
blockers.append(f"局部 Face {axis_label}修改暂只对较简单的平面实体开放。")
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
blockers.append(f"局部 Face {axis_label}修改暂只支持全平面实体;含曲面的模型请使用所属对象缩放。")
break
if len(_explore(face, TopAbs_WIRE)) != 1:
blockers.append(f"局部 Face {axis_label}修改暂不处理带内孔的 Face。")
break
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
blockers.append(f"局部 Face {axis_label}修改不可用:部分 Face 顶点环无法稳定读取。")
break
if not blockers:
points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance)
if len(points) < 3:
blockers.append(f"局部 Face {axis_label}修改不可用:当前 Face 顶点环无法稳定读取。")
else:
target_items: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for point in points:
relative = _tuple_sub(point, center)
along = _tuple_dot(relative, axis_dir)
axial = _tuple_scale(axis_dir, along)
rest = _tuple_sub(relative, axial)
scaled = _tuple_add(rest, _tuple_scale(axis_dir, along * scale))
target_point = _tuple_add(center, scaled)
target_items.append((point, target_point))
point_targets = tuple(target_items)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = f"可以只修改当前平面 Face 的{axis_label},并让相邻平面按新的顶点位置重建。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
**self._face_first_level_plan_fields(face_id),
"surface": info.get("surface"),
"face_size_axis": axis_key,
"face_size_label": axis_label,
"current_face_width": current_width,
"target_face_width": target_size if axis_key == "width" else current_width,
"current_face_height": current_height,
"target_face_height": target_size if axis_key == "height" else current_height,
"current_face_size": current_size,
"target_face_size": target_size,
"face_size_delta": delta_size,
"face_size_delta_ratio": delta_ratio,
"face_size_scale": scale,
"face_size_center": center,
"face_size_axis_direction": axis_dir,
"face_width_direction": width_dir,
"face_height_direction": height_dir,
"bbox_diagonal": diagonal,
"local_face_deform_target_kind": target_kind,
"local_face_deform_face_count": face_count,
"local_face_deform_source_point_targets": point_targets,
"local_face_deform_moved_point_count": len(point_targets),
"local_face_deform_distance_hint": abs(delta_size),
"part_solid_count": part_solid_count,
"resize_strategy": f"local-face-{axis_key}-only-deform",
"edit_strategy_label": f"{axis_label}(当前面)",
"edit_semantics": (
f"围绕当前 Face 中心,只沿 Face 平面内的{axis_label}方向缩放该 Face 顶点;"
"另一方向尺寸保持不主动缩放,周边相邻面按新顶点重建;这不是缩放所属对象。"
),
}
def face_size_owning_scale_plan(self, face_id: int, target_size: float, axis: str = "width") -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width"
axis_label = "面高" if axis_key == "height" else "面宽"
info = self.face_info(face_id)
center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
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
solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None
source_shape = solid or (part.shape if part is not None else None)
diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0
tolerance = max(diagonal * 1e-7, 1e-6)
blockers: list[str] = []
warnings: list[str] = [
f"{axis_label}(整体)会沿选中 Face 自身平面内的一个方向缩放所属特征或 Solid;同一对象上的其它几何会跟着改变。"
]
risk = "medium"
try:
target_size = float(target_size)
except (TypeError, ValueError):
target_size = 0.0
blockers.append(f"目标{axis_label}必须是数字。")
if info.get("surface") != "plane":
blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(整体)”。")
if center is None:
blockers.append("当前 Face 缺少稳定中心坐标,不能确定缩放基准。")
if target_size <= 1e-9:
blockers.append(f"目标{axis_label}必须大于 0。")
if part is None:
blockers.append("找不到当前 Face 所属特征。")
if source_shape is None:
blockers.append("找不到当前 Face 可缩放的所属对象。")
size_info = self._local_face_plane_size_info(
face_id,
center=center,
tolerance=tolerance,
)
current_width = _float_or_none(size_info.get("local_face_width"))
current_height = _float_or_none(size_info.get("local_face_height"))
width_dir = _tuple_or_none(size_info.get("local_face_width_direction"))
height_dir = _tuple_or_none(size_info.get("local_face_height_direction"))
current_size = current_height if axis_key == "height" else current_width
axis_dir = height_dir if axis_key == "height" else width_dir
if current_size is None or current_size <= 1e-9 or axis_dir is None:
blockers.append(f"当前 Face 缺少稳定{axis_label}方向或尺寸。")
scale = target_size / max(float(current_size or 1.0), 1e-9)
delta_size = target_size - float(current_size or 0.0)
delta_ratio = abs(delta_size) / max(float(current_size or 0.0), 1e-9)
if scale < 0.05:
blockers.append(f"目标{axis_label}会把所属对象沿该方向缩放到当前值的 5% 以下,容易生成退化几何。")
elif scale > 5.0:
blockers.append(f"目标{axis_label}会把所属对象沿该方向放大到当前值的 5 倍以上,风险过高。")
if current_size is not None and abs(delta_size) <= max(current_size * 1e-6, 1e-6):
blockers.append(f"目标{axis_label}与当前值几乎相同,不需要修改。")
if delta_ratio > 1.0:
risk = _max_risk(risk, "high")
warnings.append(f"目标{axis_label}变化超过当前值的 100%,所属对象会发生很大的单向缩放。")
elif delta_ratio > 0.35:
risk = _max_risk(risk, "high")
warnings.append(f"目标{axis_label}变化超过当前值的 35%,请重点检查同一对象上的孔、槽、凸台和厚度。")
elif delta_ratio > 0.15:
risk = _max_risk(risk, "medium")
warnings.append(f"目标{axis_label}变化超过当前值的 15%,其它特征会跟随缩放。")
if scale < 0.2:
risk = _max_risk(risk, "high")
warnings.append("所属对象会沿一个方向被缩得很窄,可能生成退化边或薄小面。")
elif scale > 2.5:
risk = _max_risk(risk, "high")
warnings.append("所属对象会沿一个方向被拉得很长,可能明显扭曲其它特征间距。")
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"
target_label = "Solid" if target_kind == "solid" else "特征"
if not blockers:
warnings.append(f"当前会缩放所属{target_label},不是只移动当前 Face 顶点。")
owning_rebuild_mode = "affine-transform"
point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = ()
face_count = 0
if not blockers and solid is not None and center is not None and axis_dir is not None:
solid_faces = _explore(solid, TopAbs_FACE)
face_count = len(solid_faces)
can_rebuild_planar = bool(solid_faces) and face_count <= 128
if not solid_faces:
warnings.append("所属对象没有可重建 Face,将使用通用仿射缩放。")
elif face_count > 128:
warnings.append("所属对象 Face 数较多,将使用通用仿射缩放。")
if can_rebuild_planar:
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
can_rebuild_planar = False
warnings.append("所属对象含曲面,将使用通用仿射缩放;部分解析几何可能变为 B-spline。")
break
if len(_explore(face, TopAbs_WIRE)) != 1:
can_rebuild_planar = False
warnings.append("所属对象包含带内孔的 Face,将使用通用仿射缩放。")
break
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
can_rebuild_planar = False
warnings.append("所属对象部分 Face 顶点环无法稳定读取,将使用通用仿射缩放。")
break
if can_rebuild_planar:
target_by_key: dict[tuple[int, int, int], tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
for face in solid_faces:
for point in self._local_deform_face_vertex_points(face, tolerance):
key = self._local_point_key(point, tolerance)
if key in target_by_key:
continue
relative = _tuple_sub(point, center)
along = _tuple_dot(relative, axis_dir)
axial = _tuple_scale(axis_dir, along)
rest = _tuple_sub(relative, axial)
scaled = _tuple_add(rest, _tuple_scale(axis_dir, along * scale))
target_by_key[key] = (point, _tuple_add(center, scaled))
point_targets = tuple(target_by_key.values())
if point_targets:
owning_rebuild_mode = "planar-rebuild"
warnings.append("当前所属对象是简单全平面实体,会优先重建平面 Face,减少仿射后变成样条面的风险。")
status = "blocked" if blockers else ("caution" if risk != "low" else "ready")
if blockers:
risk = "blocked"
message = " ".join(blockers)
elif warnings:
message = " ".join(warnings)
else:
message = f"可以沿当前 Face 的{axis_label}方向缩放所属{target_label}。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"face_size_axis": axis_key,
"face_size_label": axis_label,
"current_face_width": current_width,
"target_face_width": target_size if axis_key == "width" else current_width,
"current_face_height": current_height,
"target_face_height": target_size if axis_key == "height" else current_height,
"current_face_size": current_size,
"target_face_size": target_size,
"face_size_delta": delta_size,
"face_size_delta_ratio": delta_ratio,
"face_size_scale": scale,
"face_size_center": center,
"face_size_axis_direction": axis_dir,
"face_width_direction": width_dir,
"face_height_direction": height_dir,
"bbox_diagonal": diagonal,
"resize_strategy": f"axis-scale-owning-shape-from-face-{axis_key}",
"edit_strategy_label": f"{axis_label}(整体)",
"edit_semantics": (
f"围绕当前 Face 中心,沿 Face 平面内的{axis_label}方向对所属{target_label}做单向仿射缩放;"
"同一对象上的其它几何会跟随变化,这不是只改当前 Face。"
),
"affine_scale": scale,
"affine_transform_kind": "axis-affine",
"affine_transform_label": f"按当前Face{axis_label}方向缩放所属{target_label}",
"affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它特征间距。",
"affine_axis_point": center,
"affine_axis_direction": axis_dir,
"affine_axis_source": f"selected face {axis_key} direction",
"affine_anchor_source": "selected face center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
"owning_face_size_rebuild_mode": owning_rebuild_mode,
"local_face_deform_target_kind": target_kind,
"local_face_deform_face_count": face_count,
"local_face_deform_source_point_targets": point_targets,
"local_face_deform_moved_point_count": len(point_targets),
"local_face_deform_distance_hint": abs(delta_size),
}
def cylindrical_boss_height_plan(
self,
face_id: int,
target_height: float,
*,
require_boss: bool = True,
) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
feature = self.feature_info(face_id)
blockers: list[str] = []
edit_label = "凸台高度" if require_boss else "圆柱高度"
warnings: list[str] = [
f"{edit_label}调整会推拉识别到的圆柱端盖 Face;这是 B-Rep 几何编辑,不是 CAD 历史特征参数。"
]
risk = "low"
if info.get("surface") != "cylinder":
blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}。")
if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate":
blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。")
angular_span = float(info.get("angular_span", 0.0))
if angular_span < math.tau * 0.92:
blockers.append(f"{edit_label}调整当前版本只支持接近完整圆柱的圆柱面。")
if target_height <= 1e-9:
blockers.append(f"目标{edit_label}必须大于 0。")
axis_range: dict[str, object] = {
"span": 0.0,
"same_domain_face_ids": (),
"same_domain_face_count": 0,
}
if info.get("surface") == "cylinder":
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
_int_values(feature.get("feature_side_face_ids")),
)
current_height = float(axis_range.get("span", 0.0))
delta_height = float(target_height) - current_height
if current_height <= 1e-9:
blockers.append(f"当前{edit_label}估算无效。")
elif abs(delta_height) <= max(current_height * 1e-5, 1e-6):
blockers.append(f"目标{edit_label}与当前估算高度几乎相同,不需要修改。")
if str(info.get("confidence", "low")) != "high":
risk = _max_risk(risk, "medium")
warnings.append(f"{edit_label}识别置信度不是 high,修改后请重点检查结果。")
if current_height > 1e-9:
delta_ratio = abs(delta_height) / current_height
if delta_ratio > 0.8:
risk = _max_risk(risk, "high")
warnings.append("目标高度变化超过当前高度的 80%,可能导致周边几何异常。")
elif delta_ratio > 0.3:
risk = _max_risk(risk, "medium")
warnings.append("目标高度变化超过当前高度的 30%,请确认预览方向和范围。")
cap_candidates: list[tuple[tuple[int, int, float, int], dict[str, object]]] = []
if not blockers:
endpoint_groups = [
("start", "起点端盖", _int_values(feature.get("feature_start_end_face_ids")), -1.0, bool(info.get("start_end_open"))),
("end", "终点端盖", _int_values(feature.get("feature_end_end_face_ids")), 1.0, bool(info.get("end_end_open"))),
]
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if axis_direction is None:
blockers.append(f"当前{edit_label}缺少稳定轴线方向,不能换算高度修改。")
else:
for endpoint_role, endpoint_label, cap_face_ids, axis_sign, is_open_end in endpoint_groups:
desired_movement = _tuple_scale(axis_direction, axis_sign * delta_height)
desired_unit = _tuple_normalized(desired_movement)
if desired_unit is None:
continue
for cap_face_id in cap_face_ids:
if cap_face_id < 0 or cap_face_id >= len(self.faces):
continue
try:
cap_info = self.face_info(cap_face_id)
except Exception:
continue
if cap_info.get("surface") != "plane":
continue
outward = _tuple_normalized(_tuple_or_none(cap_info.get("push_pull_outward_direction")))
if outward is None:
continue
movement_alignment = abs(_tuple_dot(desired_unit, outward))
if movement_alignment < 0.82:
continue
push_pull_distance = _tuple_dot(desired_movement, outward)
if abs(push_pull_distance) <= 1e-9:
continue
push_plan = self.push_pull_plan(cap_face_id, push_pull_distance)
if push_plan["status"] == "blocked":
warnings.append(f"{endpoint_label} Face {cap_face_id} 不能推拉:{push_plan.get('message', '')}")
continue
candidate_risk = str(push_plan.get("risk", "medium"))
score = (
0 if is_open_end else 1,
{"low": 0, "medium": 1, "high": 2}.get(candidate_risk, 3),
-movement_alignment,
cap_face_id,
)
cap_candidates.append(
(
score,
{
"boss_height_cap_face_id": cap_face_id,
"boss_height_endpoint_role": endpoint_role,
"boss_height_endpoint_label": endpoint_label,
"boss_height_open_end": is_open_end,
"boss_height_movement_alignment": movement_alignment,
"boss_height_desired_movement_vector": desired_movement,
"push_pull_distance": push_pull_distance,
"push_pull_status": push_plan.get("status"),
"push_pull_risk": push_plan.get("risk"),
"push_pull_message": push_plan.get("message"),
"push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", (cap_face_id,)),
"push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1),
"push_pull_scope_note": push_plan.get("push_pull_scope_note", ""),
},
)
)
selected_cap: dict[str, object] = {}
if not blockers:
if not cap_candidates:
blockers.append("没有找到可推拉的圆柱端盖 Face,暂不能直接调整凸台高度。")
else:
cap_candidates.sort(key=lambda item: item[0])
selected_cap = cap_candidates[0][1]
risk = _max_risk(risk, str(selected_cap.get("push_pull_risk", "medium")))
if not selected_cap.get("boss_height_open_end"):
risk = _max_risk(risk, "medium")
warnings.append("未能确认所选端盖是凸台外端,执行后请重点检查是否移动了正确端面。")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
status = "ready"
message = f"可以通过推拉圆柱端盖调整{edit_label}。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"current_height": current_height,
"target_height": float(target_height),
"delta_height": delta_height,
"height_delta_ratio": abs(delta_height) / max(current_height, 1e-9),
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"axis": info.get("axis"),
"same_domain_face_ids": axis_range.get("same_domain_face_ids"),
"same_domain_face_count": axis_range.get("same_domain_face_count"),
"feature_start_end_face_ids": feature.get("feature_start_end_face_ids"),
"feature_end_end_face_ids": feature.get("feature_end_end_face_ids"),
"resize_strategy": (
"push-pull-cylindrical-boss-end-cap-to-target-height"
if require_boss
else "push-pull-cylindrical-end-cap-to-target-height"
),
"edit_strategy_label": "端盖推拉调整高度" if require_boss else "圆柱端盖推拉调整高度",
"edit_semantics": (
f"通过推拉识别到的{edit_label}端盖 Face 改变高度;这是局部端面移动,不是整体缩放。"
),
**selected_cap,
}
def cylindrical_height_plan(self, face_id: int, target_height: float) -> dict[str, object]:
return self.cylindrical_boss_height_plan(face_id, target_height, require_boss=False)
def cylindrical_boss_height_preview_polydata(
self,
face_id: int,
target_height: float,
deflection: float = 0.8,
):
plan = self.cylindrical_boss_height_plan(face_id, target_height)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self.push_pull_preview_polydata(
int(plan["boss_height_cap_face_id"]),
float(plan["push_pull_distance"]),
deflection,
)
def resize_cylindrical_boss_height(self, face_id: int, target_height: float) -> str:
plan = self.cylindrical_boss_height_plan(face_id, target_height)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
push_result = self.push_pull_face(
int(plan["boss_height_cap_face_id"]),
float(plan["push_pull_distance"]),
)
return (
"Cylindrical boss height resize completed by end-cap push/pull: "
f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, "
f"current_height={float(plan['current_height']):g}, "
f"target_height={float(plan['target_height']):g}, "
f"delta={float(plan['delta_height']):g}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"risk={plan['risk']}. {push_result}"
)
def shell_thickness_preview_polydata(
self,
face_id: int,
target_thickness: float,
deflection: float = 0.8,
):
plan = self.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self.push_pull_preview_polydata(face_id, float(plan["push_pull_distance"]), deflection)
def resize_shell_thickness(self, face_id: int, target_thickness: float) -> str:
plan = self.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
push_result = self.push_pull_face(face_id, float(plan["push_pull_distance"]))
result_check = self._face_edit_result_summary(plan)
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']}. {result_check} {push_result}"
)
def shell_thickness_owning_scale_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] = [
"该方式会沿薄壁/壳体厚度方向缩放所属特征或 Solid;它不是推拉当前平面,也不是局部壳命令参数。"
]
risk = "medium"
try:
target_thickness = float(target_thickness)
except (TypeError, ValueError):
target_thickness = 0.0
blockers.append("目标薄壁厚度必须是数字。")
current_thickness = _float_or_none(info.get("shell_thickness_estimate"))
signed_thickness = _float_or_none(info.get("shell_signed_thickness"))
normal = _tuple_normalized(_tuple_or_none(info.get("normal")))
plane_origin = _tuple_or_none(info.get("plane_origin"))
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 is None or current_thickness <= 1e-9:
blockers.append("当前薄壁厚度估算无效。")
if signed_thickness is None or abs(signed_thickness) <= 1e-9:
blockers.append("当前薄壁厚度方向无效。")
if normal is None:
blockers.append("当前平面缺少稳定法向,不能按厚度方向整体缩放。")
if target_thickness <= 1e-9:
blockers.append("目标薄壁厚度必须大于 0。")
current_value = float(current_thickness or 0.0)
scale = target_thickness / max(current_value, 1e-9)
delta_thickness = target_thickness - current_value
delta_ratio = abs(delta_thickness) / max(current_value, 1e-9)
if current_thickness is not None and abs(delta_thickness) <= max(current_thickness * 1e-6, 1e-6):
blockers.append("目标薄壁厚度与当前值几乎相同,不需要修改。")
if scale < 0.05:
blockers.append("目标薄壁厚度会把所属对象沿厚度方向缩放到当前值的 5% 以下,容易生成退化几何。")
elif scale > 5.0:
blockers.append("目标薄壁厚度会把所属对象沿厚度方向放大到当前值的 5 倍以上,风险过高。")
if delta_ratio > 0.75:
risk = _max_risk(risk, "high")
warnings.append("薄壁厚度变化超过 75%,会明显影响所属对象上的其它厚度方向尺寸。")
elif delta_ratio > 0.3:
warnings.append("薄壁厚度变化超过 30%,修改后请重点检查壁厚和相邻特征。")
confidence = str(info.get("shell_confidence", "low"))
overlap_ratio = _float_or_none(info.get("shell_overlap_ratio_estimate"))
if confidence == "low":
risk = _max_risk(risk, "high")
warnings.append("薄壁/壳体相对面识别置信度较低。")
elif confidence == "medium":
risk = _max_risk(risk, "medium")
warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。")
if overlap_ratio is not None:
if overlap_ratio < 0.25:
risk = _max_risk(risk, "high")
warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。")
elif overlap_ratio < 0.55:
risk = _max_risk(risk, "medium")
warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。")
scale_center = None
if plane_origin is not None and normal is not None and signed_thickness is not None:
scale_center = (
plane_origin[0] + normal[0] * signed_thickness * 0.5,
plane_origin[1] + normal[1] * signed_thickness * 0.5,
plane_origin[2] + normal[2] * signed_thickness * 0.5,
)
scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if scale_center is None:
blockers.append("当前薄壁区域缺少稳定缩放中心,不能整体缩放所属对象。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前薄壁区域所属特征。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
target_label = "Solid" if target_kind == "solid" else "特征"
warnings.append(f"当前会沿薄壁厚度方向缩放所属{target_label};同一对象上的其它尺寸会跟随变化。")
owning_rebuild_mode = "affine-transform"
point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = ()
face_count = 0
solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None
if not blockers and solid is not None and scale_center is not None and normal is not None:
solid_faces = _explore(solid, TopAbs_FACE)
face_count = len(solid_faces)
can_rebuild_planar = bool(solid_faces) and face_count <= 128
if not solid_faces:
warnings.append("所属对象没有可重建 Face,将使用通用仿射缩放。")
elif face_count > 128:
warnings.append("所属对象 Face 数较多,将使用通用仿射缩放。")
if can_rebuild_planar:
tolerance = max(_shape_diagonal(solid) * 1e-7, abs(delta_thickness) * 1e-7, 1e-6)
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
can_rebuild_planar = False
warnings.append("所属对象含曲面,将使用通用仿射缩放;部分解析几何可能变为 B-spline。")
break
if len(_explore(face, TopAbs_WIRE)) != 1:
can_rebuild_planar = False
warnings.append("所属对象包含带内孔的 Face,将使用通用仿射缩放。")
break
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
can_rebuild_planar = False
warnings.append("所属对象部分 Face 顶点环无法稳定读取,将使用通用仿射缩放。")
break
if can_rebuild_planar:
target_by_key: dict[tuple[int, int, int], tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
tolerance = max(_shape_diagonal(solid) * 1e-7, abs(delta_thickness) * 1e-7, 1e-6)
for face in solid_faces:
for point in self._local_deform_face_vertex_points(face, tolerance):
key = self._local_point_key(point, tolerance)
if key in target_by_key:
continue
relative = _tuple_sub(point, scale_center)
along = _tuple_dot(relative, normal)
axial = _tuple_scale(normal, along)
rest = _tuple_sub(relative, axial)
moved = _tuple_add(scale_center, _tuple_add(rest, _tuple_scale(normal, along * scale)))
target_by_key[key] = (point, moved)
point_targets = tuple(target_by_key.values())
if point_targets:
owning_rebuild_mode = "planar-rebuild"
warnings.append("当前所属对象是简单全平面实体,会优先重建平面 Face,避免整体缩放后变成样条面。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"shell_region_kind": info.get("shell_region_kind"),
"shell_confidence": confidence,
"shell_source_face_ids": tuple(_int_values(info.get("shell_source_face_ids")) or [face_id]),
"shell_opposite_face_id": info.get("shell_opposite_face_id"),
"shell_current_thickness": current_thickness,
"shell_target_thickness": target_thickness,
"shell_delta_thickness": delta_thickness,
"shell_delta_ratio": delta_ratio,
"shell_signed_thickness": signed_thickness,
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"),
"resize_strategy": "axis-scale-owning-shape-from-shell-thickness",
"edit_strategy_label": "薄壁厚度(整体)",
"edit_semantics": (
"按目标薄壁厚度和当前厚度的比例,沿厚度方向缩放所属特征或 Solid;"
"这会改变同一对象上的其它尺寸,不是局部推拉当前平面。"
),
"affine_scale": scale,
"affine_transform_kind": "axis-affine",
"affine_transform_label": f"按薄壁厚度方向缩放所属{target_label}",
"affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它尺寸。",
"affine_axis_point": scale_center,
"affine_axis_direction": normal or (0.0, 0.0, 1.0),
"affine_axis_source": "shell thickness normal",
"affine_anchor_source": "midpoint between source and opposite plane",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
"owning_shell_thickness_rebuild_mode": owning_rebuild_mode,
"local_face_deform_target_kind": target_kind,
"local_face_deform_face_count": face_count,
"local_face_deform_source_point_targets": point_targets,
"local_face_deform_moved_point_count": len(point_targets),
"local_face_deform_distance_hint": abs(delta_thickness),
}
def resize_shell_thickness_owning_scale(self, face_id: int, target_thickness: float) -> str:
plan = self.shell_thickness_owning_scale_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
if plan.get("owning_shell_thickness_rebuild_mode") == "planar-rebuild":
self._apply_local_face_deform(plan)
else:
self._apply_edge_length_affine_transform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Shell thickness resize completed by axis owning-shape scaling: "
f"face {face_id}, "
f"thickness={float(plan['shell_current_thickness']):g}->{float(plan['shell_target_thickness']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"rebuild_mode={plan.get('owning_shell_thickness_rebuild_mode')}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def resize_cylindrical_height(self, face_id: int, target_height: float) -> str:
plan = self.cylindrical_height_plan(face_id, target_height)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
push_result = self.push_pull_face(
int(plan["boss_height_cap_face_id"]),
float(plan["push_pull_distance"]),
)
return (
"Cylindrical height resize completed by end-cap push/pull: "
f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, "
f"current_height={float(plan['current_height']):g}, "
f"target_height={float(plan['target_height']):g}, "
f"delta={float(plan['delta_height']):g}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"risk={plan['risk']}. {push_result}"
)
def cylindrical_height_owning_scale_plan(self, face_id: int, target_height: 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)
try:
feature = self.feature_info(face_id)
except Exception:
feature = {}
blockers: list[str] = []
warnings: list[str] = [
"该方式会沿当前圆柱轴向缩放所属特征或 Solid;它不是推拉某个端盖,也不是只修改单个圆柱面。"
]
risk = "medium"
try:
target_height = float(target_height)
except (TypeError, ValueError):
target_height = 0.0
blockers.append("目标圆柱高度必须是数字。")
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
current_height = _float_or_none(info.get("same_domain_height_estimate"))
if current_height is None:
current_height = _float_or_none(info.get("height_estimate"))
axis_range_value = info.get("same_domain_v_range") or info.get("v_range")
if (
current_height is None
and info.get("surface") == "cylinder"
and 0 <= face_id < len(self.faces)
):
try:
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
_int_values(feature.get("feature_side_face_ids")),
)
current_height = _float_or_none(axis_range.get("span"))
axis_range_value = (axis_range.get("v_min"), axis_range.get("v_max"))
except Exception:
pass
if info.get("surface") != "cylinder":
blockers.append("当前选中 Face 不是圆柱面。")
angular_span = _float_or_none(info.get("angular_span"))
if angular_span is not None and angular_span < math.tau * 0.92:
risk = _max_risk(risk, "high")
warnings.append("当前圆柱面不是完整圆柱;轴向整体缩放会影响所属对象,但不等于稳定的局部槽/半孔高度编辑。")
if current_height is None or current_height <= 1e-9:
blockers.append("当前圆柱缺少稳定高度估算,不能按高度整体缩放。")
if axis_point is None or axis_direction is None:
blockers.append("当前圆柱缺少稳定轴线,不能沿轴向整体缩放。")
if target_height <= 1e-9:
blockers.append("目标圆柱高度必须大于 0。")
scale_center = None
if (
axis_point is not None
and axis_direction is not None
and isinstance(axis_range_value, (list, tuple))
and len(axis_range_value) >= 2
):
v_min = _float_or_none(axis_range_value[0])
v_max = _float_or_none(axis_range_value[1])
if v_min is not None and v_max is not None:
v_mid = (v_min + v_max) * 0.5
scale_center = (
axis_point[0] + axis_direction[0] * v_mid,
axis_point[1] + axis_direction[1] * v_mid,
axis_point[2] + axis_direction[2] * v_mid,
)
scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if scale_center is None:
blockers.append("当前圆柱缺少稳定缩放中心,不能整体缩放所属对象。")
current_height_value = float(current_height or 0.0)
scale = target_height / max(current_height_value, 1e-9)
delta_height = target_height - current_height_value
delta_ratio = abs(delta_height) / max(current_height_value, 1e-9)
if current_height is not None and abs(delta_height) <= max(current_height * 1e-6, 1e-6):
blockers.append("目标圆柱高度与当前值几乎相同,不需要修改。")
if delta_ratio > 0.6:
risk = _max_risk(risk, "high")
warnings.append("圆柱高度变化超过 60%,会明显影响同一对象上的其它几何位置。")
elif delta_ratio > 0.25:
warnings.append("圆柱高度变化超过 25%,修改后请重点检查相邻特征。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前圆柱面所属特征。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
target_label = "Solid" if target_kind == "solid" else "特征"
warnings.append(f"当前会沿圆柱轴向缩放所属{target_label};同一对象上的孔距、台阶位置和其它轴向尺寸会跟随变化。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"current_height": current_height,
"target_height": target_height,
"delta_height": delta_height,
"height_delta_ratio": delta_ratio,
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"axis": axis_direction,
"scale_center": scale_center,
"resize_strategy": "axis-scale-owning-shape-from-cylinder-height",
"edit_strategy_label": "高度(整体)",
"edit_semantics": (
"按目标圆柱高度和当前高度的比例,沿圆柱轴向缩放所属特征或 Solid;"
"这会改变同一对象上的其它轴向尺寸,不是端盖推拉。"
),
"affine_scale": scale,
"affine_transform_kind": "axis-affine",
"affine_transform_label": f"按圆柱高度轴向缩放所属{target_label}",
"affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它轴向尺寸。",
"affine_axis_point": scale_center,
"affine_axis_direction": axis_direction or (0.0, 0.0, 1.0),
"affine_axis_source": "cylinder axis",
"affine_anchor_source": "cylinder axis center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_cylindrical_height_owning_scale(self, face_id: int, target_height: float) -> str:
plan = self.cylindrical_height_owning_scale_plan(face_id, target_height)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
return (
"Cylindrical height resize completed by axis owning-shape scaling: "
f"face {face_id}, "
f"height={float(plan['current_height']):g}->{float(plan['target_height']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}."
)
def cylindrical_depth_owning_scale_plan(
self,
face_id: int,
target_depth: float,
bottom_face_id: int | None = None,
) -> 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)
try:
feature = self.feature_info(face_id)
except Exception:
feature = {}
blockers: list[str] = []
warnings: list[str] = [
"该方式会沿盲孔/盲槽轴向缩放所属特征或 Solid;它不是加深切削,也不是变浅补料。"
]
risk = "medium"
try:
target_depth = float(target_depth)
except (TypeError, ValueError):
target_depth = 0.0
blockers.append("目标盲孔/盲槽深度必须是数字。")
if info.get("surface") != "cylinder":
blockers.append("当前选中 Face 不是圆柱面。")
if str(info.get("feature_guess", "")) != "hole/groove candidate":
blockers.append("深度(整体)当前只对孔/槽候选开放。")
if target_depth <= 1e-9:
blockers.append("目标盲孔/盲槽深度必须大于 0。")
context: dict[str, object] = {}
if info.get("surface") == "cylinder":
try:
context = self._blind_cylindrical_depth_context(
face_id,
info,
feature,
max(target_depth, 1e-6),
bottom_face_id=bottom_face_id,
)
except Exception as exc:
context = {"context_status": "blocked", "context_message": str(exc)}
if context.get("context_status") == "blocked":
blockers.append(str(context.get("context_message") or "当前盲孔/盲槽深度方向不稳定。"))
current_depth = _float_or_none(context.get("depth_current_depth"))
if current_depth is None:
current_depth = _float_or_none(info.get("hole_depth_estimate"))
if current_depth is None:
current_depth = _float_or_none(info.get("same_domain_height_estimate"))
if current_depth is None or current_depth <= 1e-9:
blockers.append("当前对象缺少稳定深度估算,不能按深度整体缩放。")
axis_direction = _tuple_normalized(_tuple_or_none(context.get("depth_axis_direction")))
if axis_direction is None:
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if axis_direction is None:
blockers.append("当前对象缺少稳定轴线方向,不能按深度整体缩放。")
scale_center = None
open_point = _tuple_or_none(context.get("depth_open_point"))
bottom_point = _tuple_or_none(context.get("depth_current_bottom_point"))
if open_point is not None and bottom_point is not None:
scale_center = (
(open_point[0] + bottom_point[0]) * 0.5,
(open_point[1] + bottom_point[1]) * 0.5,
(open_point[2] + bottom_point[2]) * 0.5,
)
scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if scale_center is None:
blockers.append("当前对象缺少稳定缩放中心,不能整体缩放所属对象。")
current_depth_value = float(current_depth or 0.0)
scale = target_depth / max(current_depth_value, 1e-9)
delta_depth = target_depth - current_depth_value
delta_ratio = abs(delta_depth) / max(current_depth_value, 1e-9)
if current_depth is not None and abs(delta_depth) <= max(current_depth * 1e-6, 1e-6):
blockers.append("目标盲孔/盲槽深度与当前值几乎相同,不需要修改。")
if delta_ratio > 0.75:
risk = _max_risk(risk, "high")
warnings.append("盲孔/盲槽深度变化超过 75%,会明显影响所属对象上的其它轴向尺寸。")
elif delta_ratio > 0.3:
warnings.append("盲孔/盲槽深度变化超过 30%,修改后请重点检查相邻特征和壁厚。")
angular_span = _float_or_none(info.get("angular_span"))
if angular_span is not None and angular_span < math.tau * 0.92:
risk = _max_risk(risk, "high")
warnings.append("当前是局部圆柱槽/半孔;整体缩放会改变所属对象,不等于稳定的局部槽底调整。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前对象所属特征。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
target_label = "Solid" if target_kind == "solid" else "特征"
warnings.append(f"当前会沿盲孔/盲槽方向缩放所属{target_label};孔距、壁厚和其它同向尺寸会跟随变化。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"current_depth": current_depth,
"target_depth": target_depth,
"delta_depth": delta_depth,
"depth_delta_ratio": delta_ratio,
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"angular_span": info.get("angular_span"),
"cylinder_end_type": info.get("cylinder_end_type"),
"feature_bottom_face_ids": context.get("feature_bottom_face_ids", feature.get("feature_bottom_face_ids")),
"manual_bottom_face_id": context.get("manual_bottom_face_id", "" if bottom_face_id is None else bottom_face_id),
"manual_bottom_face_used": bool(context.get("manual_bottom_face_used", bottom_face_id is not None)),
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"depth_open_point": context.get("depth_open_point"),
"depth_current_bottom_point": context.get("depth_current_bottom_point"),
"depth_current_depth_source": context.get("depth_current_depth_source", ""),
"resize_strategy": "axis-scale-owning-shape-from-blind-depth",
"edit_strategy_label": "盲孔/盲槽深度(整体)",
"edit_semantics": (
"按目标盲孔/盲槽深度和当前深度的比例,沿孔/槽轴向缩放所属特征或 Solid;"
"这会改变同一对象上的其它轴向尺寸,不是局部切削或补料。"
),
"affine_scale": scale,
"affine_transform_kind": "axis-affine",
"affine_transform_label": f"按盲孔/盲槽深度轴向缩放所属{target_label}",
"affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它轴向尺寸。",
"affine_axis_point": scale_center,
"affine_axis_direction": axis_direction or (0.0, 0.0, 1.0),
"affine_axis_source": "blind depth direction",
"affine_anchor_source": "blind depth midpoint",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_cylindrical_depth_owning_scale(
self,
face_id: int,
target_depth: float,
bottom_face_id: int | None = None,
) -> str:
plan = self.cylindrical_depth_owning_scale_plan(
face_id,
target_depth,
bottom_face_id=bottom_face_id,
)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
return (
"Blind cylindrical depth resize completed by axis owning-shape scaling: "
f"face {face_id}, "
f"depth={float(plan['current_depth']):g}->{float(plan['target_depth']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}."
)
def cylindrical_owning_scale_plan(self, face_id: int, target_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"该方式会按目标直径比例均匀缩放所属特征或 Solid;它不是重切孔壁,也不是只修改单个圆柱面。"
]
risk = "medium"
try:
target_diameter = float(target_diameter)
except (TypeError, ValueError):
target_diameter = 0.0
blockers.append("目标圆柱直径必须是数字。")
current_diameter = _float_or_none(info.get("diameter"))
current_radius = _float_or_none(info.get("radius"))
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if info.get("surface") != "cylinder":
blockers.append("当前选中 Face 不是圆柱面。")
if current_diameter is None or current_diameter <= 1e-9:
blockers.append("当前圆柱面缺少有效直径。")
axis_range_value = info.get("same_domain_v_range") or info.get("v_range")
scale_center = None
if (
axis_point is not None
and axis_direction is not None
and isinstance(axis_range_value, (list, tuple))
and len(axis_range_value) >= 2
):
v_min = _float_or_none(axis_range_value[0])
v_max = _float_or_none(axis_range_value[1])
if v_min is not None and v_max is not None:
v_mid = (v_min + v_max) * 0.5
scale_center = (
axis_point[0] + axis_direction[0] * v_mid,
axis_point[1] + axis_direction[1] * v_mid,
axis_point[2] + axis_direction[2] * v_mid,
)
scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if scale_center is None:
blockers.append("当前圆柱面缺少稳定缩放中心,不能缩放所属对象。")
if target_diameter <= 1e-9:
blockers.append("目标圆柱直径必须大于 0。")
scale = target_diameter / max(current_diameter or 1.0, 1e-9)
delta_diameter = target_diameter - float(current_diameter or 0.0)
delta_ratio = abs(delta_diameter) / max(float(current_diameter or 0.0), 1e-9)
if current_diameter is not None and abs(delta_diameter) <= max(current_diameter * 1e-6, 1e-6):
blockers.append("目标圆柱直径与当前值几乎相同,不需要修改。")
if delta_ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("圆柱直径变化超过 50%,会明显影响同一对象上的高度、厚度和其它尺寸。")
elif delta_ratio > 0.2:
warnings.append("圆柱直径变化超过 20%,修改后请重点检查相邻特征。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前圆柱面所属特征。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
target_label = "Solid" if target_kind == "solid" else "特征"
warnings.append(f"当前会均匀缩放所属{target_label},同一对象上的高度、厚度和其它尺寸会同比例变化。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"feature_type": info.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"current_diameter": current_diameter,
"target_diameter": target_diameter,
"current_radius": current_radius,
"target_radius": target_diameter * 0.5,
"delta_diameter": delta_diameter,
"diameter_delta_ratio": delta_ratio,
"scale_center": scale_center,
"axis_point": axis_point,
"axis": axis_direction,
"resize_strategy": "uniform-scale-owning-shape-from-cylinder-diameter",
"edit_strategy_label": "按圆柱直径缩放所属对象",
"edit_semantics": (
"按目标圆柱直径和当前直径的比例,围绕当前圆柱面的轴向中心均匀缩放所属特征或 Solid;"
"高度、厚度和同一对象上的其它尺寸会同比例变化。"
),
"affine_scale": scale,
"affine_transform_kind": "uniform",
"affine_transform_label": "按圆柱直径均匀缩放所属对象",
"affine_axis_point": scale_center,
"affine_axis_direction": axis_direction or (0.0, 0.0, 1.0),
"affine_axis_source": "cylinder axis center",
"affine_anchor_source": "cylinder axis center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_cylindrical_owning_scale(self, face_id: int, target_diameter: float) -> str:
plan = self.cylindrical_owning_scale_plan(face_id, target_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._apply_edge_length_affine_transform(plan)
return (
"Cylindrical diameter resize completed by uniform owning-shape scaling: "
f"face {face_id}, "
f"diameter={float(plan['current_diameter']):g}->{float(plan['target_diameter']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}."
)
def conical_reference_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"圆锥面参考半径修改会围绕圆锥轴径向缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。"
]
risk = "medium"
try:
target_radius = float(target_radius)
except (TypeError, ValueError):
target_radius = 0.0
blockers.append("目标圆锥参考半径必须是数字。")
current_radius = _float_or_none(info.get("reference_radius"))
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if info.get("surface") != "cone":
blockers.append("当前选中 Face 不是圆锥面。")
if current_radius is None or current_radius <= 1e-9:
blockers.append("当前圆锥面缺少有效参考半径。")
if axis_point is None or axis_direction is None:
blockers.append("当前圆锥面缺少稳定轴线,不能做径向缩放。")
if target_radius <= 1e-9:
blockers.append("目标圆锥参考半径必须大于 0。")
scale = target_radius / max(current_radius or 1.0, 1e-9)
current_semi_angle = _float_or_none(info.get("semi_angle"))
current_semi_angle_degrees = None
target_semi_angle = None
target_semi_angle_degrees = None
if current_semi_angle is not None:
current_semi_angle_degrees = abs(math.degrees(current_semi_angle))
current_tangent = abs(math.tan(current_semi_angle))
if current_tangent > 1e-9 and scale > 0:
target_semi_angle = math.atan(current_tangent * scale)
target_semi_angle_degrees = math.degrees(target_semi_angle)
delta_radius = target_radius - float(current_radius or 0.0)
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
blockers.append("目标圆锥参考半径与当前值几乎相同,不需要修改。")
if delta_ratio > 0.6:
risk = _max_risk(risk, "high")
warnings.append("圆锥参考半径变化超过 60%,可能明显影响周边几何。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("圆锥参考半径变化超过 25%,修改后请重点检查相邻面。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前圆锥面所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
if target_kind == "part":
warnings.append("当前会缩放所属零件 shape,可能影响同一零件上的其它尺寸。")
else:
warnings.append("当前会缩放所属 Solid,可能影响同一 Solid 上的其它尺寸。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
plan = {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"current_reference_radius": current_radius,
"target_reference_radius": target_radius,
"current_reference_diameter": None if current_radius is None else current_radius * 2.0,
"target_reference_diameter": target_radius * 2.0,
"delta_reference_radius": delta_radius,
"reference_radius_delta_ratio": delta_ratio,
"semi_angle": current_semi_angle,
"semi_angle_degrees": current_semi_angle_degrees,
"target_semi_angle": target_semi_angle,
"target_semi_angle_degrees": target_semi_angle_degrees,
"axis_point": axis_point,
"axis": axis_direction,
"resize_strategy": "radial-affine-scale-cone-reference-radius",
"edit_strategy_label": "围绕圆锥轴径向缩放",
"edit_semantics": "按目标参考半径围绕圆锥轴径向缩放所属对象;会影响同一对象上的其它径向尺寸。",
"affine_scale": scale,
"affine_transform_kind": "radial-affine",
"affine_transform_label": "围绕圆锥轴径向缩放",
"affine_axis_point": axis_point,
"affine_axis_direction": axis_direction,
"affine_axis_source": "cone axis",
"affine_anchor_source": "cone axis point",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
self._annotate_simple_conical_rebuild_plan(plan, "reference-radius")
self._block_obvious_complex_conical_fallback_plan(plan, "reference-radius")
self._annotate_embedded_conical_recut_plan(plan, "reference-radius")
self._block_unstable_conical_reference_radius_plan(plan)
return plan
def conical_semi_angle_plan(self, face_id: int, target_angle_degrees: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.quick_face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"圆锥半角修改会换算为围绕圆锥轴的径向缩放;高度不变,两个端面半径会按同一比例变化。"
]
try:
target_angle_degrees = float(target_angle_degrees)
except (TypeError, ValueError):
target_angle_degrees = 0.0
blockers.append("目标圆锥半角必须是数字。")
current_radius = _float_or_none(info.get("reference_radius"))
current_angle = _float_or_none(info.get("semi_angle"))
if info.get("surface") != "cone":
blockers.append("当前选中 Face 不是圆锥面。")
if current_radius is None or current_radius <= 1e-9:
blockers.append("当前圆锥面缺少有效参考半径。")
if current_angle is None:
blockers.append("当前圆锥面缺少稳定半角。")
if target_angle_degrees <= 0 or target_angle_degrees >= 89.0:
blockers.append("目标圆锥半角必须大于 0 且小于 89 度。")
current_tangent = abs(math.tan(current_angle)) if current_angle is not None else 0.0
target_tangent = math.tan(math.radians(target_angle_degrees)) if target_angle_degrees > 0 else 0.0
if current_angle is not None and current_tangent <= 1e-9:
blockers.append("当前圆锥半角过小,不能稳定换算参考半径。")
if target_tangent <= 1e-9:
blockers.append("目标圆锥半角过小,不能稳定换算参考半径。")
target_radius = (
float(current_radius) * target_tangent / current_tangent
if current_radius is not None and current_radius > 0 and current_tangent > 1e-9 and target_tangent > 1e-9
else 0.0
)
base_plan = self.conical_reference_radius_plan(face_id, target_radius)
base_strategy = str(base_plan.get("resize_strategy") or "")
ignore_reference_fallback_blocker = base_strategy.startswith("blocked-cone-reference-radius") or base_strategy.startswith(
"blocked-complex-cone-reference-radius"
)
base_blockers = str(base_plan.get("blockers") or "")
blocker_parts = [part for part in blockers if part]
if base_blockers and not ignore_reference_fallback_blocker:
blocker_parts.extend(part for part in base_blockers.split("") if part and part not in blocker_parts)
base_warnings = str(base_plan.get("warnings") or "")
warning_parts = [part for part in warnings if part]
if base_warnings:
warning_parts.extend(part for part in base_warnings.split("") if part and part not in warning_parts)
base_status = "caution" if ignore_reference_fallback_blocker else str(base_plan.get("status") or "caution")
base_risk = "medium" if ignore_reference_fallback_blocker else str(base_plan.get("risk") or "medium")
status = "blocked" if blocker_parts else base_status
risk = "blocked" if blocker_parts else base_risk
base_plan.update(
{
"status": status,
"risk": risk,
"message": " ".join(blocker_parts + warning_parts),
"warnings": "".join(warning_parts),
"blockers": "".join(blocker_parts),
"target_semi_angle": math.radians(target_angle_degrees) if target_angle_degrees > 0 else None,
"target_semi_angle_degrees": target_angle_degrees if target_angle_degrees > 0 else None,
"target_reference_radius": target_radius if target_radius > 0 else None,
"target_reference_diameter": target_radius * 2.0 if target_radius > 0 else None,
"resize_strategy": "radial-affine-scale-cone-semi-angle",
"edit_strategy_label": "按圆锥半角径向缩放",
"edit_semantics": "按目标半角换算径向缩放比例;圆锥高度不变,端面半径和相邻径向尺寸会跟随变化。",
}
)
self._annotate_simple_conical_rebuild_plan(base_plan, "semi-angle")
self._block_obvious_complex_conical_fallback_plan(base_plan, "semi-angle")
self._annotate_embedded_conical_recut_plan(base_plan, "semi-angle")
self._block_unstable_conical_semi_angle_plan(base_plan)
return base_plan
def _annotate_simple_conical_rebuild_plan(self, plan: dict[str, object], mode: str) -> None:
if str(plan.get("status")) == "blocked":
return
try:
spec = self._simple_conical_rebuild_spec(plan)
except Exception:
spec = None
if spec is None:
plan["analytic_rebuild_available"] = False
return
if mode == "reference-radius" and not bool(spec.get("reference_radius_matches_current", False)):
plan["analytic_rebuild_available"] = False
plan["analytic_rebuild_skip_reason"] = "cone-reference-radius-does-not-match-a-cap"
return
plan["analytic_rebuild_available"] = True
plan["analytic_cone_rebuild_height"] = spec.get("height")
plan["analytic_cone_rebuild_reference_radius"] = spec.get("reference_radius")
plan["analytic_cone_rebuild_other_radius"] = spec.get("other_radius")
plan["analytic_cone_reference_radius_matches_current"] = spec.get("reference_radius_matches_current")
plan["fallback_resize_strategy"] = plan.get("resize_strategy")
if mode == "semi-angle":
plan["resize_strategy"] = "analytic-cone-rebuild-semi-angle"
else:
plan["resize_strategy"] = "analytic-cone-rebuild-reference-radius"
def _block_obvious_complex_conical_fallback_plan(self, plan: dict[str, object], mode: str) -> None:
if str(plan.get("status")) == "blocked":
return
if bool(plan.get("analytic_rebuild_available", False)):
return
if bool(plan.get("embedded_cone_recut_available", False)):
return
expected_strategy = {
"reference-radius": "radial-affine-scale-cone-reference-radius",
"semi-angle": "radial-affine-scale-cone-semi-angle",
}.get(mode)
if expected_strategy is None or str(plan.get("resize_strategy") or "") != expected_strategy:
return
current_angle = _float_or_none(plan.get("semi_angle_degrees"))
current_radius = _float_or_none(plan.get("current_reference_radius"))
face_id = int(plan.get("face_id", -1))
if current_angle is None or current_angle > 3.0 or current_radius is None:
return
face_diagonal = 0.0
if 0 <= face_id < len(self.faces):
try:
face_diagonal = _shape_diagonal(self.faces[face_id])
except Exception:
face_diagonal = 0.0
owning_diagonal = 0.0
part_id = int(plan.get("part_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is not None:
try:
owning_diagonal = _shape_diagonal(part.shape)
except Exception:
owning_diagonal = 0.0
if current_radius <= max(face_diagonal * 4.0, owning_diagonal * 2.0, 1.0):
return
current_angle_text = _format_result_number(current_angle)
current_radius_text = _format_result_number(current_radius)
if mode == "reference-radius":
target_radius = _float_or_none(plan.get("target_reference_radius"))
target_radius_text = _format_result_number(target_radius)
reason = (
"当前对象仍然是 Face,但底层是复杂浅锥/拔模面;"
f"半角约 {current_angle_text}°,参考半径约 {current_radius_text},已经远大于当前 Face/所属特征尺寸。"
f"把参考半径改到 {target_radius_text} 这类操作不适合走整体径向缩放,也不应该在界面线程里继续做昂贵的锥孔识别。"
"当前版本只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放参考半径/直径修改;"
"复杂浅锥/拔模面会提前阻止,避免界面卡死或生成无效 B-Rep。"
)
blocked_strategy = "blocked-complex-cone-reference-radius-shallow-far-axis"
label = "暂不开放复杂浅锥参考半径修改"
else:
target_angle = _float_or_none(plan.get("target_semi_angle_degrees"))
target_radius = _float_or_none(plan.get("target_reference_radius"))
target_angle_text = _format_result_number(target_angle)
target_radius_text = _format_result_number(target_radius)
reason = (
"当前对象仍然是 Face,但底层是复杂浅锥/拔模面;"
f"半角约 {current_angle_text}°,参考半径约 {current_radius_text},已经远大于当前 Face/所属特征尺寸。"
f"把半角改到 {target_angle_text}° 会换算出约 {target_radius_text} 的参考半径,"
"不适合走整体径向缩放,也不应该在界面线程里继续做昂贵的锥孔识别。"
"当前版本只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放半角修改;"
"复杂浅锥/拔模面会提前阻止,避免界面卡死或生成无效 B-Rep。"
)
blocked_strategy = "blocked-complex-cone-semi-angle-shallow-far-axis"
label = "暂不开放复杂浅锥半角修改"
existing_blockers = [part for part in str(plan.get("blockers") or "").split("") if part]
if reason not in existing_blockers:
existing_blockers.append(reason)
plan.update(
{
"status": "blocked",
"risk": "blocked",
"blockers": "".join(existing_blockers),
"message": reason,
"resize_strategy": blocked_strategy,
"edit_strategy_label": label,
"edit_semantics": reason,
"cone_fast_block_face_diagonal": face_diagonal,
"cone_fast_block_owning_diagonal": owning_diagonal,
}
)
def _block_unstable_conical_reference_radius_plan(self, plan: dict[str, object]) -> None:
if str(plan.get("status")) == "blocked":
return
if bool(plan.get("analytic_rebuild_available", False)):
return
if bool(plan.get("embedded_cone_recut_available", False)):
return
if str(plan.get("resize_strategy") or "") != "radial-affine-scale-cone-reference-radius":
return
if str(plan.get("analytic_rebuild_skip_reason") or "") == "cone-reference-radius-does-not-match-a-cap":
reason = (
"当前圆锥面的参考半径不在可识别的圆形端面上;当前版本不能稳定保留这个参考位置来直接修改参考半径,"
"否则容易出现目标值无法回读、圆锥面退化或编辑结果被回滚。"
"当前只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放参考半径/直径修改。"
)
strategy = "blocked-cone-reference-radius-non-cap"
label = "暂不开放非端面参考半径"
else:
reason = (
"当前对象仍然是 Face,但它的底层曲面类型是复杂圆锥面/拔模面;"
"当前版本没有把这个 Face 识别为简单圆锥,也没有识别成双圆边界的锥孔/沉孔,"
"因此不再使用整体径向缩放兜底修改参考半径/直径。整体缩放会影响所属特征的其它尺寸,"
"并且在复杂 STEP 上容易生成无效 B-Rep。"
)
strategy = "blocked-complex-cone-reference-radius-unsupported-fallback"
label = "暂不开放复杂圆锥参考半径兜底修改"
existing_blockers = [part for part in str(plan.get("blockers") or "").split("") if part]
if reason not in existing_blockers:
existing_blockers.append(reason)
warnings = [part for part in str(plan.get("warnings") or "").split("") if part]
plan.update(
{
"status": "blocked",
"risk": "blocked",
"blockers": "".join(existing_blockers),
"message": reason,
"resize_strategy": strategy,
"edit_strategy_label": label,
"edit_semantics": reason,
}
)
def _block_unstable_conical_semi_angle_plan(self, plan: dict[str, object]) -> None:
if str(plan.get("status")) == "blocked":
return
if bool(plan.get("analytic_rebuild_available", False)):
return
if bool(plan.get("embedded_cone_recut_available", False)):
return
if str(plan.get("resize_strategy") or "") != "radial-affine-scale-cone-semi-angle":
return
current_angle = _float_or_none(plan.get("semi_angle_degrees"))
target_angle = _float_or_none(plan.get("target_semi_angle_degrees"))
current_radius = _float_or_none(plan.get("current_reference_radius"))
target_radius = _float_or_none(plan.get("target_reference_radius"))
scale = _float_or_none(plan.get("affine_scale"))
face_id = int(plan.get("face_id", -1))
face_diagonal = 0.0
if 0 <= face_id < len(self.faces):
face_diagonal = _shape_diagonal(self.faces[face_id])
owning_diagonal = 0.0
try:
_target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan)
owning_diagonal = _shape_diagonal(source_shape)
except Exception:
try:
owning_diagonal = _shape_diagonal(self.shape)
except Exception:
owning_diagonal = 0.0
model_size = max(face_diagonal, owning_diagonal, 1.0)
angle_delta = (
abs(float(target_angle) - float(current_angle))
if current_angle is not None and target_angle is not None
else 0.0
)
shallow_far_axis = (
current_angle is not None
and current_angle <= 3.0
and current_radius is not None
and current_radius > max(face_diagonal * 4.0, owning_diagonal * 2.0, 1.0)
)
extreme_scale = scale is None or scale <= 0.0 or scale > 3.0 or scale < (1.0 / 3.0)
extreme_reference = target_radius is not None and target_radius > model_size * 8.0
large_angle_jump = angle_delta >= 3.0
current_angle_text = _format_result_number(current_angle)
target_angle_text = _format_result_number(target_angle)
current_radius_text = _format_result_number(current_radius)
target_radius_text = _format_result_number(target_radius)
scale_text = _format_result_number(scale)
is_extreme_shallow = shallow_far_axis and (extreme_scale or extreme_reference or large_angle_jump)
if is_extreme_shallow:
reason = (
"当前对象仍然是 Face,但它的底层曲面类型是复杂浅锥/拔模面;"
"1° 左右的浅锥面视觉上很像平面,不适合用整体径向缩放直接修改半角。"
f"半角从 {current_angle_text}° 改到 {target_angle_text}° 会把参考半径从 "
f"{current_radius_text} 放大到 {target_radius_text},缩放比例约 {scale_text}"
"这类结果在复杂 STEP 上容易生成无效 B-Rep。当前版本只对简单圆锥解析重建,"
"或可识别的锥孔/沉孔局部重切开放大幅半角修改。"
)
blocked_strategy = "blocked-complex-cone-semi-angle-extreme-scale"
label = "暂不开放复杂浅锥半角大幅修改"
else:
reason = (
"当前对象仍然是 Face,但它的底层曲面类型是圆锥面/拔模面;"
"当前版本没有把这个 Face 识别为简单圆锥,也没有识别成双圆边界的锥孔/沉孔,"
"因此不再使用整体径向缩放兜底修改半角。整体缩放会影响所属特征的其它尺寸,"
"并且在复杂 STEP 上容易生成无效 B-Rep。"
)
blocked_strategy = "blocked-complex-cone-semi-angle-unsupported-fallback"
label = "暂不开放复杂圆锥半角兜底修改"
existing_blockers = [part for part in str(plan.get("blockers") or "").split("") if part]
if reason not in existing_blockers:
existing_blockers.append(reason)
warnings = [part for part in str(plan.get("warnings") or "").split("") if part]
plan.update(
{
"status": "blocked",
"risk": "blocked",
"blockers": "".join(existing_blockers),
"message": reason,
"resize_strategy": blocked_strategy,
"edit_strategy_label": label,
"edit_semantics": reason,
"cone_semi_angle_block_face_diagonal": face_diagonal,
"cone_semi_angle_block_owning_diagonal": owning_diagonal,
}
)
def _annotate_embedded_conical_recut_plan(self, plan: dict[str, object], mode: str) -> None:
if str(plan.get("status")) == "blocked":
return
if bool(plan.get("analytic_rebuild_available", False)):
return
try:
spec = self._embedded_conical_recut_spec(plan, mode)
except Exception:
spec = None
if spec is None:
plan["embedded_cone_recut_available"] = False
return
recut_mode = str(spec.get("embedded_cone_recut_mode") or "enlarge")
if mode == "reference-radius":
recut_action = "先补料封回旧锥孔,再按目标参考半径重切" if recut_mode == "shrink" else "按目标参考半径局部扩大重切"
strategy = "bounded-cone-recut-preserve-angle-reference-radius"
semantics = (
"保持锥孔当前半角和轴向深度不变,按目标参考半径局部重切锥孔;"
"不会整体缩放所属对象,也不会自动联动相连的圆柱孔直径。"
)
else:
recut_action = "先补料封回旧锥孔,再按目标半角重切" if recut_mode == "shrink" else "按目标半角局部扩大重切"
strategy = "bounded-cone-recut-fixed-small-radius-semi-angle"
semantics = (
"保持锥孔较小端半径和轴向深度不变;放大半角时直接用目标圆锥 cutter 扩大开口,"
"缩小半角时先补料封回旧锥孔再按目标半角重切。"
)
warning = (
"检测到嵌入式锥孔/沉孔类圆锥 Face;本次会优先局部重切圆锥开口,"
f"不再围绕圆锥轴缩放整个所属对象。执行方式:{recut_action}。"
)
warnings = [
part
for part in str(plan.get("warnings") or "").split("")
if part
and "径向缩放" not in part
and "缩放所属" not in part
and "同一零件上的其它尺寸" not in part
and "同一 Solid 上的其它尺寸" not in part
]
if warning not in warnings:
warnings.append(warning)
plan.update(
{
**spec,
"embedded_cone_recut_available": True,
"fallback_resize_strategy": plan.get("resize_strategy"),
"resize_strategy": strategy,
"edit_strategy_label": "锥孔局部重切",
"edit_semantics": semantics,
"risk": _max_risk(str(plan.get("risk") or "medium"), "high"),
"status": "caution",
"warnings": "".join(warnings),
"message": " ".join([part for part in str(plan.get("blockers") or "").split("") if part] + warnings),
}
)
def _embedded_conical_recut_spec(self, plan: dict[str, object], mode: str) -> dict[str, object] | None:
if mode not in {"semi-angle", "reference-radius"}:
return None
face_id = int(plan.get("face_id", -1))
if face_id < 0 or face_id >= len(self.faces):
return None
axis_point = _tuple_or_none(plan.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(plan.get("axis")))
if axis_point is None or axis_direction is None:
return None
circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
if len(circles) != 2:
return None
circles = sorted(circles, key=lambda item: float(item["radius"]))
small = circles[0]
large = circles[1]
small_radius = float(small["radius"])
large_radius = float(large["radius"])
small_center = tuple(small["center"])
large_center = tuple(large["center"])
height = _vector_length(_tuple_sub(large_center, small_center))
if small_radius <= 1e-9 or large_radius <= small_radius or height <= 1e-9:
return None
current_tangent = (large_radius - small_radius) / height
if current_tangent <= 1e-9:
return None
tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, large_radius * 1e-5, 1e-5)
if mode == "semi-angle":
target_angle_degrees = _float_or_none(plan.get("target_semi_angle_degrees"))
if target_angle_degrees is None or target_angle_degrees <= 0.0 or target_angle_degrees >= 89.0:
return None
target_tangent = math.tan(math.radians(target_angle_degrees))
if target_tangent <= 1e-9:
return None
target_small_radius = small_radius
target_large_radius = small_radius + height * target_tangent
if abs(target_large_radius - large_radius) <= tolerance:
return None
edit_mode = "enlarge" if target_large_radius > large_radius else "shrink"
else:
target_reference_radius = _float_or_none(plan.get("target_reference_radius"))
current_reference_radius = _float_or_none(plan.get("current_reference_radius"))
if (
target_reference_radius is None
or current_reference_radius is None
or target_reference_radius <= 1e-9
or current_reference_radius <= 1e-9
):
return None
reference_parameter = (current_reference_radius - small_radius) / current_tangent
reference_matches_small = abs(reference_parameter) <= max(tolerance / current_tangent, tolerance)
reference_matches_large = abs(reference_parameter - height) <= max(tolerance / current_tangent, tolerance)
target_small_radius = target_reference_radius - reference_parameter * current_tangent
target_large_radius = target_small_radius + height * current_tangent
if target_small_radius <= tolerance or target_large_radius <= target_small_radius + tolerance:
return None
radial_delta = target_reference_radius - current_reference_radius
if abs(radial_delta) <= tolerance:
return None
target_tangent = current_tangent
target_angle_degrees = math.degrees(math.atan(current_tangent))
edit_mode = "enlarge" if radial_delta > 0.0 else "shrink"
target_kind, source_shape, _part, source_solid = self._edge_length_affine_target(plan)
classifier_solid = source_solid or source_shape
if not self._embedded_conical_face_is_hole_like(classifier_solid, small, large, tolerance):
return None
tool_direction = _tuple_normalized(_tuple_sub(large_center, small_center))
if tool_direction is None:
return None
if target_large_radius <= target_small_radius + tolerance:
return None
end_margin = (
min(max(height * 0.01, target_large_radius * 0.005, 0.02), max(height * 0.05, 0.05))
if edit_mode == "enlarge"
else 0.0
)
tool_height = height + end_margin
tool_end_radius = target_large_radius + target_tangent * end_margin
diagonal = max(_shape_diagonal(source_shape), target_large_radius, 1.0)
if tool_end_radius > max(diagonal * 1.2, large_radius * 6.0):
return None
return {
"embedded_cone_recut_mode": edit_mode,
"embedded_cone_fixed_small_radius": small_radius if mode == "semi-angle" else None,
"embedded_cone_current_small_radius": small_radius,
"embedded_cone_target_small_radius": target_small_radius,
"embedded_cone_current_large_radius": large_radius,
"embedded_cone_target_large_radius": target_large_radius,
"embedded_cone_current_angle_degrees": math.degrees(math.atan(current_tangent)),
"embedded_cone_target_angle_degrees": target_angle_degrees,
"embedded_cone_reference_parameter": reference_parameter if mode == "reference-radius" else None,
"embedded_cone_reference_matches_small": reference_matches_small if mode == "reference-radius" else None,
"embedded_cone_reference_matches_large": reference_matches_large if mode == "reference-radius" else None,
"embedded_cone_height": height,
"embedded_cone_tool_start_point": small_center,
"embedded_cone_tool_direction": tool_direction,
"embedded_cone_tool_start_radius": target_small_radius,
"embedded_cone_tool_end_radius": tool_end_radius,
"embedded_cone_tool_height": tool_height,
"embedded_cone_tool_end_margin": end_margin,
"embedded_cone_fill_start_radius": small_radius,
"embedded_cone_fill_end_radius": large_radius,
"embedded_cone_fill_height": height,
"embedded_cone_target_kind": target_kind,
}
def _conical_face_circle_boundaries(
self,
face_id: int,
axis_point: tuple[float, float, float],
axis_direction: tuple[float, float, float],
) -> list[dict[str, object]]:
face = self.faces[face_id]
axis_origin = gp_Pnt(*axis_point)
axis_dir = gp_Dir(*axis_direction)
tolerance = max(_shape_diagonal(face) * 1e-6, 1e-6)
circles: list[dict[str, object]] = []
for edge in _explore(face, TopAbs_EDGE):
try:
curve = BRepAdaptor_Curve(edge)
if curve.GetType() != GeomAbs_Circle:
continue
circle = curve.Circle()
center = circle.Location()
radius = float(circle.Radius())
if radius <= 1e-9:
continue
if abs(_direction_dot(circle.Axis().Direction(), axis_dir)) < 0.95:
continue
if _point_axis_distance(axis_origin, axis_dir, center) > max(radius * 1e-5, tolerance):
continue
parameter = _axis_parameter(axis_origin, axis_dir, center)
sample = curve.Value(curve.FirstParameter())
item = {
"radius": radius,
"center": _point_tuple(center),
"axis_parameter": parameter,
"sample_point": _point_tuple(sample),
}
duplicate = False
for existing in circles:
if (
abs(float(existing["radius"]) - radius) <= tolerance
and _vector_length(_tuple_sub(tuple(existing["center"]), item["center"])) <= tolerance
):
duplicate = True
break
if not duplicate:
circles.append(item)
except Exception:
continue
return circles
def _embedded_conical_face_is_hole_like(
self,
solid: TopoDS_Shape,
small: dict[str, object],
large: dict[str, object],
tolerance: float,
) -> bool:
small_center = tuple(small["center"])
large_center = tuple(large["center"])
large_sample = tuple(large["sample_point"])
radial = _tuple_normalized(_tuple_sub(large_sample, large_center))
if radial is None:
return False
small_radius = float(small["radius"])
large_radius = float(large["radius"])
mid_center = (
(small_center[0] + large_center[0]) * 0.5,
(small_center[1] + large_center[1]) * 0.5,
(small_center[2] + large_center[2]) * 0.5,
)
mid_radius = (small_radius + large_radius) * 0.5
sample_point = _tuple_add(mid_center, _tuple_scale(radial, mid_radius))
offset = max((large_radius - small_radius) * 0.08, large_radius * 0.02, tolerance * 10.0, 0.02)
toward_axis = gp_Pnt(*_tuple_sub(sample_point, _tuple_scale(radial, offset)))
away_axis = gp_Pnt(*_tuple_add(sample_point, _tuple_scale(radial, offset)))
return _solid_state(solid, toward_axis) == "outside" and _solid_state(solid, away_axis) == "inside"
def _apply_conical_analytic_rebuild_if_simple(self, plan: dict[str, object]) -> bool:
if not bool(plan.get("analytic_rebuild_available", False)):
return False
spec = self._simple_conical_rebuild_spec(plan)
if spec is None:
return False
target_kind, _source_shape, part, source_solid = self._edge_length_affine_target(plan)
maker = BRepPrimAPI_MakeCone(
float(spec["reference_radius"]),
float(spec["other_radius"]),
float(spec["height"]),
)
rebuilt = maker.Shape()
if rebuilt.IsNull():
raise RuntimeError("Analytic cone rebuild produced an empty shape.")
transform = self._axis_placement_transform(
spec["reference_center"],
spec["reference_to_other_direction"],
)
builder = BRepBuilderAPI_Transform(rebuilt, transform, True)
builder.Build()
if not builder.IsDone():
raise RuntimeError("Analytic cone placement transform failed.")
transformed = builder.Shape()
if transformed.IsNull():
raise RuntimeError("Analytic cone placement transform produced an empty shape.")
transformed = _ensure_valid_or_repaired_shape(transformed, "analytic cone rebuild")
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()
return True
def _apply_embedded_conical_recut_if_available(self, plan: dict[str, object]) -> bool:
if not bool(plan.get("embedded_cone_recut_available", False)):
return False
target_kind, source_shape, part, source_solid = self._edge_length_affine_target(plan)
start = _tuple_or_none(plan.get("embedded_cone_tool_start_point"))
direction = _tuple_normalized(_tuple_or_none(plan.get("embedded_cone_tool_direction")))
start_radius = _float_or_none(plan.get("embedded_cone_tool_start_radius"))
end_radius = _float_or_none(plan.get("embedded_cone_tool_end_radius"))
height = _float_or_none(plan.get("embedded_cone_tool_height"))
recut_mode = str(plan.get("embedded_cone_recut_mode") or "enlarge")
if (
start is None
or direction is None
or start_radius is None
or end_radius is None
or height is None
or start_radius <= 1e-9
or end_radius <= start_radius
or height <= 1e-9
):
raise RuntimeError("Embedded conical recut plan is missing a valid cutter.")
source_for_cut = source_shape
if recut_mode == "shrink":
fill_start_radius = _float_or_none(plan.get("embedded_cone_fill_start_radius"))
fill_end_radius = _float_or_none(plan.get("embedded_cone_fill_end_radius"))
fill_height = _float_or_none(plan.get("embedded_cone_fill_height"))
if (
fill_start_radius is None
or fill_end_radius is None
or fill_height is None
or fill_start_radius <= 1e-9
or fill_end_radius <= fill_start_radius
or fill_height <= 1e-9
):
raise RuntimeError("Embedded conical recut shrink plan is missing a valid filler.")
filler = BRepPrimAPI_MakeCone(
gp_Ax2(gp_Pnt(*start), gp_Dir(*direction)),
float(fill_start_radius),
float(fill_end_radius),
float(fill_height),
).Shape()
if filler.IsNull():
raise RuntimeError("Embedded conical recut produced an empty filler.")
fuse = BRepAlgoAPI_Fuse(source_shape, filler)
source_for_cut = _finalize_boolean_result(fuse, "embedded conical recut fill old cone", use_glue=False)
cutter = BRepPrimAPI_MakeCone(
gp_Ax2(gp_Pnt(*start), gp_Dir(*direction)),
float(start_radius),
float(end_radius),
float(height),
).Shape()
if cutter.IsNull():
raise RuntimeError("Embedded conical recut produced an empty cutter.")
op = BRepAlgoAPI_Cut(source_for_cut, cutter)
result = _finalize_boolean_result(op, "embedded conical recut")
if target_kind == "part":
part.shape = result
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(result)
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()
return True
def _simple_conical_rebuild_spec(self, plan: dict[str, object]) -> dict[str, object] | None:
target_kind, source_shape, _part, _source_solid = self._edge_length_affine_target(plan)
if len(_explore(source_shape, TopAbs_SOLID)) != 1:
return None
current_reference_radius = _float_or_none(plan.get("current_reference_radius"))
target_reference_radius = _float_or_none(plan.get("target_reference_radius"))
scale = _float_or_none(plan.get("affine_scale"))
if (
current_reference_radius is None
or target_reference_radius is None
or scale is None
or current_reference_radius <= 1e-9
or target_reference_radius <= 1e-9
or scale <= 1e-9
):
return None
faces = _explore(source_shape, TopAbs_FACE)
cone_faces: list[TopoDS_Shape] = []
cap_specs: list[dict[str, object]] = []
for face in faces:
try:
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
except Exception:
return None
if surface_type == GeomAbs_Cone:
cone_faces.append(face)
continue
if surface_type != GeomAbs_Plane:
return None
edge_count = len(_explore(face, TopAbs_EDGE))
if edge_count != 1:
return None
props = GProp_GProps()
try:
brepgprop.SurfaceProperties(face, props)
except Exception:
return None
area = float(props.Mass())
if area <= 1e-9:
return None
radius = math.sqrt(area / math.pi)
center = _point_tuple(props.CentreOfMass())
cap_specs.append({"radius": radius, "center": center})
if target_kind not in {"part", "solid"} or len(cone_faces) != 1 or len(cap_specs) not in {1, 2}:
return None
length_reference = max(
_shape_diagonal(source_shape),
current_reference_radius,
target_reference_radius,
1.0,
)
radius_tolerance = max(length_reference * 1e-5, 1e-5)
if len(cap_specs) == 1:
reference_cap = cap_specs[0]
reference_center = tuple(reference_cap["center"])
apex = self._simple_conical_apex_point(source_shape, reference_center, radius_tolerance, plan)
if apex is None:
return None
direction = _tuple_normalized(_tuple_sub(apex, reference_center))
height = _vector_length(_tuple_sub(apex, reference_center))
if direction is None or height <= radius_tolerance:
return None
reference_radius = float(reference_cap["radius"]) * scale
if reference_radius <= 1e-9:
return None
return {
"reference_center": reference_center,
"reference_to_other_direction": direction,
"reference_radius": reference_radius,
"other_radius": 0.0,
"height": height,
"reference_radius_matches_current": abs(float(reference_cap["radius"]) - current_reference_radius)
<= radius_tolerance,
}
matching_caps = [
cap for cap in cap_specs if abs(float(cap["radius"]) - current_reference_radius) <= radius_tolerance
]
reference_radius_matches_current = len(matching_caps) == 1
if reference_radius_matches_current:
reference_cap = matching_caps[0]
other_cap = cap_specs[0] if reference_cap is cap_specs[1] else cap_specs[1]
else:
reference_cap = cap_specs[0]
other_cap = cap_specs[1]
reference_center = tuple(reference_cap["center"])
other_center = tuple(other_cap["center"])
direction = _tuple_normalized(_tuple_sub(other_center, reference_center))
height = _vector_length(_tuple_sub(other_center, reference_center))
if direction is None or height <= radius_tolerance:
return None
reference_radius = float(reference_cap["radius"]) * scale
other_radius = float(other_cap["radius"]) * scale
if reference_radius <= 1e-9 or other_radius <= 1e-9:
return None
return {
"reference_center": reference_center,
"reference_to_other_direction": direction,
"reference_radius": reference_radius,
"other_radius": other_radius,
"height": height,
"reference_radius_matches_current": reference_radius_matches_current,
}
def _simple_conical_apex_point(
self,
shape: TopoDS_Shape,
cap_center: tuple[float, float, float],
tolerance: float,
plan: dict[str, object],
) -> tuple[float, float, float] | None:
axis_direction = _tuple_normalized(_tuple_or_none(plan.get("axis")))
unique_points: list[tuple[float, float, float]] = []
for vertex in _explore(shape, TopAbs_VERTEX):
try:
point = _point_tuple(BRep_Tool.Pnt(topods.Vertex(vertex)))
except Exception:
continue
if any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in unique_points):
continue
unique_points.append(point)
if not unique_points:
return None
best_point: tuple[float, float, float] | None = None
best_score = -math.inf
for point in unique_points:
vector = _tuple_sub(point, cap_center)
if axis_direction is not None:
score = abs(_tuple_dot(vector, axis_direction))
else:
score = _vector_length(vector)
if score > best_score:
best_score = score
best_point = point
if best_point is None or best_score <= tolerance:
return None
return best_point
def _axis_placement_transform(
self,
origin: tuple[float, float, float],
z_direction: tuple[float, float, float],
) -> gp_Trsf:
w = _tuple_normalized(z_direction)
if w is None:
raise ValueError("Missing analytic cone placement direction.")
helper = (1.0, 0.0, 0.0) if abs(w[0]) < 0.85 else (0.0, 1.0, 0.0)
u = _tuple_normalized(_tuple_cross(helper, w))
if u is None:
helper = (0.0, 0.0, 1.0)
u = _tuple_normalized(_tuple_cross(helper, w))
if u is None:
raise ValueError("Could not build analytic cone placement basis.")
v = _tuple_cross(w, u)
transform = gp_Trsf()
transform.SetValues(
u[0],
v[0],
w[0],
origin[0],
u[1],
v[1],
w[1],
origin[1],
u[2],
v[2],
w[2],
origin[2],
)
return transform
def resize_conical_reference_radius(self, face_id: int, target_radius: float) -> str:
plan = self.conical_reference_radius_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
if self._apply_conical_analytic_rebuild_if_simple(plan):
result_check = self._face_edit_result_summary(plan)
return (
"Conical face reference radius resize completed by analytic cone rebuild: "
f"face {face_id}, "
f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
if self._apply_embedded_conical_recut_if_available(plan):
result_check = self._face_edit_result_summary(plan)
return (
"Conical face reference radius resize completed by embedded local cone recut: "
f"face {face_id}, "
f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, "
f"mode={plan.get('embedded_cone_recut_mode')}, "
f"small_radius={float(plan['embedded_cone_current_small_radius']):g}->{float(plan['embedded_cone_target_small_radius']):g}, "
f"large_radius={float(plan['embedded_cone_current_large_radius']):g}->{float(plan['embedded_cone_target_large_radius']):g}, "
f"height={float(plan['embedded_cone_height']):g}, "
f"target={plan.get('embedded_cone_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
self._apply_edge_length_affine_transform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Conical face reference radius resize completed by radial affine scaling: "
f"face {face_id}, "
f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def resize_conical_semi_angle(self, face_id: int, target_angle_degrees: float) -> str:
plan = self.conical_semi_angle_plan(face_id, target_angle_degrees)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
if self._apply_conical_analytic_rebuild_if_simple(plan):
result_check = self._face_edit_result_summary(plan)
return (
"Conical face semi-angle resize completed by analytic cone rebuild: "
f"face {face_id}, "
f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, "
f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
if self._apply_embedded_conical_recut_if_available(plan):
result_check = self._face_edit_result_summary(plan)
return (
"Conical face semi-angle resize completed by embedded local cone recut: "
f"face {face_id}, "
f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, "
f"mode={plan.get('embedded_cone_recut_mode')}, "
f"fixed_small_radius={float(plan['embedded_cone_fixed_small_radius']):g}, "
f"large_radius={float(plan['embedded_cone_current_large_radius']):g}->{float(plan['embedded_cone_target_large_radius']):g}, "
f"height={float(plan['embedded_cone_height']):g}, "
f"target={plan.get('embedded_cone_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
self._apply_edge_length_affine_transform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Conical face semi-angle resize completed by radial affine scaling: "
f"face {face_id}, "
f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, "
f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def spherical_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"球面半径修改会围绕球心缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。"
]
risk = "medium"
try:
target_radius = float(target_radius)
except (TypeError, ValueError):
target_radius = 0.0
blockers.append("目标球面半径必须是数字。")
current_radius = _float_or_none(info.get("radius"))
center = _tuple_or_none(info.get("center"))
if info.get("surface") != "sphere":
blockers.append("当前选中 Face 不是球面。")
if current_radius is None or current_radius <= 1e-9:
blockers.append("当前球面缺少有效半径。")
if center is None:
blockers.append("当前球面缺少稳定球心,不能缩放。")
if target_radius <= 1e-9:
blockers.append("目标球面半径必须大于 0。")
scale = target_radius / max(current_radius or 1.0, 1e-9)
delta_radius = target_radius - float(current_radius or 0.0)
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
blockers.append("目标球面半径与当前值几乎相同,不需要修改。")
if delta_ratio > 0.6:
risk = _max_risk(risk, "high")
warnings.append("球面半径变化超过 60%,可能明显影响周边几何。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("球面半径变化超过 25%,修改后请重点检查相邻面。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前球面所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"current_radius": current_radius,
"target_radius": target_radius,
"current_diameter": None if current_radius is None else current_radius * 2.0,
"target_diameter": target_radius * 2.0,
"delta_radius": delta_radius,
"radius_delta_ratio": delta_ratio,
"center": center,
"resize_strategy": "uniform-scale-sphere-radius",
"edit_strategy_label": "围绕球心均匀缩放",
"edit_semantics": "按目标球面半径围绕球心均匀缩放所属对象;不是只替换单个球面历史参数。",
"affine_scale": scale,
"affine_transform_kind": "uniform",
"affine_transform_label": "围绕球心均匀缩放",
"affine_axis_point": center,
"affine_axis_direction": (0.0, 0.0, 1.0),
"affine_axis_source": "sphere center",
"affine_anchor_source": "sphere center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_spherical_radius(self, face_id: int, target_radius: float) -> str:
plan = self.spherical_radius_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
self._apply_edge_length_affine_transform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Spherical face radius resize completed by uniform scaling: "
f"face {face_id}, "
f"radius={float(plan['current_radius']):g}->{float(plan['target_radius']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def toroidal_radius_plan(self, face_id: int, target_radius: float, mode: str = "minor") -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
mode_key = "major" if str(mode).lower() in {"major", "main", "major_radius"} else "minor"
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"环面半径修改会围绕环面中心均匀缩放所属零件/Solid;主半径和小半径会等比例变化。"
]
risk = "medium"
try:
target_radius = float(target_radius)
except (TypeError, ValueError):
target_radius = 0.0
blockers.append("目标环面半径必须是数字。")
current_major = _float_or_none(info.get("major_radius"))
current_minor = _float_or_none(info.get("minor_radius"))
current_radius = current_major if mode_key == "major" else current_minor
center = _tuple_or_none(info.get("center"))
axis = _tuple_normalized(_tuple_or_none(info.get("axis"))) or (0.0, 0.0, 1.0)
if info.get("surface") != "torus":
blockers.append("当前选中 Face 不是环面。")
if current_major is None or current_major <= 1e-9 or current_minor is None or current_minor <= 1e-9:
blockers.append("当前环面缺少有效主半径或小半径。")
if center is None:
blockers.append("当前环面缺少稳定中心,不能缩放。")
if target_radius <= 1e-9:
blockers.append("目标环面半径必须大于 0。")
scale = target_radius / max(current_radius or 1.0, 1e-9)
target_major = None if current_major is None else current_major * scale
target_minor = None if current_minor is None else current_minor * scale
delta_radius = target_radius - float(current_radius or 0.0)
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
blockers.append("目标环面半径与当前值几乎相同,不需要修改。")
if delta_ratio > 0.6:
risk = _max_risk(risk, "high")
warnings.append("环面半径变化超过 60%,可能明显影响周边几何。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("环面半径变化超过 25%,修改后请重点检查相邻面。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前环面所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"torus_radius_mode": mode_key,
"current_major_radius": current_major,
"target_major_radius": target_major,
"current_minor_radius": current_minor,
"target_minor_radius": target_minor,
"target_radius": target_radius,
"delta_radius": delta_radius,
"radius_delta_ratio": delta_ratio,
"center": center,
"axis": axis,
"resize_strategy": "uniform-scale-torus-radius",
"edit_strategy_label": "围绕环面中心均匀缩放",
"edit_semantics": "按目标环面半径围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。",
"affine_scale": scale,
"affine_transform_kind": "uniform",
"affine_transform_label": "围绕环面中心均匀缩放",
"affine_axis_point": center,
"affine_axis_direction": axis,
"affine_axis_source": "torus center",
"affine_anchor_source": "torus center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_toroidal_radius(self, face_id: int, target_radius: float, mode: str = "minor") -> str:
plan = self.toroidal_radius_plan(face_id, target_radius, mode)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
self._apply_edge_length_affine_transform(plan)
result_check = self._face_edit_result_summary(plan)
mode_label = "major" if plan.get("torus_radius_mode") == "major" else "minor"
current = plan.get("current_major_radius") if mode_label == "major" else plan.get("current_minor_radius")
target = plan.get("target_major_radius") if mode_label == "major" else plan.get("target_minor_radius")
return (
"Toroidal face radius resize completed by uniform scaling: "
f"face {face_id}, {mode_label}_radius={float(current):g}->{float(target):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def face_area_scale_plan(self, face_id: int, target_area: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
blockers: list[str] = []
warnings: list[str] = [
"目标面面积不是 STEP 原始 CAD 历史参数;当前采用围绕当前面的面积中心均匀缩放所属特征或 Solid 的语义。"
]
risk = "high"
try:
target_area = float(target_area)
except (TypeError, ValueError):
target_area = 0.0
blockers.append("目标面面积必须是数字。")
current_area = _float_or_none(info.get("area"))
center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if current_area is None or current_area <= 1e-9:
blockers.append("当前面缺少有效面积。")
if center is None:
blockers.append("当前面缺少稳定面积中心,不能缩放。")
if target_area <= 1e-9:
blockers.append("目标面面积必须大于 0。")
if current_area is not None and abs(target_area - current_area) <= max(current_area * 1e-6, 1e-6):
blockers.append("目标面面积与当前值几乎相同,不需要修改。")
scale = (
math.sqrt(target_area / max(float(current_area), 1e-9))
if target_area > 1e-9 and current_area is not None and current_area > 1e-9
else 1.0
)
area_delta = target_area - float(current_area or 0.0)
area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9)
if scale < 0.05:
blockers.append("目标面面积会把所属对象整体缩放到当前尺寸的 5% 以下,容易生成退化几何。")
elif scale > 5.0:
blockers.append("目标面面积会把所属对象整体放大到当前尺寸的 5 倍以上,风险过高。")
if area_delta_ratio <= 0.15:
risk = "medium"
elif area_delta_ratio > 0.8:
warnings.append("目标面积变化超过 80%,很可能明显影响周边几何。")
else:
warnings.append("目标面积变化较大,修改后请重点检查周边尺寸。")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None:
blockers.append("找不到当前面所属零件。")
part_solid_count = 0
else:
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
target_label = "Solid" if target_kind == "solid" else "特征"
warnings.append(f"当前会缩放所属{target_label},该对象上的其它尺寸会一起变化。")
status = "blocked" if blockers else "caution"
if blockers:
risk = "blocked"
return {
"status": status,
"risk": risk,
"message": " ".join(blockers + warnings),
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": part_id,
"solid_id": solid_id,
"surface": info.get("surface"),
"current_area": current_area,
"target_area": target_area,
"area_delta": area_delta,
"area_delta_ratio": area_delta_ratio,
"area_center": center,
"resize_strategy": "uniform-scale-face-area-fallback",
"edit_strategy_label": "按目标面面积缩放所属对象",
"edit_semantics": "围绕当前面的面积中心均匀缩放所属特征或 Solid;目标 Face 和同一对象上的其它尺寸会一起变化。",
"affine_scale": scale,
"affine_transform_kind": "uniform",
"affine_transform_label": "按目标面面积缩放所属对象",
"affine_transform_note": "围绕当前面面积中心做均匀缩放;目标 Face 和同一所属对象上的其它尺寸会一起变化。",
"affine_axis_point": center,
"affine_axis_direction": (0.0, 0.0, 1.0),
"affine_axis_source": "face area center",
"affine_anchor_source": "face area center",
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
def resize_face_area(self, face_id: int, target_area: float) -> str:
plan = self.face_area_scale_plan(face_id, target_area)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
self._apply_edge_length_affine_transform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Face area resize completed by uniform scaling fallback: "
f"face {face_id}, "
f"area={float(plan['current_area']):g}->{float(plan['target_area']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
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",
strategy_mode: str = "auto",
):
plan = self.general_edge_length_plan(
edge_id,
target_length,
anchor_mode=anchor_mode,
strategy_mode=strategy_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",
strategy_mode: str = "auto",
) -> str:
return self.resize_general_edge_length(
edge_id,
target_length,
anchor_mode=anchor_mode,
strategy_mode=strategy_mode,
)
def resize_general_edge_length(
self,
edge_id: int,
target_length: float,
anchor_mode: str = "auto",
strategy_mode: str = "auto",
) -> str:
plan = self.general_edge_length_plan(
edge_id,
target_length,
anchor_mode=anchor_mode,
strategy_mode=strategy_mode,
)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("resize_strategy") == "local-edge-only-deform":
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by local edge-only deformation: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"anchor={plan.get('local_edge_deform_anchor')}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull":
push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"]))
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by end-face push/pull: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"end_face={int(plan['end_face_id'])}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"risk={plan['risk']}. {result_check} {push_result}"
)
if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length":
face_id = int(plan["cylinder_resize_face_id"])
target_diameter = float(plan["cylinder_resize_target_diameter"])
if plan.get("circular_edge_cylinder_mode") == "boss":
resize_result = self.resize_cylindrical_boss(face_id, target_diameter)
else:
resize_result = self.resize_cylindrical_hole(face_id, target_diameter)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by adjacent cylinder diameter edit: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"cylinder_face={face_id}, "
f"target_diameter={target_diameter:g}, "
f"mode={plan.get('circular_edge_cylinder_mode_label')}, "
f"risk={plan['risk']}. {result_check} {resize_result}"
)
self._apply_edge_length_affine_transform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by geometric scale fallback: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"transform={plan.get('affine_transform_label') or plan.get('affine_transform_kind')}, "
f"predicted_edge_length={float(plan.get('affine_predicted_edge_length') or 0.0):g}, "
f"axis_source={plan.get('affine_axis_source')}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def move_edge_endpoint(
self,
edge_id: int,
endpoint_role: str,
target_point: tuple[float, float, float],
) -> str:
plan = self.edge_endpoint_move_plan(edge_id, endpoint_role, target_point)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("resize_strategy") != "local-edge-endpoint-deform":
raise ValueError("Unsupported Edge endpoint move strategy.")
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge endpoint move completed by local edge deformation: "
f"edge {edge_id}, "
f"endpoint={plan.get('edge_endpoint_role')}, "
f"current_endpoint={plan.get('current_endpoint_point')}, "
f"target_endpoint={plan.get('target_endpoint_point')}, "
f"move={plan.get('moved_endpoint_delta')}, "
f"current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def move_edge_center(
self,
edge_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.edge_center_move_plan(edge_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("resize_strategy") != "local-edge-center-deform":
raise ValueError("Unsupported Edge center move strategy.")
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge center move completed by local edge deformation: "
f"edge {edge_id}, "
f"current_center={plan.get('current_edge_center')}, "
f"target_center={plan.get('target_edge_center')}, "
f"move={plan.get('moved_edge_center_delta')}, "
f"edge_length={float(plan['current_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def face_center_local_move_preview_polydata(
self,
face_id: int,
target_center: tuple[float, float, float],
deflection: float = 0.8,
):
plan = self.face_center_local_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
preview_shape = self._local_face_deform_shape(plan)
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def move_face_center_local(
self,
face_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.face_center_local_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
self._apply_local_face_deform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Face center move completed by local face-only deformation: "
f"face {face_id}, "
f"current_center={plan.get('current_face_center')}, "
f"target_center={plan.get('target_face_center')}, "
f"move={plan.get('face_center_move_vector')}, "
f"moved_points={plan.get('local_face_deform_moved_point_count')}, "
f"rebuilt_faces={plan.get('local_face_deform_face_count')}, "
f"target={plan.get('local_face_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def face_area_local_resize_preview_polydata(
self,
face_id: int,
target_area: float,
deflection: float = 0.8,
):
plan = self.face_area_local_resize_plan(face_id, target_area)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
preview_shape = self._local_face_deform_shape(plan)
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def resize_face_area_local(self, face_id: int, target_area: float) -> str:
plan = self.face_area_local_resize_plan(face_id, target_area)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
self._apply_local_face_deform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Face area resize completed by local face-only deformation: "
f"face {face_id}, "
f"current_area={float(plan['current_area']):g}, "
f"target_area={float(plan['target_area']):g}, "
f"delta={float(plan['area_delta']):g}, "
f"area_scale={float(plan['local_face_area_scale']):g}, "
f"moved_points={plan.get('local_face_deform_moved_point_count')}, "
f"rebuilt_faces={plan.get('local_face_deform_face_count')}, "
f"target={plan.get('local_face_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def face_size_local_resize_preview_polydata(
self,
face_id: int,
target_size: float,
axis: str = "width",
deflection: float = 0.8,
):
plan = self.face_size_local_resize_plan(face_id, target_size, axis)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
preview_shape = self._local_face_deform_shape(plan)
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def resize_face_size_local(self, face_id: int, target_size: float, axis: str = "width") -> str:
plan = self.face_size_local_resize_plan(face_id, target_size, axis)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
self._apply_local_face_deform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Face local size resize completed by local face-only deformation: "
f"face {face_id}, "
f"axis={plan.get('face_size_axis')}, "
f"current_size={float(plan['current_face_size']):g}, "
f"target_size={float(plan['target_face_size']):g}, "
f"delta={float(plan['face_size_delta']):g}, "
f"scale={float(plan['face_size_scale']):g}, "
f"moved_points={plan.get('local_face_deform_moved_point_count')}, "
f"rebuilt_faces={plan.get('local_face_deform_face_count')}, "
f"target={plan.get('local_face_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def face_size_owning_scale_preview_polydata(
self,
face_id: int,
target_size: float,
axis: str = "width",
deflection: float = 0.8,
):
plan = self.face_size_owning_scale_plan(face_id, target_size, axis)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild":
preview_shape = self._local_face_deform_shape(plan)
else:
preview_shape = self._edge_length_affine_preview_shape(plan)
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def resize_face_size_owning_scale(self, face_id: int, target_size: float, axis: str = "width") -> str:
plan = self.face_size_owning_scale_plan(face_id, target_size, axis)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
self._remember_face_target_logical_id(plan, face_id)
if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild":
self._apply_local_face_deform(plan)
else:
self._apply_edge_length_affine_transform(plan)
result_check = self._face_edit_result_summary(plan)
return (
"Face owning size resize completed by axis-affine scaling: "
f"face {face_id}, "
f"axis={plan.get('face_size_axis')}, "
f"current_size={float(plan['current_face_size']):g}, "
f"target_size={float(plan['target_face_size']):g}, "
f"delta={float(plan['face_size_delta']):g}, "
f"scale={float(plan['face_size_scale']):g}, "
f"rebuild_mode={plan.get('owning_face_size_rebuild_mode')}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
def _remember_face_target_logical_id(self, plan: dict[str, object], face_id: int) -> None:
try:
if 0 <= int(face_id) < len(self.faces):
plan["target_logical_id"] = self.face_region_logical_id(int(face_id))
except Exception:
pass
def _face_edit_result_summary(self, plan: dict[str, object]) -> str:
check = self._face_edit_result_check(plan)
if check is None:
return "Face result check unavailable."
if int(check.get("face_id", -1)) < 0:
return f"Face result check: no matching Face found, metric={check.get('metric')}, scope={check.get('scope')}."
target = check.get("target")
actual = check.get("actual")
target_text = _format_tuple(target) if isinstance(target, tuple) else _format_result_number(target)
actual_text = _format_tuple(actual) if isinstance(actual, tuple) else _format_result_number(actual)
return (
"Face result check: "
f"nearest_face={check['face_id']}, "
f"metric={check['metric']}, "
f"actual={actual_text}, "
f"target={target_text}, "
f"error={float(check['error']):g}, "
f"tolerance={float(check['tolerance']):g}, "
f"scope={check['scope']}."
)
def _face_edit_result_check(self, plan: dict[str, object]) -> dict[str, object] | None:
face_ids, scope = self._face_edit_result_candidate_ids(plan)
if not face_ids:
return None
metric = ""
target: float | tuple[float, ...] | None = None
tolerance = 1e-4
getter: Callable[[int], float | tuple[float, ...] | None] | None = None
target_area = _float_or_none(plan.get("target_area"))
target_size = _float_or_none(plan.get("target_face_size"))
target_center = _tuple_or_none(plan.get("target_face_center"))
target_position = _float_or_none(plan.get("target_plane_position"))
target_thickness = _float_or_none(plan.get("shell_target_thickness"))
embedded_small_radius = _float_or_none(plan.get("embedded_cone_target_small_radius"))
embedded_large_radius = _float_or_none(plan.get("embedded_cone_target_large_radius"))
if target_area is not None and target_area > 0:
metric = "area"
target = target_area
tolerance = max(abs(target_area) * 0.02, 1e-4)
getter = lambda face_id: _float_or_none(self.face_info(face_id).get("area"))
elif target_size is not None and target_size > 0:
axis_key = "height" if str(plan.get("face_size_axis") or "").lower() == "height" else "width"
info_key = "local_face_height" if axis_key == "height" else "local_face_width"
metric = info_key
target = target_size
tolerance = max(abs(target_size) * 0.02, 1e-4)
getter = lambda face_id, key=info_key: _float_or_none(self.face_info(face_id).get(key))
elif target_center is not None:
metric = "center"
target = target_center
reference = max(max(abs(item) for item in target_center), 1.0)
tolerance = max(reference * 1e-5, 1e-4)
def center_getter(face_id: int) -> tuple[float, float, float] | None:
info = self.face_info(face_id)
return _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
getter = center_getter
elif target_position is not None:
direction = (
_tuple_normalized(_tuple_or_none(plan.get("plane_direction")))
or _tuple_normalized(_tuple_or_none(plan.get("outward_direction")))
)
if direction is None:
return None
metric = "plane_position"
target = target_position
bbox_diagonal = _float_or_none(plan.get("bbox_diagonal")) or _shape_diagonal(self.shape)
tolerance = max(bbox_diagonal * 1e-4, abs(target_position) * 1e-5, 1e-4)
def plane_position_getter(face_id: int) -> float | None:
origin = _tuple_or_none(self.face_info(face_id).get("plane_origin"))
if origin is None:
return None
return _tuple_dot(origin, direction)
getter = plane_position_getter
elif target_thickness is not None and target_thickness > 0:
metric = "shell_thickness"
target = target_thickness
tolerance = max(abs(target_thickness) * 0.03, 1e-4)
getter = lambda face_id: _float_or_none(self.feature_info(face_id).get("shell_thickness_estimate"))
elif (
embedded_small_radius is not None
and embedded_small_radius > 0
and embedded_large_radius is not None
and embedded_large_radius > embedded_small_radius
):
metric = "cone_boundary_radii"
target = (embedded_small_radius, embedded_large_radius)
tolerance = max(abs(embedded_large_radius) * 0.01, abs(embedded_small_radius) * 0.01, 1e-4)
def cone_boundary_getter(face_id: int) -> tuple[float, float] | None:
info = self.face_info(face_id)
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if axis_point is None or axis_direction is None:
return None
circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2:
return None
return (radii[0], radii[1])
getter = cone_boundary_getter
else:
resize_strategy = str(plan.get("resize_strategy") or "")
surface = str(plan.get("surface") or "")
if "semi-angle" in resize_strategy:
numeric_specs = (
("semi_angle_degrees", "target_semi_angle_degrees", 0.01, 0.05),
("reference_radius", "target_reference_radius", 0.01, 1e-4),
("radius", "target_radius", 0.01, 1e-4),
)
elif surface == "torus":
if str(plan.get("torus_radius_mode") or "") == "major":
numeric_specs = (
("major_radius", "target_major_radius", 0.01, 1e-4),
("minor_radius", "target_minor_radius", 0.01, 1e-4),
)
else:
numeric_specs = (
("minor_radius", "target_minor_radius", 0.01, 1e-4),
("major_radius", "target_major_radius", 0.01, 1e-4),
)
else:
numeric_specs = (
("reference_radius", "target_reference_radius", 0.01, 1e-4),
("major_radius", "target_major_radius", 0.01, 1e-4),
("minor_radius", "target_minor_radius", 0.01, 1e-4),
("radius", "target_radius", 0.01, 1e-4),
("semi_angle_degrees", "target_semi_angle_degrees", 0.01, 0.05),
)
for info_key, target_key, ratio, floor in numeric_specs:
value = _float_or_none(plan.get(target_key))
if value is None or value <= 0:
continue
metric = info_key
target = value
tolerance = max(abs(value) * ratio, floor)
if info_key == "semi_angle_degrees":
getter = lambda face_id: _angle_degrees_or_none(self.face_info(face_id).get("semi_angle"))
else:
getter = lambda face_id, key=info_key: _float_or_none(self.face_info(face_id).get(key))
break
if getter is None or target is None:
return None
best: dict[str, object] | None = None
for face_id in face_ids:
try:
actual = getter(face_id)
except Exception:
actual = None
if actual is None:
continue
error = _result_value_error(actual, target)
if best is None or error < float(best["error"]):
best = {
"face_id": face_id,
"metric": metric,
"actual": actual,
"target": target,
"error": error,
"tolerance": tolerance,
"scope": scope,
}
if best is not None:
return best
return {
"face_id": -1,
"metric": metric,
"actual": "",
"target": target,
"error": math.inf,
"tolerance": tolerance,
"scope": scope,
}
def _face_edit_result_candidate_ids(self, plan: dict[str, object]) -> tuple[list[int], str]:
part_id = _int_or_none(plan.get("part_id"))
solid_id = _int_or_none(plan.get("solid_id"))
surface = str(plan.get("surface") or "")
target_kind = str(
plan.get("local_face_deform_target_kind")
or plan.get("affine_target_kind")
or plan.get("target_kind")
or ("solid" if solid_id is not None and solid_id >= 0 else "part")
)
scope = f"part {part_id}" if part_id is not None else "model"
if target_kind == "solid" and solid_id is not None and solid_id >= 0:
scope = f"solid {solid_id}"
primary: list[int] = []
logical_id = _int_or_none(plan.get("target_logical_id"))
if logical_id is not None:
try:
primary.extend(self.face_ids_for_logical_id(logical_id))
except Exception:
pass
plan_face_id = _int_or_none(plan.get("face_id"))
if plan_face_id is not None:
primary.append(plan_face_id)
def filtered(source_ids: Iterable[int], *, require_solid: bool = True) -> list[int]:
result: list[int] = []
for candidate_id in source_ids:
try:
face_id = int(candidate_id)
except (TypeError, ValueError):
continue
if not (0 <= face_id < len(self.faces)):
continue
if part_id is not None and int(self.face_part_ids[face_id]) != part_id:
continue
if (
require_solid
and solid_id is not None
and solid_id >= 0
and int(self.face_solid_ids[face_id]) != solid_id
):
continue
if surface in {"plane", "cylinder", "cone", "sphere", "torus"}:
try:
if self.face_surface_kind(face_id) != surface:
continue
except Exception:
continue
if face_id not in result:
result.append(face_id)
return result
primary_ids = filtered(primary)
all_ids = filtered(range(len(self.faces)))
if not all_ids and solid_id is not None and solid_id >= 0:
all_ids = filtered(range(len(self.faces)), require_solid=False)
combined: list[int] = []
for face_id in [*primary_ids, *all_ids]:
if face_id not in combined:
combined.append(face_id)
return combined, scope
def _edge_length_result_summary(self, plan: dict[str, object]) -> str:
check = self._edge_length_result_check(plan)
if check is None:
return "Result check unavailable."
return (
"Result check: "
f"match={check['match_method']}, "
f"nearest_edge={check['edge_id']}, "
f"nearest_length={float(check['nearest_length']):g}, "
f"target_error={float(check['target_error']):g}, "
f"relative_error={float(check['relative_error']):g}, "
f"endpoint_error={float(check['endpoint_error']):g}, "
f"scope={check['scope']}."
)
def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None:
try:
target_length = float(plan.get("target_length", 0.0))
except (TypeError, ValueError):
return None
if target_length <= 1e-9 or not self.edges:
return None
try:
part_id = int(plan.get("part_id", -1))
except (TypeError, ValueError):
part_id = -1
try:
solid_id = int(plan.get("solid_id", -1))
except (TypeError, ValueError):
solid_id = -1
target_kind = str(
plan.get("local_edge_deform_target_kind")
or plan.get("affine_target_kind")
or ("solid" if solid_id >= 0 else "part")
)
edge_ids = [edge_id for edge_id in range(len(self.edges)) if part_id < 0 or self.edge_part_ids[edge_id] == part_id]
scope = f"零件 {part_id}" if part_id >= 0 else "model"
if target_kind == "solid" and solid_id >= 0:
solid_edge_ids = [edge_id for edge_id in edge_ids if self.edge_solid_ids[edge_id] == solid_id]
if solid_edge_ids:
edge_ids = solid_edge_ids
scope = f"solid {solid_id}"
if not edge_ids:
edge_ids = list(range(len(self.edges)))
scope = "model"
expected = self._edge_length_expected_endpoints(plan)
best_length: tuple[float, int, float, float | None] | None = None
best_endpoint: tuple[float, float, int, float] | None = None
for edge_id in edge_ids:
try:
length = float(self.edge_info(edge_id).get("length", 0.0))
except Exception:
continue
if length <= 1e-9:
continue
error = abs(length - target_length)
endpoint_error = None
if expected is not None:
endpoint_error = self._edge_endpoint_pair_error(edge_id, expected[0], expected[1])
if endpoint_error is not None and (
best_endpoint is None
or endpoint_error < best_endpoint[0]
or (
abs(endpoint_error - best_endpoint[0]) <= 1e-9
and (error < best_endpoint[1] or (abs(error - best_endpoint[1]) <= 1e-9 and edge_id < best_endpoint[2]))
)
):
best_endpoint = (endpoint_error, error, edge_id, length)
if best_length is None or error < best_length[0] or (
abs(error - best_length[0]) <= 1e-9 and edge_id < best_length[1]
):
best_length = (error, edge_id, length, endpoint_error)
if best_length is None:
return None
match_method = "length"
endpoint_error_value = best_length[3]
error, edge_id, length = best_length[0], best_length[1], best_length[2]
if expected is not None and best_endpoint is not None:
endpoint_tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4)
if best_endpoint[0] <= endpoint_tolerance:
endpoint_error_value, error, edge_id, length = best_endpoint
match_method = str(expected[2])
return {
"edge_id": edge_id,
"nearest_length": length,
"target_error": error,
"relative_error": error / max(target_length, 1e-9),
"endpoint_error": endpoint_error_value if endpoint_error_value is not None else -1.0,
"match_method": match_method,
"scope": scope,
}
def _edge_length_expected_endpoints(
self,
plan: dict[str, object],
) -> tuple[tuple[float, float, float], tuple[float, float, float], str] | None:
start = _tuple_or_none(plan.get("start_point"))
end = _tuple_or_none(plan.get("end_point"))
if start is None or end is None:
return None
strategy = str(plan.get("resize_strategy", ""))
if strategy in {"local-edge-only-deform", "local-edge-endpoint-deform", "local-edge-center-deform"}:
start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0)
end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0)
if strategy == "local-edge-endpoint-deform":
match_label = "endpoint-local-coordinate"
elif strategy == "local-edge-center-deform":
match_label = "endpoint-local-center"
else:
match_label = "endpoint-local"
return (
(start[0] + start_move[0], start[1] + start_move[1], start[2] + start_move[2]),
(end[0] + end_move[0], end[1] + end_move[1], end[2] + end_move[2]),
match_label,
)
if strategy == "move-edge-end-plane-by-push-pull":
movement = _tuple_or_none(plan.get("desired_movement_vector"))
endpoint_role = str(plan.get("end_face_endpoint_role", ""))
if movement is None or endpoint_role not in {"start", "end"}:
return None
if endpoint_role == "start":
start = (start[0] + movement[0], start[1] + movement[1], start[2] + movement[2])
else:
end = (end[0] + movement[0], end[1] + movement[1], end[2] + movement[2])
return start, end, "endpoint-push-pull"
if strategy == "scale-owning-shape-from-edge":
transformed_start = self._edge_length_affine_point(start, plan)
transformed_end = self._edge_length_affine_point(end, plan)
if transformed_start is None or transformed_end is None:
return None
return transformed_start, transformed_end, "endpoint-affine"
return None
def _edge_endpoint_pair_error(
self,
edge_id: int,
expected_start: tuple[float, float, float],
expected_end: tuple[float, float, float],
) -> float | None:
info = self.edge_info(edge_id)
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
if start is None or end is None:
return None
direct = max(_vector_length(_tuple_sub(start, expected_start)), _vector_length(_tuple_sub(end, expected_end)))
reversed_order = max(
_vector_length(_tuple_sub(start, expected_end)),
_vector_length(_tuple_sub(end, expected_start)),
)
return min(direct, reversed_order)
def _edge_length_affine_point(
self,
point: tuple[float, float, float],
plan: dict[str, object],
) -> tuple[float, float, float] | None:
axis_point = _tuple_or_none(plan.get("affine_axis_point"))
if axis_point is None:
return None
try:
scale = float(plan.get("affine_scale", 1.0))
except (TypeError, ValueError):
return None
relative = _tuple_sub(point, axis_point)
transform_kind = str(plan.get("affine_transform_kind", "axis-affine"))
if transform_kind == "uniform":
moved = _tuple_scale(relative, scale)
else:
axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction")))
if axis_direction is None:
return None
axial = _tuple_scale(axis_direction, _tuple_dot(relative, axis_direction))
radial = _tuple_sub(relative, axial)
if transform_kind == "radial-affine":
moved = (
axial[0] + radial[0] * scale,
axial[1] + radial[1] * scale,
axial[2] + radial[2] * scale,
)
else:
moved = (
radial[0] + axial[0] * scale,
radial[1] + axial[1] * scale,
radial[2] + axial[2] * scale,
)
return (axis_point[0] + moved[0], axis_point[1] + moved[1], axis_point[2] + moved[2])
def _local_edge_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape:
_target_kind, solid, _part, _source_solid = self._local_edge_deform_target(plan)
tolerance = max(_shape_diagonal(solid) * 1e-7, abs(float(plan.get("delta_length", 0.0))) * 1e-7, 1e-6)
faces = _explore(solid, TopAbs_FACE)
moved_faces: list[TopoDS_Shape] = []
moved_points: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for face in faces:
points = self._local_deform_face_vertex_points(face, tolerance)
if len(points) < 3:
raise RuntimeError("Local edge deformation could not read a stable face vertex loop.")
for point in points:
moved = self._local_edge_deform_moved_point(point, plan, tolerance)
moved_points[self._local_point_key(moved, tolerance)] = moved
if not moved_points:
raise RuntimeError("Local edge deformation produced no moved vertices.")
center = (
sum(point[0] for point in moved_points.values()) / len(moved_points),
sum(point[1] for point in moved_points.values()) / len(moved_points),
sum(point[2] for point in moved_points.values()) / len(moved_points),
)
for face in faces:
points = [
self._local_edge_deform_moved_point(point, plan, tolerance)
for point in self._local_deform_face_vertex_points(face, tolerance)
]
points = self._dedupe_local_points(points, tolerance)
if len(points) < 3:
raise RuntimeError("Local edge deformation collapsed a face.")
points = self._orient_local_polygon_outward(points, center)
if self._local_points_are_planar(points, tolerance):
moved_faces.append(self._make_local_polygon_face(points))
else:
for index in range(1, len(points) - 1):
triangle = [points[0], points[index], points[index + 1]]
triangle = self._orient_local_polygon_outward(triangle, center)
moved_faces.append(self._make_local_polygon_face(triangle))
sewing = BRepBuilderAPI_Sewing(tolerance)
for face in moved_faces:
sewing.Add(face)
sewing.Perform()
sewed = sewing.SewedShape()
if sewed.IsNull():
raise RuntimeError("Local edge deformation sewing produced an empty shape.")
if sewed.ShapeType() == TopAbs_SHELL:
shell = topods.Shell(sewed)
else:
shells = _explore(sewed, TopAbs_SHELL)
if not shells:
raise RuntimeError("Local edge deformation did not produce a sewable shell.")
shell = topods.Shell(shells[0])
solid_builder = BRepBuilderAPI_MakeSolid(shell)
solid = solid_builder.Solid()
if solid.IsNull():
raise RuntimeError("Local edge deformation could not create a solid from the rebuilt shell.")
return _ensure_valid_or_repaired_shape(solid, "local edge deformation")
def _apply_local_edge_deform(self, plan: dict[str, object]) -> None:
target_kind, _source_shape, part, source_solid = self._local_edge_deform_target(plan)
transformed = self._local_edge_deform_shape(plan)
if target_kind == "part":
part.shape = transformed
else:
part_solids = _explore(part.shape, TopAbs_SOLID)
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and source_solid is not None and _same_shape(item, source_solid):
shapes.append(transformed)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
def _local_face_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape:
_target_kind, solid, _part, _source_solid = self._local_face_deform_target(plan)
move_distance = _float_or_none(plan.get("local_face_deform_distance_hint"))
if move_distance is None:
move_distance = _float_or_none(plan.get("face_center_move_distance"))
if move_distance is None:
move_distance = 0.0
tolerance = max(_shape_diagonal(solid) * 1e-7, abs(move_distance) * 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]] = {}
moved_count = 0
for face in faces:
points = self._local_deform_face_vertex_points(face, tolerance)
if len(points) < 3:
raise RuntimeError("Local face deformation could not read a stable face vertex loop.")
for point in points:
moved = self._local_face_deform_moved_point(point, plan, tolerance)
if _vector_length(_tuple_sub(moved, point)) > tolerance:
moved_count += 1
moved_points[self._local_point_key(moved, tolerance)] = moved
if not moved_points or moved_count == 0:
raise RuntimeError("Local face 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_face_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 face 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 face 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 face deformation did not produce a sewable shell.")
shell = topods.Shell(shells[0])
solid_builder = BRepBuilderAPI_MakeSolid(shell)
solid_shape = solid_builder.Solid()
if solid_shape.IsNull():
raise RuntimeError("Local face deformation could not create a solid from the rebuilt shell.")
return _ensure_valid_or_repaired_shape(solid_shape, "local face deformation")
def _apply_local_face_deform(self, plan: dict[str, object]) -> None:
target_kind, source_shape, part, source_solid = self._local_face_deform_target(plan)
target_face_id = int(plan.get("face_id", -1))
target_part_id = int(plan.get("part_id", -1))
target_logical_id: int | None = None
target_points: tuple[tuple[float, float, float], ...] = ()
move_distance = _float_or_none(plan.get("local_face_deform_distance_hint"))
if move_distance is None:
move_distance = _float_or_none(plan.get("face_center_move_distance"))
if move_distance is None:
move_distance = 0.0
mapping_tolerance = max(_shape_diagonal(source_shape) * 1e-7, abs(move_distance) * 1e-7, 1e-6)
if 0 <= target_face_id < len(self.faces):
try:
target_logical_id = self.face_region_logical_id(target_face_id)
target_points = self._local_face_deform_target_points_for_face(
target_face_id,
plan,
mapping_tolerance,
)
except Exception:
target_logical_id = None
target_points = ()
transformed = self._local_face_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()
if target_logical_id is not None and target_points:
self._assign_logical_id_to_matching_face_points(
target_logical_id,
target_points,
target_part_id,
mapping_tolerance,
)
def _local_face_deform_target_points_for_face(
self,
face_id: int,
plan: dict[str, object],
tolerance: float,
) -> tuple[tuple[float, float, float], ...]:
points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance)
return tuple(self._local_face_deform_moved_point(point, plan, tolerance) for point in points)
def _assign_logical_id_to_matching_face_points(
self,
logical_id: int,
target_points: Iterable[tuple[float, float, float]],
part_id: int,
tolerance: float,
) -> None:
target = self._dedupe_local_points([tuple(point) for point in target_points], tolerance)
if len(target) < 3:
return
max_error = max(tolerance * 500.0, _shape_diagonal(self.shape) * 1e-6, 1e-4)
best_face_id: int | None = None
best_error = math.inf
for face_id, face in enumerate(self.faces):
if part_id >= 0 and self.face_part_ids[face_id] != part_id:
continue
try:
points = self._dedupe_local_points(self._local_deform_face_vertex_points(face, tolerance), tolerance)
except Exception:
continue
if len(points) < 3:
continue
error = self._local_point_cloud_error(target, points)
error += abs(len(points) - len(target)) * max_error * 0.25
if error < best_error:
best_error = error
best_face_id = face_id
if best_face_id is None or best_error > max_error:
return
region_ids = self.connected_same_domain_face_ids(best_face_id) or [best_face_id]
self.assign_logical_face_region_exclusive(int(logical_id), region_ids)
def _local_point_cloud_error(
self,
left: list[tuple[float, float, float]],
right: list[tuple[float, float, float]],
) -> float:
if not left or not right:
return math.inf
def one_way(source: list[tuple[float, float, float]], target: list[tuple[float, float, float]]) -> float:
return max(min(_vector_length(_tuple_sub(item, other)) for other in target) for item in source)
return max(one_way(left, right), one_way(right, left))
def _translated_face_region_mapping_specs(
self,
specs: Iterable[dict[str, object]],
vector: tuple[float, float, float],
*,
logical_id: int | None = None,
) -> list[dict[str, object]]:
shifted_specs: list[dict[str, object]] = []
for spec in specs:
shifted = dict(spec)
if str(shifted.get("surface", "")) == "plane":
point = _tuple_or_none(shifted.get("point"))
if point is not None:
shifted["point"] = _tuple_add(point, vector)
if logical_id is not None:
shifted["logical_id"] = int(logical_id)
shifted_specs.append(shifted)
return shifted_specs
def _apply_face_region_mapping_specs_exclusive(
self,
logical_id: int | None,
specs: Iterable[dict[str, object]],
) -> None:
if logical_id is None:
return
region_ids: set[int] = set()
for spec in specs:
try:
seed_face_ids = self._matching_face_ids_for_region_spec(spec)
except Exception:
seed_face_ids = []
for seed_face_id in seed_face_ids:
region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id])
if region_ids:
self.assign_logical_face_region_exclusive(int(logical_id), region_ids)
def _local_face_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_face_deform_target_kind", "part"))
solid = self.solids[solid_id][1]
return ("solid" if target_kind == "solid" else "part"), solid, part, solid
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 _local_face_deform_moved_point(
self,
point: tuple[float, float, float],
plan: dict[str, object],
tolerance: float,
) -> tuple[float, float, float]:
point_targets = plan.get("local_face_deform_source_point_targets")
if isinstance(point_targets, (list, tuple)):
for item in point_targets:
if not isinstance(item, (list, tuple)) or len(item) < 2:
continue
source = _tuple_or_none(item[0])
target = _tuple_or_none(item[1])
if source is not None and target is not None and _vector_length(_tuple_sub(point, source)) <= tolerance:
return target
source_points = tuple(_tuple_or_none(item) for item in plan.get("local_face_deform_source_points", ()))
move = _tuple_or_none(plan.get("face_center_move_vector")) or (0.0, 0.0, 0.0)
for source in source_points:
if source is not None and _vector_length(_tuple_sub(point, source)) <= tolerance:
return (point[0] + move[0], point[1] + move[1], point[2] + 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)
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
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()
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
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)
target_logical_id: int | None = None
target_region_mapping_specs: list[dict[str, object]] = []
try:
target_logical_id = self.face_region_logical_id(face_id)
plan["target_logical_id"] = target_logical_id
target_region_mapping_specs = self._translated_face_region_mapping_specs(
self._face_region_mapping_specs([face_id]),
_tuple_scale(outward, float(distance)),
logical_id=target_logical_id,
)
except Exception:
target_logical_id = None
target_region_mapping_specs = []
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)
self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs)
result_check = self._face_edit_result_summary(plan)
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']}. {result_check}"
)
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)
self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs)
action = "fused outward prism" if distance >= 0 else "cut inward prism"
result_check = self._face_edit_result_summary(plan)
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']}. {result_check}"
)
def _cylindrical_cap_extension_plan(
self,
face_id: int,
distance: float,
outward: tuple[float, float, float],
) -> dict[str, object] | None:
if distance <= 0:
return None
if face_id < 0 or face_id >= len(self.faces):
return None
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1:
return None
cap_center = _surface_center(self.faces[face_id])
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)
if not adjacent_face_ids:
return None
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2]))
candidates: list[tuple[float, dict[str, object]]] = []
for adjacent_id in adjacent_face_ids:
side_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
if side_surf.GetType() != GeomAbs_Cylinder:
continue
angular_span = abs(side_surf.LastUParameter() - side_surf.FirstUParameter())
if angular_span < math.tau * 0.92:
continue
cylinder = side_surf.Cylinder()
radius = float(cylinder.Radius())
axis = cylinder.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
axis_alignment = _direction_dot(outward_dir, axis_dir)
if abs(axis_alignment) < 0.92:
continue
if _point_axis_distance(axis_point, axis_dir, cap_center) > max(radius * 0.08, tolerance * 10.0):
continue
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
old_height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_center)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if axis_alignment > 0 and end_distance <= end_tolerance:
new_min = v_min
new_max = v_max + distance
end_score = end_distance
elif axis_alignment < 0 and start_distance <= end_tolerance:
new_min = v_min - distance
new_max = v_max
end_score = start_distance
else:
continue
height = max(new_max - new_min, 1e-6)
start = _point_on_axis(axis_point, axis_dir, new_min)
tool_shape = BRepPrimAPI_MakeCylinder(gp_Ax2(start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, height).Shape()
score = end_score + _point_axis_distance(axis_point, axis_dir, cap_center)
candidate = {
"tool_shape": tool_shape,
"side_face_ids": axis_range["same_domain_face_ids"],
"radius": radius,
"old_height": old_height,
"new_height": height,
"axis_alignment": axis_alignment,
"cap_axis_parameter": cap_parameter,
"start_parameter": new_min,
"end_parameter": new_max,
}
candidates.append((score, candidate))
if not candidates:
return None
distinct_radii: list[float] = []
for _score, candidate in candidates:
radius = float(candidate["radius"])
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii):
distinct_radii.append(radius)
if len(distinct_radii) > 1:
return None
return min(candidates, key=lambda item: item[0])[1]
def resize_existing_fillet(self, face_id: int, target_radius: float) -> str:
plan = self.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = int(plan["part_id"])
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
source_face = topods.Face(self.faces[face_id])
defeatured = _defeature_faces(part.shape, [source_face])
axis_point = gp_Pnt(*plan["axis_point"])
axis_dir = gp_Dir(*plan["axis"])
root_edges = _axis_aligned_edge_candidates(
defeatured,
axis_point,
axis_dir,
expected_length=float(plan.get("height_estimate") or 0.0),
reference_radius=float(plan["current_radius"]),
)
if not root_edges:
raise RuntimeError(
"已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;"
"该圆角可能不是简单直线边圆角。"
)
result = None
failures: list[str] = []
for index, root_edge in enumerate(root_edges[:16], start=1):
try:
maker = BRepFilletAPI_MakeFillet(defeatured)
maker.Add(float(target_radius), topods.Edge(root_edge))
result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}")
break
except Exception as exc:
failures.append(str(exc))
if result is None:
detail = failures[-1] if failures else "没有可用的候选边。"
raise RuntimeError(
"已移除已有圆角面,但所有候选锐边都无法重新倒圆;"
f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}"
)
part.shape = result
self.refresh_topology()
return (
"Existing fillet radius resize completed: "
f"face {face_id}, current_radius={float(plan['current_radius']):g}, "
f"target_radius={target_radius:g}, "
f"delta_radius={float(plan['delta_radius']):g}, "
f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, "
f"risk={plan['risk']}."
)
def fillet_edge(self, edge_id: int, radius: float) -> str:
plan = self.edge_fillet_plan(edge_id, radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
maker = BRepFilletAPI_MakeFillet(part.shape)
maker.Add(radius, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge fillet")
part.shape = result
self.refresh_topology()
return (
f"Edge fillet completed: edge {edge_id}, radius={radius:g}, "
f"edge_length={float(plan['edge_length']):g}, "
f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, "
f"risk={plan['risk']}."
)
def chamfer_edge(self, edge_id: int, distance: float) -> str:
plan = self.edge_chamfer_plan(edge_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
maker = BRepFilletAPI_MakeChamfer(part.shape)
maker.Add(distance, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge chamfer")
part.shape = result
self.refresh_topology()
return (
f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, "
f"edge_length={float(plan['edge_length']):g}, "
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
f"risk={plan['risk']}."
)
def chamfer_edge_asymmetric(
self,
edge_id: int,
distance1: float,
distance2: float,
reference_face_id: int | None = None,
) -> str:
plan = self.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
resolved_reference_face_id = int(plan["reference_face_id"])
maker = BRepFilletAPI_MakeChamfer(part.shape)
try:
maker.Add(
float(distance1),
float(distance2),
topods.Edge(self.edges[edge_id]),
topods.Face(self.faces[resolved_reference_face_id]),
)
except TypeError as exc:
raise RuntimeError("The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face).") from exc
result = _finalize_builder_result(maker, "asymmetric edge chamfer")
part.shape = result
self.refresh_topology()
return (
f"Asymmetric Edge chamfer completed: edge {edge_id}, "
f"distance1={float(distance1):g}, distance2={float(distance2):g}, "
f"reference_face={resolved_reference_face_id}, "
f"edge_length={float(plan['edge_length']):g}, "
f"distance1_to_length_ratio={float(plan['distance1_to_length_ratio']):g}, "
f"distance2_to_length_ratio={float(plan['distance2_to_length_ratio']):g}, "
f"risk={plan['risk']}."
)
def chamfer_edge_distance_angle(
self,
edge_id: int,
distance: float,
angle_degrees: float,
reference_face_id: int | None = None,
) -> str:
plan = self.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
resolved_reference_face_id = int(plan["reference_face_id"])
maker = BRepFilletAPI_MakeChamfer(part.shape)
try:
maker.AddDA(
float(distance),
float(plan["target_angle_radians"]),
topods.Edge(self.edges[edge_id]),
topods.Face(self.faces[resolved_reference_face_id]),
)
except AttributeError as exc:
raise RuntimeError("The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
except TypeError as exc:
raise RuntimeError("The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
result = _finalize_builder_result(maker, "distance-angle edge chamfer")
part.shape = result
self.refresh_topology()
return (
f"Distance-angle Edge chamfer completed: edge {edge_id}, "
f"distance={float(distance):g}, angle_degrees={float(angle_degrees):g}, "
f"reference_face={resolved_reference_face_id}, "
f"edge_length={float(plan['edge_length']):g}, "
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
f"risk={plan['risk']}."
)
def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
return self.resize_cylindrical_hole(face_id, new_diameter)
def resize_cylindrical_slot_width(
self,
face_id: int,
target_width: float,
pair_face_id: int | None = None,
) -> str:
return self._resize_cylindrical_slot_parameter(face_id, target_width, "width", pair_face_id=pair_face_id)
def resize_cylindrical_slot_depth(
self,
face_id: int,
target_depth: float,
pair_face_id: int | None = None,
) -> str:
return self._resize_cylindrical_slot_parameter(face_id, target_depth, "depth", pair_face_id=pair_face_id)
def resize_cylindrical_slot_arc_length(
self,
face_id: int,
target_arc_length: float,
pair_face_id: int | None = None,
) -> str:
return self._resize_cylindrical_slot_parameter(face_id, target_arc_length, "arc_length", pair_face_id=pair_face_id)
def resize_cylindrical_slot_angular_span(self, face_id: int, target_angular_span: float) -> str:
plan = self.cylindrical_slot_angular_span_plan(face_id, target_angular_span)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self._resize_cylindrical_slot_angular_span_with_sector_tool(plan)
def resize_cylindrical_slot_total_length(
self,
face_id: int,
target_total_length: float,
pair_face_id: int | None = None,
) -> str:
plan = self.cylindrical_slot_total_length_plan(face_id, target_total_length, pair_face_id=pair_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self._resize_cylindrical_slot_length_with_capsule_tool(plan)
def resize_cylindrical_slot_center_distance(
self,
face_id: int,
target_center_distance: float,
pair_face_id: int | None = None,
) -> str:
plan = self.cylindrical_slot_center_distance_plan(
face_id,
target_center_distance,
pair_face_id=pair_face_id,
)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self._resize_cylindrical_slot_length_with_capsule_tool(plan)
def _resize_cylindrical_slot_parameter(
self,
face_id: int,
target_value: float,
mode: str,
pair_face_id: int | None = None,
) -> str:
plan = self.cylindrical_slot_resize_plan(face_id, target_value, mode, pair_face_id=pair_face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
target_diameter = float(plan["slot_target_diameter"])
if plan.get("slot_resize_strategy") == "paired-obround-slot-prism":
resize_result = self._resize_cylindrical_slot_with_capsule_tool(plan)
tool_path = "paired-obround-slot-prism"
else:
resize_result = self._resize_cylindrical_slot_with_sector_tool(plan)
tool_path = "slot-sector"
mode_key = str(plan.get("slot_resize_mode") or mode)
if mode_key == "depth":
current_value = plan.get("slot_current_depth")
final_value = plan.get("slot_target_depth")
label = "depth"
elif mode_key == "arc_length":
current_value = plan.get("slot_current_arc_length")
final_value = plan.get("slot_target_arc_length")
label = "arc_length"
else:
current_value = plan.get("slot_current_width")
final_value = plan.get("slot_target_width")
label = "width"
def fmt(value: object) -> str:
try:
return f"{float(value):g}"
except (TypeError, ValueError):
return ""
return (
f"Cylindrical slot {label} resize completed: face {face_id}, "
f"{label}={fmt(current_value)}->{fmt(final_value)}, "
f"derived_diameter={target_diameter:g}, "
f"angular_span={fmt(plan.get('slot_angular_span'))}, "
f"strategy={plan.get('slot_resize_strategy')}, "
f"tool_path={tool_path}, "
f"risk={plan['risk']}. {resize_result}"
)
def _slot_fill_radius(self, plan: dict[str, object]) -> float:
fill_radius = _float_or_none(plan.get("fill_radius"))
nominal_diameter = _float_or_none(plan.get("current_diameter"))
if nominal_diameter is None:
nominal_diameter = _float_or_none(plan.get("slot_target_diameter"))
nominal_radius = nominal_diameter * 0.5 if nominal_diameter is not None else None
if fill_radius is None or fill_radius <= 1e-9:
fill_radius = nominal_radius if nominal_radius is not None else 0.0
if nominal_radius is not None and nominal_radius > 0:
overlap = min(max(nominal_radius * 0.02, 0.02), 0.2)
fill_radius = max(fill_radius, nominal_radius + overlap)
return float(fill_radius)
def _drop_tiny_artifact_solids(
self,
result: TopoDS_Shape,
reference_shape: TopoDS_Shape,
) -> tuple[TopoDS_Shape, dict[str, object]]:
solids = _explore(result, TopAbs_SOLID)
if len(solids) <= 1:
return result, {"discarded_count": 0, "discarded_volume": 0.0}
def solid_volume(shape: TopoDS_Shape) -> float:
value = _shape_volume_info(shape).get("volume")
try:
return abs(float(value))
except (TypeError, ValueError):
return 0.0
volumes = [solid_volume(solid) for solid in solids]
largest_index = max(range(len(solids)), key=lambda index: volumes[index])
largest_volume = volumes[largest_index]
if largest_volume <= 1e-9:
return result, {"discarded_count": 0, "discarded_volume": 0.0}
reference_volume = solid_volume(reference_shape)
volume_limit = max(largest_volume, reference_volume, 1.0) * 1e-4
tiny_indices = [index for index, volume in enumerate(volumes) if index != largest_index and volume <= volume_limit]
discarded_volume = sum(volumes[index] for index in tiny_indices)
if len(tiny_indices) != len(solids) - 1 or discarded_volume > volume_limit:
return result, {"discarded_count": 0, "discarded_volume": 0.0}
return (
solids[largest_index],
{
"discarded_count": len(tiny_indices),
"discarded_volume": discarded_volume,
"largest_volume": largest_volume,
"volume_limit": volume_limit,
},
)
def _resize_cylindrical_slot_length_with_capsule_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
target_plan = dict(plan)
target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1")
target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2")
try:
filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan))
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "obround slot length fill/fuse", use_glue=False)
cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) / 2.0)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "obround slot length cut")
part.shape = result
self.refresh_topology()
verification = self._verify_obround_slot_length_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "obround slot length result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
mode_label = "center distance" if plan.get("slot_resize_mode") == "center_distance" else "total length"
return (
f"Obround cylindrical slot {mode_label} resize completed: "
f"length {float(plan.get('slot_current_total_length', 0.0)):g} -> {float(plan['slot_target_total_length']):g}, "
f"center_distance={float(plan['slot_current_center_distance']):g}->{float(plan['slot_target_center_distance']):g}, "
f"diameter={float(plan['slot_target_diameter']):g}, "
f"paired_face={plan.get('slot_pair_face_id', '')}, "
f"mode={plan['resize_mode']}, action=capsule fill and recut, "
f"verified_face={verification.get('face_id', '')}."
)
def _resize_cylindrical_slot_angular_span_with_sector_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
try:
filler = self._slot_sector_prism_tool(
face_id,
self._slot_fill_radius(plan),
float(plan["fill_start_parameter"]),
float(plan["fill_end_parameter"]),
)
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "slot angular-span fill/fuse", use_glue=False)
cutter = self._slot_sector_prism_tool(
face_id,
float(plan["slot_target_diameter"]) / 2.0,
float(plan["cutter_start_parameter"]),
float(plan["cutter_end_parameter"]),
u_first_override=float(plan["slot_target_u_first"]),
u_last_override=float(plan["slot_target_u_last"]),
u_padding=0.0,
)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "slot angular-span cut")
part.shape = result
self.refresh_topology()
verification = self._verify_slot_angular_span_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "slot angular-span result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Cylindrical slot angular-span resize completed: "
f"span {float(plan['slot_current_angular_span']):g} -> {float(plan['slot_target_angular_span']):g}, "
f"degrees={math.degrees(float(plan['slot_current_angular_span'])):g}->{math.degrees(float(plan['slot_target_angular_span'])):g}, "
f"diameter={float(plan['slot_target_diameter']):g}, "
f"mode={plan['resize_mode']}, action=sector fill and recut, "
f"height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def _verify_slot_angular_span_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_span = _float_or_none(plan.get("slot_target_angular_span"))
target_diameter = _float_or_none(plan.get("slot_target_diameter"))
if target_span is None or target_span <= 1e-9 or target_diameter is None or target_diameter <= 1e-9:
return {"matched": False, "detail": " Missing target slot angular-span verification data."}
try:
axis_point = gp_Pnt(*plan["cutter_axis_point"])
axis_direction = gp_Dir(*plan["cutter_axis_direction"])
except Exception:
return {"matched": False, "detail": " Missing original slot axis data."}
target_radius = target_diameter * 0.5
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5)
axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4)
span_tolerance = max(target_span * 0.08, 0.02)
best: dict[str, object] | None = None
best_score = math.inf
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
candidate_radius = float(cyl.Radius())
radius_error = abs(candidate_radius - target_radius)
axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction()))
if axis_dot < 1.0 - 1e-5:
continue
axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location())
angular_span = abs(float(surf.LastUParameter()) - float(surf.FirstUParameter()))
span_error = abs(angular_span - target_span)
except Exception:
continue
score = (
radius_error / max(radius_tolerance, 1e-9)
+ axis_distance / max(axis_tolerance, 1e-9)
+ span_error / max(span_tolerance, 1e-9)
)
candidate = {
"matched": (
radius_error <= radius_tolerance
and axis_distance <= axis_tolerance
and span_error <= span_tolerance
),
"face_id": face_id,
"angular_span": angular_span,
"span_error": span_error,
"diameter": candidate_radius * 2.0,
"radius_error": radius_error,
"axis_distance": axis_distance,
"span_tolerance": span_tolerance,
}
if candidate["matched"]:
return self._attach_cylindrical_first_level_result_check(candidate)
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " No cylindrical slot face on the original axis was found after angular-span edit."}
return {
"matched": False,
"detail": (
f" Closest slot Face {best['face_id']} span {float(best['angular_span']):.6g}, "
f"target {target_span:.6g}, span error {float(best['span_error']):.6g}, "
f"diameter {float(best['diameter']):.6g}."
),
**best,
}
def _verify_obround_slot_length_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_distance = _float_or_none(plan.get("slot_target_center_distance"))
target_diameter = _float_or_none(plan.get("slot_target_diameter"))
axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction")))
length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction")))
if target_distance is None or target_diameter is None or axis_dir is None or length_dir is None:
return {"matched": False, "detail": " Missing obround slot length verification data."}
target_radius = target_diameter * 0.5
if target_radius <= 1e-9 or target_distance <= 1e-9:
return {"matched": False, "detail": " Invalid target slot length or diameter."}
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5)
distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4)
candidates: list[dict[str, object]] = []
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
radius = float(cyl.Radius())
if abs(radius - target_radius) > radius_tolerance:
continue
candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction()))
if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4:
continue
axis_range = self._cylindrical_axis_range(face_id, surf)
mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5
mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter))
candidates.append({"face_id": face_id, "mid": mid, "radius": radius})
except Exception:
continue
best: dict[str, object] | None = None
best_score = math.inf
for index, first in enumerate(candidates):
for second in candidates[index + 1 :]:
raw_offset = _tuple_sub(second["mid"], first["mid"])
axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir))
section_offset = _tuple_sub(raw_offset, axis_offset)
distance = _vector_length(section_offset)
if distance <= 1e-9:
continue
direction = _tuple_normalized(section_offset)
if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96:
continue
error = abs(distance - target_distance)
score = error / max(distance_tolerance, 1e-9)
candidate = {
"matched": error <= distance_tolerance,
"face_id": first["face_id"],
"paired_face_id": second["face_id"],
"center_distance": distance,
"target_center_distance": target_distance,
"center_distance_error": error,
"center_distance_tolerance": distance_tolerance,
}
if candidate["matched"]:
return self._attach_cylindrical_first_level_result_check(candidate)
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " No paired cylindrical slot ends matching the target radius were found."}
return {
"matched": False,
"detail": (
f" Closest paired slot center distance {float(best['center_distance']):.6g}, "
f"target {target_distance:.6g}, error {float(best['center_distance_error']):.6g}."
),
**best,
}
def _verify_obround_slot_axis_move_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_center_1 = _tuple_or_none(plan.get("slot_pair_target_axis_center_1"))
target_center_2 = _tuple_or_none(plan.get("slot_pair_target_axis_center_2"))
target_distance = _float_or_none(plan.get("slot_target_center_distance"))
if target_distance is None:
target_distance = _float_or_none(plan.get("slot_pair_axis_distance"))
target_diameter = _float_or_none(plan.get("slot_target_diameter"))
axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction")))
length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction")))
if (
target_center_1 is None
or target_center_2 is None
or target_distance is None
or target_diameter is None
or axis_dir is None
or length_dir is None
):
return {"matched": False, "detail": " Missing obround slot axis verification data."}
target_radius = target_diameter * 0.5
if target_radius <= 1e-9 or target_distance <= 1e-9:
return {"matched": False, "detail": " Invalid target obround slot axis data."}
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5)
distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4)
center_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4)
candidates: list[dict[str, object]] = []
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
radius = float(cyl.Radius())
if abs(radius - target_radius) > radius_tolerance:
continue
candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction()))
if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4:
continue
axis_range = self._cylindrical_axis_range(face_id, surf)
mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5
mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter))
candidates.append({"face_id": face_id, "mid": mid, "radius": radius})
except Exception:
continue
best: dict[str, object] | None = None
best_score = math.inf
for index, first in enumerate(candidates):
for second in candidates[index + 1 :]:
raw_offset = _tuple_sub(second["mid"], first["mid"])
axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir))
section_offset = _tuple_sub(raw_offset, axis_offset)
distance = _vector_length(section_offset)
if distance <= 1e-9:
continue
direction = _tuple_normalized(section_offset)
if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96:
continue
distance_error = abs(distance - target_distance)
direct_center_error = max(
_vector_length(_tuple_sub(first["mid"], target_center_1)),
_vector_length(_tuple_sub(second["mid"], target_center_2)),
)
swapped_center_error = max(
_vector_length(_tuple_sub(first["mid"], target_center_2)),
_vector_length(_tuple_sub(second["mid"], target_center_1)),
)
center_error = min(direct_center_error, swapped_center_error)
score = (
distance_error / max(distance_tolerance, 1e-9)
+ center_error / max(center_tolerance, 1e-9)
)
candidate = {
"matched": distance_error <= distance_tolerance and center_error <= center_tolerance,
"face_id": first["face_id"],
"paired_face_id": second["face_id"],
"center_distance": distance,
"target_center_distance": target_distance,
"center_distance_error": distance_error,
"center_error": center_error,
"center_tolerance": center_tolerance,
}
if candidate["matched"]:
return self._attach_cylindrical_first_level_result_check(candidate)
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " No paired obround slot ends were found at the target axis."}
return {
"matched": False,
"detail": (
f" Closest obround slot center error {float(best['center_error']):.6g}, "
f"distance {float(best['center_distance']):.6g}, target distance {target_distance:.6g}."
),
**best,
}
def _resize_cylindrical_slot_with_capsule_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Obround slot resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = float(cyl.Radius())
new_radius = float(plan["slot_target_diameter"]) / 2.0
if new_radius <= 0:
raise ValueError("Target slot diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
try:
if plan["resize_mode"] == "shrink":
filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan))
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "obround slot fill/fuse")
cutter = self._slot_capsule_prism_tool(plan, new_radius)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "obround slot cut")
part.shape = result
self.refresh_topology()
verification = self._verify_cylindrical_resize_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "obround slot result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
action = "capsule cut" if plan["resize_mode"] == "enlarge" else "capsule fill and recut"
return (
"Obround cylindrical slot resize completed: "
f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, "
f"paired_face={plan.get('slot_pair_face_id', '')}, "
f"axis_distance={float(plan.get('slot_pair_axis_distance', 0.0)):g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def _slot_capsule_prism_tool(self, plan: dict[str, object], radius: float) -> TopoDS_Shape:
if radius <= 1e-9:
raise ValueError("Slot capsule radius must be greater than 0.")
center_1 = _tuple_or_none(plan.get("slot_capsule_start_center_1"))
center_2 = _tuple_or_none(plan.get("slot_capsule_start_center_2"))
axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction")))
length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction")))
side_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_side_direction")))
if center_1 is None or center_2 is None or axis_dir is None or length_dir is None or side_dir is None:
raise ValueError("Obround slot tool is missing capsule frame information.")
center_distance = _vector_length(_tuple_sub(center_2, center_1))
if center_distance <= 1e-9:
raise ValueError("Obround slot tool requires two distinct slot centers.")
if abs(_tuple_dot(axis_dir, length_dir)) > 1e-4 or abs(_tuple_dot(axis_dir, side_dir)) > 1e-4:
raise ValueError("Obround slot capsule frame is not perpendicular to the extrusion axis.")
if abs(_tuple_dot(length_dir, side_dir)) > 1e-4:
raise ValueError("Obround slot capsule frame length/side axes are not perpendicular.")
height = max(float(plan.get("cutter_height", 0.0)), 1e-6)
height_vec = gp_Vec(axis_dir[0] * height, axis_dir[1] * height, axis_dir[2] * height)
try:
top_1 = _tuple_add(center_1, _tuple_scale(side_dir, radius))
top_2 = _tuple_add(center_2, _tuple_scale(side_dir, radius))
bottom_2 = _tuple_add(center_2, _tuple_scale(side_dir, -radius))
bottom_1 = _tuple_add(center_1, _tuple_scale(side_dir, -radius))
end_mid_2 = _tuple_add(center_2, _tuple_scale(length_dir, radius))
end_mid_1 = _tuple_add(center_1, _tuple_scale(length_dir, -radius))
top_edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*top_1), gp_Pnt(*top_2)).Edge()
end_arc_2 = GC_MakeArcOfCircle(gp_Pnt(*top_2), gp_Pnt(*end_mid_2), gp_Pnt(*bottom_2)).Value()
end_edge_2 = BRepBuilderAPI_MakeEdge(end_arc_2).Edge()
bottom_edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*bottom_2), gp_Pnt(*bottom_1)).Edge()
end_arc_1 = GC_MakeArcOfCircle(gp_Pnt(*bottom_1), gp_Pnt(*end_mid_1), gp_Pnt(*top_1)).Value()
end_edge_1 = BRepBuilderAPI_MakeEdge(end_arc_1).Edge()
wire = BRepBuilderAPI_MakeWire(top_edge, end_edge_2, bottom_edge, end_edge_1).Wire()
tool_face = _finalize_builder_result(BRepBuilderAPI_MakeFace(wire), "obround slot circular profile")
return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "obround slot capsule prism")
except Exception:
# Keep the older sampled profile as a fallback for unusual frames. The
# circular profile is preferred because it preserves cylindrical slot ends.
pass
sample_count = max(12, min(96, int(math.pi * max(radius, 1.0) / max(radius * 0.12, 0.02))))
points: list[tuple[float, float, float]] = [
_tuple_add(center_1, _tuple_scale(side_dir, radius)),
_tuple_add(center_2, _tuple_scale(side_dir, radius)),
]
for index in range(1, sample_count + 1):
theta = math.pi / 2.0 - math.pi * index / sample_count
radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius))
points.append(_tuple_add(center_2, radial))
points.append(_tuple_add(center_1, _tuple_scale(side_dir, -radius)))
for index in range(1, sample_count + 1):
theta = -math.pi / 2.0 - math.pi * index / sample_count
radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius))
points.append(_tuple_add(center_1, radial))
tool_face = self._make_local_polygon_face(self._dedupe_local_points(points, max(radius * 1e-7, 1e-7)))
return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "obround slot capsule prism")
def _resize_cylindrical_slot_with_sector_tool(self, plan: dict[str, object]) -> str:
face_id = int(plan["face_id"])
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Slot resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = float(plan["slot_target_diameter"]) / 2.0
if new_radius <= 0:
raise ValueError("Target slot diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
source_shape = part.shape
try:
if plan["resize_mode"] == "shrink":
filler = self._slot_sector_prism_tool(
face_id,
self._slot_fill_radius(plan),
float(plan["fill_start_parameter"]),
float(plan["fill_end_parameter"]),
)
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "slot sector fill/fuse")
cutter = self._slot_sector_prism_tool(
face_id,
new_radius,
float(plan["cutter_start_parameter"]),
float(plan["cutter_end_parameter"]),
)
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "slot sector cut")
part.shape = result
self.refresh_topology()
verification = self._verify_cylindrical_resize_result(plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "slot sector result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
action = "sector cut" if plan["resize_mode"] == "enlarge" else "sector fill and recut"
return (
"Cylindrical slot sector resize completed: "
f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def _slot_sector_prism_tool(
self,
face_id: int,
radius: float,
start_parameter: float,
end_parameter: float,
*,
u_first_override: float | None = None,
u_last_override: float | None = None,
axis_point_offset: tuple[float, float, float] | None = None,
u_padding: float | None = None,
) -> TopoDS_Shape:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Slot sector tool requires a cylindrical face.")
if radius <= 1e-9:
raise ValueError("Slot sector tool radius must be greater than 0.")
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
direction = axis.Direction()
tool_axis_point = axis_point
if axis_point_offset is not None:
tool_axis_point = gp_Pnt(
axis_point.X() + float(axis_point_offset[0]),
axis_point.Y() + float(axis_point_offset[1]),
axis_point.Z() + float(axis_point_offset[2]),
)
u_first = float(surf.FirstUParameter()) if u_first_override is None else float(u_first_override)
u_last = float(surf.LastUParameter()) if u_last_override is None else float(u_last_override)
span = abs(u_last - u_first)
if span <= 1e-6 or span >= math.tau * 0.98:
raise ValueError("Slot sector tool requires a stable partial-cylinder U span.")
if end_parameter < start_parameter:
start_parameter, end_parameter = end_parameter, start_parameter
height = max(float(end_parameter) - float(start_parameter), 1e-6)
pad = (
min(max(1e-4, 0.001 / max(float(radius), 1e-6)), max(span * 0.02, 1e-4))
if u_padding is None
else max(float(u_padding), 0.0)
)
if u_last >= u_first:
u_start = u_first - pad
u_end = u_last + pad
else:
u_start = u_first + pad
u_end = u_last - pad
v_mid = (float(surf.FirstVParameter()) + float(surf.LastVParameter())) / 2.0
axis_start = _point_on_axis(tool_axis_point, direction, float(start_parameter))
axis_start_tuple = _point_tuple(axis_start)
def radial_point(u: float) -> tuple[float, float, float]:
source = surf.Value(float(u), v_mid)
source_parameter = _axis_parameter(axis_point, direction, source)
source_axis = _point_on_axis(axis_point, direction, source_parameter)
radial = _tuple_sub(_point_tuple(source), _point_tuple(source_axis))
unit = _tuple_normalized(radial)
if unit is None:
raise ValueError("Could not derive slot sector radial direction.")
return (
axis_start_tuple[0] + unit[0] * float(radius),
axis_start_tuple[1] + unit[1] * float(radius),
axis_start_tuple[2] + unit[2] * float(radius),
)
height_vec = gp_Vec(direction.X() * height, direction.Y() * height, direction.Z() * height)
try:
start_tuple = radial_point(u_start)
mid_tuple = radial_point((u_start + u_end) * 0.5)
end_tuple = radial_point(u_end)
center_point = gp_Pnt(*axis_start_tuple)
start_point = gp_Pnt(*start_tuple)
mid_point = gp_Pnt(*mid_tuple)
end_point = gp_Pnt(*end_tuple)
center_to_start = BRepBuilderAPI_MakeEdge(center_point, start_point).Edge()
arc = GC_MakeArcOfCircle(start_point, mid_point, end_point).Value()
arc_edge = BRepBuilderAPI_MakeEdge(arc).Edge()
end_to_center = BRepBuilderAPI_MakeEdge(end_point, center_point).Edge()
wire = BRepBuilderAPI_MakeWire(center_to_start, arc_edge, end_to_center).Wire()
tool_face = _finalize_builder_result(BRepBuilderAPI_MakeFace(wire), "slot sector circular profile")
return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "slot sector prism")
except Exception:
# Fall back to the older sampled polygon path for unusual parameterizations.
# The circular path is preferred because it preserves a real cylindrical
# wall after Cut/Fuse; the fallback keeps the edit attempt available.
pass
sample_count = max(8, min(96, int(abs(u_end - u_start) / (math.pi / 36.0)) + 2))
points: list[tuple[float, float, float]] = [axis_start_tuple]
for index in range(sample_count):
u = u_start + (u_end - u_start) * index / max(sample_count - 1, 1)
points.append(radial_point(u))
tool_face = self._make_local_polygon_face(points)
return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "slot sector prism")
def move_cylindrical_slot_axis(
self,
face_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.cylindrical_slot_axis_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
verification: dict[str, object] = {}
artifact_cleanup: dict[str, object] = {"discarded_count": 0, "discarded_volume": 0.0}
try:
if str(plan.get("resize_strategy", "")) == "paired-obround-slot-axis-prism":
filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan))
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
filled_shape = _finalize_boolean_result(fuse, "obround slot axis move fill/fuse", use_glue=False)
target_plan = dict(plan)
target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1")
target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2")
cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) * 0.5)
op = BRepAlgoAPI_Cut(filled_shape, cutter)
result = _finalize_boolean_result(op, "obround slot axis move cut")
else:
filler = self._slot_sector_prism_tool(
face_id,
self._slot_fill_radius(plan),
float(plan["fill_start_parameter"]),
float(plan["fill_end_parameter"]),
)
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
filled_shape = _finalize_boolean_result(fuse, "slot axis move old-sector fill/fuse", use_glue=False)
movement = _tuple_or_none(plan.get("axis_move_vector"))
if movement is None:
raise ValueError("Could not derive slot axis movement vector.")
cutter = self._slot_sector_prism_tool(
face_id,
float(plan["slot_target_diameter"]) * 0.5,
float(plan["cutter_start_parameter"]),
float(plan["cutter_end_parameter"]),
axis_point_offset=movement,
)
op = BRepAlgoAPI_Cut(filled_shape, cutter)
result = _finalize_boolean_result(op, "slot axis move target-sector cut")
result = _prepare_shape_for_step_export(result)
before_solids = _topology_shape_count(old_part_shape, TopAbs_SOLID)
after_solids = _topology_shape_count(result, TopAbs_SOLID)
if before_solids == 1 and after_solids > 1:
result, artifact_cleanup = self._drop_tiny_artifact_solids(result, old_part_shape)
after_solids = _topology_shape_count(result, TopAbs_SOLID)
if before_solids and after_solids != before_solids:
raise RuntimeError(
"槽/半孔轴心移动结果改变了 Solid 数量,当前版本已回滚,"
"避免把一个实体拆成多个独立实体。请改用“轴心(整体)”或槽孔总长度/槽宽等更稳定参数。"
)
part.shape = result
self.refresh_topology()
if str(plan.get("resize_strategy", "")) == "paired-obround-slot-axis-prism":
verification = self._verify_obround_slot_axis_move_result(plan, part_id)
else:
verification_plan = dict(plan)
verification_plan["cutter_axis_point"] = plan["target_cutter_axis_point"]
verification_plan["cutter_start_point"] = plan["target_cutter_start_point"]
verification = self._verify_slot_angular_span_result(verification_plan, part_id)
if not verification["matched"]:
detail = str(verification.get("detail", "slot axis move result verification failed"))
raise RuntimeError(
"槽/半孔轴心坐标布尔计算返回了结果,但结果里没有检测到目标轴心位置的槽面,"
"已回滚到修改前状态。"
f"{detail}"
)
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Cylindrical slot axis move completed: "
f"face {face_id}, "
f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, "
f"move={plan.get('axis_move_vector')}, "
f"diameter={float(plan['slot_target_diameter']):g}, "
f"span={float(plan['slot_target_angular_span']):g}, "
f"height={float(plan['cutter_height']):g}, "
f"discarded_tiny_solids={int(artifact_cleanup.get('discarded_count', 0))}, "
f"discarded_tiny_volume={float(artifact_cleanup.get('discarded_volume', 0.0)):g}, "
f"risk={plan['risk']}, "
f"verified_face={verification.get('face_id', '')}."
)
def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Hole resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
if new_radius <= 0:
raise ValueError("Target diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
direction = cyl.Axis().Direction()
old_part_shape = part.shape
source_shape = part.shape
if plan["resize_mode"] == "shrink":
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse", use_glue=False)
cutter_start = gp_Pnt(*plan["cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape()
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "cylinder cut")
part.shape = result
self.refresh_topology()
verification = self._verify_cylindrical_resize_result(plan, part_id)
if not verification["matched"]:
part.shape = old_part_shape
self.refresh_topology()
detail = str(verification.get("detail", ""))
raise RuntimeError(
"孔/圆柱直径布尔计算返回了结果,但结果里没有检测到目标直径的圆柱面,"
"已回滚到修改前状态。"
f"{detail}"
)
action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut"
return (
f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def move_cylindrical_hole_axis(
self,
face_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.cylindrical_axis_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
direction = gp_Dir(*plan["cutter_axis_direction"])
old_part_shape = part.shape
try:
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, direction)
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
filled_shape = _finalize_boolean_result(fuse, "cylinder axis move old-hole fill/fuse", use_glue=False)
cutter_start = gp_Pnt(*plan["target_cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, direction)
cutter = BRepPrimAPI_MakeCylinder(
cutter_axis,
float(plan["target_radius"]),
float(plan["cutter_height"]),
).Shape()
op = BRepAlgoAPI_Cut(filled_shape, cutter)
result = _finalize_boolean_result(op, "cylinder axis move target-hole cut")
part.shape = result
self.refresh_topology()
verification_plan = dict(plan)
verification_plan["cutter_axis_point"] = plan["target_cutter_axis_point"]
verification_plan["cutter_start_point"] = plan["target_cutter_start_point"]
verification = self._verify_cylindrical_resize_result(verification_plan, part_id)
if not verification["matched"]:
raise RuntimeError(str(verification.get("detail", "cylinder axis move result verification failed")))
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Cylindrical hole axis move completed: "
f"face {face_id}, "
f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, "
f"move={plan.get('axis_move_vector')}, "
f"diameter={float(plan['target_diameter']):g}, "
f"height={float(plan['cutter_height']):g}, "
f"risk={plan['risk']}, "
f"verified_face={verification.get('face_id', '')}."
)
def _attach_cylindrical_first_level_result_check(self, candidate: dict[str, object]) -> dict[str, object]:
if not candidate.get("matched"):
return candidate
face_ids: list[int] = []
for key in ("face_id", "paired_face_id"):
face_id = _int_or_none(candidate.get(key))
if face_id is not None and face_id not in face_ids:
face_ids.append(face_id)
if not face_ids:
return {
**candidate,
"matched": False,
"first_level_topology_matched": False,
"detail": " Result verification matched geometry but did not identify a Face ID for first-level topology.",
}
summaries: list[dict[str, object]] = []
for face_id in face_ids:
try:
topology = self.cylindrical_feature_first_level_topology(face_id)
side_face_count = int(topology.get("cylindrical_feature_side_face_count", 0) or 0)
boundary_edge_count = int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0)
adjacent_face_count = int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0)
except Exception as exc:
return {
**candidate,
"matched": False,
"first_level_topology_matched": False,
"detail": f" Result Face {face_id} matched target geometry but first-level topology failed: {exc}",
}
summaries.append(
{
"face_id": face_id,
"side_face_count": side_face_count,
"boundary_edge_count": boundary_edge_count,
"adjacent_face_count": adjacent_face_count,
}
)
if side_face_count <= 0 or boundary_edge_count <= 0 or adjacent_face_count <= 0:
return {
**candidate,
"matched": False,
"first_level_topology_matched": False,
"first_level_topology_summaries": tuple(summaries),
"detail": (
f" Result Face {face_id} matched target geometry but lost first-level topology "
f"(side={side_face_count}, boundary_edges={boundary_edge_count}, "
f"adjacent_faces={adjacent_face_count})."
),
}
return {
**candidate,
"first_level_topology_matched": True,
"first_level_topology_summaries": tuple(summaries),
}
def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_diameter = float(plan.get("target_diameter", 0.0))
target_radius = target_diameter / 2.0
if target_radius <= 1e-9:
return {"matched": False, "detail": " 目标直径无效。"}
try:
axis_point = gp_Pnt(*plan["cutter_axis_point"])
axis_direction = gp_Dir(*plan["cutter_axis_direction"])
except Exception:
return {"matched": False, "detail": " 缺少原孔轴信息,无法确认结果。"}
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5)
axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4)
best: dict[str, object] | None = None
best_score = math.inf
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
candidate_radius = float(cyl.Radius())
radius_error = abs(candidate_radius - target_radius)
axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction()))
if axis_dot < 1.0 - 1e-5:
continue
axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location())
except Exception:
continue
score = radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9)
candidate = {
"matched": radius_error <= radius_tolerance and axis_distance <= axis_tolerance,
"face_id": face_id,
"diameter": candidate_radius * 2.0,
"radius_error": radius_error,
"axis_distance": axis_distance,
"radius_tolerance": radius_tolerance,
"axis_tolerance": axis_tolerance,
}
if candidate["matched"]:
return self._attach_cylindrical_first_level_result_check(candidate)
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " 未找到同零件内与原孔轴平行的圆柱面。"}
return {
"matched": False,
"detail": (
f" 最近候选 Face {best['face_id']} 的直径约 {float(best['diameter']):.6g}"
f"目标直径 {target_diameter:.6g}"
f"半径误差 {float(best['radius_error']):.6g}"
f"轴距 {float(best['axis_distance']):.6g}。"
),
**best,
}
def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
start = gp_Pnt(*plan["boss_tool_start_point"])
direction = gp_Dir(*plan["boss_tool_axis_direction"])
axis = gp_Ax2(start, direction)
height = float(plan["boss_tool_height"])
if plan["resize_mode"] == "enlarge":
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
op = BRepAlgoAPI_Fuse(part.shape, tool)
result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False)
action = "enlarged by bounded fuse"
else:
removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
remove_op = BRepAlgoAPI_Cut(part.shape, removal)
removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False)
if _topology_shape_count(removed, TopAbs_FACE) == 0:
exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"])
exact_axis = gp_Ax2(exact_start, direction)
exact_replacement = BRepPrimAPI_MakeCylinder(
exact_axis,
float(plan["boss_tool_inner_radius"]),
float(plan["boss_tool_exact_height"]),
).Shape()
result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement")
else:
fuse_op = BRepAlgoAPI_Fuse(removed, replacement)
result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False)
action = "shrunk by removing old envelope and fusing target cylinder"
part.shape = result
self.refresh_topology()
return (
f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, "
f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, "
f"height={float(plan['boss_tool_height']):g}."
)
def move_cylindrical_boss_axis(
self,
face_id: int,
target_center: tuple[float, float, float],
) -> str:
plan = self.cylindrical_boss_axis_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
old_part_shape = part.shape
verification: dict[str, object] = {}
try:
direction = gp_Dir(*plan["boss_tool_axis_direction"])
height = float(plan["boss_tool_height"])
removal_start = gp_Pnt(*plan["boss_tool_start_point"])
removal_axis = gp_Ax2(removal_start, direction)
removal_radius = float(
plan.get("boss_tool_outer_radius")
or plan.get("boss_tool_old_radius")
or plan.get("target_radius")
)
removal = BRepPrimAPI_MakeCylinder(removal_axis, removal_radius, height).Shape()
remove_op = BRepAlgoAPI_Cut(part.shape, removal)
removed = _finalize_boolean_result(remove_op, "cylindrical boss axis move remove old envelope", use_glue=False)
target_start_values = _tuple_or_none(plan.get("target_boss_tool_start_point"))
target_height = height
if _topology_shape_count(removed, TopAbs_FACE) == 0:
exact_start = _tuple_or_none(plan.get("target_boss_tool_exact_start_point"))
if exact_start is not None:
target_start_values = exact_start
target_height = float(plan.get("boss_tool_exact_height") or height)
if target_start_values is None:
raise ValueError("Could not derive target boss tool start point.")
target_start = gp_Pnt(*target_start_values)
target_axis = gp_Ax2(target_start, direction)
replacement = BRepPrimAPI_MakeCylinder(
target_axis,
float(plan["target_boss_tool_radius"]),
max(target_height, 1e-6),
).Shape()
if _topology_shape_count(removed, TopAbs_FACE) == 0:
result = _ensure_valid_or_repaired_shape(replacement, "cylindrical boss axis move replacement")
action = "rebuilt moved cylinder"
else:
fuse_op = BRepAlgoAPI_Fuse(removed, replacement)
result = _finalize_boolean_result(fuse_op, "cylindrical boss axis move fuse target cylinder", use_glue=False)
action = "removed old envelope and fused moved cylinder"
part.shape = result
self.refresh_topology()
verification_plan = dict(plan)
verification_plan["cutter_axis_point"] = plan["target_boss_tool_axis_point"]
verification_plan["cutter_axis_direction"] = plan["boss_tool_axis_direction"]
verification = self._verify_cylindrical_resize_result(verification_plan, part_id)
if not verification["matched"]:
detail = str(verification.get("detail", "cylindrical boss axis move result verification failed"))
raise RuntimeError(
"圆柱凸台轴心坐标布尔计算返回了结果,但结果里没有检测到目标轴心位置的圆柱凸台,"
"已回滚到修改前状态。"
f"{detail}"
)
except Exception:
part.shape = old_part_shape
self.refresh_topology()
raise
return (
"Cylindrical boss axis move completed: "
f"face {face_id}, "
f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, "
f"move={plan.get('axis_move_vector')}, "
f"diameter={float(plan['target_diameter']):g}, "
f"height={float(plan['boss_tool_height']):g}, "
f"risk={plan['risk']}, action={action}, "
f"verified_face={verification.get('face_id', '')}."
)
def suppress_cylindrical_hole(self, face_id: int) -> str:
plan = self.cylindrical_suppress_plan(face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Cylinder suppress currently supports cylindrical faces only.")
direction = surf.Cylinder().Axis().Direction()
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
result = _finalize_boolean_result(fuse, "cylinder suppress/fill", use_glue=False)
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", use_glue=False)
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}."
)