6894 lines
333 KiB
Python
6894 lines
333 KiB
Python
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_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
|
||
|
||
|
||
class OperationMixin:
|
||
def repair_model(self) -> str:
|
||
before_stats = self.stats()
|
||
repaired_parts = 0
|
||
skipped_parts = 0
|
||
for part in self.display_parts():
|
||
if part.shape.IsNull():
|
||
skipped_parts += 1
|
||
continue
|
||
repaired = _prepare_shape_for_step_export(part.shape)
|
||
if repaired.IsNull():
|
||
skipped_parts += 1
|
||
continue
|
||
part.shape = repaired
|
||
repaired_parts += 1
|
||
|
||
if repaired_parts == 0:
|
||
raise RuntimeError("No valid part shape was available for repair.")
|
||
|
||
self.refresh_topology()
|
||
after_stats = self.stats()
|
||
return (
|
||
"Model repair completed: "
|
||
f"parts repaired={repaired_parts}, skipped={skipped_parts}, "
|
||
f"solids {before_stats.solids}->{after_stats.solids}, "
|
||
f"faces {before_stats.faces}->{after_stats.faces}, "
|
||
f"edges {before_stats.edges}->{after_stats.edges}."
|
||
)
|
||
|
||
def repair_part(self, part_id: int) -> str:
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"未知零件 ID {part_id}")
|
||
if part.shape.IsNull():
|
||
raise RuntimeError(f"零件 {part_id} 为空 shape,无法修复。")
|
||
before_stats = self.part_topology_stats(part_id)
|
||
repaired = _prepare_shape_for_step_export(part.shape)
|
||
if repaired.IsNull():
|
||
raise RuntimeError(f"零件 {part_id} 修复结果为空 shape。")
|
||
part.shape = repaired
|
||
self.refresh_topology()
|
||
after_stats = self.part_topology_stats(part_id)
|
||
return (
|
||
f"零件修复完成: 零件 {part_id}, "
|
||
f"solids {before_stats.solids}->{after_stats.solids}, "
|
||
f"faces {before_stats.faces}->{after_stats.faces}, "
|
||
f"edges {before_stats.edges}->{after_stats.edges}."
|
||
)
|
||
|
||
def repair_solid(self, solid_id: int) -> str:
|
||
if solid_id < 0 or solid_id >= len(self.solids):
|
||
raise ValueError(f"Unknown solid id {solid_id}")
|
||
part_id, solid = self.solids[solid_id]
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
|
||
before_part_stats = self.part_topology_stats(part_id)
|
||
repaired = _prepare_shape_for_step_export(solid)
|
||
if repaired.IsNull():
|
||
raise RuntimeError(f"Solid {solid_id} repair returned a null shape.")
|
||
|
||
part_solids = _explore(part.shape, TopAbs_SOLID)
|
||
if len(part_solids) <= 1:
|
||
part.shape = repaired
|
||
else:
|
||
replaced = False
|
||
shapes: list[TopoDS_Shape] = []
|
||
for item in part_solids:
|
||
if not replaced and _same_shape(item, solid):
|
||
shapes.append(repaired)
|
||
replaced = True
|
||
else:
|
||
shapes.append(item)
|
||
if not replaced:
|
||
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
|
||
part.shape = _compound_from_shapes(shapes)
|
||
|
||
_ensure_valid_shape(part.shape)
|
||
self.refresh_topology()
|
||
after_part_stats = self.part_topology_stats(part_id)
|
||
return (
|
||
f"Solid repair completed: solid {solid_id}, part {part_id}, "
|
||
f"part solids {before_part_stats.solids}->{after_part_stats.solids}, "
|
||
f"faces {before_part_stats.faces}->{after_part_stats.faces}, "
|
||
f"edges {before_part_stats.edges}->{after_part_stats.edges}."
|
||
)
|
||
|
||
def edge_fillet_plan(self, edge_id: int, radius: float) -> dict[str, object]:
|
||
if edge_id < 0 or edge_id >= len(self.edges):
|
||
raise ValueError(f"Unknown edge id {edge_id}")
|
||
info = self.edge_info(edge_id)
|
||
readiness = _edge_fillet_readiness(info, radius)
|
||
part_id = int(info["part_id"])
|
||
part_stats = None
|
||
try:
|
||
part_stats = self.part_topology_stats(part_id)
|
||
except Exception:
|
||
part_stats = None
|
||
if part_stats is not None and part_stats.solids != 1:
|
||
readiness = dict(readiness)
|
||
if readiness["fillet_status"] != "blocked":
|
||
readiness["fillet_status"] = "caution"
|
||
readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high")
|
||
readiness["fillet_warnings"] = _join_nonempty(
|
||
readiness["fillet_warnings"],
|
||
f"当前零件包含 {part_stats.solids} 个Solid,Edge倒圆会作用在整个零件 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} 个Solid,Edge倒角会作用在整个零件 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 _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_plan(self, face_id: int, distance: float) -> dict[str, object]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
face = self.faces[face_id]
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() != GeomAbs_Plane:
|
||
return {
|
||
"status": "blocked",
|
||
"risk": "blocked",
|
||
"message": "当前选中的 Face 不是平面,不能执行推拉平面。",
|
||
"blockers": "当前选中的 Face 不是平面。",
|
||
"warnings": "",
|
||
"face_id": face_id,
|
||
"part_id": self.face_part_ids[face_id],
|
||
"solid_id": self.face_solid_ids[face_id],
|
||
"distance": distance,
|
||
}
|
||
|
||
info = self.face_info(face_id)
|
||
scope_face_ids = self._connected_coplanar_planar_face_ids(face_id)
|
||
if len(scope_face_ids) > 1:
|
||
scope_note = f"将一起推拉 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。"
|
||
else:
|
||
scope_note = "只推拉当前Face。"
|
||
direction_confidence = str(info.get("push_pull_confidence", "low"))
|
||
bbox_diagonal = float(info.get("bbox_diagonal", 0.0))
|
||
distance_abs = abs(distance)
|
||
warnings: list[str] = []
|
||
blockers: list[str] = []
|
||
risk = "low"
|
||
status = "ready"
|
||
|
||
if distance_abs <= 1e-9:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
blockers.append("推拉距离为 0,不需要修改。")
|
||
if direction_confidence != "high":
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。")
|
||
if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("推拉距离超过当前Face包围盒对角线的 20%,容易导致布尔失败或大范围变形。")
|
||
elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("推拉距离相对当前Face尺寸偏大,请确认预览范围。")
|
||
|
||
if risk in {"medium", "high"} and status != "blocked":
|
||
status = "caution"
|
||
if blockers:
|
||
message = " ".join(blockers + warnings)
|
||
elif warnings:
|
||
message = " ".join(warnings)
|
||
else:
|
||
message = "可以尝试推拉该平面。"
|
||
|
||
return {
|
||
"status": status,
|
||
"risk": risk,
|
||
"message": message,
|
||
"warnings": ";".join(warnings),
|
||
"blockers": ";".join(blockers),
|
||
"face_id": face_id,
|
||
"part_id": info["part_id"],
|
||
"solid_id": info["solid_id"],
|
||
"distance": distance,
|
||
"surface": info.get("surface"),
|
||
"area": info.get("area"),
|
||
"bbox_diagonal": info.get("bbox_diagonal"),
|
||
"outward_direction": info.get("push_pull_outward_direction"),
|
||
"direction_confidence": direction_confidence,
|
||
"direction_note": info.get("push_pull_note"),
|
||
"push_pull_scope_face_ids": tuple(scope_face_ids),
|
||
"push_pull_scope_face_count": len(scope_face_ids),
|
||
"push_pull_scope_note": scope_note,
|
||
}
|
||
|
||
def shell_thickness_plan(self, face_id: int, target_thickness: float) -> dict[str, object]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
info = self.feature_info(face_id)
|
||
blockers: list[str] = []
|
||
warnings: list[str] = [
|
||
"薄壁/壳体厚度调整基于相对平面几何估算,会移动当前选中平面区域,保留相对平面不动。"
|
||
]
|
||
risk = "low"
|
||
status = "ready"
|
||
|
||
target_thickness = float(target_thickness)
|
||
current_thickness = float(info.get("shell_thickness_estimate", 0.0))
|
||
signed_thickness = float(info.get("shell_signed_thickness", 0.0))
|
||
delta_thickness = target_thickness - current_thickness
|
||
confidence = str(info.get("shell_confidence", "low"))
|
||
overlap_ratio = float(info.get("shell_overlap_ratio_estimate", 0.0))
|
||
source_face_ids = tuple(_int_values(info.get("shell_source_face_ids")) or [face_id])
|
||
opposite_face_id = int(info.get("shell_opposite_face_id", -1))
|
||
|
||
if info.get("surface") != "plane":
|
||
blockers.append("当前选中 Face 不是平面,不能调整薄壁/壳体厚度。")
|
||
if info.get("shell_region_status") != "candidate":
|
||
blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对薄壁/壳体平面。")))
|
||
if current_thickness <= 1e-9 or abs(signed_thickness) <= 1e-9:
|
||
blockers.append("当前薄壁厚度估算无效。")
|
||
if target_thickness <= 1e-9:
|
||
blockers.append("目标薄壁厚度必须大于 0。")
|
||
if abs(delta_thickness) <= max(current_thickness * 1e-5, 1e-6):
|
||
blockers.append("目标厚度与当前估算厚度几乎相同,不需要修改。")
|
||
|
||
normal = _tuple_normalized(_tuple_or_none(info.get("normal")))
|
||
outward = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction")))
|
||
desired_movement = None
|
||
push_pull_distance = 0.0
|
||
movement_alignment = 0.0
|
||
if not blockers:
|
||
if normal is None or outward is None:
|
||
blockers.append("当前平面缺少稳定法向或推拉方向,不能换算厚度修改。")
|
||
else:
|
||
sign = 1.0 if signed_thickness >= 0.0 else -1.0
|
||
toward_opposite = _tuple_scale(normal, sign)
|
||
desired_movement = _tuple_scale(toward_opposite, -delta_thickness)
|
||
desired_unit = _tuple_normalized(desired_movement)
|
||
if desired_unit is None:
|
||
blockers.append("目标厚度变化量无效。")
|
||
else:
|
||
movement_alignment = abs(_tuple_dot(desired_unit, outward))
|
||
if movement_alignment < 0.82:
|
||
blockers.append("当前平面推拉方向与薄壁厚度方向不匹配,暂不执行自动厚度修改。")
|
||
else:
|
||
push_pull_distance = _tuple_dot(desired_movement, outward)
|
||
|
||
if not blockers:
|
||
delta_ratio = abs(delta_thickness) / max(current_thickness, 1e-9)
|
||
if confidence == "low":
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("薄壁/壳体相对面识别置信度较低。")
|
||
elif confidence == "medium":
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。")
|
||
if overlap_ratio < 0.25:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。")
|
||
elif overlap_ratio < 0.55:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。")
|
||
if delta_ratio > 0.8:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("目标厚度变化超过当前厚度的 80%,容易导致布尔失败或周边变形。")
|
||
elif delta_ratio > 0.35:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("目标厚度变化超过当前厚度的 35%,请确认预览。")
|
||
|
||
push_plan = self.push_pull_plan(face_id, push_pull_distance)
|
||
if push_plan["status"] == "blocked":
|
||
blockers.append(str(push_plan["message"]))
|
||
else:
|
||
risk = _max_risk(risk, str(push_plan["risk"]))
|
||
push_warnings = str(push_plan.get("warnings", ""))
|
||
if push_warnings:
|
||
warnings.append(push_warnings)
|
||
|
||
if blockers:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
message = " ".join(blockers)
|
||
push_plan = {}
|
||
elif risk != "low":
|
||
status = "caution"
|
||
message = " ".join(warnings)
|
||
else:
|
||
message = "可以通过推拉当前平面区域调整薄壁/壳体厚度。"
|
||
|
||
return {
|
||
"status": status,
|
||
"risk": risk,
|
||
"message": message,
|
||
"warnings": ";".join(warnings),
|
||
"blockers": ";".join(blockers),
|
||
"face_id": face_id,
|
||
"part_id": info.get("part_id"),
|
||
"solid_id": info.get("solid_id"),
|
||
"surface": info.get("surface"),
|
||
"shell_region_kind": info.get("shell_region_kind"),
|
||
"shell_confidence": confidence,
|
||
"shell_source_face_ids": source_face_ids,
|
||
"shell_opposite_face_id": opposite_face_id,
|
||
"shell_current_thickness": current_thickness,
|
||
"shell_target_thickness": target_thickness,
|
||
"shell_delta_thickness": delta_thickness,
|
||
"shell_delta_ratio": abs(delta_thickness) / max(current_thickness, 1e-9),
|
||
"shell_signed_thickness": signed_thickness,
|
||
"shell_overlap_ratio_estimate": overlap_ratio,
|
||
"shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"),
|
||
"shell_desired_movement_vector": desired_movement,
|
||
"shell_movement_alignment": movement_alignment,
|
||
"push_pull_distance": push_pull_distance,
|
||
"outward_direction": info.get("push_pull_outward_direction"),
|
||
"push_pull_status": push_plan.get("status"),
|
||
"push_pull_risk": push_plan.get("risk"),
|
||
"push_pull_message": push_plan.get("message"),
|
||
"push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", source_face_ids),
|
||
"push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", len(source_face_ids)),
|
||
"push_pull_scope_note": push_plan.get("push_pull_scope_note", ""),
|
||
"resize_strategy": "push-pull-shell-source-plane-to-target-thickness",
|
||
}
|
||
|
||
def _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 > 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,
|
||
"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 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,
|
||
"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 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,
|
||
"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 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"]))
|
||
push_result = self.push_pull_face(face_id, float(plan["push_pull_distance"]))
|
||
return (
|
||
"Shell thickness resize completed by planar push/pull: "
|
||
f"face {face_id}, current_thickness={float(plan['shell_current_thickness']):g}, "
|
||
f"target_thickness={float(plan['shell_target_thickness']):g}, "
|
||
f"delta={float(plan['shell_delta_thickness']):g}, "
|
||
f"opposite_face={plan.get('shell_opposite_face_id')}, "
|
||
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
|
||
f"risk={plan['risk']}. {push_result}"
|
||
)
|
||
|
||
def 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 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};同一对象上的其它尺寸会跟随变化。")
|
||
|
||
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,
|
||
}
|
||
|
||
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._apply_edge_length_affine_transform(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"target={plan.get('affine_target_kind')}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
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)
|
||
delta_radius = target_radius - float(current_radius or 0.0)
|
||
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
|
||
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
|
||
blockers.append("目标圆锥参考半径与当前值几乎相同,不需要修改。")
|
||
if delta_ratio > 0.6:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("圆锥参考半径变化超过 60%,可能明显影响周边几何。")
|
||
elif delta_ratio > 0.25:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("圆锥参考半径变化超过 25%,修改后请重点检查相邻面。")
|
||
|
||
part_id = int(info.get("part_id", -1))
|
||
solid_id = int(info.get("solid_id", -1))
|
||
part = self.part_by_id(part_id) if part_id >= 0 else None
|
||
if part is None:
|
||
blockers.append("找不到当前圆锥面所属零件。")
|
||
part_solid_count = 0
|
||
else:
|
||
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
|
||
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
|
||
if target_kind == "part":
|
||
warnings.append("当前会缩放所属零件 shape,可能影响同一零件上的其它尺寸。")
|
||
else:
|
||
warnings.append("当前会缩放所属 Solid,可能影响同一 Solid 上的其它尺寸。")
|
||
|
||
status = "blocked" if blockers else "caution"
|
||
if blockers:
|
||
risk = "blocked"
|
||
return {
|
||
"status": status,
|
||
"risk": risk,
|
||
"message": " ".join(blockers + warnings),
|
||
"warnings": ";".join(warnings),
|
||
"blockers": ";".join(blockers),
|
||
"face_id": face_id,
|
||
"part_id": part_id,
|
||
"solid_id": solid_id,
|
||
"surface": info.get("surface"),
|
||
"current_reference_radius": current_radius,
|
||
"target_reference_radius": target_radius,
|
||
"current_reference_diameter": None if current_radius is None else current_radius * 2.0,
|
||
"target_reference_diameter": target_radius * 2.0,
|
||
"delta_reference_radius": delta_radius,
|
||
"reference_radius_delta_ratio": delta_ratio,
|
||
"semi_angle": info.get("semi_angle"),
|
||
"axis_point": axis_point,
|
||
"axis": axis_direction,
|
||
"resize_strategy": "radial-affine-scale-cone-reference-radius",
|
||
"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,
|
||
}
|
||
|
||
def resize_conical_reference_radius(self, face_id: int, target_radius: float) -> str:
|
||
plan = self.conical_reference_radius_plan(face_id, target_radius)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
self._apply_edge_length_affine_transform(plan)
|
||
return (
|
||
"Conical face reference radius resize completed by radial affine scaling: "
|
||
f"face {face_id}, "
|
||
f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, "
|
||
f"scale={float(plan['affine_scale']):g}, "
|
||
f"target={plan.get('affine_target_kind')}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def spherical_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
info = self.face_info(face_id)
|
||
blockers: list[str] = []
|
||
warnings: list[str] = [
|
||
"球面半径修改会围绕球心缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。"
|
||
]
|
||
risk = "medium"
|
||
|
||
try:
|
||
target_radius = float(target_radius)
|
||
except (TypeError, ValueError):
|
||
target_radius = 0.0
|
||
blockers.append("目标球面半径必须是数字。")
|
||
|
||
current_radius = _float_or_none(info.get("radius"))
|
||
center = _tuple_or_none(info.get("center"))
|
||
if info.get("surface") != "sphere":
|
||
blockers.append("当前选中 Face 不是球面。")
|
||
if current_radius is None or current_radius <= 1e-9:
|
||
blockers.append("当前球面缺少有效半径。")
|
||
if center is None:
|
||
blockers.append("当前球面缺少稳定球心,不能缩放。")
|
||
if target_radius <= 1e-9:
|
||
blockers.append("目标球面半径必须大于 0。")
|
||
|
||
scale = target_radius / max(current_radius or 1.0, 1e-9)
|
||
delta_radius = target_radius - float(current_radius or 0.0)
|
||
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
|
||
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
|
||
blockers.append("目标球面半径与当前值几乎相同,不需要修改。")
|
||
if delta_ratio > 0.6:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("球面半径变化超过 60%,可能明显影响周边几何。")
|
||
elif delta_ratio > 0.25:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("球面半径变化超过 25%,修改后请重点检查相邻面。")
|
||
|
||
part_id = int(info.get("part_id", -1))
|
||
solid_id = int(info.get("solid_id", -1))
|
||
part = self.part_by_id(part_id) if part_id >= 0 else None
|
||
if part is None:
|
||
blockers.append("找不到当前球面所属零件。")
|
||
part_solid_count = 0
|
||
else:
|
||
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
|
||
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
|
||
warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。")
|
||
|
||
status = "blocked" if blockers else "caution"
|
||
if blockers:
|
||
risk = "blocked"
|
||
return {
|
||
"status": status,
|
||
"risk": risk,
|
||
"message": " ".join(blockers + warnings),
|
||
"warnings": ";".join(warnings),
|
||
"blockers": ";".join(blockers),
|
||
"face_id": face_id,
|
||
"part_id": part_id,
|
||
"solid_id": solid_id,
|
||
"surface": info.get("surface"),
|
||
"current_radius": current_radius,
|
||
"target_radius": target_radius,
|
||
"current_diameter": None if current_radius is None else current_radius * 2.0,
|
||
"target_diameter": target_radius * 2.0,
|
||
"delta_radius": delta_radius,
|
||
"radius_delta_ratio": delta_ratio,
|
||
"center": center,
|
||
"resize_strategy": "uniform-scale-sphere-radius",
|
||
"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._apply_edge_length_affine_transform(plan)
|
||
return (
|
||
"Spherical face radius resize completed by uniform scaling: "
|
||
f"face {face_id}, "
|
||
f"radius={float(plan['current_radius']):g}->{float(plan['target_radius']):g}, "
|
||
f"scale={float(plan['affine_scale']):g}, "
|
||
f"target={plan.get('affine_target_kind')}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def toroidal_radius_plan(self, face_id: int, target_radius: float, mode: str = "minor") -> dict[str, object]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
mode_key = "major" if str(mode).lower() in {"major", "main", "major_radius"} else "minor"
|
||
info = self.face_info(face_id)
|
||
blockers: list[str] = []
|
||
warnings: list[str] = [
|
||
"环面半径修改会围绕环面中心均匀缩放所属零件/Solid;主半径和小半径会等比例变化。"
|
||
]
|
||
risk = "medium"
|
||
|
||
try:
|
||
target_radius = float(target_radius)
|
||
except (TypeError, ValueError):
|
||
target_radius = 0.0
|
||
blockers.append("目标环面半径必须是数字。")
|
||
|
||
current_major = _float_or_none(info.get("major_radius"))
|
||
current_minor = _float_or_none(info.get("minor_radius"))
|
||
current_radius = current_major if mode_key == "major" else current_minor
|
||
center = _tuple_or_none(info.get("center"))
|
||
axis = _tuple_normalized(_tuple_or_none(info.get("axis"))) or (0.0, 0.0, 1.0)
|
||
if info.get("surface") != "torus":
|
||
blockers.append("当前选中 Face 不是环面。")
|
||
if current_major is None or current_major <= 1e-9 or current_minor is None or current_minor <= 1e-9:
|
||
blockers.append("当前环面缺少有效主半径或小半径。")
|
||
if center is None:
|
||
blockers.append("当前环面缺少稳定中心,不能缩放。")
|
||
if target_radius <= 1e-9:
|
||
blockers.append("目标环面半径必须大于 0。")
|
||
|
||
scale = target_radius / max(current_radius or 1.0, 1e-9)
|
||
target_major = None if current_major is None else current_major * scale
|
||
target_minor = None if current_minor is None else current_minor * scale
|
||
delta_radius = target_radius - float(current_radius or 0.0)
|
||
delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9)
|
||
if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6):
|
||
blockers.append("目标环面半径与当前值几乎相同,不需要修改。")
|
||
if delta_ratio > 0.6:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("环面半径变化超过 60%,可能明显影响周边几何。")
|
||
elif delta_ratio > 0.25:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("环面半径变化超过 25%,修改后请重点检查相邻面。")
|
||
|
||
part_id = int(info.get("part_id", -1))
|
||
solid_id = int(info.get("solid_id", -1))
|
||
part = self.part_by_id(part_id) if part_id >= 0 else None
|
||
if part is None:
|
||
blockers.append("找不到当前环面所属零件。")
|
||
part_solid_count = 0
|
||
else:
|
||
part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
|
||
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
|
||
warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。")
|
||
|
||
status = "blocked" if blockers else "caution"
|
||
if blockers:
|
||
risk = "blocked"
|
||
return {
|
||
"status": status,
|
||
"risk": risk,
|
||
"message": " ".join(blockers + warnings),
|
||
"warnings": ";".join(warnings),
|
||
"blockers": ";".join(blockers),
|
||
"face_id": face_id,
|
||
"part_id": part_id,
|
||
"solid_id": solid_id,
|
||
"surface": info.get("surface"),
|
||
"torus_radius_mode": mode_key,
|
||
"current_major_radius": current_major,
|
||
"target_major_radius": target_major,
|
||
"current_minor_radius": current_minor,
|
||
"target_minor_radius": target_minor,
|
||
"target_radius": target_radius,
|
||
"delta_radius": delta_radius,
|
||
"radius_delta_ratio": delta_ratio,
|
||
"center": center,
|
||
"axis": axis,
|
||
"resize_strategy": "uniform-scale-torus-radius",
|
||
"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._apply_edge_length_affine_transform(plan)
|
||
mode_label = "major" if plan.get("torus_radius_mode") == "major" else "minor"
|
||
current = plan.get("current_major_radius") if mode_label == "major" else plan.get("current_minor_radius")
|
||
target = plan.get("target_major_radius") if mode_label == "major" else plan.get("target_minor_radius")
|
||
return (
|
||
"Toroidal face radius resize completed by uniform scaling: "
|
||
f"face {face_id}, {mode_label}_radius={float(current):g}->{float(target):g}, "
|
||
f"scale={float(plan['affine_scale']):g}, "
|
||
f"target={plan.get('affine_target_kind')}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def face_area_scale_plan(self, face_id: int, target_area: float) -> dict[str, object]:
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
info = self.face_info(face_id)
|
||
blockers: list[str] = []
|
||
warnings: list[str] = [
|
||
"目标面面积不是 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 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._apply_edge_length_affine_transform(plan)
|
||
return (
|
||
"Face area resize completed by uniform scaling fallback: "
|
||
f"face {face_id}, "
|
||
f"area={float(plan['current_area']):g}->{float(plan['target_area']):g}, "
|
||
f"scale={float(plan['affine_scale']):g}, "
|
||
f"target={plan.get('affine_target_kind')}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def cylindrical_resize_preview_polydata(
|
||
self,
|
||
face_id: int,
|
||
new_diameter: float,
|
||
deflection: float = 0.8,
|
||
) -> list[dict[str, object]]:
|
||
plan = self.cylindrical_resize_plan(face_id, new_diameter)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
face = self.faces[face_id]
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() != GeomAbs_Cylinder:
|
||
raise ValueError("Cylinder resize preview currently supports cylindrical faces only.")
|
||
|
||
direction = surf.Cylinder().Axis().Direction()
|
||
previews: list[dict[str, object]] = []
|
||
|
||
if plan["resize_mode"] == "shrink" and "fill_start_point" in plan:
|
||
fill_start = gp_Pnt(*plan["fill_start_point"])
|
||
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
|
||
filler = BRepPrimAPI_MakeCylinder(
|
||
fill_axis,
|
||
float(plan["fill_radius"]),
|
||
float(plan["fill_height"]),
|
||
).Shape()
|
||
BRepMesh_IncrementalMesh(filler, deflection)
|
||
previews.append(
|
||
{
|
||
"role": "fill",
|
||
"label": "补料预览",
|
||
"polydata": _shape_faces_polydata(filler),
|
||
}
|
||
)
|
||
|
||
cutter_start = gp_Pnt(*plan["cutter_start_point"])
|
||
cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
|
||
cutter = BRepPrimAPI_MakeCylinder(
|
||
cutter_axis,
|
||
float(plan["cutter_radius"]),
|
||
float(plan["cutter_height"]),
|
||
).Shape()
|
||
BRepMesh_IncrementalMesh(cutter, deflection)
|
||
previews.append(
|
||
{
|
||
"role": "cutter",
|
||
"label": "切削预览",
|
||
"polydata": _shape_faces_polydata(cutter),
|
||
}
|
||
)
|
||
return previews
|
||
|
||
def cylindrical_boss_resize_preview_polydata(
|
||
self,
|
||
face_id: int,
|
||
new_diameter: float,
|
||
deflection: float = 0.8,
|
||
) -> list[dict[str, object]]:
|
||
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
start = gp_Pnt(*plan["boss_tool_start_point"])
|
||
direction = gp_Dir(*plan["boss_tool_axis_direction"])
|
||
axis = gp_Ax2(start, direction)
|
||
height = float(plan["boss_tool_height"])
|
||
if plan["resize_mode"] == "enlarge":
|
||
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
|
||
BRepMesh_IncrementalMesh(tool, deflection)
|
||
return [
|
||
{
|
||
"role": "fill",
|
||
"label": "凸台扩大补料预览",
|
||
"polydata": _shape_faces_polydata(tool),
|
||
}
|
||
]
|
||
|
||
removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
|
||
replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
|
||
BRepMesh_IncrementalMesh(removal, deflection)
|
||
BRepMesh_IncrementalMesh(replacement, deflection)
|
||
return [
|
||
{
|
||
"role": "cutter",
|
||
"label": "凸台缩小移除范围预览",
|
||
"polydata": _shape_faces_polydata(removal),
|
||
},
|
||
{
|
||
"role": "fill",
|
||
"label": "凸台缩小重建目标预览",
|
||
"polydata": _shape_faces_polydata(replacement),
|
||
},
|
||
]
|
||
|
||
def cylindrical_suppress_preview_polydata(
|
||
self,
|
||
face_id: int,
|
||
deflection: float = 0.8,
|
||
) -> list[dict[str, object]]:
|
||
plan = self.cylindrical_suppress_plan(face_id)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
face = self.faces[face_id]
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() != GeomAbs_Cylinder:
|
||
raise ValueError("Cylinder suppress preview currently supports cylindrical faces only.")
|
||
|
||
direction = surf.Cylinder().Axis().Direction()
|
||
fill_start = gp_Pnt(*plan["fill_start_point"])
|
||
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
|
||
filler = BRepPrimAPI_MakeCylinder(
|
||
fill_axis,
|
||
float(plan["fill_radius"]),
|
||
float(plan["fill_height"]),
|
||
).Shape()
|
||
BRepMesh_IncrementalMesh(filler, deflection)
|
||
return [
|
||
{
|
||
"role": "fill",
|
||
"label": "封堵补料预览",
|
||
"polydata": _shape_faces_polydata(filler),
|
||
}
|
||
]
|
||
|
||
def cylindrical_depth_preview_polydata(
|
||
self,
|
||
face_id: int,
|
||
target_depth: float,
|
||
bottom_face_id: int | None = None,
|
||
deflection: float = 0.8,
|
||
) -> list[dict[str, object]]:
|
||
plan = self.cylindrical_depth_plan(
|
||
face_id,
|
||
target_depth,
|
||
bottom_face_id=bottom_face_id,
|
||
)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
start = gp_Pnt(*plan["depth_tool_start_point"])
|
||
direction = gp_Dir(*plan["depth_axis_direction"])
|
||
axis = gp_Ax2(start, direction)
|
||
tool = BRepPrimAPI_MakeCylinder(
|
||
axis,
|
||
float(plan["depth_tool_radius"]),
|
||
float(plan["depth_tool_height"]),
|
||
).Shape()
|
||
BRepMesh_IncrementalMesh(tool, deflection)
|
||
role = str(plan["depth_tool_role"])
|
||
return [
|
||
{
|
||
"role": role,
|
||
"label": "切削预览" if role == "cutter" else "补料预览",
|
||
"polydata": _shape_faces_polydata(tool),
|
||
}
|
||
]
|
||
|
||
def existing_fillet_resize_preview_polydata(
|
||
self,
|
||
face_id: int,
|
||
target_radius: float,
|
||
deflection: float = 0.8,
|
||
) -> list[dict[str, object]]:
|
||
plan = self.existing_fillet_resize_plan(face_id, target_radius)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
return [
|
||
{
|
||
"role": "remove",
|
||
"label": "将移除并重建的已有圆角面",
|
||
"polydata": self.build_face_polydata(face_ids=[face_id], deflection=deflection),
|
||
}
|
||
]
|
||
|
||
def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8):
|
||
plan = self.push_pull_plan(face_id, distance)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
face = self.faces[face_id]
|
||
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
|
||
profile_shape = self._push_pull_profile_shape(scope_face_ids)
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() != GeomAbs_Plane:
|
||
raise ValueError("Push/pull preview currently supports planar faces only.")
|
||
|
||
outward = plan["outward_direction"]
|
||
vec = gp_Vec(
|
||
float(outward[0]) * distance,
|
||
float(outward[1]) * distance,
|
||
float(outward[2]) * distance,
|
||
)
|
||
preview_shape = BRepPrimAPI_MakePrism(profile_shape, vec).Shape()
|
||
BRepMesh_IncrementalMesh(preview_shape, deflection)
|
||
return _shape_faces_polydata(preview_shape)
|
||
|
||
def straight_edge_length_preview_polydata(
|
||
self,
|
||
edge_id: int,
|
||
target_length: float,
|
||
deflection: float = 0.8,
|
||
anchor_mode: str = "auto",
|
||
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._apply_local_face_deform(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']}."
|
||
)
|
||
|
||
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._apply_local_face_deform(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']}."
|
||
)
|
||
|
||
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._apply_local_face_deform(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']}."
|
||
)
|
||
|
||
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"]))
|
||
|
||
if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild":
|
||
self._apply_local_face_deform(plan)
|
||
else:
|
||
self._apply_edge_length_affine_transform(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']}."
|
||
)
|
||
|
||
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)
|
||
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()
|
||
|
||
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)
|
||
transformed = self._affine_scaled_shape_along_edge(source_shape, plan)
|
||
_ensure_valid_shape(transformed)
|
||
if target_kind == "part":
|
||
part.shape = transformed
|
||
else:
|
||
part_solids = _explore(part.shape, TopAbs_SOLID)
|
||
replaced = False
|
||
shapes: list[TopoDS_Shape] = []
|
||
for item in part_solids:
|
||
if not replaced and _same_shape(item, solid):
|
||
shapes.append(transformed)
|
||
replaced = True
|
||
else:
|
||
shapes.append(item)
|
||
if not replaced:
|
||
raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.")
|
||
part.shape = _compound_from_shapes(shapes)
|
||
_ensure_valid_shape(part.shape)
|
||
self.refresh_topology()
|
||
|
||
def _edge_length_affine_target(
|
||
self,
|
||
plan: dict[str, object],
|
||
) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]:
|
||
part_id = int(plan.get("part_id", -1))
|
||
solid_id = int(plan.get("solid_id", -1))
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
target_kind = str(plan.get("affine_target_kind", "part"))
|
||
if target_kind == "solid" and 0 <= solid_id < len(self.solids):
|
||
return target_kind, self.solids[solid_id][1], part, self.solids[solid_id][1]
|
||
return "part", part.shape, part, None
|
||
|
||
def _affine_scaled_shape_along_edge(self, shape: TopoDS_Shape, plan: dict[str, object]) -> TopoDS_Shape:
|
||
axis_point = _tuple_or_none(plan.get("affine_axis_point"))
|
||
axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction")))
|
||
scale = float(plan.get("affine_scale", 1.0))
|
||
transform_kind = str(plan.get("affine_transform_kind", "axis-affine"))
|
||
if axis_point is None:
|
||
raise ValueError("Missing affine edge-length axis.")
|
||
if transform_kind == "uniform":
|
||
transform = gp_Trsf()
|
||
transform.SetScale(gp_Pnt(*axis_point), scale)
|
||
builder = BRepBuilderAPI_Transform(shape, transform, True)
|
||
builder.Build()
|
||
if not builder.IsDone():
|
||
raise RuntimeError("Uniform edge-length scale transform failed.")
|
||
result = builder.Shape()
|
||
if result.IsNull():
|
||
raise RuntimeError("Uniform edge-length scale transform produced an empty shape.")
|
||
return result
|
||
|
||
if axis_direction is None:
|
||
raise ValueError("Missing affine edge-length axis direction.")
|
||
ux, uy, uz = axis_direction
|
||
if transform_kind == "radial-affine":
|
||
matrix = [
|
||
[scale + (1.0 - scale) * ux * ux, (1.0 - scale) * ux * uy, (1.0 - scale) * ux * uz],
|
||
[(1.0 - scale) * uy * ux, scale + (1.0 - scale) * uy * uy, (1.0 - scale) * uy * uz],
|
||
[(1.0 - scale) * uz * ux, (1.0 - scale) * uz * uy, scale + (1.0 - scale) * uz * uz],
|
||
]
|
||
else:
|
||
matrix = [
|
||
[1.0 + (scale - 1.0) * ux * ux, (scale - 1.0) * ux * uy, (scale - 1.0) * ux * uz],
|
||
[(scale - 1.0) * uy * ux, 1.0 + (scale - 1.0) * uy * uy, (scale - 1.0) * uy * uz],
|
||
[(scale - 1.0) * uz * ux, (scale - 1.0) * uz * uy, 1.0 + (scale - 1.0) * uz * uz],
|
||
]
|
||
cx, cy, cz = axis_point
|
||
moved_center = (
|
||
matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz,
|
||
matrix[1][0] * cx + matrix[1][1] * cy + matrix[1][2] * cz,
|
||
matrix[2][0] * cx + matrix[2][1] * cy + matrix[2][2] * cz,
|
||
)
|
||
translation = (cx - moved_center[0], cy - moved_center[1], cz - moved_center[2])
|
||
transform = gp_GTrsf()
|
||
for row in range(3):
|
||
for column in range(3):
|
||
transform.SetValue(row + 1, column + 1, matrix[row][column])
|
||
transform.SetTranslationPart(gp_XYZ(*translation))
|
||
builder = BRepBuilderAPI_GTransform(shape, transform, True)
|
||
builder.Build()
|
||
if not builder.IsDone():
|
||
raise RuntimeError(f"{transform_kind} edge-length transform failed.")
|
||
result = builder.Shape()
|
||
if result.IsNull():
|
||
raise RuntimeError(f"{transform_kind} edge-length transform produced an empty shape.")
|
||
return result
|
||
|
||
def push_pull_face(self, face_id: int, distance: float) -> str:
|
||
plan = self.push_pull_plan(face_id, distance)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
face = self.faces[face_id]
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() != GeomAbs_Plane:
|
||
raise ValueError("Push/pull currently supports planar faces only.")
|
||
|
||
part_id = self.face_part_ids[face_id]
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
|
||
outward = plan["outward_direction"]
|
||
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
|
||
profile_shape = self._push_pull_profile_shape(scope_face_ids)
|
||
boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids)
|
||
side_face_ids = sorted(
|
||
set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(scope_face_ids)
|
||
)
|
||
side_region_mapping_specs = self._face_region_mapping_specs(side_face_ids)
|
||
|
||
cap_extension = self._cylindrical_cap_extension_plan(face_id, distance, outward)
|
||
if cap_extension is not None:
|
||
op = BRepAlgoAPI_Fuse(part.shape, cap_extension["tool_shape"])
|
||
result = _finalize_boolean_result(op, "cylindrical cap push/pull")
|
||
result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance)
|
||
part.shape = result
|
||
self.refresh_topology()
|
||
self._apply_face_region_mapping_specs(side_region_mapping_specs)
|
||
return (
|
||
"Planar face push/pull completed: cylindrical cap extension, "
|
||
f"semantic_distance={distance:g}, "
|
||
f"radius={float(cap_extension['radius']):g}, "
|
||
f"old_height={float(cap_extension['old_height']):g}, "
|
||
f"new_height={float(cap_extension['new_height']):g}, "
|
||
f"side_faces={cap_extension['side_face_ids']}, "
|
||
f"outward_direction={_format_tuple(outward)}, "
|
||
f"direction_confidence={plan['direction_confidence']}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
overlap = _boolean_overlap_distance(part.shape, distance)
|
||
start_offset = -overlap if distance >= 0 else overlap
|
||
tool_distance = distance + overlap if distance >= 0 else distance - overlap
|
||
tool_face = _translated_shape(profile_shape, outward, start_offset)
|
||
vec = gp_Vec(
|
||
float(outward[0]) * tool_distance,
|
||
float(outward[1]) * tool_distance,
|
||
float(outward[2]) * tool_distance,
|
||
)
|
||
tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape()
|
||
op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape)
|
||
result = _finalize_boolean_result(op, "push/pull")
|
||
result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance)
|
||
part.shape = result
|
||
self.refresh_topology()
|
||
self._apply_face_region_mapping_specs(side_region_mapping_specs)
|
||
action = "fused outward prism" if distance >= 0 else "cut inward prism"
|
||
return (
|
||
"Planar face push/pull completed: "
|
||
f"{action}, semantic_distance={distance:g}, "
|
||
f"tool_overlap={overlap:g}, "
|
||
f"scope_faces={len(scope_face_ids)}, "
|
||
f"outward_direction={_format_tuple(outward)}, "
|
||
f"direction_confidence={plan['direction_confidence']}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def _cylindrical_cap_extension_plan(
|
||
self,
|
||
face_id: int,
|
||
distance: float,
|
||
outward: tuple[float, float, float],
|
||
) -> dict[str, object] | None:
|
||
if distance <= 0:
|
||
return None
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
return None
|
||
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1:
|
||
return None
|
||
|
||
cap_center = _surface_center(self.faces[face_id])
|
||
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
|
||
adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)
|
||
if not adjacent_face_ids:
|
||
return None
|
||
|
||
diagonal = _shape_diagonal(self.shape)
|
||
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
||
outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2]))
|
||
candidates: list[tuple[float, dict[str, object]]] = []
|
||
|
||
for adjacent_id in adjacent_face_ids:
|
||
side_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
|
||
if side_surf.GetType() != GeomAbs_Cylinder:
|
||
continue
|
||
angular_span = abs(side_surf.LastUParameter() - side_surf.FirstUParameter())
|
||
if angular_span < math.tau * 0.92:
|
||
continue
|
||
|
||
cylinder = side_surf.Cylinder()
|
||
radius = float(cylinder.Radius())
|
||
axis = cylinder.Axis()
|
||
axis_point = axis.Location()
|
||
axis_dir = axis.Direction()
|
||
axis_alignment = _direction_dot(outward_dir, axis_dir)
|
||
if abs(axis_alignment) < 0.92:
|
||
continue
|
||
if _point_axis_distance(axis_point, axis_dir, cap_center) > max(radius * 0.08, tolerance * 10.0):
|
||
continue
|
||
|
||
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
|
||
v_min = float(axis_range["v_min"])
|
||
v_max = float(axis_range["v_max"])
|
||
old_height = max(v_max - v_min, 1e-9)
|
||
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_center)
|
||
start_distance = abs(cap_parameter - v_min)
|
||
end_distance = abs(cap_parameter - v_max)
|
||
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
|
||
|
||
if axis_alignment > 0 and end_distance <= end_tolerance:
|
||
new_min = v_min
|
||
new_max = v_max + distance
|
||
end_score = end_distance
|
||
elif axis_alignment < 0 and start_distance <= end_tolerance:
|
||
new_min = v_min - distance
|
||
new_max = v_max
|
||
end_score = start_distance
|
||
else:
|
||
continue
|
||
|
||
height = max(new_max - new_min, 1e-6)
|
||
start = _point_on_axis(axis_point, axis_dir, new_min)
|
||
tool_shape = BRepPrimAPI_MakeCylinder(gp_Ax2(start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, height).Shape()
|
||
score = end_score + _point_axis_distance(axis_point, axis_dir, cap_center)
|
||
candidate = {
|
||
"tool_shape": tool_shape,
|
||
"side_face_ids": axis_range["same_domain_face_ids"],
|
||
"radius": radius,
|
||
"old_height": old_height,
|
||
"new_height": height,
|
||
"axis_alignment": axis_alignment,
|
||
"cap_axis_parameter": cap_parameter,
|
||
"start_parameter": new_min,
|
||
"end_parameter": new_max,
|
||
}
|
||
candidates.append((score, candidate))
|
||
|
||
if not candidates:
|
||
return None
|
||
distinct_radii: list[float] = []
|
||
for _score, candidate in candidates:
|
||
radius = float(candidate["radius"])
|
||
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii):
|
||
distinct_radii.append(radius)
|
||
if len(distinct_radii) > 1:
|
||
return None
|
||
return min(candidates, key=lambda item: item[0])[1]
|
||
|
||
def resize_existing_fillet(self, face_id: int, target_radius: float) -> str:
|
||
plan = self.existing_fillet_resize_plan(face_id, target_radius)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
part_id = int(plan["part_id"])
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
|
||
source_face = topods.Face(self.faces[face_id])
|
||
defeatured = _defeature_faces(part.shape, [source_face])
|
||
axis_point = gp_Pnt(*plan["axis_point"])
|
||
axis_dir = gp_Dir(*plan["axis"])
|
||
root_edges = _axis_aligned_edge_candidates(
|
||
defeatured,
|
||
axis_point,
|
||
axis_dir,
|
||
expected_length=float(plan.get("height_estimate") or 0.0),
|
||
reference_radius=float(plan["current_radius"]),
|
||
)
|
||
if not root_edges:
|
||
raise RuntimeError(
|
||
"已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;"
|
||
"该圆角可能不是简单直线边圆角。"
|
||
)
|
||
|
||
result = None
|
||
failures: list[str] = []
|
||
for index, root_edge in enumerate(root_edges[:16], start=1):
|
||
try:
|
||
maker = BRepFilletAPI_MakeFillet(defeatured)
|
||
maker.Add(float(target_radius), topods.Edge(root_edge))
|
||
result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}")
|
||
break
|
||
except Exception as exc:
|
||
failures.append(str(exc))
|
||
if result is None:
|
||
detail = failures[-1] if failures else "没有可用的候选边。"
|
||
raise RuntimeError(
|
||
"已移除已有圆角面,但所有候选锐边都无法重新倒圆;"
|
||
f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}"
|
||
)
|
||
|
||
part.shape = result
|
||
self.refresh_topology()
|
||
return (
|
||
"Existing fillet radius resize completed: "
|
||
f"face {face_id}, current_radius={float(plan['current_radius']):g}, "
|
||
f"target_radius={target_radius:g}, "
|
||
f"delta_radius={float(plan['delta_radius']):g}, "
|
||
f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def fillet_edge(self, edge_id: int, radius: float) -> str:
|
||
plan = self.edge_fillet_plan(edge_id, radius)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
part_id = self.edge_part_ids[edge_id]
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
|
||
maker = BRepFilletAPI_MakeFillet(part.shape)
|
||
maker.Add(radius, topods.Edge(self.edges[edge_id]))
|
||
result = _finalize_builder_result(maker, "edge fillet")
|
||
part.shape = result
|
||
self.refresh_topology()
|
||
return (
|
||
f"Edge fillet completed: edge {edge_id}, radius={radius:g}, "
|
||
f"edge_length={float(plan['edge_length']):g}, "
|
||
f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def chamfer_edge(self, edge_id: int, distance: float) -> str:
|
||
plan = self.edge_chamfer_plan(edge_id, distance)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
part_id = self.edge_part_ids[edge_id]
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
|
||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||
maker.Add(distance, topods.Edge(self.edges[edge_id]))
|
||
result = _finalize_builder_result(maker, "edge chamfer")
|
||
part.shape = result
|
||
self.refresh_topology()
|
||
return (
|
||
f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, "
|
||
f"edge_length={float(plan['edge_length']):g}, "
|
||
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def chamfer_edge_asymmetric(
|
||
self,
|
||
edge_id: int,
|
||
distance1: float,
|
||
distance2: float,
|
||
reference_face_id: int | None = None,
|
||
) -> str:
|
||
plan = self.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
part_id = self.edge_part_ids[edge_id]
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
resolved_reference_face_id = int(plan["reference_face_id"])
|
||
|
||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||
try:
|
||
maker.Add(
|
||
float(distance1),
|
||
float(distance2),
|
||
topods.Edge(self.edges[edge_id]),
|
||
topods.Face(self.faces[resolved_reference_face_id]),
|
||
)
|
||
except TypeError as exc:
|
||
raise RuntimeError("The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face).") from exc
|
||
result = _finalize_builder_result(maker, "asymmetric edge chamfer")
|
||
part.shape = result
|
||
self.refresh_topology()
|
||
return (
|
||
f"Asymmetric Edge chamfer completed: edge {edge_id}, "
|
||
f"distance1={float(distance1):g}, distance2={float(distance2):g}, "
|
||
f"reference_face={resolved_reference_face_id}, "
|
||
f"edge_length={float(plan['edge_length']):g}, "
|
||
f"distance1_to_length_ratio={float(plan['distance1_to_length_ratio']):g}, "
|
||
f"distance2_to_length_ratio={float(plan['distance2_to_length_ratio']):g}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def chamfer_edge_distance_angle(
|
||
self,
|
||
edge_id: int,
|
||
distance: float,
|
||
angle_degrees: float,
|
||
reference_face_id: int | None = None,
|
||
) -> str:
|
||
plan = self.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
part_id = self.edge_part_ids[edge_id]
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
resolved_reference_face_id = int(plan["reference_face_id"])
|
||
|
||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||
try:
|
||
maker.AddDA(
|
||
float(distance),
|
||
float(plan["target_angle_radians"]),
|
||
topods.Edge(self.edges[edge_id]),
|
||
topods.Face(self.faces[resolved_reference_face_id]),
|
||
)
|
||
except AttributeError as exc:
|
||
raise RuntimeError("The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
|
||
except TypeError as exc:
|
||
raise RuntimeError("The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
|
||
result = _finalize_builder_result(maker, "distance-angle edge chamfer")
|
||
part.shape = result
|
||
self.refresh_topology()
|
||
return (
|
||
f"Distance-angle Edge chamfer completed: edge {edge_id}, "
|
||
f"distance={float(distance):g}, angle_degrees={float(angle_degrees):g}, "
|
||
f"reference_face={resolved_reference_face_id}, "
|
||
f"edge_length={float(plan['edge_length']):g}, "
|
||
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
|
||
f"risk={plan['risk']}."
|
||
)
|
||
|
||
def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
|
||
return self.resize_cylindrical_hole(face_id, new_diameter)
|
||
|
||
def resize_cylindrical_slot_width(
|
||
self,
|
||
face_id: int,
|
||
target_width: float,
|
||
pair_face_id: int | None = None,
|
||
) -> str:
|
||
return self._resize_cylindrical_slot_parameter(face_id, target_width, "width", pair_face_id=pair_face_id)
|
||
|
||
def resize_cylindrical_slot_depth(
|
||
self,
|
||
face_id: int,
|
||
target_depth: float,
|
||
pair_face_id: int | None = None,
|
||
) -> str:
|
||
return self._resize_cylindrical_slot_parameter(face_id, target_depth, "depth", pair_face_id=pair_face_id)
|
||
|
||
def resize_cylindrical_slot_arc_length(
|
||
self,
|
||
face_id: int,
|
||
target_arc_length: float,
|
||
pair_face_id: int | None = None,
|
||
) -> str:
|
||
return self._resize_cylindrical_slot_parameter(face_id, target_arc_length, "arc_length", pair_face_id=pair_face_id)
|
||
|
||
def resize_cylindrical_slot_angular_span(self, face_id: int, target_angular_span: float) -> str:
|
||
plan = self.cylindrical_slot_angular_span_plan(face_id, target_angular_span)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
return self._resize_cylindrical_slot_angular_span_with_sector_tool(plan)
|
||
|
||
def resize_cylindrical_slot_total_length(
|
||
self,
|
||
face_id: int,
|
||
target_total_length: float,
|
||
pair_face_id: int | None = None,
|
||
) -> str:
|
||
plan = self.cylindrical_slot_total_length_plan(face_id, target_total_length, pair_face_id=pair_face_id)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
return self._resize_cylindrical_slot_length_with_capsule_tool(plan)
|
||
|
||
def resize_cylindrical_slot_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 candidate
|
||
if score < best_score:
|
||
best_score = score
|
||
best = candidate
|
||
|
||
if best is None:
|
||
return {"matched": False, "detail": " No cylindrical slot face on the original axis was found after angular-span edit."}
|
||
return {
|
||
"matched": False,
|
||
"detail": (
|
||
f" Closest slot Face {best['face_id']} span {float(best['angular_span']):.6g}, "
|
||
f"target {target_span:.6g}, span error {float(best['span_error']):.6g}, "
|
||
f"diameter {float(best['diameter']):.6g}."
|
||
),
|
||
**best,
|
||
}
|
||
|
||
def _verify_obround_slot_length_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
|
||
target_distance = _float_or_none(plan.get("slot_target_center_distance"))
|
||
target_diameter = _float_or_none(plan.get("slot_target_diameter"))
|
||
axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction")))
|
||
length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction")))
|
||
if target_distance is None or target_diameter is None or axis_dir is None or length_dir is None:
|
||
return {"matched": False, "detail": " Missing obround slot length verification data."}
|
||
target_radius = target_diameter * 0.5
|
||
if target_radius <= 1e-9 or target_distance <= 1e-9:
|
||
return {"matched": False, "detail": " Invalid target slot length or diameter."}
|
||
|
||
part = self.part_by_id(part_id)
|
||
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0)
|
||
radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5)
|
||
distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4)
|
||
candidates: list[dict[str, object]] = []
|
||
for face_id, face in enumerate(self.faces):
|
||
if self.face_part_ids[face_id] != part_id:
|
||
continue
|
||
try:
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() != GeomAbs_Cylinder:
|
||
continue
|
||
cyl = surf.Cylinder()
|
||
radius = float(cyl.Radius())
|
||
if abs(radius - target_radius) > radius_tolerance:
|
||
continue
|
||
candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction()))
|
||
if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4:
|
||
continue
|
||
axis_range = self._cylindrical_axis_range(face_id, surf)
|
||
mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5
|
||
mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter))
|
||
candidates.append({"face_id": face_id, "mid": mid, "radius": radius})
|
||
except Exception:
|
||
continue
|
||
|
||
best: dict[str, object] | None = None
|
||
best_score = math.inf
|
||
for index, first in enumerate(candidates):
|
||
for second in candidates[index + 1 :]:
|
||
raw_offset = _tuple_sub(second["mid"], first["mid"])
|
||
axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir))
|
||
section_offset = _tuple_sub(raw_offset, axis_offset)
|
||
distance = _vector_length(section_offset)
|
||
if distance <= 1e-9:
|
||
continue
|
||
direction = _tuple_normalized(section_offset)
|
||
if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96:
|
||
continue
|
||
error = abs(distance - target_distance)
|
||
score = error / max(distance_tolerance, 1e-9)
|
||
candidate = {
|
||
"matched": error <= distance_tolerance,
|
||
"face_id": first["face_id"],
|
||
"paired_face_id": second["face_id"],
|
||
"center_distance": distance,
|
||
"target_center_distance": target_distance,
|
||
"center_distance_error": error,
|
||
"center_distance_tolerance": distance_tolerance,
|
||
}
|
||
if candidate["matched"]:
|
||
return candidate
|
||
if score < best_score:
|
||
best_score = score
|
||
best = candidate
|
||
|
||
if best is None:
|
||
return {"matched": False, "detail": " No paired cylindrical slot ends matching the target radius were found."}
|
||
return {
|
||
"matched": False,
|
||
"detail": (
|
||
f" Closest paired slot center distance {float(best['center_distance']):.6g}, "
|
||
f"target {target_distance:.6g}, error {float(best['center_distance_error']):.6g}."
|
||
),
|
||
**best,
|
||
}
|
||
|
||
def _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 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 _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
|
||
target_diameter = float(plan.get("target_diameter", 0.0))
|
||
target_radius = target_diameter / 2.0
|
||
if target_radius <= 1e-9:
|
||
return {"matched": False, "detail": " 目标直径无效。"}
|
||
try:
|
||
axis_point = gp_Pnt(*plan["cutter_axis_point"])
|
||
axis_direction = gp_Dir(*plan["cutter_axis_direction"])
|
||
except Exception:
|
||
return {"matched": False, "detail": " 缺少原孔轴信息,无法确认结果。"}
|
||
|
||
part = self.part_by_id(part_id)
|
||
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0)
|
||
radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5)
|
||
axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4)
|
||
best: dict[str, object] | None = None
|
||
best_score = math.inf
|
||
|
||
for face_id, face in enumerate(self.faces):
|
||
if self.face_part_ids[face_id] != part_id:
|
||
continue
|
||
try:
|
||
surf = BRepAdaptor_Surface(face)
|
||
if surf.GetType() != GeomAbs_Cylinder:
|
||
continue
|
||
cyl = surf.Cylinder()
|
||
candidate_radius = float(cyl.Radius())
|
||
radius_error = abs(candidate_radius - target_radius)
|
||
axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction()))
|
||
if axis_dot < 1.0 - 1e-5:
|
||
continue
|
||
axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location())
|
||
except Exception:
|
||
continue
|
||
|
||
score = radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9)
|
||
candidate = {
|
||
"matched": radius_error <= radius_tolerance and axis_distance <= axis_tolerance,
|
||
"face_id": face_id,
|
||
"diameter": candidate_radius * 2.0,
|
||
"radius_error": radius_error,
|
||
"axis_distance": axis_distance,
|
||
"radius_tolerance": radius_tolerance,
|
||
"axis_tolerance": axis_tolerance,
|
||
}
|
||
if candidate["matched"]:
|
||
return candidate
|
||
if score < best_score:
|
||
best_score = score
|
||
best = candidate
|
||
|
||
if best is None:
|
||
return {"matched": False, "detail": " 未找到同零件内与原孔轴平行的圆柱面。"}
|
||
return {
|
||
"matched": False,
|
||
"detail": (
|
||
f" 最近候选 Face {best['face_id']} 的直径约 {float(best['diameter']):.6g},"
|
||
f"目标直径 {target_diameter:.6g},"
|
||
f"半径误差 {float(best['radius_error']):.6g},"
|
||
f"轴距 {float(best['axis_distance']):.6g}。"
|
||
),
|
||
**best,
|
||
}
|
||
|
||
def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str:
|
||
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
|
||
if plan["status"] == "blocked":
|
||
raise ValueError(str(plan["message"]))
|
||
|
||
part_id = self.face_part_ids[face_id]
|
||
part = self.part_by_id(part_id)
|
||
if part is None:
|
||
raise ValueError(f"Unknown part id {part_id}")
|
||
|
||
start = gp_Pnt(*plan["boss_tool_start_point"])
|
||
direction = gp_Dir(*plan["boss_tool_axis_direction"])
|
||
axis = gp_Ax2(start, direction)
|
||
height = float(plan["boss_tool_height"])
|
||
if plan["resize_mode"] == "enlarge":
|
||
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
|
||
op = BRepAlgoAPI_Fuse(part.shape, tool)
|
||
result = _finalize_boolean_result(op, "cylindrical boss fuse", 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}."
|
||
)
|
||
|