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

1340 lines
49 KiB
Python
Raw Normal View History

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)]