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

1418 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
from 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_GTransform, 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_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ
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 _scaled_shape(shape: TopoDS_Shape, scale: float, center: tuple[float, float, float]) -> TopoDS_Shape:
if abs(scale - 1.0) <= 1e-12:
return shape
if scale <= 0:
raise ValueError("Scale factor must be greater than 0.")
trsf = gp_Trsf()
trsf.SetScale(gp_Pnt(*center), float(scale))
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
def _axis_scaled_shape(
shape: TopoDS_Shape,
axis_name: str,
scale: float,
center: tuple[float, float, float],
) -> TopoDS_Shape:
if abs(scale - 1.0) <= 1e-12:
return shape
if scale <= 0:
raise ValueError("Scale factor must be greater than 0.")
axis = axis_name.upper()
axis_index = {"X": 0, "Y": 1, "Z": 2}.get(axis)
if axis_index is None:
raise ValueError("Scale axis must be X, Y or Z.")
matrix = [
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
]
matrix[axis_index][axis_index] = float(scale)
cx, cy, cz = center
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("Axis scale transform failed.")
result = builder.Shape()
if result.IsNull():
raise RuntimeError("Axis scale transform produced an empty shape.")
return result
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 _float_or_none(value: object) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
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_add(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)
# A 90-degree edge fillet restores a sharp edge about sqrt(2) * R away
# from the fillet cylinder axis, so 1.25R misses the common box case.
distance_limit = max(reference_radius * 1.8, 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, *, use_glue: bool | None = None) -> TopoDS_Shape:
op.SetNonDestructive(True)
glue_enabled = "cut" not in operation_name.lower() if use_glue is None else bool(use_glue)
if glue_enabled and 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 new_diameter > current_diameter * 2.0:
status = "blocked"
risk = "blocked"
blockers.append(
"目标直径超过当前直径的 2 倍;当前受限 B-Rep 孔径编辑会直接阻止这种极端放大,"
"否则很容易出现布尔返回成功但孔壁没有真正变成目标直径。"
)
elif 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:
status = "blocked"
risk = "blocked"
blockers.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)]