Files
reducers/backend/src/joint_module/step_geometry.py
T

299 lines
10 KiB
Python

from __future__ import annotations
import math
from itertools import combinations
from pathlib import Path
from typing import Iterable
from ..cad.ocp_interference import (
InterferenceResult,
OcpInterferenceError,
ShapeMetrics,
_bbox_overlap,
_import_ocp,
common_volume,
read_step_shape,
shape_bbox,
shape_valid,
shape_volume,
)
from .models import BBox, ComponentPlacement, StepShapeMetrics
def _normalize(vector: Iterable[float]) -> tuple[float, float, float]:
values = tuple(float(value) for value in vector)
length = math.sqrt(sum(value * value for value in values))
if length <= 1e-12:
return (0.0, 0.0, 1.0)
return tuple(value / length for value in values) # type: ignore[return-value]
def dot(a: Iterable[float], b: Iterable[float]) -> float:
return sum(float(x) * float(y) for x, y in zip(a, b))
def cross(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]:
ax, ay, az = (float(value) for value in a)
bx, by, bz = (float(value) for value in b)
return (ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx)
def vector_add(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]:
return tuple(float(x) + float(y) for x, y in zip(a, b)) # type: ignore[return-value]
def vector_sub(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]:
return tuple(float(x) - float(y) for x, y in zip(a, b)) # type: ignore[return-value]
def vector_scale(a: Iterable[float], scale: float) -> tuple[float, float, float]:
return tuple(float(x) * scale for x in a) # type: ignore[return-value]
def axis_angle_between(
source: Iterable[float],
target: Iterable[float],
) -> tuple[float, float, float, float]:
source_n = _normalize(source)
target_n = _normalize(target)
cross_value = cross(source_n, target_n)
cross_norm = math.sqrt(dot(cross_value, cross_value))
dot_value = max(-1.0, min(1.0, dot(source_n, target_n)))
if cross_norm <= 1e-12:
if dot_value >= 0.0:
return (0.0, 0.0, 1.0, 0.0)
fallback = cross(source_n, (1.0, 0.0, 0.0))
if math.sqrt(dot(fallback, fallback)) <= 1e-12:
fallback = cross(source_n, (0.0, 1.0, 0.0))
axis = _normalize(fallback)
return (*axis, 180.0)
axis = _normalize(cross_value)
return (*axis, math.degrees(math.atan2(cross_norm, dot_value)))
def rotate_vector(
vector: Iterable[float],
rotation_axis_angle_deg: Iterable[float],
) -> tuple[float, float, float]:
vx, vy, vz = (float(value) for value in vector)
ax, ay, az, angle_deg = (float(value) for value in rotation_axis_angle_deg)
axis = _normalize((ax, ay, az))
angle = math.radians(angle_deg)
cos_a = math.cos(angle)
sin_a = math.sin(angle)
ux, uy, uz = axis
cross_part = cross(axis, (vx, vy, vz))
dot_part = dot(axis, (vx, vy, vz))
return (
vx * cos_a + cross_part[0] * sin_a + ux * dot_part * (1.0 - cos_a),
vy * cos_a + cross_part[1] * sin_a + uy * dot_part * (1.0 - cos_a),
vz * cos_a + cross_part[2] * sin_a + uz * dot_part * (1.0 - cos_a),
)
def apply_placement_to_point(
point: Iterable[float],
placement: ComponentPlacement,
) -> tuple[float, float, float]:
rotated = rotate_vector(point, placement.rotation_axis_angle_deg)
return vector_add(rotated, placement.translation_mm)
def apply_placement_to_direction(
direction: Iterable[float],
placement: ComponentPlacement,
) -> tuple[float, float, float]:
return _normalize(rotate_vector(direction, placement.rotation_axis_angle_deg))
def bbox_axis_range(bbox: BBox, axis: Iterable[float]) -> tuple[float, float]:
xmin, ymin, zmin, xmax, ymax, zmax = bbox
axis_n = _normalize(axis)
values = []
for x in [xmin, xmax]:
for y in [ymin, ymax]:
for z in [zmin, zmax]:
values.append(dot((x, y, z), axis_n))
return (min(values), max(values))
def bbox_center(bbox: BBox) -> tuple[float, float, float]:
xmin, ymin, zmin, xmax, ymax, zmax = bbox
return ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0)
def union_bbox(bboxes: Iterable[BBox]) -> BBox:
items = list(bboxes)
if not items:
raise ValueError("union_bbox requires at least one bbox")
return (
min(item[0] for item in items),
min(item[1] for item in items),
min(item[2] for item in items),
max(item[3] for item in items),
max(item[4] for item in items),
max(item[5] for item in items),
)
def subshape_count(shape, kind_name: str) -> int:
ocp = _import_ocp()
kind = {
"solid": ocp["TopAbs_SOLID"],
"face": ocp["TopAbs_FACE"],
}[kind_name]
explorer = ocp["TopExp_Explorer"](shape, kind)
count = 0
while explorer.More():
count += 1
explorer.Next()
return count
def face_type_counts(shape) -> tuple[int, int]:
ocp = _import_ocp()
explorer = ocp["TopExp_Explorer"](shape, ocp["TopAbs_FACE"])
plane_count = 0
cylinder_count = 0
while explorer.More():
try:
face = ocp["TopoDS"].Face_s(explorer.Current())
surface = ocp["BRepAdaptor_Surface"](face)
surface_type = surface.GetType()
if surface_type == ocp["GeomAbs_Plane"]:
plane_count += 1
elif surface_type == ocp["GeomAbs_Cylinder"]:
cylinder_count += 1
except Exception: # noqa: BLE001
pass
explorer.Next()
return plane_count, cylinder_count
def metrics_for_shape(
*,
component_id: str,
step_path: Path,
shape,
) -> StepShapeMetrics:
plane_count, cylinder_count = face_type_counts(shape)
return StepShapeMetrics(
component_id=component_id,
step_path=str(step_path.resolve()),
valid=shape_valid(shape),
volume_mm3=shape_volume(shape),
bbox=shape_bbox(shape),
solid_count=subshape_count(shape, "solid"),
face_count=subshape_count(shape, "face"),
plane_face_count=plane_count,
cylinder_face_count=cylinder_count,
)
def metrics_for_step(component_id: str, path: Path) -> StepShapeMetrics:
shape = read_step_shape(path)
return metrics_for_shape(component_id=component_id, step_path=path, shape=shape)
def apply_transform(shape, placement: ComponentPlacement):
ocp = _import_ocp()
ax, ay, az, angle_deg = placement.rotation_axis_angle_deg
transformed = shape
if abs(angle_deg) > 1e-12:
rotation = ocp["gp_Trsf"]()
rotation.SetRotation(
ocp["gp_Ax1"](
ocp["gp_Pnt"](0.0, 0.0, 0.0),
ocp["gp_Dir"](float(ax), float(ay), float(az)),
),
math.radians(float(angle_deg)),
)
transformed = ocp["BRepBuilderAPI_Transform"](transformed, rotation, True).Shape()
tx, ty, tz = placement.translation_mm
if abs(tx) > 1e-12 or abs(ty) > 1e-12 or abs(tz) > 1e-12:
translation = ocp["gp_Trsf"]()
translation.SetTranslation(ocp["gp_Vec"](float(tx), float(ty), float(tz)))
transformed = ocp["BRepBuilderAPI_Transform"](transformed, translation, True).Shape()
return transformed
def write_compound_step(shapes: dict[str, object], path: Path) -> None:
if not shapes:
raise OcpInterferenceError("cannot_write_empty_joint_compound")
compound = compound_shape(shapes)
ocp = _import_ocp()
path.parent.mkdir(parents=True, exist_ok=True)
writer = ocp["STEPControl_Writer"]()
writer.Transfer(compound, ocp["STEPControl_AsIs"])
status = writer.Write(str(path))
if status != ocp["IFSelect_RetDone"]:
raise OcpInterferenceError(f"step_write_failed: {path}")
def compound_shape(shapes: dict[str, object]):
if not shapes:
raise OcpInterferenceError("cannot_build_empty_joint_compound")
ocp = _import_ocp()
compound = ocp["TopoDS_Compound"]()
builder = ocp["BRep_Builder"]()
builder.MakeCompound(compound)
for shape in shapes.values():
builder.Add(compound, shape)
return compound
def detect_interferences_from_placed_shapes(
shapes: dict[str, object],
*,
step_paths: dict[str, Path],
check_pairs: Iterable[tuple[str, str]] | None = None,
volume_tolerance_mm3: float = 1e-6,
bbox_tolerance_mm: float = 1e-7,
) -> tuple[list[ShapeMetrics], list[InterferenceResult]]:
if len(shapes) < 2:
raise OcpInterferenceError("need_at_least_two_placed_shapes")
metrics = [
ShapeMetrics(
component_id=component_id,
valid=shape_valid(shape),
volume_mm3=shape_volume(shape),
bbox=shape_bbox(shape),
)
for component_id, shape in shapes.items()
]
metric_by_id = {metric.component_id: metric for metric in metrics}
pairs = list(check_pairs) if check_pairs is not None else list(combinations(sorted(shapes), 2))
results: list[InterferenceResult] = []
for component_a, component_b in pairs:
if component_a not in shapes or component_b not in shapes:
raise OcpInterferenceError(f"unknown_interference_pair: {component_a}, {component_b}")
bbox_overlap = _bbox_overlap(
metric_by_id[component_a].bbox,
metric_by_id[component_b].bbox,
tolerance_mm=bbox_tolerance_mm,
)
if not bbox_overlap:
results.append(
InterferenceResult(
component_a=component_a,
component_b=component_b,
common_volume_mm3=0.0,
bbox_overlap=False,
checked_boolean=False,
interfering=False,
)
)
continue
volume = common_volume(shapes[component_a], shapes[component_b])
results.append(
InterferenceResult(
component_a=component_a,
component_b=component_b,
common_volume_mm3=volume,
bbox_overlap=True,
checked_boolean=True,
interfering=volume > volume_tolerance_mm3,
)
)
return metrics, results