"""Reusable arc-polyline deskew geometry for BGA diff-pair escapes."""
import math
from dataclasses import dataclass
from jitx.feature import OverlappableCopper
from jitx.shapes.primitive import Arc, ArcPolyline
DEFAULT_LAYER = 7 # L8-Signal4, zero-based layer index
DEFAULT_TRACE_W = 0.13
DEFAULT_PAIR_SPACING = 0.10
DEFAULT_BALL_DIA = 0.5
DEFAULT_EXIT_ABOVE_LOWER_BALL = 0.25
DEFAULT_EXIT_BALL_CLEARANCE = 0.0
DEFAULT_RIGHT_R_WRAP = 0.5
_ANGLE_EPS_DEG = 1e-6
_MIN_SWEEP_DEG = 1e-3
_MAX_ARC_SWEEP_DEG = 180.0
_GEOMETRY_EPS = 1e-9
[docs]
@dataclass(frozen=True)
class DeskewBuild:
"""Result of one ``deskew_pair`` call."""
right_shape: ArcPolyline
left_shape: ArcPolyline
right_copper: OverlappableCopper
left_copper: OverlappableCopper
right_exit: tuple[float, float]
left_exit: tuple[float, float]
bbox: tuple[float, float, float, float]
def _copper(shape: ArcPolyline, layer: int):
return OverlappableCopper(shape, layer=layer)
[docs]
def is_overlappable_copper(copper) -> bool:
return OverlappableCopper is not None and isinstance(copper, OverlappableCopper)
def _require_positive(name: str, value: float) -> None:
if not math.isfinite(value) or value <= 0.0:
raise ValueError(f"{name} must be positive, got {value!r}")
def _normalize_angle_deg(angle_deg: float) -> float:
if not math.isfinite(angle_deg):
raise ValueError(f"angle must be finite, got {angle_deg!r}")
value = angle_deg % 360.0
return 0.0 if math.isclose(value, 360.0, abs_tol=_ANGLE_EPS_DEG) else value
def _validate_arc_sweep(
name: str,
sweep_deg: float,
*,
min_abs_deg: float = _MIN_SWEEP_DEG,
max_abs_deg: float = _MAX_ARC_SWEEP_DEG,
) -> None:
if not math.isfinite(sweep_deg):
raise ValueError(f"{name} sweep must be finite, got {sweep_deg!r}")
abs_sweep = abs(sweep_deg)
if abs_sweep < min_abs_deg:
raise ValueError(
f"{name} sweep {sweep_deg:.6f} deg is too small; "
"choose a different theta_exit_deg / right_r_wrap"
)
if abs_sweep > max_abs_deg + _ANGLE_EPS_DEG:
raise ValueError(
f"{name} sweep {sweep_deg:.6f} deg exceeds {max_abs_deg:.1f} deg; "
"refusing to generate a near-full-circle deskew arc"
)
[docs]
def deskew_pair(
*,
right_pad: tuple[float, float],
left_pad: tuple[float, float],
theta_exit_deg: float = 0.0,
layer: int = DEFAULT_LAYER,
trace_w: float = DEFAULT_TRACE_W,
pair_spacing: float = DEFAULT_PAIR_SPACING,
ball_dia: float = DEFAULT_BALL_DIA,
exit_above_lower: float = DEFAULT_EXIT_ABOVE_LOWER_BALL,
exit_ball_clearance: float = DEFAULT_EXIT_BALL_CLEARANCE,
right_r_wrap: float = DEFAULT_RIGHT_R_WRAP,
) -> DeskewBuild:
"""Build deskew copper for one vertical diff pair.
``right_pad`` is the lower-Y ball and receives the wrapping leg.
``left_pad`` is the upper-Y ball and receives the larger concentric arc.
The names describe geometric slots from the recovered generator, not
electrical polarity.
"""
_require_positive("trace_w", trace_w)
if not math.isfinite(pair_spacing) or pair_spacing < 0.0:
raise ValueError(f"pair_spacing must be non-negative, got {pair_spacing!r}")
_require_positive("ball_dia", ball_dia)
_require_positive("right_r_wrap", right_r_wrap)
if not math.isclose(right_pad[0], left_pad[0]):
raise ValueError(
f"right_pad and left_pad must share an X column "
f"({right_pad[0]} vs {left_pad[0]})"
)
if right_pad[1] >= left_pad[1]:
raise ValueError(
f"right_pad must sit below left_pad in Y ({right_pad[1]} >= {left_pad[1]})"
)
pair_y_pitch = left_pad[1] - right_pad[1]
if not (0.0 < exit_above_lower < pair_y_pitch):
raise ValueError(
f"exit_above_lower must be between 0 and pair pitch "
f"{pair_y_pitch:.4f}, got {exit_above_lower!r}"
)
pair_pitch = trace_w + pair_spacing
ball_half = ball_dia / 2
ball_x = right_pad[0]
exit_y = right_pad[1] + exit_above_lower
struct_right_x = ball_x - ball_half - exit_ball_clearance
right_trace_x = struct_right_x - trace_w / 2
left_trace_x = right_trace_x - pair_pitch
right_exit = (right_trace_x, exit_y)
left_exit = (left_trace_x, exit_y)
bx, by = right_pad
rex, rey = right_exit
dx_r = rex - bx
dy_r = rey - by
denom = 2.0 * (dx_r + right_r_wrap)
if abs(denom) <= _GEOMETRY_EPS:
raise ValueError("Singular geometry: dx + right_r_wrap = 0")
r3 = (right_r_wrap * right_r_wrap - dx_r * dx_r - dy_r * dy_r) / denom
if not (0.0 < r3 < right_r_wrap):
raise ValueError(
f"r3 = {r3:.4f} out of (0, {right_r_wrap}); choose a different "
f"right_r_wrap / exit"
)
sin_t2 = dy_r / (right_r_wrap - r3)
cos_t2 = (dx_r + r3) / (right_r_wrap - r3)
theta_2_deg = math.degrees(math.atan2(sin_t2, cos_t2))
c_3 = (rex + r3, rey)
right_arcline = _build_right_trace(
right_pad,
right_exit,
right_r_wrap,
theta_exit_deg,
c_3,
r3,
theta_2_deg,
trace_w,
)
left_arcline = _build_left_trace(left_pad, left_exit, c_3, trace_w)
xmin = left_trace_x - trace_w / 2
xmax = ball_x + max(ball_half, right_r_wrap)
ymin = right_pad[1] - right_r_wrap
ymax = left_pad[1] + ball_half
return DeskewBuild(
right_shape=right_arcline,
left_shape=left_arcline,
right_copper=_copper(right_arcline, layer),
left_copper=_copper(left_arcline, layer),
right_exit=right_exit,
left_exit=left_exit,
bbox=(xmin, ymin, xmax, ymax),
)
def _build_left_trace(
left_pad: tuple[float, float],
left_exit: tuple[float, float],
c_3: tuple[float, float],
trace_w: float,
) -> ArcPolyline:
# Left leg: an arc on the circle of radius |c_3->left_exit| about c_3, swept
# clockwise from left_exit to the point where it becomes tangent to the line
# into the off-circle left_pad, then a straight segment into the pad.
cx, cy = c_3
r = math.hypot(left_exit[0] - cx, left_exit[1] - cy)
px, py = left_pad
dx_p = px - cx
dy_p = py - cy
norm = math.hypot(dx_p, dy_p)
if r > norm + _GEOMETRY_EPS:
raise ValueError(
f"left_pad inside fillet circle (r={r:.4f} > |c_3-pad|={norm:.4f})"
)
if norm <= _GEOMETRY_EPS:
raise ValueError("left_pad coincides with fillet center")
phi = math.degrees(math.atan2(dy_p, dx_p))
delta = math.degrees(math.acos(max(-1.0, min(1.0, r / norm))))
alpha_end = phi + delta
sweep_deg = alpha_end - 180.0
if sweep_deg >= -_MIN_SWEEP_DEG:
raise ValueError(
f"left trace arc sweep {sweep_deg:.6f} deg is not clockwise; "
"choose a different theta_exit_deg / right_r_wrap"
)
_validate_arc_sweep("left trace arc", sweep_deg)
return ArcPolyline(
trace_w,
[
left_exit,
Arc((cx, cy), r, 180.0, sweep_deg),
left_pad,
],
)
def _build_right_trace(
right_pad: tuple[float, float],
right_exit: tuple[float, float],
radius: float,
theta_exit_deg: float,
c_3: tuple[float, float],
r3: float,
theta_2_deg: float,
trace_w: float,
) -> ArcPolyline:
# Right leg: three tangent arcs from right_pad to right_exit -- a half-circle
# wrap of radius/2 off the pad, a main arc of radius about the pad, then a
# fillet arc of r3 on the c_3 circle into the exit.
bx, by = right_pad
theta_exit_norm = _normalize_angle_deg(theta_exit_deg)
theta_e = math.radians(theta_exit_norm)
cos_e, sin_e = math.cos(theta_e), math.sin(theta_e)
m1 = (bx + (radius / 2) * cos_e, by + (radius / 2) * sin_e)
arc1_start_deg = theta_exit_norm + 180.0
arc1_sweep_deg = 180.0
arc2_sweep_deg = theta_2_deg - theta_exit_norm
arc3_sweep_deg = 180.0 - theta_2_deg
if arc2_sweep_deg <= _MIN_SWEEP_DEG:
raise ValueError(
f"theta_exit_deg={theta_exit_deg:.6f} deg is at or beyond the "
f"right-trace tangent angle {theta_2_deg:.6f} deg for "
f"right_r_wrap={radius:.4f}; raw arc sweep would be "
f"{arc2_sweep_deg:.6f} deg"
)
_validate_arc_sweep("right trace arc 1", arc1_sweep_deg)
_validate_arc_sweep("right trace arc 2", arc2_sweep_deg)
_validate_arc_sweep("right trace arc 3", arc3_sweep_deg)
return ArcPolyline(
trace_w,
[
right_pad,
Arc(m1, radius / 2, arc1_start_deg, arc1_sweep_deg),
Arc((bx, by), radius, theta_exit_norm, arc2_sweep_deg),
Arc(c_3, r3, theta_2_deg, arc3_sweep_deg),
right_exit,
],
)