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