158 lines
6.6 KiB
Python
158 lines
6.6 KiB
Python
"""Parametric equal-thickness sheet-metal bend generator (``bend_add``).
|
|
|
|
A bent sheet is an equal-thickness ribbon around its mid-plane centreline:
|
|
straight wings joined by tangent circular-arc corners of radius
|
|
``r_m = r_i + t/2``. We walk the wing chain in the local XY bending plane
|
|
(first wing along +X), round each fold vertex with ``fillet_2d``, offset the
|
|
centreline by ``+t/2`` / ``-t/2``, close both offset curves with square end
|
|
caps and extrude by ``width_mm`` along +Z. Cross-section area is exactly
|
|
``thickness * centreline_length`` so the closed-form volume is
|
|
``t * w * L_mid`` with ``L_mid = sum(leg) - sum(2 r_m cot(alpha/2)) +
|
|
sum(r_m (pi - alpha))`` (alpha = interior angle in radians). Geometric-
|
|
equivalence only: no K-factor flattening, so the volume deliberately differs
|
|
from the flat blank. Local frame: +X = first wing, +Y = thickness normal,
|
|
+Z = fold (width) axis, first wing starts at the origin. The module reads
|
|
only ``runtime_types.BendSpec`` and returns an OCC ``Solid``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from build123d import Edge, Face, Side, Solid, Vector, Wire
|
|
|
|
try: # build123d >= 0.9 exposes Kind; keep the import optional.
|
|
from build123d import Kind as _Kind
|
|
|
|
_KIND_ARC = _Kind.ARC
|
|
except ImportError: # pragma: no cover
|
|
_KIND_ARC = None
|
|
|
|
from .runtime_types import BendLeg, BendSpec
|
|
|
|
|
|
def _unit(angle_deg: float) -> tuple[float, float]:
|
|
angle = math.radians(angle_deg)
|
|
return math.cos(angle), math.sin(angle)
|
|
|
|
|
|
def _perp(value: Vector) -> Vector:
|
|
return Vector(-value.Y, value.X, 0.0)
|
|
|
|
|
|
def bend_folds(spec: BendSpec) -> tuple[list[float], list[tuple[float, float, float, int]]]:
|
|
"""Return ``(legs, folds)``; ``folds[i] = (angle, r_i, r_m, side)``."""
|
|
if not spec.chain:
|
|
raise ValueError("bend chain must contain at least one wing")
|
|
legs = [float(leg.leg_mm) for leg in spec.chain]
|
|
folds: list[tuple[float, float, float, int]] = []
|
|
for index in range(len(spec.chain) - 1):
|
|
leg: BendLeg = spec.chain[index]
|
|
if leg.bend_angle_deg is None:
|
|
raise ValueError(f"bend chain[{index}] needs bend_angle_deg towards the next wing")
|
|
angle = float(leg.bend_angle_deg)
|
|
if not 0 < angle < 180:
|
|
raise ValueError("bend interior angle must be between 0 and 180 degrees")
|
|
radius = float(leg.inner_radius_mm)
|
|
if radius < 0:
|
|
raise ValueError("bend inner radius must be non-negative")
|
|
folds.append((angle, radius, radius + spec.thickness_mm / 2.0, int(leg.side)))
|
|
return legs, folds
|
|
|
|
|
|
def fold_vertices(legs: list[float], folds: list[tuple[float, float, float, int]]) -> list[Vector]:
|
|
"""Fold-vertex polyline (both end points included) in the XY plane."""
|
|
angle = 0.0
|
|
points = [Vector(0.0, 0.0, 0.0)]
|
|
x = y = 0.0
|
|
for index in range(len(legs)):
|
|
dx, dy = _unit(angle)
|
|
x += dx * legs[index]
|
|
y += dy * legs[index]
|
|
points.append(Vector(x, y, 0.0))
|
|
if index < len(folds):
|
|
bend_angle_deg, _r_i, _r_m, side = folds[index]
|
|
angle += float(side) * (180.0 - bend_angle_deg)
|
|
return points
|
|
|
|
|
|
def mid_path_length_mm(spec: BendSpec) -> float:
|
|
"""Exact centreline length of the bent part (mm)."""
|
|
legs, folds = bend_folds(spec)
|
|
total = sum(legs)
|
|
for bend_angle_deg, _r_i, mid_radius, _side in folds:
|
|
delta = math.radians(180.0 - bend_angle_deg)
|
|
cut = mid_radius / math.tan(math.radians(bend_angle_deg) / 2.0)
|
|
total -= 2.0 * cut
|
|
total += mid_radius * delta
|
|
return total
|
|
|
|
|
|
def validate_chain(spec: BendSpec) -> float:
|
|
"""Range-check a bend spec; return the centreline length.
|
|
|
|
Every wing must keep a positive straight portion after its neighbouring
|
|
bend corners consume their tangent lengths (otherwise corners overlap).
|
|
"""
|
|
if spec.thickness_mm <= 0 or spec.width_mm <= 0:
|
|
raise ValueError("bend requires positive thickness_mm and width_mm")
|
|
legs, folds = bend_folds(spec)
|
|
cuts: list[float] = []
|
|
for bend_angle_deg, _r_i, mid_radius, _side in folds:
|
|
cuts.append(mid_radius / math.tan(math.radians(bend_angle_deg) / 2.0))
|
|
for index in range(len(legs)):
|
|
left = cuts[index - 1] if index > 0 else 0.0
|
|
right = cuts[index] if index < len(folds) else 0.0
|
|
if legs[index] - left - right <= 0:
|
|
raise ValueError(
|
|
f"bend wing {index} (leg {legs[index]:.4f} mm) is consumed by the adjacent "
|
|
f"bend radii (needs > {left + right:.4f} mm)"
|
|
)
|
|
return mid_path_length_mm(spec)
|
|
|
|
|
|
def _centerline_wire(legs, folds):
|
|
"""Tangent-continuous filleted centreline wire in the XY plane."""
|
|
points = fold_vertices(legs, folds)
|
|
wire = Wire([Edge.make_line(points[i], points[i + 1]) for i in range(len(points) - 1)])
|
|
for index, (bend_angle_deg, _r_i, mid_radius, _side) in enumerate(folds):
|
|
if mid_radius <= 0:
|
|
continue
|
|
target = points[index + 1]
|
|
selected = [
|
|
vertex for vertex in wire.vertices()
|
|
if (vertex.X - target.X) ** 2 + (vertex.Y - target.Y) ** 2 < 1e-9
|
|
]
|
|
if not selected:
|
|
raise ValueError(f"bend corner {index} vertex not found")
|
|
try:
|
|
wire = wire.fillet_2d(mid_radius, selected)
|
|
except Exception as error: # pragma: no cover - defensive
|
|
raise ValueError(
|
|
f"bend corner {index} fillet of radius {mid_radius:.4f} mm failed"
|
|
) from error
|
|
return wire, points
|
|
|
|
|
|
def build_bend_solid(spec: BendSpec) -> Solid:
|
|
"""Build the bent sheet in the local frame (+Z = width axis).
|
|
|
|
The first wing's mid-plane runs from the origin along +X; thickness spans
|
|
+/- t/2 across the XY mid-plane and the width spans z in [0, width_mm].
|
|
The caller (geometry adapter) places the solid at ``spec.frame``.
|
|
"""
|
|
thickness = float(spec.thickness_mm)
|
|
width = float(spec.width_mm)
|
|
legs, folds = bend_folds(spec)
|
|
validate_chain(spec)
|
|
half = thickness / 2.0
|
|
wire, points = _centerline_wire(legs, folds)
|
|
left = wire.offset_2d(half, kind=_KIND_ARC, side=Side.LEFT, closed=False)
|
|
right = wire.offset_2d(half, kind=_KIND_ARC, side=Side.RIGHT, closed=False)
|
|
start_tangent = (points[1] - points[0]).normalized()
|
|
end_tangent = (points[-1] - points[-2]).normalized()
|
|
cap_start = Edge.make_line(points[0] + _perp(start_tangent) * half, points[0] - _perp(start_tangent) * half)
|
|
cap_end = Edge.make_line(points[-1] + _perp(end_tangent) * half, points[-1] - _perp(end_tangent) * half)
|
|
closed = Wire([*left.edges(), cap_end, *right.edges(), cap_start])
|
|
return Solid.extrude(Face(closed), Vector(0.0, 0.0, width))
|