Files

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