1340 lines
49 KiB
Python
1340 lines
49 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Iterable
|
||
|
||
from OCC.Core.BRep import BRep_Tool
|
||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Defeaturing
|
||
from OCC.Core.BRepBndLib import brepbndlib
|
||
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
|
||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
||
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
||
from OCC.Core.BRepGProp import brepgprop
|
||
from OCC.Core.Bnd import Bnd_Box
|
||
from OCC.Core.GeomAbs import (
|
||
GeomAbs_Circle,
|
||
GeomAbs_Cylinder,
|
||
GeomAbs_Line,
|
||
GeomAbs_Plane,
|
||
)
|
||
from OCC.Core.GProp import GProp_GProps
|
||
from OCC.Core.ShapeFix import ShapeFix_Shape
|
||
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
||
from OCC.Core.TopAbs import (
|
||
TopAbs_EDGE,
|
||
TopAbs_FACE,
|
||
TopAbs_IN,
|
||
TopAbs_OUT,
|
||
TopAbs_REVERSED,
|
||
TopAbs_SOLID,
|
||
)
|
||
from OCC.Core.TopExp import TopExp_Explorer
|
||
from OCC.Core.TopLoc import TopLoc_Location
|
||
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
|
||
from OCC.Core.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||
from OCC.Extend.TopologyUtils import TopologyExplorer
|
||
|
||
from .constants import CURVE_TYPES, ORIENTATION_TYPES
|
||
|
||
|
||
def _compound_from_shapes(shapes: Iterable[TopoDS_Shape]) -> TopoDS_Shape:
|
||
from OCC.Core.BRep import BRep_Builder
|
||
|
||
valid_shapes = [shape for shape in shapes if not shape.IsNull()]
|
||
if len(valid_shapes) == 1:
|
||
return valid_shapes[0]
|
||
|
||
compound = TopoDS_Compound()
|
||
builder = BRep_Builder()
|
||
builder.MakeCompound(compound)
|
||
for shape in valid_shapes:
|
||
builder.Add(compound, shape)
|
||
return compound
|
||
|
||
|
||
def _explore(shape: TopoDS_Shape, shape_type: int) -> list[TopoDS_Shape]:
|
||
items: list[TopoDS_Shape] = []
|
||
explorer = TopExp_Explorer(shape, shape_type)
|
||
while explorer.More():
|
||
current = explorer.Current()
|
||
if shape_type == TopAbs_FACE:
|
||
items.append(topods.Face(current))
|
||
elif shape_type == TopAbs_EDGE:
|
||
items.append(topods.Edge(current))
|
||
elif shape_type == TopAbs_SOLID:
|
||
items.append(topods.Solid(current))
|
||
else:
|
||
items.append(current)
|
||
explorer.Next()
|
||
return items
|
||
|
||
|
||
def _same_shape(left: TopoDS_Shape, right: TopoDS_Shape) -> bool:
|
||
try:
|
||
return bool(left.IsSame(right))
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _surfaces_are_coplanar(left: BRepAdaptor_Surface, right: BRepAdaptor_Surface, tolerance: float) -> bool:
|
||
if left.GetType() != GeomAbs_Plane or right.GetType() != GeomAbs_Plane:
|
||
return False
|
||
left_plane = left.Plane()
|
||
right_plane = right.Plane()
|
||
left_dir = left_plane.Axis().Direction()
|
||
right_dir = right_plane.Axis().Direction()
|
||
dot = abs(
|
||
left_dir.X() * right_dir.X()
|
||
+ left_dir.Y() * right_dir.Y()
|
||
+ left_dir.Z() * right_dir.Z()
|
||
)
|
||
if dot < 1.0 - 1e-7:
|
||
return False
|
||
left_point = left_plane.Location()
|
||
right_point = right_plane.Location()
|
||
distance = abs(
|
||
(right_point.X() - left_point.X()) * left_dir.X()
|
||
+ (right_point.Y() - left_point.Y()) * left_dir.Y()
|
||
+ (right_point.Z() - left_point.Z()) * left_dir.Z()
|
||
)
|
||
return distance <= tolerance
|
||
|
||
|
||
def _surfaces_are_cocylindrical(left: BRepAdaptor_Surface, right: BRepAdaptor_Surface, tolerance: float) -> bool:
|
||
if left.GetType() != GeomAbs_Cylinder or right.GetType() != GeomAbs_Cylinder:
|
||
return False
|
||
left_cylinder = left.Cylinder()
|
||
right_cylinder = right.Cylinder()
|
||
left_axis = left_cylinder.Axis()
|
||
right_axis = right_cylinder.Axis()
|
||
left_dir = left_axis.Direction()
|
||
right_dir = right_axis.Direction()
|
||
axis_dot = abs(_direction_dot(left_dir, right_dir))
|
||
if axis_dot < 1.0 - 1e-6:
|
||
return False
|
||
radius_tolerance = max(tolerance, max(left_cylinder.Radius(), right_cylinder.Radius()) * 1e-6)
|
||
if abs(left_cylinder.Radius() - right_cylinder.Radius()) > radius_tolerance:
|
||
return False
|
||
axis_distance = _point_axis_distance(left_axis.Location(), left_dir, right_axis.Location())
|
||
return axis_distance <= max(tolerance, radius_tolerance)
|
||
|
||
|
||
def _surface_matches_plane_spec(surf: BRepAdaptor_Surface, spec: dict[str, object], tolerance: float) -> bool:
|
||
if surf.GetType() != GeomAbs_Plane:
|
||
return False
|
||
plane = surf.Plane()
|
||
normal = plane.Axis().Direction()
|
||
spec_normal = gp_Dir(*spec["normal"])
|
||
if abs(_direction_dot(normal, spec_normal)) < 1.0 - 1e-7:
|
||
return False
|
||
spec_point = gp_Pnt(*spec["point"])
|
||
distance = abs(_axis_parameter(spec_point, spec_normal, plane.Location()))
|
||
return distance <= tolerance
|
||
|
||
|
||
def _surface_matches_cylinder_spec(surf: BRepAdaptor_Surface, spec: dict[str, object], tolerance: float) -> bool:
|
||
if surf.GetType() != GeomAbs_Cylinder:
|
||
return False
|
||
cylinder = surf.Cylinder()
|
||
axis = cylinder.Axis()
|
||
axis_dir = axis.Direction()
|
||
spec_axis_point = gp_Pnt(*spec["axis_point"])
|
||
spec_axis_dir = gp_Dir(*spec["axis_direction"])
|
||
if abs(_direction_dot(axis_dir, spec_axis_dir)) < 1.0 - 1e-6:
|
||
return False
|
||
radius = float(spec["radius"])
|
||
radius_tolerance = max(tolerance, max(radius, float(cylinder.Radius())) * 1e-6)
|
||
if abs(float(cylinder.Radius()) - radius) > radius_tolerance:
|
||
return False
|
||
return _point_axis_distance(spec_axis_point, spec_axis_dir, axis.Location()) <= max(tolerance, radius_tolerance)
|
||
|
||
|
||
def _mapped_edge_solid_id(
|
||
edge: TopoDS_Shape,
|
||
solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]],
|
||
) -> int:
|
||
for solid_id, edge_map in solid_edge_maps:
|
||
if edge_map.Contains(edge):
|
||
return solid_id
|
||
return -1
|
||
|
||
|
||
def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) -> dict[str, object]:
|
||
warnings: list[str] = []
|
||
if shape.IsNull():
|
||
return {
|
||
"quality_label": label,
|
||
"quality_status": "blocked",
|
||
"brep_valid": False,
|
||
"solids": 0,
|
||
"faces": 0,
|
||
"edges": 0,
|
||
"vertices": 0,
|
||
"quality_warnings": "导出对象是空 shape,不能可靠导出。",
|
||
}
|
||
|
||
try:
|
||
brep_valid = BRepCheck_Analyzer(shape).IsValid()
|
||
except Exception as exc:
|
||
brep_valid = False
|
||
warnings.append(f"B-Rep 校验执行失败:{exc}")
|
||
|
||
topo = TopologyExplorer(shape, ignore_orientation=True)
|
||
solids = len(list(topo.solids()))
|
||
faces = len(list(topo.faces()))
|
||
edges = len(list(topo.edges()))
|
||
vertices = len(list(topo.vertices()))
|
||
|
||
if not brep_valid:
|
||
warnings.append("B-Rep 校验未通过,导出后其他 CAD 软件可能无法正常识别。")
|
||
if faces == 0:
|
||
warnings.append("没有检测到Face,导出结果可能不可用。")
|
||
if expect_solid and solids == 0:
|
||
warnings.append("没有检测到Solid,导出后可能不是实体。")
|
||
elif expect_solid and solids > 1:
|
||
warnings.append(
|
||
f"检测到 {solids} 个Solid。"
|
||
"如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。"
|
||
)
|
||
|
||
geometry_info = _shape_volume_info(shape)
|
||
volume = geometry_info.get("volume", "")
|
||
if expect_solid and isinstance(volume, (int, float)) and abs(float(volume)) <= 1e-9:
|
||
warnings.append("实体体积接近 0,请确认导出对象是否为有效实体。")
|
||
|
||
bounds_info = _shape_bounds_info(shape)
|
||
return {
|
||
"quality_label": label,
|
||
"quality_status": "warning" if warnings else "ok",
|
||
"brep_valid": brep_valid,
|
||
"solids": solids,
|
||
"faces": faces,
|
||
"edges": edges,
|
||
"vertices": vertices,
|
||
"volume": volume,
|
||
"bbox_diagonal": bounds_info.get("bbox_diagonal", ""),
|
||
"quality_warnings": ";".join(warnings),
|
||
}
|
||
|
||
|
||
def _shape_faces_polydata(shape: TopoDS_Shape):
|
||
import vtk
|
||
|
||
points = vtk.vtkPoints()
|
||
polys = vtk.vtkCellArray()
|
||
for face in _explore(shape, TopAbs_FACE):
|
||
loc = TopLoc_Location()
|
||
tri = BRep_Tool.Triangulation(topods.Face(face), loc)
|
||
if tri is None:
|
||
continue
|
||
transform = loc.Transformation()
|
||
node_offset = points.GetNumberOfPoints()
|
||
for node_index in range(1, tri.NbNodes() + 1):
|
||
pnt = tri.Node(node_index).Transformed(transform)
|
||
points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z())
|
||
|
||
reversed_face = face.Orientation() == TopAbs_REVERSED
|
||
for tri_index in range(1, tri.NbTriangles() + 1):
|
||
n1, n2, n3 = tri.Triangle(tri_index).Get()
|
||
if reversed_face:
|
||
n2, n3 = n3, n2
|
||
vtk_tri = vtk.vtkTriangle()
|
||
vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1)
|
||
vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1)
|
||
vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1)
|
||
polys.InsertNextCell(vtk_tri)
|
||
|
||
poly = vtk.vtkPolyData()
|
||
poly.SetPoints(points)
|
||
poly.SetPolys(polys)
|
||
return poly
|
||
|
||
|
||
def _shape_bounds(shape: TopoDS_Shape) -> tuple[float, float, float, float, float, float]:
|
||
if shape.IsNull():
|
||
raise RuntimeError("Shape is null and has no usable bounds.")
|
||
box = Bnd_Box()
|
||
brepbndlib.Add(shape, box)
|
||
try:
|
||
if box.IsVoid():
|
||
raise RuntimeError("Shape has no usable bounds.")
|
||
except AttributeError:
|
||
pass
|
||
try:
|
||
return box.Get()
|
||
except Exception as exc:
|
||
raise RuntimeError("Shape has no usable bounds.") from exc
|
||
|
||
|
||
def _shape_bounds_info(shape: TopoDS_Shape) -> dict[str, object]:
|
||
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
|
||
dx = xmax - xmin
|
||
dy = ymax - ymin
|
||
dz = zmax - zmin
|
||
return {
|
||
"bbox_min": (xmin, ymin, zmin),
|
||
"bbox_max": (xmax, ymax, zmax),
|
||
"bbox_size": (dx, dy, dz),
|
||
"bbox_diagonal": math.sqrt(dx * dx + dy * dy + dz * dz),
|
||
}
|
||
|
||
|
||
def _shape_volume_info(shape: TopoDS_Shape) -> dict[str, object]:
|
||
props = GProp_GProps()
|
||
try:
|
||
brepgprop.VolumeProperties(shape, props)
|
||
except Exception:
|
||
return {"volume": "unavailable"}
|
||
volume = props.Mass()
|
||
info: dict[str, object] = {"volume": volume}
|
||
if abs(volume) > 1e-9:
|
||
info["center_of_mass"] = _point_tuple(props.CentreOfMass())
|
||
return info
|
||
|
||
|
||
def _shape_diagonal(shape: TopoDS_Shape) -> float:
|
||
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
|
||
return math.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2)
|
||
|
||
|
||
def _shape_center(shape: TopoDS_Shape) -> tuple[float, float, float]:
|
||
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
|
||
return ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0)
|
||
|
||
|
||
def _translated_shape(shape: TopoDS_Shape, direction: tuple[float, float, float], distance: float) -> TopoDS_Shape:
|
||
if abs(distance) <= 1e-12:
|
||
return shape
|
||
trsf = gp_Trsf()
|
||
trsf.SetTranslation(
|
||
gp_Vec(
|
||
float(direction[0]) * distance,
|
||
float(direction[1]) * distance,
|
||
float(direction[2]) * distance,
|
||
)
|
||
)
|
||
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
|
||
|
||
|
||
def _translated_shape_by_vector(shape: TopoDS_Shape, vector: tuple[float, float, float]) -> TopoDS_Shape:
|
||
if _vector_length(vector) <= 1e-12:
|
||
return shape
|
||
trsf = gp_Trsf()
|
||
trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2])))
|
||
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
|
||
|
||
|
||
def _rotated_shape(
|
||
shape: TopoDS_Shape,
|
||
axis_name: str,
|
||
angle_degrees: float,
|
||
center: tuple[float, float, float],
|
||
) -> TopoDS_Shape:
|
||
if abs(angle_degrees) <= 1e-12:
|
||
return shape
|
||
axis_dir = _axis_dir_from_name(axis_name)
|
||
trsf = gp_Trsf()
|
||
trsf.SetRotation(gp_Ax1(gp_Pnt(*center), axis_dir), math.radians(angle_degrees))
|
||
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
|
||
|
||
|
||
def _axis_dir_from_name(axis_name: str) -> gp_Dir:
|
||
axis = axis_name.upper()
|
||
if axis == "X":
|
||
return gp_Dir(1.0, 0.0, 0.0)
|
||
if axis == "Y":
|
||
return gp_Dir(0.0, 1.0, 0.0)
|
||
if axis == "Z":
|
||
return gp_Dir(0.0, 0.0, 1.0)
|
||
raise ValueError("Rotation axis must be X, Y or Z.")
|
||
|
||
|
||
def _vector_length(vector: tuple[float, float, float]) -> float:
|
||
return math.sqrt(float(vector[0]) ** 2 + float(vector[1]) ** 2 + float(vector[2]) ** 2)
|
||
|
||
|
||
def _tuple_or_none(value: object) -> tuple[float, float, float] | None:
|
||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||
return None
|
||
try:
|
||
return (float(value[0]), float(value[1]), float(value[2]))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _tuple_sub(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]:
|
||
return (left[0] - right[0], left[1] - right[1], left[2] - right[2])
|
||
|
||
|
||
def _tuple_scale(values: tuple[float, float, float], scale: float) -> tuple[float, float, float]:
|
||
return (values[0] * scale, values[1] * scale, values[2] * scale)
|
||
|
||
|
||
def _tuple_dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
|
||
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
|
||
|
||
|
||
def _tuple_cross(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]:
|
||
return (
|
||
left[1] * right[2] - left[2] * right[1],
|
||
left[2] * right[0] - left[0] * right[2],
|
||
left[0] * right[1] - left[1] * right[0],
|
||
)
|
||
|
||
|
||
def _tuple_normalized(value: tuple[float, float, float] | None) -> tuple[float, float, float] | None:
|
||
if value is None:
|
||
return None
|
||
length = _vector_length(value)
|
||
if length <= 1e-12:
|
||
return None
|
||
return (value[0] / length, value[1] / length, value[2] / length)
|
||
|
||
|
||
def _rotation_readiness(axis_name: str, angle_degrees: float) -> dict[str, object]:
|
||
risk = "low"
|
||
status = "ready"
|
||
warnings: list[str] = []
|
||
blockers: list[str] = []
|
||
axis = axis_name.upper()
|
||
|
||
if axis not in {"X", "Y", "Z"}:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
blockers.append("旋转轴必须是 X、Y 或 Z。")
|
||
if abs(angle_degrees) <= 1e-9:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
blockers.append("旋转角度为 0,不需要修改。")
|
||
if abs(angle_degrees) > 360.0:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("旋转角度超过 360 度,请确认输入是否符合预期。")
|
||
|
||
if blockers:
|
||
note = " ".join(blockers + warnings)
|
||
elif warnings:
|
||
status = "caution"
|
||
note = " ".join(warnings)
|
||
else:
|
||
note = "可以尝试旋转当前对象。"
|
||
return {
|
||
"rotate_status": status,
|
||
"rotate_risk": risk,
|
||
"rotate_warnings": ";".join(warnings),
|
||
"rotate_blockers": ";".join(blockers),
|
||
"rotate_note": note,
|
||
}
|
||
|
||
|
||
def _translation_readiness(vector: tuple[float, float, float], shape: TopoDS_Shape) -> dict[str, object]:
|
||
risk = "low"
|
||
status = "ready"
|
||
warnings: list[str] = []
|
||
blockers: list[str] = []
|
||
distance = _vector_length(vector)
|
||
diagonal = _shape_diagonal(shape)
|
||
|
||
if distance <= 1e-9:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
blockers.append("平移向量为 0,不需要修改。")
|
||
elif diagonal > 1e-9:
|
||
ratio = distance / diagonal
|
||
if ratio > 2.0:
|
||
risk = "high"
|
||
warnings.append("平移距离超过目标包围盒对角线的 2 倍,请确认单位和方向。")
|
||
elif ratio > 0.5:
|
||
risk = "medium"
|
||
warnings.append("平移距离超过目标包围盒对角线的 50%,请确认单位和方向。")
|
||
|
||
if blockers:
|
||
note = " ".join(blockers + warnings)
|
||
elif warnings:
|
||
status = "caution"
|
||
note = " ".join(warnings)
|
||
else:
|
||
note = "可以尝试平移当前对象。"
|
||
return {
|
||
"translate_status": status,
|
||
"translate_risk": risk,
|
||
"translate_warnings": ";".join(warnings),
|
||
"translate_blockers": ";".join(blockers),
|
||
"translate_note": note,
|
||
}
|
||
|
||
|
||
def _boolean_overlap_distance(shape: TopoDS_Shape, requested_distance: float) -> float:
|
||
diagonal = _shape_diagonal(shape)
|
||
size_based = diagonal * 1e-5 if diagonal > 0 else 0.01
|
||
distance_based = abs(requested_distance) * 0.02
|
||
return min(max(size_based, distance_based, 0.001), max(abs(requested_distance) * 0.25, 0.01))
|
||
|
||
|
||
def _shape_cleaning_tolerance(
|
||
source_shape: TopoDS_Shape,
|
||
profile_shape: TopoDS_Shape,
|
||
requested_distance: float,
|
||
) -> float:
|
||
source_diagonal = _shape_diagonal(source_shape)
|
||
profile_diagonal = _shape_diagonal(profile_shape)
|
||
reference = max(source_diagonal, profile_diagonal, abs(requested_distance), 1.0)
|
||
size_based = reference * 1e-7
|
||
distance_based = abs(requested_distance) * 1e-5
|
||
lower = max(size_based, distance_based, 1e-5)
|
||
upper = max(reference * 1e-4, 0.02)
|
||
return min(lower, upper)
|
||
|
||
|
||
def _topology_shape_count(shape: TopoDS_Shape, shape_type) -> int:
|
||
explorer = TopExp_Explorer(shape, shape_type)
|
||
count = 0
|
||
while explorer.More():
|
||
count += 1
|
||
explorer.Next()
|
||
return count
|
||
|
||
|
||
def _defeature_faces(shape: TopoDS_Shape, faces: Iterable[TopoDS_Shape]) -> TopoDS_Shape:
|
||
builder = BRepAlgoAPI_Defeaturing()
|
||
builder.SetShape(shape)
|
||
for face in faces:
|
||
builder.AddFaceToRemove(topods.Face(face))
|
||
return _finalize_builder_result(builder, "existing fillet defeature")
|
||
|
||
|
||
def _find_axis_aligned_edge(
|
||
shape: TopoDS_Shape,
|
||
axis_point: gp_Pnt,
|
||
axis_dir: gp_Dir,
|
||
expected_length: float,
|
||
reference_radius: float,
|
||
) -> TopoDS_Shape | None:
|
||
candidates = _axis_aligned_edge_candidates(shape, axis_point, axis_dir, expected_length, reference_radius)
|
||
return candidates[0] if candidates else None
|
||
|
||
|
||
def _axis_aligned_edge_candidates(
|
||
shape: TopoDS_Shape,
|
||
axis_point: gp_Pnt,
|
||
axis_dir: gp_Dir,
|
||
expected_length: float,
|
||
reference_radius: float,
|
||
) -> list[TopoDS_Shape]:
|
||
candidates: list[tuple[float, TopoDS_Shape]] = []
|
||
length_reference = max(expected_length, reference_radius, 1.0)
|
||
distance_limit = max(reference_radius * 1.25, length_reference * 0.08, 0.2)
|
||
|
||
for edge in TopologyExplorer(shape, ignore_orientation=True).edges():
|
||
try:
|
||
curve = BRepAdaptor_Curve(edge)
|
||
if curve.GetType() != GeomAbs_Line:
|
||
continue
|
||
line = curve.Line()
|
||
parallel = abs(_direction_dot(line.Direction(), axis_dir))
|
||
if parallel < 0.96:
|
||
continue
|
||
|
||
props = GProp_GProps()
|
||
brepgprop.LinearProperties(edge, props)
|
||
edge_length = props.Mass()
|
||
if edge_length <= 1e-9:
|
||
continue
|
||
|
||
line_distance = _point_axis_distance(axis_point, axis_dir, line.Location())
|
||
center_distance = _point_axis_distance(axis_point, axis_dir, props.CentreOfMass())
|
||
length_penalty = 0.0
|
||
if expected_length > 1e-9:
|
||
length_penalty = abs(edge_length - expected_length) / expected_length
|
||
score = max(line_distance, center_distance) + length_penalty * max(reference_radius * 0.15, 0.05)
|
||
if score <= distance_limit:
|
||
candidates.append((score, edge))
|
||
except Exception:
|
||
continue
|
||
|
||
candidates.sort(key=lambda item: item[0])
|
||
return [edge for _score, edge in candidates]
|
||
|
||
|
||
def _finalize_boolean_result(op, operation_name: str) -> TopoDS_Shape:
|
||
op.SetNonDestructive(True)
|
||
if hasattr(op, "SetGlue"):
|
||
try:
|
||
op.SetGlue(BOPAlgo_GlueFull)
|
||
except Exception:
|
||
pass
|
||
if hasattr(op, "SetFuzzyValue"):
|
||
try:
|
||
op.SetFuzzyValue(1e-7)
|
||
except Exception:
|
||
pass
|
||
op.Build()
|
||
if not op.IsDone():
|
||
raise RuntimeError(f"{operation_name} Boolean operation failed.")
|
||
raw_result = _ensure_valid_or_repaired_shape(op.Shape(), operation_name)
|
||
try:
|
||
_simplify_boolean_builder(op)
|
||
simplified = _ensure_valid_or_repaired_shape(
|
||
op.Shape(), f"{operation_name} simplify"
|
||
)
|
||
unified = _unify_same_domain_shape(simplified)
|
||
return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify")
|
||
except Exception:
|
||
unified = _unify_same_domain_shape(raw_result)
|
||
return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify")
|
||
|
||
|
||
def _simplify_boolean_builder(builder) -> None:
|
||
if not hasattr(builder, "SimplifyResult"):
|
||
return
|
||
for args in ((True, True, 1e-5), (True, True)):
|
||
try:
|
||
builder.SimplifyResult(*args)
|
||
return
|
||
except TypeError:
|
||
continue
|
||
except Exception:
|
||
return
|
||
|
||
|
||
def _finalize_builder_result(builder, operation_name: str) -> TopoDS_Shape:
|
||
builder.Build()
|
||
if hasattr(builder, "IsDone") and not builder.IsDone():
|
||
raise RuntimeError(f"{operation_name} operation failed.")
|
||
result = _ensure_valid_or_repaired_shape(builder.Shape(), operation_name)
|
||
unified = _unify_same_domain_shape(result)
|
||
return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify")
|
||
|
||
|
||
def _cleanup_push_pull_result(
|
||
result: TopoDS_Shape,
|
||
source_shape: TopoDS_Shape,
|
||
profile_shape: TopoDS_Shape,
|
||
distance: float,
|
||
) -> TopoDS_Shape:
|
||
base_tolerance = _shape_cleaning_tolerance(source_shape, profile_shape, distance)
|
||
cleaned = result
|
||
for multiplier in (1.0, 5.0, 20.0):
|
||
tolerance = base_tolerance * multiplier
|
||
for safe_input_mode in (True, False):
|
||
candidate = _unify_same_domain_shape(
|
||
cleaned,
|
||
linear_tolerance=tolerance,
|
||
angular_tolerance=1e-5,
|
||
allow_internal_edges=False,
|
||
safe_input_mode=safe_input_mode,
|
||
)
|
||
candidate = _ensure_valid_or_repaired_shape(candidate, f"push/pull cleanup {multiplier:g}x")
|
||
if _topology_shape_count(candidate, TopAbs_SOLID) == _topology_shape_count(result, TopAbs_SOLID):
|
||
cleaned = candidate
|
||
return cleaned
|
||
|
||
|
||
def _unify_same_domain_shape(
|
||
shape: TopoDS_Shape,
|
||
linear_tolerance: float | None = None,
|
||
angular_tolerance: float | None = None,
|
||
allow_internal_edges: bool = False,
|
||
safe_input_mode: bool = True,
|
||
concat_bsplines: bool = False,
|
||
) -> TopoDS_Shape:
|
||
try:
|
||
unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, concat_bsplines)
|
||
unifier.SetSafeInputMode(safe_input_mode)
|
||
if hasattr(unifier, "AllowInternalEdges"):
|
||
unifier.AllowInternalEdges(allow_internal_edges)
|
||
if linear_tolerance is not None and hasattr(unifier, "SetLinearTolerance"):
|
||
unifier.SetLinearTolerance(max(float(linear_tolerance), 0.0))
|
||
if angular_tolerance is not None and hasattr(unifier, "SetAngularTolerance"):
|
||
unifier.SetAngularTolerance(max(float(angular_tolerance), 0.0))
|
||
unifier.Build()
|
||
unified = unifier.Shape()
|
||
_ensure_valid_shape(unified)
|
||
return unified
|
||
except Exception:
|
||
return shape
|
||
|
||
|
||
def _ensure_valid_or_repaired_shape(
|
||
shape: TopoDS_Shape, operation_name: str
|
||
) -> TopoDS_Shape:
|
||
try:
|
||
_ensure_valid_shape(shape)
|
||
return shape
|
||
except RuntimeError as original_error:
|
||
repaired = _repair_shape(shape)
|
||
try:
|
||
_ensure_valid_shape(repaired)
|
||
return repaired
|
||
except RuntimeError:
|
||
raise RuntimeError(
|
||
f"{operation_name} returned an invalid B-Rep shape, and automatic repair did not fix it."
|
||
) from original_error
|
||
|
||
|
||
def _repair_shape(shape: TopoDS_Shape) -> TopoDS_Shape:
|
||
if shape.IsNull():
|
||
return shape
|
||
try:
|
||
fixer = ShapeFix_Shape(shape)
|
||
fixer.Perform()
|
||
repaired = fixer.Shape()
|
||
if repaired.IsNull():
|
||
return shape
|
||
return repaired
|
||
except Exception:
|
||
return shape
|
||
|
||
|
||
def _ensure_valid_shape(shape: TopoDS_Shape) -> None:
|
||
if shape.IsNull():
|
||
raise RuntimeError("Operation returned a null shape.")
|
||
analyzer = BRepCheck_Analyzer(shape)
|
||
if not analyzer.IsValid():
|
||
raise RuntimeError("Operation returned an invalid B-Rep shape.")
|
||
try:
|
||
_shape_bounds(shape)
|
||
except RuntimeError as exc:
|
||
raise RuntimeError("Operation returned a shape without usable geometry.") from exc
|
||
|
||
|
||
def _solid_state(solid: TopoDS_Shape, point: gp_Pnt) -> str:
|
||
classifier = BRepClass3d_SolidClassifier(solid, point, 1e-6)
|
||
state = classifier.State()
|
||
if state == TopAbs_IN:
|
||
return "inside"
|
||
if state == TopAbs_OUT:
|
||
return "outside"
|
||
return "on/unknown"
|
||
|
||
|
||
def _state_summary(states: list[str]) -> str:
|
||
if not states:
|
||
return "unknown"
|
||
counts: dict[str, int] = {}
|
||
for state in states:
|
||
counts[state] = counts.get(state, 0) + 1
|
||
if len(counts) == 1:
|
||
return states[0]
|
||
return ", ".join(f"{state}:{count}" for state, count in sorted(counts.items()))
|
||
|
||
|
||
def _cylinder_resize_readiness(
|
||
info: dict[str, object],
|
||
new_diameter: float | None = None,
|
||
) -> dict[str, object]:
|
||
risk = "low"
|
||
status = "ready"
|
||
warnings: list[str] = []
|
||
blockers: list[str] = []
|
||
guess = str(info.get("feature_guess", "cylindrical face"))
|
||
confidence = str(info.get("confidence", "low"))
|
||
angular_span = float(info.get("angular_span", 0.0))
|
||
|
||
if guess == "round/fillet candidate":
|
||
risk = "high"
|
||
warnings.append("当前圆柱面更像圆角/倒圆,调整圆柱孔径很可能误切圆角。")
|
||
elif guess == "boss/outer-round candidate":
|
||
risk = "high"
|
||
warnings.append("当前圆柱面更像凸柱或外圆,调整圆柱孔径可能切掉外部结构。")
|
||
elif guess != "hole/groove candidate":
|
||
risk = "high"
|
||
warnings.append("当前圆柱面还没有被识别为孔/槽候选。")
|
||
|
||
if guess == "hole/groove candidate" and confidence == "low":
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("孔/槽判断置信度较低。")
|
||
if guess == "hole/groove candidate" and angular_span < math.tau * 0.92:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("这是局部圆柱面,更像槽或半孔,不是完整圆孔。")
|
||
|
||
if new_diameter is not None:
|
||
current_diameter = float(info.get("diameter", 0.0))
|
||
height_estimate = float(info.get("height_estimate", 0.0))
|
||
if new_diameter <= 0:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
blockers.append("目标直径必须大于 0。")
|
||
elif abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6):
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
blockers.append("目标直径与当前直径几乎相同,不需要修改。")
|
||
else:
|
||
diameter_delta = abs(new_diameter - current_diameter)
|
||
delta_ratio = diameter_delta / max(current_diameter, 1e-9)
|
||
if delta_ratio > 1.0:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。")
|
||
elif delta_ratio > 0.35:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。")
|
||
|
||
if height_estimate > 0 and new_diameter > height_estimate * 2.0:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("目标直径超过圆柱面估算高度的 2 倍,几何比例异常。")
|
||
elif height_estimate > 0 and new_diameter > height_estimate:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。")
|
||
|
||
if new_diameter < current_diameter:
|
||
if guess != "hole/groove candidate":
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
blockers.append("缩小孔径当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
|
||
else:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。")
|
||
|
||
if risk in {"medium", "high"} and status != "blocked":
|
||
status = "caution"
|
||
if not warnings and not blockers:
|
||
note = "可以尝试调整圆柱孔径。"
|
||
else:
|
||
note = " ".join(blockers + warnings)
|
||
return {
|
||
"resize_status": status,
|
||
"resize_risk": risk,
|
||
"resize_warnings": ";".join(warnings),
|
||
"resize_blockers": ";".join(blockers),
|
||
"resize_note": note,
|
||
}
|
||
|
||
|
||
def _cylinder_boss_resize_readiness(
|
||
info: dict[str, object],
|
||
new_diameter: float | None = None,
|
||
) -> dict[str, object]:
|
||
risk = "low"
|
||
status = "ready"
|
||
warnings: list[str] = []
|
||
blockers: list[str] = []
|
||
guess = str(info.get("feature_guess", "cylindrical face"))
|
||
confidence = str(info.get("confidence", "low"))
|
||
angular_span = float(info.get("angular_span", 0.0))
|
||
current_diameter = float(info.get("diameter", 0.0))
|
||
height_estimate = float(info.get("height_estimate", 0.0))
|
||
|
||
if guess != "boss/outer-round candidate":
|
||
blockers.append("凸台直径调整当前版本只支持明确的凸台/外圆柱候选。")
|
||
if angular_span < math.tau * 0.92:
|
||
blockers.append("凸台直径调整当前版本只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。")
|
||
if current_diameter <= 1e-9:
|
||
blockers.append("当前圆柱面的直径估算无效。")
|
||
|
||
if guess == "boss/outer-round candidate" and confidence != "high":
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("凸台判断置信度不是 high,修改后请重点检查结果。")
|
||
|
||
if new_diameter is not None:
|
||
if new_diameter <= 0:
|
||
blockers.append("目标凸台直径必须大于 0。")
|
||
elif current_diameter > 1e-9 and abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6):
|
||
blockers.append("目标凸台直径与当前直径几乎相同,不需要修改。")
|
||
elif current_diameter > 1e-9:
|
||
delta_ratio = abs(new_diameter - current_diameter) / current_diameter
|
||
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%,请确认预览范围。")
|
||
if new_diameter < current_diameter * 0.15:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("目标凸台直径非常小,可能生成很薄或断开的几何。")
|
||
if height_estimate > 1e-9 and new_diameter > height_estimate * 3.0:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("目标凸台直径超过圆柱面估算高度的 3 倍,几何比例异常。")
|
||
elif height_estimate > 1e-9 and new_diameter > height_estimate * 1.5:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("目标凸台直径明显大于圆柱面估算高度,请确认单位。")
|
||
|
||
if blockers:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
elif risk in {"medium", "high"}:
|
||
status = "caution"
|
||
|
||
if not warnings and not blockers:
|
||
note = "可以尝试调整圆柱凸台直径。"
|
||
else:
|
||
note = " ".join(blockers + warnings)
|
||
return {
|
||
"boss_resize_status": status,
|
||
"boss_resize_risk": risk,
|
||
"boss_resize_warnings": ";".join(warnings),
|
||
"boss_resize_blockers": ";".join(blockers),
|
||
"boss_resize_note": note,
|
||
}
|
||
|
||
|
||
def _cylinder_depth_readiness(
|
||
info: dict[str, object],
|
||
target_depth: float | None = None,
|
||
) -> dict[str, object]:
|
||
risk = "low"
|
||
status = "ready"
|
||
warnings: list[str] = []
|
||
blockers: list[str] = []
|
||
guess = str(info.get("feature_guess", "cylindrical face"))
|
||
confidence = str(info.get("confidence", "low"))
|
||
angular_span = float(info.get("angular_span", 0.0))
|
||
end_type = str(info.get("cylinder_end_type", "unknown"))
|
||
current_depth = float(info.get("hole_depth_estimate", 0.0))
|
||
manual_bottom_face_used = bool(info.get("manual_bottom_face_used"))
|
||
|
||
if guess != "hole/groove candidate":
|
||
blockers.append("孔深调整当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
|
||
if end_type != "blind" and not manual_bottom_face_used:
|
||
blockers.append("孔深调整当前版本只支持端部类型为 blind 的盲孔/盲槽。")
|
||
elif end_type != "blind" and manual_bottom_face_used:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("端部类型不是明确 blind,当前按手动底面 Face ID 推断孔深方向。")
|
||
if current_depth <= 1e-9:
|
||
blockers.append("当前圆柱面没有可靠的深度估算。")
|
||
|
||
if guess == "hole/groove candidate" and confidence == "low":
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("孔/槽判断置信度较低。")
|
||
if guess == "hole/groove candidate" and angular_span < math.tau * 0.92:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("这是局部圆柱面,更像槽或半孔,孔深调整会按局部槽处理。")
|
||
|
||
if target_depth is not None:
|
||
if target_depth <= 0:
|
||
blockers.append("目标深度必须大于 0。")
|
||
elif current_depth > 1e-9 and abs(target_depth - current_depth) <= max(current_depth * 1e-5, 1e-6):
|
||
blockers.append("目标深度与当前深度几乎相同,不需要修改。")
|
||
elif current_depth > 1e-9:
|
||
delta_ratio = abs(target_depth - current_depth) / current_depth
|
||
if delta_ratio > 1.0:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("目标深度变化超过当前深度的 100%,很可能导致贯穿、误切或布尔失败。")
|
||
elif delta_ratio > 0.35:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("目标深度变化超过当前深度的 35%,请确认预览范围。")
|
||
|
||
if target_depth < current_depth * 0.08:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("目标深度非常浅,补料后可能生成很薄的局部面。")
|
||
|
||
if blockers:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
elif risk in {"medium", "high"}:
|
||
status = "caution"
|
||
|
||
if not warnings and not blockers:
|
||
note = "可以尝试调整盲孔深度。"
|
||
else:
|
||
note = " ".join(blockers + warnings)
|
||
return {
|
||
"depth_status": status,
|
||
"depth_risk": risk,
|
||
"depth_warnings": ";".join(warnings),
|
||
"depth_blockers": ";".join(blockers),
|
||
"depth_note": note,
|
||
}
|
||
|
||
|
||
def _cylinder_suppress_readiness(info: dict[str, object]) -> dict[str, object]:
|
||
risk = "low"
|
||
status = "ready"
|
||
warnings: list[str] = []
|
||
blockers: list[str] = []
|
||
guess = str(info.get("feature_guess", "cylindrical face"))
|
||
confidence = str(info.get("confidence", "low"))
|
||
angular_span = float(info.get("angular_span", 0.0))
|
||
end_type = str(info.get("cylinder_end_type", "unknown"))
|
||
height = float(info.get("height_estimate", 0.0))
|
||
diameter = float(info.get("diameter", 0.0))
|
||
|
||
if guess != "hole/groove candidate":
|
||
blockers.append("封堵圆柱孔当前版本只支持孔候选,不支持圆角、凸柱或未明确圆柱面。")
|
||
if angular_span < math.tau * 0.92:
|
||
blockers.append("封堵圆柱孔当前版本只支持接近完整圆柱的孔,不支持半孔/槽。")
|
||
if end_type == "closed/internal":
|
||
blockers.append("当前圆柱两端都像在材料内部,不像可封堵的外部孔。")
|
||
if height <= 1e-9 or diameter <= 1e-9:
|
||
blockers.append("当前圆柱孔的直径或高度估算无效。")
|
||
|
||
if guess == "hole/groove candidate" and confidence != "high":
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("孔判断置信度不是 high,封堵后请重点检查结果。")
|
||
if end_type not in {"blind", "through/open-ended"}:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("孔端部类型不明确,补料范围可能不是期望的孔范围。")
|
||
|
||
if blockers:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
elif risk in {"medium", "high"}:
|
||
status = "caution"
|
||
|
||
if not warnings and not blockers:
|
||
note = "可以尝试封堵该圆柱孔。"
|
||
else:
|
||
note = " ".join(blockers + warnings)
|
||
return {
|
||
"suppress_status": status,
|
||
"suppress_risk": risk,
|
||
"suppress_warnings": ";".join(warnings),
|
||
"suppress_blockers": ";".join(blockers),
|
||
"suppress_note": note,
|
||
}
|
||
|
||
|
||
def _edge_fillet_readiness(
|
||
info: dict[str, object],
|
||
radius: float | None = None,
|
||
) -> dict[str, object]:
|
||
risk = "medium"
|
||
status = "caution"
|
||
warnings: list[str] = ["STEP 没有建模历史,边倒圆依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"]
|
||
blockers: list[str] = []
|
||
curve = str(info.get("curve", ""))
|
||
length = float(info.get("length", 0.0))
|
||
adjacent_count = int(info.get("adjacent_face_count", 0))
|
||
|
||
if curve != "line":
|
||
blockers.append("添加圆角当前版本只支持直线Edge。")
|
||
if length <= 1e-9:
|
||
blockers.append("当前Edge长度无效。")
|
||
if adjacent_count < 2:
|
||
blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加圆角。")
|
||
elif adjacent_count > 2:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。")
|
||
|
||
if radius is not None:
|
||
if radius <= 0:
|
||
blockers.append("圆角半径必须大于 0。")
|
||
elif length > 1e-9:
|
||
ratio = radius / length
|
||
if ratio >= 0.45:
|
||
blockers.append("圆角半径接近或超过Edge长度的一半,当前版本会直接阻止。")
|
||
elif ratio > 0.25:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("圆角半径超过Edge长度的 25%,很容易导致倒圆失败。")
|
||
elif ratio > 0.12:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("圆角半径相对Edge长度偏大,请确认预览范围。")
|
||
|
||
if blockers:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
elif risk in {"medium", "high"}:
|
||
status = "caution"
|
||
|
||
if not warnings and not blockers:
|
||
note = "可以尝试给该直线边添加圆角。"
|
||
else:
|
||
note = " ".join(blockers + warnings)
|
||
return {
|
||
"fillet_status": status,
|
||
"fillet_risk": risk,
|
||
"fillet_warnings": ";".join(warnings),
|
||
"fillet_blockers": ";".join(blockers),
|
||
"fillet_note": note,
|
||
}
|
||
|
||
|
||
def _edge_chamfer_readiness(
|
||
info: dict[str, object],
|
||
distance: float | None = None,
|
||
) -> dict[str, object]:
|
||
risk = "medium"
|
||
status = "caution"
|
||
warnings: list[str] = ["STEP 没有建模历史,边倒角依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"]
|
||
blockers: list[str] = []
|
||
curve = str(info.get("curve", ""))
|
||
length = float(info.get("length", 0.0))
|
||
adjacent_count = int(info.get("adjacent_face_count", 0))
|
||
|
||
if curve != "line":
|
||
blockers.append("添加倒角当前版本只支持直线Edge。")
|
||
if length <= 1e-9:
|
||
blockers.append("当前Edge长度无效。")
|
||
if adjacent_count < 2:
|
||
blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加倒角。")
|
||
elif adjacent_count > 2:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。")
|
||
|
||
if distance is not None:
|
||
if distance <= 0:
|
||
blockers.append("倒角距离必须大于 0。")
|
||
elif length > 1e-9:
|
||
ratio = distance / length
|
||
if ratio >= 0.45:
|
||
blockers.append("倒角距离接近或超过Edge长度的一半,当前版本会直接阻止。")
|
||
elif ratio > 0.25:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("倒角距离超过Edge长度的 25%,很容易导致倒角失败。")
|
||
elif ratio > 0.12:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("倒角距离相对Edge长度偏大,请确认预览范围。")
|
||
|
||
if blockers:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
elif risk in {"medium", "high"}:
|
||
status = "caution"
|
||
|
||
if not warnings and not blockers:
|
||
note = "可以尝试给该直线边添加倒角。"
|
||
else:
|
||
note = " ".join(blockers + warnings)
|
||
return {
|
||
"chamfer_status": status,
|
||
"chamfer_risk": risk,
|
||
"chamfer_warnings": ";".join(warnings),
|
||
"chamfer_blockers": ";".join(blockers),
|
||
"chamfer_note": note,
|
||
}
|
||
|
||
|
||
def _max_risk(current: str, candidate: str) -> str:
|
||
levels = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
|
||
return candidate if levels[candidate] > levels[current] else current
|
||
|
||
|
||
def _resize_mode(current_diameter: float, target_diameter: float) -> str:
|
||
return "enlarge" if target_diameter > current_diameter else "shrink"
|
||
|
||
|
||
def _join_nonempty(*values: object) -> str:
|
||
return ";".join(str(value) for value in values if value not in {"", None})
|
||
|
||
|
||
def _int_values(value: object) -> list[int]:
|
||
if value is None or value == "":
|
||
return []
|
||
if isinstance(value, int):
|
||
return [value]
|
||
if isinstance(value, (list, tuple, set)):
|
||
result: list[int] = []
|
||
for item in value:
|
||
try:
|
||
result.append(int(item))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return result
|
||
return []
|
||
|
||
|
||
def _dir_tuple(direction) -> tuple[float, float, float]:
|
||
return (direction.X(), direction.Y(), direction.Z())
|
||
|
||
|
||
def _oriented_dir_tuple(direction, shape: TopoDS_Shape) -> tuple[float, float, float]:
|
||
values = _dir_tuple(direction)
|
||
if shape.Orientation() == TopAbs_REVERSED:
|
||
return (-values[0], -values[1], -values[2])
|
||
return values
|
||
|
||
|
||
def _neg_tuple(values: tuple[float, float, float]) -> tuple[float, float, float]:
|
||
return (-values[0], -values[1], -values[2])
|
||
|
||
|
||
def _point_tuple(point) -> tuple[float, float, float]:
|
||
return (point.X(), point.Y(), point.Z())
|
||
|
||
|
||
def _point_on_axis(axis_point: gp_Pnt, direction, parameter: float) -> gp_Pnt:
|
||
return gp_Pnt(
|
||
axis_point.X() + direction.X() * parameter,
|
||
axis_point.Y() + direction.Y() * parameter,
|
||
axis_point.Z() + direction.Z() * parameter,
|
||
)
|
||
|
||
|
||
def _direction_dot(left, right) -> float:
|
||
return left.X() * right.X() + left.Y() * right.Y() + left.Z() * right.Z()
|
||
|
||
|
||
def _axis_parameter(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float:
|
||
return (
|
||
(point.X() - axis_point.X()) * direction.X()
|
||
+ (point.Y() - axis_point.Y()) * direction.Y()
|
||
+ (point.Z() - axis_point.Z()) * direction.Z()
|
||
)
|
||
|
||
|
||
def _point_axis_distance(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float:
|
||
projected = _point_on_axis(axis_point, direction, _axis_parameter(axis_point, direction, point))
|
||
return _vec_from_points(projected, point).Magnitude()
|
||
|
||
|
||
def _shape_axis_parameters(shape: TopoDS_Shape, axis_point: gp_Pnt, direction) -> list[float]:
|
||
parameters: list[float] = []
|
||
try:
|
||
for vertex in TopologyExplorer(shape, ignore_orientation=True).vertices():
|
||
point = BRep_Tool.Pnt(topods.Vertex(vertex))
|
||
parameters.append(_axis_parameter(axis_point, direction, point))
|
||
except Exception:
|
||
parameters.clear()
|
||
try:
|
||
parameters.append(_axis_parameter(axis_point, direction, _surface_center(shape)))
|
||
except Exception:
|
||
pass
|
||
return parameters
|
||
|
||
|
||
def _shape_axis_interval(shape: TopoDS_Shape, axis_point: gp_Pnt, direction) -> tuple[float, float] | None:
|
||
parameters = _shape_axis_parameters(shape, axis_point, direction)
|
||
if not parameters:
|
||
return None
|
||
return (min(parameters), max(parameters))
|
||
|
||
|
||
def _shape_plane_interval(
|
||
shape: TopoDS_Shape,
|
||
origin: gp_Pnt,
|
||
u_dir: gp_Dir,
|
||
v_dir: gp_Dir,
|
||
) -> tuple[tuple[float, float], tuple[float, float]] | None:
|
||
u_values = _shape_axis_parameters(shape, origin, u_dir)
|
||
v_values = _shape_axis_parameters(shape, origin, v_dir)
|
||
if not u_values or not v_values:
|
||
return None
|
||
return ((min(u_values), max(u_values)), (min(v_values), max(v_values)))
|
||
|
||
|
||
def _plane_intervals_touch_or_overlap(
|
||
left: tuple[tuple[float, float], tuple[float, float]],
|
||
right: tuple[tuple[float, float], tuple[float, float]],
|
||
tolerance: float,
|
||
) -> bool:
|
||
return _intervals_touch_or_overlap(left[0], right[0], tolerance) and _intervals_touch_or_overlap(left[1], right[1], tolerance)
|
||
|
||
|
||
def _intervals_touch_or_overlap(
|
||
left: tuple[float, float],
|
||
right: tuple[float, float],
|
||
tolerance: float,
|
||
) -> bool:
|
||
left_min, left_max = min(left), max(left)
|
||
right_min, right_max = min(right), max(right)
|
||
return left_max + tolerance >= right_min and right_max + tolerance >= left_min
|
||
|
||
|
||
def _plane_basis_dirs(normal) -> tuple[gp_Dir, gp_Dir]:
|
||
nx, ny, nz = _dir_tuple(normal)
|
||
reference = (1.0, 0.0, 0.0) if abs(nx) < 0.85 else (0.0, 1.0, 0.0)
|
||
u = _tuple_normalized(_tuple_cross((nx, ny, nz), reference)) or (1.0, 0.0, 0.0)
|
||
v = _tuple_normalized(_tuple_cross((nx, ny, nz), u)) or (0.0, 1.0, 0.0)
|
||
return gp_Dir(*u), gp_Dir(*v)
|
||
|
||
|
||
def _edge_duplicate_key(edge: TopoDS_Shape, tolerance: float) -> tuple[object, ...] | None:
|
||
try:
|
||
curve = BRepAdaptor_Curve(edge)
|
||
curve_type = curve.GetType()
|
||
start = curve.Value(curve.FirstParameter())
|
||
end = curve.Value(curve.LastParameter())
|
||
except Exception:
|
||
return None
|
||
|
||
if curve_type == GeomAbs_Circle:
|
||
try:
|
||
circle = curve.Circle()
|
||
endpoint_keys = sorted((_point_quantized_key(start, tolerance), _point_quantized_key(end, tolerance)))
|
||
return (
|
||
"circle",
|
||
_point_quantized_key(circle.Location(), tolerance),
|
||
_direction_quantized_key(circle.Axis().Direction()),
|
||
_number_quantized_key(float(circle.Radius()), tolerance),
|
||
tuple(endpoint_keys),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
endpoint_keys = sorted((_point_quantized_key(start, tolerance), _point_quantized_key(end, tolerance)))
|
||
if curve_type == GeomAbs_Line:
|
||
return ("line", tuple(endpoint_keys))
|
||
return (CURVE_TYPES.get(curve_type, f"type {curve_type}"), tuple(endpoint_keys))
|
||
|
||
|
||
def _point_quantized_key(point, tolerance: float) -> tuple[int, int, int]:
|
||
scale = max(float(tolerance), 1e-9)
|
||
return (
|
||
_number_quantized_key(float(point.X()), scale),
|
||
_number_quantized_key(float(point.Y()), scale),
|
||
_number_quantized_key(float(point.Z()), scale),
|
||
)
|
||
|
||
|
||
def _direction_quantized_key(direction) -> tuple[int, int, int]:
|
||
values = _tuple_normalized((float(direction.X()), float(direction.Y()), float(direction.Z()))) or (1.0, 0.0, 0.0)
|
||
for value in values:
|
||
if abs(value) > 1e-9:
|
||
if value < 0:
|
||
values = (-values[0], -values[1], -values[2])
|
||
break
|
||
return (
|
||
int(round(values[0] * 1_000_000)),
|
||
int(round(values[1] * 1_000_000)),
|
||
int(round(values[2] * 1_000_000)),
|
||
)
|
||
|
||
|
||
def _number_quantized_key(value: float, tolerance: float) -> int:
|
||
return int(round(float(value) / max(float(tolerance), 1e-9)))
|
||
|
||
|
||
def _surface_center(shape: TopoDS_Shape) -> gp_Pnt:
|
||
props = GProp_GProps()
|
||
brepgprop.SurfaceProperties(shape, props)
|
||
return props.CentreOfMass()
|
||
|
||
|
||
def _orientation_name(orientation) -> str:
|
||
return ORIENTATION_TYPES.get(orientation, f"type {orientation}")
|
||
|
||
|
||
def _format_tuple(values: tuple[float, float, float]) -> str:
|
||
return "(" + ", ".join(f"{float(value):.6g}" for value in values) + ")"
|
||
|
||
|
||
def _vec_from_points(a: gp_Pnt, b: gp_Pnt) -> gp_Vec:
|
||
return gp_Vec(b.X() - a.X(), b.Y() - a.Y(), b.Z() - a.Z())
|
||
|
||
|
||
def _point_distance_sq(
|
||
point: tuple[float, float, float],
|
||
target: tuple[float, float, float],
|
||
) -> float:
|
||
dx = point[0] - target[0]
|
||
dy = point[1] - target[1]
|
||
dz = point[2] - target[2]
|
||
return dx * dx + dy * dy + dz * dz
|
||
|
||
|
||
def _point_segment_distance_sq(
|
||
point: tuple[float, float, float],
|
||
start: tuple[float, float, float],
|
||
end: tuple[float, float, float],
|
||
) -> float:
|
||
vx = end[0] - start[0]
|
||
vy = end[1] - start[1]
|
||
vz = end[2] - start[2]
|
||
wx = point[0] - start[0]
|
||
wy = point[1] - start[1]
|
||
wz = point[2] - start[2]
|
||
length_sq = vx * vx + vy * vy + vz * vz
|
||
if length_sq <= 1e-18:
|
||
return _point_distance_sq(point, start)
|
||
t = (wx * vx + wy * vy + wz * vz) / length_sq
|
||
t = max(0.0, min(1.0, t))
|
||
projection = (start[0] + t * vx, start[1] + t * vy, start[2] + t * vz)
|
||
return _point_distance_sq(point, projection)
|
||
|
||
|
||
def _dot(vec: gp_Vec, direction) -> float:
|
||
return vec.X() * direction.X() + vec.Y() * direction.Y() + vec.Z() * direction.Z()
|
||
|
||
__all__ = [name for name, value in globals().items() if name.startswith("_") and callable(value)]
|