"""Geometric primitives for ASITIC shapes.
The original C code packs every shape into a 188-byte (``0xbc``)
record with field offsets recovered by Ghidra; here we use plain
dataclasses. The conceptual model preserved is::
Shape ──> Polygon ──> Polygon ──> ... (linked list, +0xec next)
│
└── per-polygon: vertices, metal layer, edge data
The numerical kernel doesn't actually consume polygon vertices
directly — it consumes a list of *segments* (straight conductor
runs) that get further discretised into *filaments*. Each segment
carries its endpoints, width, thickness, metal layer and direction
unit-vector.
For the closed-form Grover formulas used in the inductance kernels
the segment orientation is captured by its endpoints; width and
thickness come from the metal-layer descriptor.
"""
from __future__ import annotations
import math
from collections.abc import Callable
from dataclasses import dataclass, field
from reasitic.tech import Metal, Tech
[docs]
@dataclass(frozen=True)
class Point:
"""A 3D point in microns. ``z`` is the metal-layer center height."""
x: float
y: float
z: float = 0.0
def __add__(self, other: Point) -> Point:
"""Component-wise vector addition."""
return Point(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other: Point) -> Point:
"""Component-wise vector subtraction."""
return Point(self.x - other.x, self.y - other.y, self.z - other.z)
[docs]
def distance_to(self, other: Point) -> float:
"""3D Euclidean distance to ``other``."""
dx = self.x - other.x
dy = self.y - other.y
dz = self.z - other.z
return math.sqrt(dx * dx + dy * dy + dz * dz)
[docs]
@dataclass
class Segment:
"""A straight conductor run between two endpoints.
``a`` and ``b`` are the endpoints; ``width`` and ``thickness`` are
the cross-section dimensions (microns). ``metal`` is the metal
layer index (resolved against :class:`reasitic.tech.Tech`).
"""
a: Point
b: Point
width: float
thickness: float
metal: int
@property
def length(self) -> float:
"""Length of the segment in the same units as the endpoints."""
return self.a.distance_to(self.b)
@property
def direction(self) -> tuple[float, float, float]:
"""Unit vector from ``a`` to ``b``. Zero-length segments return ``(0, 0, 0)``."""
L = self.length
if L == 0.0:
return (0.0, 0.0, 0.0)
return (
(self.b.x - self.a.x) / L,
(self.b.y - self.a.y) / L,
(self.b.z - self.a.z) / L,
)
[docs]
@dataclass
class Polygon:
"""A closed polyline on a single metal layer."""
vertices: list[Point]
metal: int
width: float = 0.0
thickness: float = 0.0
[docs]
def edges(self) -> list[Segment]:
"""Return the segments connecting consecutive vertices."""
segs: list[Segment] = []
n = len(self.vertices)
for i in range(n - 1):
segs.append(
Segment(
a=self.vertices[i],
b=self.vertices[i + 1],
width=self.width,
thickness=self.thickness,
metal=self.metal,
)
)
return segs
[docs]
@dataclass
class Shape:
"""A named structure built up from polygons.
Mirrors the original C ``Shape`` record (offsets 0x00..0xbc).
Per-shape parameters (``width``, ``spacing``, ``turns``, etc.)
are stored verbatim from the build call so downstream code can
re-emit the original CLI form.
"""
name: str
polygons: list[Polygon] = field(default_factory=list)
# Build parameters retained for re-export / Geom-info display
width: float = 0.0
length: float = 0.0
spacing: float = 0.0
turns: float = 0.0
sides: int = 4
metal: int = 0
exit_metal: int | None = None
x_origin: float = 0.0
y_origin: float = 0.0
orientation: int = 0 # 1 == cw, -1 == ccw, 0 == as-built
phase: float = 0.0
kind: str = ""
# Polygon-spiral specific: outer-vertex radius used by the builder.
# Lets downstream code recover the spiral centre (which sits at
# ``(x_origin + radius - pitch_radial/4, y_origin + radius - pitch_radial/2)``
# for the binary's coord convention). Default 0.0 = "not a polygon spiral".
radius: float = 0.0
# SYMSQ / SYMPOLY specific: centre-tap span (ASITIC's ILEN parameter).
ilen: float = 0.0
[docs]
def segments(self) -> list[Segment]:
"""Flat list of every polygon edge in the shape."""
out: list[Segment] = []
for poly in self.polygons:
out.extend(poly.edges())
return out
[docs]
def bounding_box(self) -> tuple[float, float, float, float]:
"""Return ``(xmin, ymin, xmax, ymax)`` over all vertices."""
xs: list[float] = []
ys: list[float] = []
for p in self.polygons:
for v in p.vertices:
xs.append(v.x)
ys.append(v.y)
if not xs:
return (0.0, 0.0, 0.0, 0.0)
return (min(xs), min(ys), max(xs), max(ys))
[docs]
def translate(self, dx: float, dy: float) -> Shape:
"""Return a copy translated by ``(dx, dy)``."""
return self._map_vertices(lambda v: Point(v.x + dx, v.y + dy, v.z),
dx_origin=dx, dy_origin=dy)
[docs]
def flip_horizontal(self) -> Shape:
"""Mirror across the y-axis through the shape's origin (``x → -x``)."""
cx = self.x_origin
def f(v: Point) -> Point:
return Point(2.0 * cx - v.x, v.y, v.z)
return self._map_vertices(f)
[docs]
def flip_vertical(self) -> Shape:
"""Mirror across the x-axis through the shape's origin (``y → -y``)."""
cy = self.y_origin
def f(v: Point) -> Point:
return Point(v.x, 2.0 * cy - v.y, v.z)
return self._map_vertices(f)
[docs]
def rotate_xy(self, angle_rad: float) -> Shape:
"""Rotate the shape by ``angle_rad`` about its (x_origin, y_origin)."""
c = math.cos(angle_rad)
s = math.sin(angle_rad)
cx, cy = self.x_origin, self.y_origin
def f(v: Point) -> Point:
x = v.x - cx
y = v.y - cy
return Point(cx + c * x - s * y, cy + s * x + c * y, v.z)
return self._map_vertices(f)
def _map_vertices(
self,
f: Callable[[Point], Point],
*,
dx_origin: float = 0.0,
dy_origin: float = 0.0,
) -> Shape:
new_polys = [
Polygon(
vertices=[f(v) for v in p.vertices],
metal=p.metal,
width=p.width,
thickness=p.thickness,
)
for p in self.polygons
]
return Shape(
name=self.name,
polygons=new_polys,
width=self.width,
length=self.length,
spacing=self.spacing,
turns=self.turns,
sides=self.sides,
metal=self.metal,
exit_metal=self.exit_metal,
x_origin=self.x_origin + dx_origin,
y_origin=self.y_origin + dy_origin,
orientation=self.orientation,
phase=self.phase,
kind=self.kind,
)
# Polygon utilities ------------------------------------------------------
[docs]
def polygon_edge_vectors(
poly: Polygon,
*,
direction: str = "forward",
) -> list[tuple[float, float]]:
"""Return the per-edge ``(dx, dy)`` vectors for a polygon.
Mirrors the binary's ``forward_diff_2d_inplace`` (decomp address
``0x08056198``) and ``backward_diff_2d_inplace`` (``0x08056148``)
in-place differencing helpers.
* ``direction="forward"`` returns ``vertices[i+1] - vertices[i]``
for ``i in 0..N-2``; matches ``forward_diff_2d_inplace`` after
``-`` sign flip (the binary stores ``arr[i] -= arr[i+1]``, i.e.
``-(next - curr)``; we return the geometric forward edge).
* ``direction="backward"`` returns ``vertices[i] - vertices[i-1]``
for ``i in 1..N-1``; matches ``backward_diff_2d_inplace``.
"""
if direction not in ("forward", "backward"):
raise ValueError(f"direction must be 'forward' or 'backward', got {direction!r}")
verts = poly.vertices
if len(verts) < 2:
return []
if direction == "forward":
return [
(verts[i + 1].x - verts[i].x, verts[i + 1].y - verts[i].y)
for i in range(len(verts) - 1)
]
return [
(verts[i].x - verts[i - 1].x, verts[i].y - verts[i - 1].y)
for i in range(1, len(verts))
]
[docs]
def shapes_bounding_box(
shapes: list[Shape] | dict[str, Shape],
tech: Tech | None = None,
) -> tuple[float, float, float, float]:
"""Return the union bbox ``(x_min, y_min, x_max, y_max)`` of ``shapes``.
Mirrors the binary's ``compute_overall_bounding_box`` (decomp
address ``0x08081ed4``). If the input is empty and ``tech`` is
provided, falls back to the chip outline ``(0, 0, chipx, chipy)``;
if neither shapes nor tech is supplied, returns the all-zero bbox.
The world-frame translation ``(x_origin, y_origin)`` of each
:class:`Shape` is folded into the bounding-box result, matching
the binary which adds the cell offset to each shape's local bbox.
"""
items: list[Shape] = (
list(shapes.values()) if isinstance(shapes, dict) else list(shapes)
)
if not items:
if tech is not None and tech.chip.chipx > 0 and tech.chip.chipy > 0:
return (0.0, 0.0, tech.chip.chipx, tech.chip.chipy)
return (0.0, 0.0, 0.0, 0.0)
x_min = float("+inf")
y_min = float("+inf")
x_max = float("-inf")
y_max = float("-inf")
for sh in items:
bx0, by0, bx1, by1 = sh.bounding_box()
if bx0 == bx1 == by0 == by1 == 0.0:
continue
x_min = min(x_min, bx0 + sh.x_origin)
y_min = min(y_min, by0 + sh.y_origin)
x_max = max(x_max, bx1 + sh.x_origin)
y_max = max(y_max, by1 + sh.y_origin)
if x_min == float("+inf"):
# Every shape was empty
if tech is not None and tech.chip.chipx > 0 and tech.chip.chipy > 0:
return (0.0, 0.0, tech.chip.chipx, tech.chip.chipy)
return (0.0, 0.0, 0.0, 0.0)
return (x_min, y_min, x_max, y_max)
[docs]
def extend_terminal_segment(shape: Shape, *, dx_um: float = 0.0) -> Shape:
"""Extend the tail of ``shape``'s last polygon along its own axis.
Mirrors ``shape_terminal_segment_extend_unit`` (decomp
``0x0805b348``). Walks to the last polygon, normalises the last
edge's direction vector, then re-projects its endpoint to
``length/2 + dx_um`` along that direction.
The binary uses this when extending a winding terminal so the
last segment leaves the chip with a fixed unit length plus a
small ``dx`` offset. Returns a copy; the original is untouched.
"""
if not shape.polygons:
return shape
new_polys = [
Polygon(
vertices=list(p.vertices),
metal=p.metal,
width=p.width,
thickness=p.thickness,
)
for p in shape.polygons
]
last_poly = new_polys[-1]
if len(last_poly.vertices) < 2:
return shape
a = last_poly.vertices[-2]
b = last_poly.vertices[-1]
dx = b.x - a.x
dy = b.y - a.y
dz = b.z - a.z
length = math.sqrt(dx * dx + dy * dy + dz * dz)
if length < 1e-12:
return shape
ux, uy, uz = dx / length, dy / length, dz / length
new_length = 0.5 * length + dx_um
new_b = Point(
a.x + new_length * ux,
a.y + new_length * uy,
a.z + new_length * uz,
)
last_poly.vertices[-1] = new_b
return Shape(
name=shape.name,
polygons=new_polys,
width=shape.width,
length=shape.length,
spacing=shape.spacing,
turns=shape.turns,
sides=shape.sides,
metal=shape.metal,
exit_metal=shape.exit_metal,
x_origin=shape.x_origin,
y_origin=shape.y_origin,
orientation=shape.orientation,
phase=shape.phase,
kind=shape.kind,
)
[docs]
def emit_vias_at_layer_transitions(shape: Shape, tech: Tech) -> Shape:
"""Insert via polygons between adjacent polygons on different metals.
Mirrors ``shape_emit_vias_at_layer_transitions`` (decomp
``0x0805ba2c``). Walks the polygon list pair-wise; whenever two
adjacent polygons are on different metal layers, looks up the
via that bridges them and inserts a single-vertex (zero-extent)
via polygon at the midpoint of the metal-to-metal transition.
The via is placed on the via index of the matching ``Via``
record in the tech file (matching ``top``/``bottom`` to the
adjacent metal indices). If no via record matches, no insertion
is made for that transition.
Returns a copy; the original is untouched.
"""
if len(shape.polygons) < 2:
return shape
# Build the via lookup: (top, bottom) → via_index (in tech.vias)
via_lookup: dict[tuple[int, int], int] = {}
for vidx, v in enumerate(tech.vias):
via_lookup[(v.top, v.bottom)] = vidx
via_lookup[(v.bottom, v.top)] = vidx
new_polys: list[Polygon] = []
for i, p in enumerate(shape.polygons):
new_polys.append(
Polygon(
vertices=list(p.vertices),
metal=p.metal,
width=p.width,
thickness=p.thickness,
)
)
if i + 1 >= len(shape.polygons):
continue
nxt = shape.polygons[i + 1]
if p.metal == nxt.metal:
continue
# Different metal layers — emit a via polygon
key = (p.metal, nxt.metal)
via_idx = via_lookup.get(key)
if via_idx is None:
continue
# Midpoint of the transition: average of last vertex of p and
# first vertex of nxt
if not p.vertices or not nxt.vertices:
continue
a = p.vertices[-1]
b = nxt.vertices[0]
mid = Point(
0.5 * (a.x + b.x),
0.5 * (a.y + b.y),
0.5 * (a.z + b.z),
)
# Tag the via polygon with a metal index past the metal-layer
# count so downstream cap/inductance code can tell it apart
new_polys.append(
Polygon(
vertices=[mid],
metal=len(tech.metals) + via_idx,
width=tech.vias[via_idx].width,
thickness=0.0,
)
)
return Shape(
name=shape.name,
polygons=new_polys,
width=shape.width,
length=shape.length,
spacing=shape.spacing,
turns=shape.turns,
sides=shape.sides,
metal=shape.metal,
exit_metal=shape.exit_metal,
x_origin=shape.x_origin,
y_origin=shape.y_origin,
orientation=shape.orientation,
phase=shape.phase,
kind=shape.kind,
)
[docs]
def extend_last_segment_to_chip_edge(shape: Shape, tech: Tech) -> Shape:
"""Push the last segment of ``shape`` out to the nearest chip boundary.
Mirrors ``shape_extend_last_to_chip_edge`` (decomp ``0x0805b154``).
The binary uses this on the export path so a winding's terminal
segment sticks out of the chip outline by enough to become a port.
The decision tree:
* If the last segment runs in +Y → snap its tail to ``chipy``.
* If it runs in -Y → snap its tail to ``0``.
* If it runs in +X → snap its tail to ``chipx``.
* If it runs in -X → snap its tail to ``0``.
A copy of the shape is returned; the original is untouched.
"""
if not shape.polygons:
return shape
chipx = tech.chip.chipx
chipy = tech.chip.chipy
if chipx <= 0 and chipy <= 0:
return shape
new_polys = [
Polygon(
vertices=list(p.vertices),
metal=p.metal,
width=p.width,
thickness=p.thickness,
)
for p in shape.polygons
]
last_poly = new_polys[-1]
if len(last_poly.vertices) < 2:
return shape
a = last_poly.vertices[-2]
b = last_poly.vertices[-1]
dx = b.x - a.x
dy = b.y - a.y
eps = 1e-10
if abs(dy) >= eps:
# Vertical segment
new_y = chipy if dy > 0 else 0.0
last_poly.vertices[-1] = Point(b.x, new_y, b.z)
elif abs(dx) <= eps:
# Degenerate — leave it
return shape
else:
new_x = chipx if dx > 0 else 0.0
last_poly.vertices[-1] = Point(new_x, b.y, b.z)
return Shape(
name=shape.name,
polygons=new_polys,
width=shape.width,
length=shape.length,
spacing=shape.spacing,
turns=shape.turns,
sides=shape.sides,
metal=shape.metal,
exit_metal=shape.exit_metal,
x_origin=shape.x_origin,
y_origin=shape.y_origin,
orientation=shape.orientation,
phase=shape.phase,
kind=shape.kind,
)
def _closed_poly(
corners: list[tuple[float, float]],
*,
z: float,
metal: int,
width: float,
thickness: float,
) -> Polygon:
verts = [Point(x, y, z) for x, y in corners]
if verts and (verts[0].x != verts[-1].x or verts[0].y != verts[-1].y):
verts.append(verts[0])
return Polygon(vertices=verts, metal=metal, width=width, thickness=thickness)
def _polygon_record_to_poly(
corners: list[tuple[float, float]],
metal_rec: Metal,
width: float,
) -> Polygon:
z = metal_rec.d + metal_rec.t * 0.5
return _closed_poly(
corners,
z=z,
metal=metal_rec.index,
width=width,
thickness=metal_rec.t,
)
def _square_layout_polygons(
shape: Shape,
tech: Tech,
*,
include_access: bool = True,
trim_final: bool = True,
) -> list[Polygon]:
"""Return ASITIC display polygons for an SQ/MMSQ winding.
Direct port of the polygon-emission part of
``cmd_square_build_geometry``. ASITIC's CIF/GDS path stores
trapezoidal metal ribbons, not centerlines, so exporters use this
helper while analysis keeps using :meth:`Shape.segments`.
"""
metal_rec = tech.metals[shape.metal]
W = shape.width
S = shape.spacing
# ``length`` is not a Shape field; recover it from the layout bbox
# metadata when available, otherwise from the centerline footprint.
length = shape.length
if length <= 0.0:
if shape.polygons:
bx0, by0, bx1, by1 = shape.bounding_box()
length = max(bx1 - bx0, by1 - by0)
else:
return []
if length <= 0.0:
return []
pitch = W + S
n_int = math.floor(shape.turns)
frac_side = round((shape.turns - n_int) * 4.0)
polys: list[Polygon] = []
last_corners: list[tuple[float, float]] | None = None
last_side = 0
for turn in range(n_int + 1):
if turn == n_int and frac_side == 0:
if last_corners is not None:
# Binary trims the final side by W/2 when there is no
# explicit exit-layer segment.
pass
break
if turn > shape.turns:
break
inset = pitch * turn
x0 = shape.x_origin + inset
y0 = shape.y_origin + inset
x1 = shape.x_origin + length - inset
y1 = shape.y_origin + length - inset
ix0 = x0 + W
iy0 = y0 + W
ix1 = x1 - W
iy1 = y1 - W
top_left_outer_x = shape.x_origin + max(0, turn - 1) * pitch
top_left_inner_x = top_left_outer_x if turn == 0 else top_left_outer_x + W
side_polys = [
[(top_left_outer_x, y1), (x1, y1), (ix1, iy1), (top_left_inner_x, iy1)],
[(x1, y1), (x1, y0), (ix1, iy0), (ix1, iy1)],
[(x1, y0), (x0, y0), (ix0, iy0), (ix1, iy0)],
[
(x0, y0),
(x0, y1 - pitch),
(ix0, y1 - pitch - W),
(ix0, iy0),
],
]
max_sides = 4
if turn == n_int:
max_sides = frac_side
for side in range(max_sides):
corners = side_polys[side]
is_final_side = (
(frac_side == 0 and turn == n_int - 1 and side == 3)
or (frac_side > 0 and turn == n_int and side == max_sides - 1)
)
if is_final_side and not trim_final:
# No exit / no next turn: the side runs straight to its
# terminal outer corner without the chamfer that would
# accommodate the next turn's perpendicular side.
if side == 0:
# Top side: inner-end chamfer becomes the inner edge
# at the same x as the outer end.
corners = [corners[0], corners[1],
(corners[1][0], corners[2][1]), corners[3]]
elif side == 1:
# Right side: bottom-inner chamfer disappears.
corners = [corners[0], corners[1],
(corners[2][0], corners[1][1]), corners[3]]
elif side == 2:
# Bottom side: left-inner chamfer disappears.
corners = [corners[0], corners[1],
(corners[1][0], corners[2][1]), corners[3]]
else:
# Left side: top-inner chamfer disappears.
corners = [corners[0], corners[1],
(corners[2][0], corners[1][1]), corners[3]]
elif is_final_side and trim_final:
if side == 0:
corners = [corners[0], (corners[1][0] - W, corners[1][1]),
corners[2], corners[3]]
elif side == 1:
corners = [corners[0], (corners[1][0], corners[1][1] + W),
corners[2], corners[3]]
elif side == 2:
corners = [corners[0], (corners[1][0] + W, corners[1][1]),
corners[2], corners[3]]
else:
corners = [corners[0], (corners[1][0], corners[2][1]), corners[2], corners[3]]
polys.append(_polygon_record_to_poly(corners, metal_rec, W))
last_corners = corners
last_side = side
if include_access and polys:
access = _square_access_polygons(shape, tech, polys[-1], last_side)
polys.extend(access)
return polys
def _square_access_polygons(
shape: Shape,
tech: Tech,
last_poly: Polygon,
last_side: int,
) -> list[Polygon]:
exit_idx = shape.exit_metal
if exit_idx is None:
exit_idx = shape.metal - 1
if exit_idx is None or exit_idx < 0 or exit_idx >= len(tech.metals):
return []
if exit_idx == shape.metal:
return []
via_rec = None
via_idx = -1
for i, v in enumerate(tech.vias):
if {v.top, v.bottom} == {shape.metal, exit_idx}:
via_rec = v
via_idx = i
break
if via_rec is None:
return []
W = shape.width
half = W * 0.5
verts = last_poly.vertices
if len(verts) < 4:
return []
# ASITIC places the via cluster at the centre of the terminal trace
# width and one half-width back from the terminal end.
xs = [v.x for v in verts[:-1]]
ys = [v.y for v in verts[:-1]]
if last_side == 0: # top side, exits right
cx, cy = max(xs) - half, (min(ys) + max(ys)) * 0.5
ux, uy = 1.0, 0.0
elif last_side == 1: # right side, exits down
cx, cy = (min(xs) + max(xs)) * 0.5, min(ys) + half
ux, uy = 0.0, -1.0
elif last_side == 2: # bottom side, exits left
cx, cy = min(xs) + half, (min(ys) + max(ys)) * 0.5
ux, uy = -1.0, 0.0
else: # left side, exits up
cx, cy = (min(xs) + max(xs)) * 0.5, max(ys) - half
ux, uy = 0.0, 1.0
out: list[Polygon] = []
top = tech.metals[shape.metal]
exit_m = tech.metals[exit_idx]
pad = [
(cx - half, cy - half), (cx + half, cy - half),
(cx + half, cy + half), (cx - half, cy + half),
]
out.append(_polygon_record_to_poly(pad, exit_m, W))
out.append(_polygon_record_to_poly(pad, top, W))
overplot = max(via_rec.overplot1, via_rec.overplot2)
pitch = via_rec.width + via_rec.space
# Use floor to match the C binary's via-count convention
# (asitic_repl.c: cmd_square_build_geometry's via cluster sizing
# gives n = floor((W - 2·op + via_s) / (via_w + via_s))).
# Verified against gold sq_170 (W=10 → n=4) and trans_200x8x3x3
# (W=8 → n=3).
n = max(1, math.floor((W - 2.0 * overplot + via_rec.space) / pitch))
span = (n - 1) * via_rec.space + n * via_rec.width
z = 0.0
via_metal = len(tech.metals) + via_idx
for i in range(n):
for j in range(n):
x0 = cx - span * 0.5 + i * pitch
y0 = cy - span * 0.5 + j * pitch
out.append(_closed_poly(
[(x0, y0), (x0 + via_rec.width, y0),
(x0 + via_rec.width, y0 + via_rec.width),
(x0, y0 + via_rec.width)],
z=z,
metal=via_metal,
width=via_rec.width,
thickness=0.0,
))
bx0 = shape.x_origin
by0 = shape.y_origin
outer_len = shape.length if shape.length > 0.0 else max(
shape.bounding_box()[2] - shape.bounding_box()[0],
shape.bounding_box()[3] - shape.bounding_box()[1],
)
bx1 = shape.x_origin + outer_len
by1 = shape.y_origin + outer_len
tail_x = cx - ux * half
tail_y = cy - uy * half
if abs(ux) > abs(uy):
outer = bx1 if ux > 0 else bx0
ext = (outer - tail_x) * (1.0 if ux > 0 else -1.0)
else:
outer = by1 if uy > 0 else by0
ext = (outer - tail_y) * (1.0 if uy > 0 else -1.0)
lead_len = max(ext + W, 2.0 * W)
head_x = tail_x + ux * lead_len
head_y = tail_y + uy * lead_len
nx = -uy * half
ny = ux * half
lead = [
(tail_x + nx, tail_y + ny),
(head_x + nx, head_y + ny),
(head_x - nx, head_y - ny),
(tail_x - nx, tail_y - ny),
]
out.append(_polygon_record_to_poly(lead, exit_m, W))
return out
def _polygon_bbox(polys: list[Polygon]) -> tuple[float, float, float, float]:
"""Return (xmin, xmax, ymin, ymax) over all vertices of ``polys``."""
xs = [v.x for p in polys for v in p.vertices]
ys = [v.y for p in polys for v in p.vertices]
return (min(xs), max(xs), min(ys), max(ys))
def _polygon_fliph_apply(
polys: list[Polygon],
*,
y_axis: float | None = None,
) -> list[Polygon]:
"""Mirror Y about a horizontal centerline.
Mirrors ``cmd_fliph_apply`` (decomp ``0x08078d20``): computes
``y_sum = ymin + ymax`` of the bbox and replaces each ``y``
with ``y_sum - y``. Pass ``y_axis = ymin + ymax`` explicitly
to mirror about a known axis (useful when the input polygons'
bbox includes access routing whose post-flip direction is the
opposite of the desired one).
"""
if not polys:
return polys
if y_axis is None:
_, _, ymin, ymax = _polygon_bbox(polys)
y_axis = ymin + ymax
out: list[Polygon] = []
for p in polys:
verts = [Point(v.x, y_axis - v.y, v.z) for v in p.vertices]
out.append(Polygon(vertices=verts, metal=p.metal,
width=p.width, thickness=p.thickness))
return out
def _polygon_flipv_apply(
polys: list[Polygon],
*,
x_axis: float | None = None,
) -> list[Polygon]:
"""Mirror X about a vertical centerline.
Mirrors ``cmd_flipv_apply`` (decomp ``0x08078cdc``): computes
``x_sum = xmin + xmax`` of the bbox and replaces each ``x``
with ``x_sum - x``. Pass ``x_axis = xmin + xmax`` explicitly
when the bbox-derived value would include unwanted offsets.
"""
if not polys:
return polys
if x_axis is None:
xmin, xmax, _, _ = _polygon_bbox(polys)
x_axis = xmin + xmax
out: list[Polygon] = []
for p in polys:
verts = [Point(x_axis - v.x, v.y, v.z) for v in p.vertices]
out.append(Polygon(vertices=verts, metal=p.metal,
width=p.width, thickness=p.thickness))
return out
def _polygons_relayer(polys: list[Polygon], tech: Tech, new_metal: int) -> list[Polygon]:
"""Return ``polys`` with ``metal``, ``thickness`` and vertex ``z``
fields swapped to ``new_metal``."""
if new_metal < 0 or new_metal >= len(tech.metals):
return polys
m = tech.metals[new_metal]
z = m.d + m.t * 0.5
out: list[Polygon] = []
for p in polys:
verts = [Point(v.x, v.y, z) for v in p.vertices]
out.append(Polygon(vertices=verts, metal=new_metal,
width=p.width, thickness=m.t))
return out
def _mmsquare_layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
"""Return CIF/GDS-equivalent polygons for an MMSQ multi-metal stack.
Mirrors ``cmd_mmsquare_build_geometry`` (decomp ``0x0805af5c``):
1. Build a square spiral on the top metal (``shape.metal``)
with no exit routing — pure square spiral.
2. For each metal layer between top and ``shape.exit_metal``
(inclusive), clone the spiral, swap the metal layer, apply
``cmd_fliph_apply`` (Y-mirror about bbox centerline) for
integer or half-integer turns, then reverse the linked
list order.
The C alternates the flip direction between ``fliph`` and
``flipv`` for half-integer turns. For integer turns it always
uses ``fliph``; that's what we implement here. Half-integer
behaviour is a follow-up.
"""
if shape.exit_metal is None:
return _square_layout_polygons(shape, tech, include_access=False)
top_metal = shape.metal
bot_metal = shape.exit_metal
if top_metal <= bot_metal:
return _square_layout_polygons(shape, tech, include_access=False)
# Build the top-layer square spiral (no exit access routing — MMSQ
# forces exit_metal to -1 inside the C cmd_square_build_geometry call)
top_shape = Shape(
name=shape.name, polygons=shape.polygons,
width=shape.width, length=shape.length, spacing=shape.spacing,
turns=shape.turns, sides=4, metal=top_metal, exit_metal=None,
x_origin=shape.x_origin, y_origin=shape.y_origin,
phase=shape.phase, kind="square",
)
top_polys = _square_layout_polygons(
top_shape, tech, include_access=False, trim_final=False,
)
out: list[Polygon] = list(top_polys)
# Build each subsequent metal layer from the top via fliph + reverse
prev_polys = top_polys
for layer_idx in range(top_metal - 1, bot_metal - 1, -1):
flipped = _polygon_fliph_apply(prev_polys)
flipped = list(reversed(flipped))
flipped = _polygons_relayer(flipped, tech, layer_idx)
out.extend(flipped)
prev_polys = flipped
return out
def _polygon_spiral_layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
metal_rec = tech.metals[shape.metal]
sides = shape.sides
if sides < 3 or shape.radius <= 0.0:
return []
R = shape.radius
W = shape.width
S = shape.spacing
cos_half = math.cos(math.pi / sides)
radial_w = W / cos_half
radial_step = (W + S) / cos_half / sides
n_round = round(shape.turns)
loops = round(shape.turns + 1.0)
r = R
raw: list[list[tuple[float, float]]] = []
for turn in range(1, loops + 1):
n_side = sides
if turn == loops:
n_side = round((shape.turns - n_round) * sides + 1.0 / (2.0 * sides))
for i in range(1, n_side + 1):
a0 = shape.phase + 2.0 * math.pi * (i - 1) / sides
a1 = shape.phase + 2.0 * math.pi * i / sides
outer0 = (r * math.cos(a0), r * math.sin(a0))
inner0 = ((r - radial_w) * math.cos(a0), (r - radial_w) * math.sin(a0))
r -= radial_step
outer1 = (r * math.cos(a1), r * math.sin(a1))
inner1 = ((r - radial_w) * math.cos(a1), (r - radial_w) * math.sin(a1))
raw.append([outer0, outer1, inner1, inner0])
if not raw:
return []
xs = [x for poly in raw for x, _ in poly]
ys = [y for poly in raw for _, y in poly]
dx = shape.x_origin + (max(xs) - min(xs)) * 0.5
dy = shape.y_origin + (max(ys) - min(ys)) * 0.5
return [
_polygon_record_to_poly([(x + dx, y + dy) for x, y in poly], metal_rec, W)
for poly in raw
]
def _ring_layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
metal_rec = tech.metals[shape.metal]
sides = shape.sides
if sides < 3 or shape.radius <= 0.0:
return []
gap_rad = math.radians(abs(shape.spacing))
radial_w = shape.width / math.cos(math.pi / sides)
per_side = (2.0 * math.pi - gap_rad) / (sides - 1)
start = shape.phase + gap_rad * 0.5
angles = [start + k * per_side for k in range(sides - 1)]
angles.append(shape.phase - gap_rad * 0.5)
outer = [(shape.radius * math.cos(a), shape.radius * math.sin(a)) for a in angles]
inner_r = shape.radius - radial_w
inner = [(inner_r * math.cos(a), inner_r * math.sin(a)) for a in angles]
xs = [x for x, _ in outer + inner]
ys = [y for _, y in outer + inner]
dx = shape.x_origin + (max(xs) - min(xs)) * 0.5
dy = shape.y_origin + (max(ys) - min(ys)) * 0.5
out: list[Polygon] = []
for i in range(sides - 1):
out.append(_polygon_record_to_poly(
[
(outer[i][0] + dx, outer[i][1] + dy),
(outer[i + 1][0] + dx, outer[i + 1][1] + dy),
(inner[i + 1][0] + dx, inner[i + 1][1] + dy),
(inner[i][0] + dx, inner[i][1] + dy),
],
metal_rec,
shape.width,
))
return out
[docs]
def layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
"""Return filled layout polygons matching ASITIC's CIF/GDS geometry."""
if shape.kind == "square":
return _square_layout_polygons(shape, tech)
if shape.kind == "mmsquare":
return _mmsquare_layout_polygons(shape, tech)
if shape.kind == "transformer_secondary" or shape.kind == "transformer_primary":
# Polygons already laid out & adjusted; stored on the shape.
return list(shape.polygons)
if shape.kind == "symsq":
return _symsq_layout_polygons(shape, tech)
if shape.kind == "sympoly":
return _sympoly_layout_polygons(shape, tech)
if shape.kind == "balun_primary":
return _balun_primary_layout_polygons(shape, tech)
if shape.kind == "balun_secondary":
return _balun_secondary_layout_polygons(shape, tech)
if shape.kind == "polygon_spiral":
return _polygon_spiral_layout_polygons(shape, tech)
if shape.kind == "ring":
return _ring_layout_polygons(shape, tech)
if shape.kind == "wire" and shape.polygons and len(shape.polygons[0].vertices) >= 2:
p = shape.polygons[0]
a, b = p.vertices[0], p.vertices[-1]
dx = b.x - a.x
dy = b.y - a.y
length = math.hypot(dx, dy)
if length <= 1e-12:
return []
nx = -dy / length * p.width * 0.5
ny = dx / length * p.width * 0.5
metal_rec = tech.metals[p.metal]
return [_polygon_record_to_poly(
[(a.x + nx, a.y + ny), (b.x + nx, b.y + ny),
(b.x - nx, b.y - ny), (a.x - nx, a.y - ny)],
metal_rec,
p.width,
)]
return [
Polygon(vertices=list(p.vertices), metal=p.metal, width=p.width, thickness=p.thickness)
for p in shape.polygons
]
# Geometry builders ------------------------------------------------------
def _resolve_metal(tech: Tech, metal: int | str) -> Metal:
if isinstance(metal, str):
return tech.metal_by_name(metal)
return tech.metals[metal]
[docs]
def square_spiral(
name: str,
*,
length: float,
width: float,
spacing: float,
turns: float,
tech: Tech,
metal: int | str = 0,
x_origin: float = 0.0,
y_origin: float = 0.0,
phase: float = 0.0,
) -> Shape:
"""Build a square (4-sided) spiral.
Mirrors the binary's ``cmd_square_build_geometry`` for the simple
case (no exit metal, no ILEN inner-bound, no 3D mirroring): each
turn is one closed square loop, with the spiral collapsing
inward by ``width + spacing`` per turn. The spiral occupies the
metal layer ``metal`` (index or tech-file name).
Parameters are in **microns**. ``turns`` may be fractional;
integer turns each emit four sides, and the fractional remainder
contributes ``round(4*frac)`` additional sides on a partial turn.
The trace is generated as a single connected polyline matching
ASITIC's ``cmd_square_build_geometry`` (decompiled at ``0x08056670``):
* centerlines are inset by ``W/2`` from the outer ``length × length``
bounding box, so the outer metal edge sits exactly at ``±L/2``;
* the entry lead extends the outermost top-side centerline all the
way to the left edge (``x = -L/2``) so the spiral can be probed
at the chip boundary;
* each successive turn shrinks inward by ``pitch = W + S`` and the
bottom-left corner of one turn connects to the top-left corner
of the next via the outer-left side, producing a true Archimedean
spiral instead of nested closed loops;
* the very last segment is trimmed by ``1.5 × W`` to leave clearance
for the exit-via attachment, matching the binary's reference output.
The Python signature follows ASITIC's documented convention:
``(x_origin, y_origin)`` is the **lower-left corner** of the
spiral's outer bounding box, so the spiral metal occupies
``[x_origin, x_origin + length] × [y_origin, y_origin + length]``
in world coords (with ``phase = 0``).
Verified vertex-for-vertex against the 1999 ASITIC binary's
``LISTSEGS`` output (BiCMOS tech, captured under qemu-i386-static).
"""
metal_rec = _resolve_metal(tech, metal)
metal_idx = metal_rec.index
z = metal_rec.d + metal_rec.t * 0.5
thickness = metal_rec.t
# Local corner-anchored frame: spiral occupies [0, length] x [0, length].
# Phase rotates about the lower-left corner; then translate by
# (x_origin, y_origin) so the lower-left lands at the user's origin —
# matching ASITIC's "Origin of spiral is the lower-left corner".
L = length
W = width
halfw = W * 0.5
pitch = W + spacing
n_full = math.floor(turns)
frac = turns - n_full
n_partial = round(4 * frac)
cphase = math.cos(phase)
sphase = math.sin(phase)
def to_world(lx: float, ly: float) -> Point:
rx = lx * cphase - ly * sphase
ry = lx * sphase + ly * cphase
return Point(rx + x_origin, ry + y_origin, z)
# Centerline corners of turn `k` in the local corner-anchored frame.
# See ASITIC's cmd_square_build_geometry for the corresponding XFillPolygon
# corner triples (offsets +0x44 / +0x5c / +0x88 / +0xa0 of the polygon
# record); here we only need the centerline vertex positions.
def tl(k: int) -> tuple[float, float]:
return (halfw + max(0, k - 1) * pitch, L - halfw - k * pitch)
def tr(k: int) -> tuple[float, float]:
return (L - halfw - k * pitch, L - halfw - k * pitch)
def br(k: int) -> tuple[float, float]:
return (L - halfw - k * pitch, halfw + k * pitch)
def bl(k: int) -> tuple[float, float]:
return (halfw + k * pitch, halfw + k * pitch)
def collapsed(k: int) -> bool:
# Innermost half-side of turn k must be larger than W/2 for the
# turn to fit; otherwise the spiral has collapsed and we stop.
return (L * 0.5 - halfw - k * pitch) <= halfw * 0.5
verts: list[Point] = []
# Cap the requested turn count at the geometric limit so we don't emit
# tracks that have collapsed past the spiral centre.
n_full_emit = min(n_full, max(0, math.floor((L * 0.5 - halfw) / pitch + 1)))
if n_full_emit == 0 and n_partial == 0:
return Shape(
name=name,
polygons=[],
width=width,
length=length,
spacing=spacing,
turns=turns,
sides=4,
metal=metal_idx,
x_origin=x_origin,
y_origin=y_origin,
phase=phase,
kind="square",
)
# Entry lead: the outermost top-side centerline starts at x = 0 (the
# left edge of the bounding box) instead of the inner TL corner. This
# is the ASITIC convention so a probe / pad can attach at the chip
# boundary without an extra wire.
verts.append(to_world(0.0, L - halfw))
last_seg_dir: tuple[float, float] | None = None
for k in range(n_full_emit):
if collapsed(k):
break
# Top side completes at TR_k.
verts.append(to_world(*tr(k)))
# Right side -> BR_k.
verts.append(to_world(*br(k)))
# Bottom side -> BL_k.
verts.append(to_world(*bl(k)))
# Left side -> TL of the next turn (for chained spiraling). On the
# last full turn with no fractional remainder, this is the spiral's
# exit toward where TL_{k+1} would sit.
nxt_tl = tl(k + 1)
verts.append(to_world(*nxt_tl))
last_seg_dir = (nxt_tl[0] - bl(k)[0], nxt_tl[1] - bl(k)[1])
# Partial turn (top → right → bottom → left, in that order).
if n_partial > 0 and not collapsed(n_full_emit):
k = n_full_emit
partial_corners = [tr(k), br(k), bl(k), tl(k + 1)]
prev = (verts[-1].x - x_origin, verts[-1].y - y_origin) # rough check unused
anchor = tl(k)
seq = [tr(k), br(k), bl(k), tl(k + 1)]
for i in range(n_partial):
verts.append(to_world(*seq[i]))
if i == 0:
last_seg_dir = (seq[i][0] - anchor[0], seq[i][1] - anchor[1])
else:
last_seg_dir = (seq[i][0] - seq[i - 1][0], seq[i][1] - seq[i - 1][1])
del partial_corners, prev
# Exit-lead trim: ASITIC shortens the final M3 centerline by W/2 so
# the polygon's outer-end lands exactly at the top of the via cell
# that drops to the exit metal layer. The polygon corners (after
# chamfering by the bridge) then match the binary's CIF byte-for-byte.
if len(verts) >= 2 and last_seg_dir is not None:
dx, dy = last_seg_dir
seg_len = math.hypot(dx, dy)
trim = 0.5 * W
if seg_len > trim:
f = (seg_len - trim) / seg_len
p_prev = verts[-2]
p_end = verts[-1]
new_x = p_prev.x + (p_end.x - p_prev.x) * f
new_y = p_prev.y + (p_end.y - p_prev.y) * f
verts[-1] = Point(new_x, new_y, p_end.z)
polygons = [
Polygon(vertices=verts, metal=metal_idx, width=width, thickness=thickness)
]
return Shape(
name=name,
polygons=polygons,
width=width,
length=length,
spacing=spacing,
turns=turns,
sides=4,
metal=metal_idx,
x_origin=x_origin,
y_origin=y_origin,
phase=phase,
kind="square",
)
[docs]
def polygon_spiral(
name: str,
*,
radius: float,
width: float,
spacing: float,
turns: float,
tech: Tech,
sides: int = 8,
metal: int | str = 0,
x_origin: float = 0.0,
y_origin: float = 0.0,
phase: float = 0.0,
) -> Shape:
"""Build an ``n``-sided polygon spiral inscribed in ``radius``.
Mirrors ASITIC's ``cmd_spiral_build_geometry`` (decompiled at
``0x08057248``): the spiral is generated as one connected polyline
that turns by ``2π/sides`` each step while the radius decreases by
``pitch_radial / sides`` per side, where
``pitch_radial = (W + S) / cos(π/sides)`` is the turn-to-turn radial
pitch measured along the polygon's perpendicular bisector.
The bbox is then centered on ``(x_origin, y_origin)`` (per ASITIC's
``shape_translate_inplace_xy`` post-build pass) so the user's origin
parameter ends up at the spiral centre — matching the documented
behaviour for ``Spiral (NAME:RADIUS:SIDES:…:XORG:YORG)``.
"""
if sides < 3:
raise ValueError("polygon spiral needs at least 3 sides")
metal_rec = _resolve_metal(tech, metal)
metal_idx = metal_rec.index
z = metal_rec.d + metal_rec.t * 0.5
thickness = metal_rec.t
half_angle = math.pi / sides
cos_half = math.cos(half_angle)
# The "radial half-width" — how much the trace extends inward along
# the radial direction at each vertex of the polygon (the trace face
# is perpendicular to the bisector of each side, so along the radial
# direction the half-width is W / (2·cos(π/sides))).
radial_half_w = width / (2.0 * cos_half)
pitch_radial = (width + spacing) / cos_half
n_full = math.floor(turns)
frac = turns - n_full
n_partial_sides = round(sides * frac)
total_sides = n_full * sides + n_partial_sides
cphase = math.cos(phase)
sphase = math.sin(phase)
# Build the centerline polyline in a frame anchored at the spiral
# center, then bbox-center-shift to (x_origin, y_origin) at the end.
cx_local: list[float] = []
cy_local: list[float] = []
# Centerline radius starts one half-width inside the outer-vertex
# radius and decrements by pitch_radial per turn (= pitch_radial /
# sides per side). The first vertex sits exactly on the polygon's
# outer face at phase angle.
r_centerline = radius - radial_half_w
pitch_per_side = pitch_radial / sides
if r_centerline <= width * 0.5 or total_sides < 1:
return Shape(
name=name, polygons=[], width=width, length=radius * 2.0, spacing=spacing,
turns=turns, sides=sides, metal=metal_idx,
x_origin=x_origin, y_origin=y_origin, phase=phase,
kind="polygon_spiral",
)
cx_local.append(r_centerline * math.cos(phase))
cy_local.append(r_centerline * math.sin(phase))
for k in range(total_sides):
r_centerline = r_centerline - pitch_per_side
if r_centerline <= width * 0.5:
break
theta = phase + 2.0 * math.pi * (k + 1) / sides
cx_local.append(r_centerline * math.cos(theta))
cy_local.append(r_centerline * math.sin(theta))
# ASITIC's ``shape_translate_inplace_xy`` for polygon spirals shifts
# the centered polyline so the spiral's first centerline vertex lands
# at world ``(XORG + 2R - radial_half_w - pitch_radial/4,
# YORG + R - pitch_radial/2)``. Equivalently, the spiral's
# geometric centre lands at ``(XORG + R - pitch_radial/4,
# YORG + R - pitch_radial/2)``.
#
# This formula was reverse-engineered from the binary's LISTSEGS
# output (sp_r80, sp_r100, sp_r120) and matches all three cases
# vertex-for-vertex.
dx = x_origin + radius - pitch_radial * 0.25
dy = y_origin + radius - pitch_radial * 0.5
verts: list[Point] = [
Point(x + dx, y + dy, z)
for x, y in zip(cx_local, cy_local, strict=True)
]
polygons = [
Polygon(vertices=verts, metal=metal_idx, width=width, thickness=thickness)
]
# phase rotation is already baked into the cos/sin calls above.
_ = (cphase, sphase)
return Shape(
name=name,
polygons=polygons,
width=width,
length=radius * 2.0,
spacing=spacing,
turns=turns,
sides=sides,
metal=metal_idx,
x_origin=x_origin,
y_origin=y_origin,
phase=phase,
radius=radius,
kind="polygon_spiral",
)
[docs]
def wire(
name: str,
*,
length: float,
width: float,
tech: Tech,
metal: int | str = 0,
x_origin: float = 0.0,
y_origin: float = 0.0,
phase: float = 0.0,
) -> Shape:
"""Build a single straight wire of length ``length`` on ``metal``.
Matches ASITIC's ``W NAME=…:LEN=…:WID=…:METAL=…:XORG=…:YORG=…``
convention: ``(x_origin, y_origin)`` is the **lower-left corner**
of the wire's bounding box, so the metal occupies
``[x_origin, x_origin + length] × [y_origin, y_origin + width]``
when ``phase=0``. The centerline runs along ``y = y_origin + W/2``.
"""
metal_rec = _resolve_metal(tech, metal)
metal_idx = metal_rec.index
z = metal_rec.d + metal_rec.t * 0.5
cphase = math.cos(phase)
sphase = math.sin(phase)
# Local frame: wire occupies [0, length] × [0, width]; centerline
# at y = width/2. Apply phase rotation about the lower-left corner,
# then translate by (x_origin, y_origin).
halfw = width * 0.5
def to_world(lx: float, ly: float) -> Point:
rx = lx * cphase - ly * sphase
ry = lx * sphase + ly * cphase
return Point(rx + x_origin, ry + y_origin, z)
a = to_world(0.0, halfw)
b = to_world(length, halfw)
polygons = [
Polygon(vertices=[a, b], metal=metal_idx, width=width, thickness=metal_rec.t)
]
return Shape(
name=name,
polygons=polygons,
width=width,
length=length,
spacing=0.0,
turns=1.0,
sides=1,
metal=metal_idx,
x_origin=x_origin,
y_origin=y_origin,
phase=phase,
kind="wire",
)
[docs]
def via(
name: str,
*,
tech: Tech,
via_index: int = 0,
nx: int = 1,
ny: int = 1,
x_origin: float = 0.0,
y_origin: float = 0.0,
) -> Shape:
"""Build a via cluster of size ``nx`` × ``ny`` at ``(x, y)``.
Mirrors ``cmd_via_build_geometry`` (decomp ``0x08057b78``):
emits one polygon record at the shape's origin with X-extent
``nx · via_w + (nx-1) · via_s`` and Y-extent
``ny · via_w + (ny-1) · via_s``. The polygon is tagged with
both metal-layer colours (top via table[cc] and bottom via
table[d0]) so the CIF/GDS emitter expands it to:
* an M2 box pad covering the array
* an M3 box pad at the same position
* ``nx × ny`` VIA squares of size ``via_w × via_w`` spaced
by ``via_s + via_w`` in a regular grid
The Python form returns a Shape carrying all three layer
polygons directly (the C's "polygon record references both
metals + emits a grid" is decoded into individual records).
"""
if via_index < 0 or via_index >= len(tech.vias):
raise ValueError(f"no via at index {via_index}")
v = tech.vias[via_index]
top_metal = tech.metals[v.top]
bot_metal = tech.metals[v.bottom]
via_metal_idx = len(tech.metals) + via_index
# Cluster span (decoded from cmd_via_build_geometry @ :4091-4095):
# local_c4 = via_w * ny + via_s * (ny - 1) -> Y span
# local_dc = via_w * nx + via_s * (nx - 1) -> X span
span_x = v.width * nx + v.space * max(0, nx - 1)
span_y = v.width * ny + v.space * max(0, ny - 1)
half_x = span_x * 0.5
half_y = span_y * 0.5
# Pad corners centred on (x_origin, y_origin).
pad = [
(x_origin - half_x, y_origin - half_y),
(x_origin + half_x, y_origin - half_y),
(x_origin + half_x, y_origin + half_y),
(x_origin - half_x, y_origin + half_y),
]
polys: list[Polygon] = []
polys.append(_polygon_record_to_poly(pad, top_metal, span_x))
polys.append(_polygon_record_to_poly(pad, bot_metal, span_x))
# Lay out the nx × ny via grid. Each via is a via_w × via_w
# square; cell pitch = via_w + via_s.
pitch = v.width + v.space
cell0_x = x_origin - half_x
cell0_y = y_origin - half_y
for i in range(nx):
for j in range(ny):
x0 = cell0_x + i * pitch
y0 = cell0_y + j * pitch
polys.append(_closed_poly(
[(x0, y0), (x0 + v.width, y0),
(x0 + v.width, y0 + v.width),
(x0, y0 + v.width)],
z=0.0, metal=via_metal_idx,
width=v.width, thickness=0.0,
))
return Shape(
name=name,
polygons=polys,
width=v.width,
length=0.0,
spacing=v.space,
turns=1.0,
sides=1,
metal=top_metal.index,
exit_metal=bot_metal.index,
x_origin=x_origin,
y_origin=y_origin,
kind="via",
)
[docs]
def ring(
name: str,
*,
radius: float,
width: float,
gap: float = 0.0,
sides: int = 32,
tech: Tech,
metal: int | str = 0,
x_origin: float = 0.0,
y_origin: float = 0.0,
phase: float = 0.0,
) -> Shape:
"""Build a single closed-ring loop (``Ring``, command id 22).
A ring is a polygon spiral with exactly one turn — implemented
as a thin wrapper for clarity, since the binary's REPL exposes
``Ring`` as a separate command.
"""
sh = polygon_spiral(
name,
radius=radius,
width=width,
spacing=0.0,
turns=1.0,
tech=tech,
sides=sides,
metal=metal,
x_origin=x_origin,
y_origin=y_origin,
phase=phase,
)
sh.kind = "ring"
sh.spacing = gap
sh.radius = radius
return sh
def _trans_extend_primary_lead(
polys: list[Polygon], pitch: float,
) -> list[Polygon]:
"""Extend the primary's outermost top side leftward by ``pitch``.
Mirrors cmd_trans_build_geometry's primary.first_polygon shift
(asitic_repl.c:3886-3893). The first polygon emitted by
``_square_layout_polygons`` is the outermost top side; its
"start" corners (left-end outer-y, left-end inner-y, and the
centerline-start) need to shift left by pitch = W + S.
"""
if not polys:
return polys
out: list[Polygon] = []
first = polys[0]
# Find the leftmost x of the first polygon — the "start" corners
# are the two vertices at min(x).
xs = [v.x for v in first.vertices]
xmin = min(xs)
new_verts = [
Point(v.x - pitch if abs(v.x - xmin) < 1e-9 else v.x, v.y, v.z)
for v in first.vertices
]
out.append(Polygon(vertices=new_verts, metal=first.metal,
width=first.width, thickness=first.thickness))
out.extend(polys[1:])
return out
def _symsq_u_ring_polygons(
*,
outer_x_min: float, outer_x_max: float,
outer_top_y: float, arm_bottom_y: float,
W: float,
open_side: str,
metal_rec: Metal,
) -> list[Polygon]:
"""Three-polygon "U" ring with chamfered inner corners.
The SYMSQ output is a stack of these U-rings. Each U has:
* a "top" horizontal segment (chamfered inward at both ends)
* two vertical arms (chamfered at the corner where they meet
the top)
``open_side="bottom"`` means the U opens downward (centre-U
convention; arms hang down from a horizontal top). For the
outer ring, mini-loop, etc., these are open at the TOP — pass
``open_side="top"`` and the helper internally swaps the y
semantics so ``outer_top_y`` becomes the "outer-bottom" of the
flipped U and ``arm_bottom_y`` becomes its arm-top.
Each polygon is a 4-corner trapezoid; the inner corner that
abuts the open side has ``W`` shaved off both x and y so the
next ring can fit.
"""
inner_x_min = outer_x_min + W
inner_x_max = outer_x_max - W
if open_side == "bottom":
outer_y = outer_top_y
inner_y = outer_top_y - W
arm_outer_y = arm_bottom_y
arm_inner_y = arm_bottom_y
elif open_side == "top":
outer_y = arm_bottom_y # bottom of the U (now its outer)
inner_y = arm_bottom_y + W # one W up, the inner-bottom
arm_outer_y = outer_top_y # top of the U arms (no chamfer)
arm_inner_y = outer_top_y # same y (no chamfer)
else:
raise ValueError("open_side must be 'top' or 'bottom'")
# Left arm
left = [
(outer_x_min, arm_outer_y),
(outer_x_min, outer_y),
(inner_x_min, inner_y),
(inner_x_min, arm_inner_y),
]
# Top (or bottom for open_top)
top = [
(outer_x_min, outer_y),
(outer_x_max, outer_y),
(inner_x_max, inner_y),
(inner_x_min, inner_y),
]
# Right arm
right = [
(outer_x_max, outer_y),
(outer_x_max, arm_outer_y),
(inner_x_max, arm_inner_y),
(inner_x_max, inner_y),
]
return [
_polygon_record_to_poly(left, metal_rec, W),
_polygon_record_to_poly(top, metal_rec, W),
_polygon_record_to_poly(right, metal_rec, W),
]
def _symsq_centre_arm_polygons(
L: float, W: float, ilen: float,
x_origin: float, y_origin: float,
metal_rec: Metal,
) -> list[Polygon]:
"""The 3-poly U-shape that sits at the top of a SYMSQ.
The U is the centre-tap connector between the two arms of the
symmetric square inductor. Decoded by linear regression across
the three golden cases (symsq_150x8x2x2, symsq_200x10x3x3,
symsq_300x12x4x3):
* U_outer_top_y = YORG + L + ILEN/2
* U_inner_top_y = U_outer_top_y − W
* U_arm_bottom_y = YORG + L/2 + ILEN
* U_outer_x: XORG → XORG + L
* U_inner_x: XORG + W → XORG + L − W
* U_height = (L − ILEN) / 2
Each polygon is a chamfered trapezoid: the U arms have a chamfer
at the top-inner corner (where they meet the U top); the U top
has chamfers at both inner corners.
"""
u_outer_top = y_origin + L + ilen * 0.5
u_inner_top = u_outer_top - W
u_arm_bot = y_origin + L * 0.5 + ilen
x_left_outer = x_origin
x_left_inner = x_origin + W
x_right_outer = x_origin + L
x_right_inner = x_origin + L - W
# Left arm — chamfer at top-right inner corner (where it meets the U top).
left_arm = [
(x_left_outer, u_arm_bot),
(x_left_outer, u_outer_top),
(x_left_inner, u_inner_top),
(x_left_inner, u_arm_bot),
]
# Top — chamfers at both inner corners.
top_bar = [
(x_left_outer, u_outer_top),
(x_right_outer, u_outer_top),
(x_right_inner, u_inner_top),
(x_left_inner, u_inner_top),
]
# Right arm — chamfer at top-left inner corner (mirror of left).
right_arm = [
(x_right_outer, u_outer_top),
(x_right_outer, u_arm_bot),
(x_right_inner, u_arm_bot),
(x_right_inner, u_inner_top),
]
return [
_polygon_record_to_poly(left_arm, metal_rec, W),
_polygon_record_to_poly(top_bar, metal_rec, W),
_polygon_record_to_poly(right_arm, metal_rec, W),
]
def _symsq_layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
"""CIF-equivalent polygons for a SYMSQ centre-tapped square.
Mirrors ``cmd_symsq_build_geometry`` (decomp ``0x08059854``).
Currently handles **N=2 only** (the simplest case) — the
state-machine logic for N≥3 (where additional nested U-rings
fill in between outer and centre) is a follow-up.
Output layers:
* M3 (15 polys): centre-U + outer ring + inner mini-loop +
upper inner ring + 2-poly stub on left + 1-poly slant on right
* M3 + M2 box pads (2 each, 4 total): via overlap pads
* M2 (1 poly): chamfered diagonal trace connecting the two pads
* VIA3 (18 polys): two 3×3 via clusters
All formulas decoded by linear regression across the three
SYMSQ golden cases — see ``docs/asitic_geometry_c.md`` for
derivations.
"""
L = shape.length
W = shape.width
S = shape.spacing
ILEN = getattr(shape, "ilen", 0.0) or 0.0
if L <= 0 or W <= 0:
return []
pitch = W + S
X = shape.x_origin
Y = shape.y_origin
metal_rec = tech.metals[shape.metal]
# Exit / via metal: shape.exit_metal if set, otherwise next metal down
exit_idx = shape.exit_metal if shape.exit_metal is not None else shape.metal - 1
if exit_idx < 0 or exit_idx >= len(tech.metals):
# No exit; emit M3-only structure
exit_idx = shape.metal
exit_metal_rec = tech.metals[exit_idx]
polys: list[Polygon] = []
n_int = round(shape.turns)
# 1) Top rings (k=0..N-1). k=0 is the centre-U (full-L wide
# + special chamfer-only-at-inner-corners pattern).
polys.extend(_symsq_centre_arm_polygons(L, W, ILEN, X, Y, metal_rec))
for k in range(1, n_int):
polys.extend(_symsq_u_ring_polygons(
outer_x_min=X + k * pitch, outer_x_max=X + L - k * pitch,
outer_top_y=Y + L + ILEN * 0.5 - k * pitch,
arm_bottom_y=Y + L * 0.5 + ILEN,
W=W, open_side="bottom", metal_rec=metal_rec,
))
# 2) Bottom rings (k=0..N-1) all open-at-top
for k in range(n_int):
polys.extend(_symsq_u_ring_polygons(
outer_x_min=X + k * pitch, outer_x_max=X + L - k * pitch,
outer_top_y=Y + L * 0.5,
arm_bottom_y=Y + ILEN * 0.5 + k * pitch,
W=W, open_side="top", metal_rec=metal_rec,
))
# 5) ILEN-stub bridges the centre transition between the bottom
# and top rings on one side. Decoded:
# N=2: stub on LEFT at offset 1*pitch from XORG.
# N=3: stub on RIGHT at offset 2*pitch from XORG+L.
# Generalising: stub at offset (N-1)*pitch from one edge,
# side alternates with N (LEFT for even, RIGHT for odd).
if n_int >= 2 and ILEN > 0:
if n_int % 2 == 0:
# LEFT side
stub_x0 = X + (n_int - 1) * pitch
stub_x1 = stub_x0 + W
else:
# RIGHT side
stub_x1 = X + L - (n_int - 1) * pitch
stub_x0 = stub_x1 - W
stub_mid = Y + L * 0.5 + ILEN * 0.5
for y0, y1 in [(Y + L * 0.5, stub_mid), (stub_mid, Y + L * 0.5 + ILEN)]:
polys.append(_polygon_record_to_poly(
[(stub_x0, y0), (stub_x0, y1), (stub_x1, y1), (stub_x1, y0)],
metal_rec, W,
))
# 6) Slanted centre-transition polygons.
# N=2: one slant on the RIGHT side.
# N=3: TWO slants — one on each side (symmetric pair),
# bridging between adjacent ring layers across the centre gap.
if ILEN > 0:
u_arm_bot = Y + L * 0.5 + ILEN
if n_int == 2:
right_slant = [
(X + L - pitch, u_arm_bot),
(X + L, Y + L * 0.5),
(X + L - W, Y + L * 0.5),
(X + L - pitch - W, u_arm_bot),
]
polys.append(_polygon_record_to_poly(right_slant, metal_rec, W))
elif n_int >= 3:
# Right slant for N≥3
right_slant = [
(X + L - pitch, u_arm_bot),
(X + L, Y + L * 0.5),
(X + L - W, Y + L * 0.5),
(X + L - pitch - W, u_arm_bot),
]
polys.append(_polygon_record_to_poly(right_slant, metal_rec, W))
# Left slant for N≥3: at offset 1 from LEFT edge.
# Decoded from case 2/3:
# bottom (y=Y+L/2): x = X+pitch to X+pitch+W
# top (y=u_arm_bot): x = X+2*pitch to X+2*pitch+W
# i.e. bottom edge at ring k=1, top edge at ring k=2.
left_slant = [
(X + 2 * pitch, u_arm_bot),
(X + pitch, Y + L * 0.5),
(X + pitch + W, Y + L * 0.5),
(X + 2 * pitch + W, u_arm_bot),
]
polys.append(_polygon_record_to_poly(left_slant, metal_rec, W))
# 7) Via clusters with M3 + M2 overlap pads. The C uses
# lookup_via_for_metal_pair (asitic_repl.c:??) — formulas
# decoded from gold:
#
# Pad 1 (right-arm-base, above the centre-U's right arm):
# cx = X + L - W/2
# cy = U_arm_bot + pad_h/2
#
# Pad 2 (lower-spiral-attachment, below the inner mini-loop):
# cx = X + L - pitch - W/2
# cy = Y + L/2 - pad_h/2
#
# Pad size = W × (n_vias × via_w + (n_vias-1) × via_s + 2 × overplot)
if exit_idx != shape.metal:
via_rec, via_idx = None, -1
for i, v in enumerate(tech.vias):
if {v.top, v.bottom} == {shape.metal, exit_idx}:
via_rec, via_idx = v, i
break
if via_rec is not None:
overplot = max(via_rec.overplot1, via_rec.overplot2)
vp = via_rec.width + via_rec.space
n_vias = max(1, math.floor((W - 2.0 * overplot + via_rec.space) / vp))
cluster_span = n_vias * via_rec.width + (n_vias - 1) * via_rec.space
pad_h = cluster_span + 2.0 * overplot
u_arm_bot = Y + L * 0.5 + ILEN
# RIGHT-side pads: always 2 (decoded from N=2 and N=3 cases)
pad_centers = [
(X + L - W * 0.5, u_arm_bot + pad_h * 0.5),
(X + L - pitch - W * 0.5, Y + L * 0.5 - pad_h * 0.5),
]
# For N>=3 the C state machine emits LEFT-side pads too.
# Decoded from cases 2/3:
# Left pad k cx = X + W/2 + (k+1)*pitch for k = 0..N-2
# Left pad k cy alternates U_arm_bot+pad_h/2 and Y+L/2-pad_h/2
if n_int >= 3:
pad_centers.append((X + W * 0.5 + 1 * pitch,
u_arm_bot + pad_h * 0.5))
pad_centers.append((X + W * 0.5 + 2 * pitch,
Y + L * 0.5 - pad_h * 0.5))
via_metal = len(tech.metals) + via_idx
half_w = W * 0.5
half_h = pad_h * 0.5
for cx, cy in pad_centers:
pad_corners = [
(cx - half_w, cy - half_h), (cx + half_w, cy - half_h),
(cx + half_w, cy + half_h), (cx - half_w, cy + half_h),
]
# M2 box and M3 box at same corners (different layers)
polys.append(_polygon_record_to_poly(pad_corners, exit_metal_rec, W))
polys.append(_polygon_record_to_poly(pad_corners, metal_rec, W))
# Via cells: n_vias × n_vias grid centred at (cx, cy)
cell0_x = cx - cluster_span * 0.5
cell0_y = cy - cluster_span * 0.5
for i in range(n_vias):
for j in range(n_vias):
x0 = cell0_x + i * vp
y0 = cell0_y + j * vp
polys.append(_closed_poly(
[(x0, y0), (x0 + via_rec.width, y0),
(x0 + via_rec.width, y0 + via_rec.width),
(x0, y0 + via_rec.width)],
z=0.0, metal=via_metal,
width=via_rec.width, thickness=0.0,
))
# 8) M2 chamfered transition traces. Right-side trace
# always emitted; left-side trace for N≥3 only.
# CIF vertex order for the right trace:
# (XORG+L, U_arm_bot)
# (XORG+L-pitch, Y+L/2)
# (XORG+L-pitch-W, Y+L/2)
# (XORG+L-W, U_arm_bot)
right_trace = [
(X + L, u_arm_bot),
(X + L - pitch, Y + L * 0.5),
(X + L - pitch - W, Y + L * 0.5),
(X + L - W, u_arm_bot),
]
polys.append(_polygon_record_to_poly(right_trace, exit_metal_rec, W))
if n_int >= 3:
# Left trace connects from the LEFT pads. CIF order:
# (X+pitch, U_arm_bot)
# (X+2*pitch, Y+L/2)
# (X+2*pitch+W, Y+L/2)
# (X+pitch+W, U_arm_bot)
left_trace = [
(X + pitch, u_arm_bot),
(X + 2 * pitch, Y + L * 0.5),
(X + 2 * pitch + W, Y + L * 0.5),
(X + pitch + W, u_arm_bot),
]
polys.append(_polygon_record_to_poly(left_trace, exit_metal_rec, W))
return polys
def _trans_extend_secondary_lead(
polys: list[Polygon], pitch: float,
) -> list[Polygon]:
"""Extend the secondary's first (post-flip) polygon outward.
After fliph+flipv the secondary's first polygon is what was the
last pre-flip — its "start corners" land at the post-flip right
end. Mirroring the +dVar2 shift in cmd_trans_build_geometry, we
extend the rightmost x by pitch.
"""
if not polys:
return polys
out: list[Polygon] = []
first = polys[0]
xs = [v.x for v in first.vertices]
xmax = max(xs)
new_verts = [
Point(v.x + pitch if abs(v.x - xmax) < 1e-9 else v.x, v.y, v.z)
for v in first.vertices
]
out.append(Polygon(vertices=new_verts, metal=first.metal,
width=first.width, thickness=first.thickness))
out.extend(polys[1:])
return out
[docs]
def symmetric_square(
name: str,
*,
length: float,
width: float,
spacing: float,
turns: float,
tech: Tech,
metal: int | str = 0,
primary_metal: int | str | None = None,
exit_metal: int | str | None = None,
bridge_metal: int | str | None = None,
ilen: float = 0.0,
x_origin: float = 0.0,
y_origin: float = 0.0,
) -> Shape:
"""Build a symmetric centre-tapped square spiral (``SymSq``).
Mirrors ``cmd_symsq_build_geometry`` (decomp ``0x08059854``).
The geometry is decoded by piece in
:func:`_symsq_layout_polygons`; this builder just wires the
arguments through. Currently full CIF parity is reached for
``turns=2`` (the simplest case); ``turns≥3`` is a follow-up.
Args:
length: outer side length L (μm).
width: metal trace width W (μm).
spacing: edge-to-edge gap S between turns (μm).
turns: integer turn count N (only N=2 fully supported).
ilen: centre-tap span (ASITIC's ``ILEN`` parameter).
metal / primary_metal: trace metal layer.
exit_metal: layer used for the centre-tap M2 trace + via
cluster connection.
x_origin / y_origin: lower-left of the LxL bbox in μm.
"""
if primary_metal is not None:
metal = primary_metal
metal_idx = _resolve_metal(tech, metal).index
exit_idx: int | None = None
if exit_metal is not None:
exit_idx = _resolve_metal(tech, exit_metal).index
elif bridge_metal is not None:
exit_idx = _resolve_metal(tech, bridge_metal).index
return Shape(
name=name,
polygons=[],
width=width, length=length, spacing=spacing, turns=turns,
sides=4, metal=metal_idx, exit_metal=exit_idx,
x_origin=x_origin, y_origin=y_origin,
ilen=ilen, kind="symsq",
)
[docs]
def capacitor(
name: str,
*,
length: float,
width: float,
metal_top: int | str,
metal_bottom: int | str,
tech: Tech,
x_origin: float = 0.0,
y_origin: float = 0.0,
) -> Shape:
"""Build a metal-insulator-metal (MIM) capacitor (``Capacitor``).
Two stacked rectangles on different metal layers. The geometric
overlap × dielectric thickness gives the MIM capacitance; we
emit both rectangles as separate polygons so the caller can run
geometry-only analyses (area, footprint).
Mirrors ``cmd_capacitor_build_geometry`` in the original.
"""
top = _resolve_metal(tech, metal_top)
bot = _resolve_metal(tech, metal_bottom)
z_top = top.d + top.t * 0.5
z_bot = bot.d + bot.t * 0.5
def rect(metal_idx: int, z: float, thickness: float) -> Polygon:
return Polygon(
vertices=[
Point(x_origin, y_origin, z),
Point(x_origin + length, y_origin, z),
Point(x_origin + length, y_origin + width, z),
Point(x_origin, y_origin + width, z),
Point(x_origin, y_origin, z),
],
metal=metal_idx,
width=width,
thickness=thickness,
)
polygons = [
rect(top.index, z_top, top.t),
rect(bot.index, z_bot, bot.t),
]
return Shape(
name=name,
polygons=polygons,
width=width,
length=length,
spacing=0.0,
turns=1.0,
sides=4,
metal=top.index,
exit_metal=bot.index,
x_origin=x_origin,
y_origin=y_origin,
kind="capacitor",
)
[docs]
def symmetric_polygon(
name: str,
*,
radius: float,
width: float,
spacing: float,
turns: float,
ilen: float = 0.0,
sides: int = 8,
tech: Tech,
metal: int | str = 0,
primary_metal: int | str | None = None,
exit_metal: int | str | None = None,
x_origin: float = 0.0,
y_origin: float = 0.0,
) -> Shape:
"""Symmetric centre-tapped polygon spiral (``SymPoly``, case 17).
Mirrors ``cmd_sympoly_build_geometry`` (decomp ``0x0805a45c``):
one continuous polygon spiral that runs ``2N`` half-turns from
outer-to-inner-to-outer with a centre-tap stub at the apex and
cross-ring slants at every other transition. The structure is
decoded by :func:`_sympoly_layout_polygons`; this builder just
wires the parameters through.
Args:
radius: outer-corner polygon radius R (μm).
width: metal trace width W (μm).
spacing: edge-to-edge gap S between turns (μm).
turns: integer turn count N.
ilen: centre-tap span (ASITIC's ``ILEN`` parameter; defaults
to ``W + S`` per the C edit_args fallback).
sides: polygon side count (must be even, ≥ 4).
metal / primary_metal: trace metal layer.
exit_metal: alternating slant + via-cluster metal (typically
one layer below ``metal``).
"""
if primary_metal is not None:
metal = primary_metal
metal_idx = _resolve_metal(tech, metal).index
exit_idx: int | None = None
if exit_metal is not None:
exit_idx = _resolve_metal(tech, exit_metal).index
if ilen <= 0:
ilen = width + spacing
return Shape(
name=name,
polygons=[],
width=width,
length=radius * 2.0,
spacing=spacing,
turns=turns,
sides=sides,
metal=metal_idx,
exit_metal=exit_idx,
x_origin=x_origin,
y_origin=y_origin,
radius=radius,
ilen=ilen,
kind="sympoly",
)
[docs]
def balun(
name: str,
*,
length: float,
width: float,
spacing: float,
turns: float,
tech: Tech,
metal: int | str | None = None,
metal2: int | str | None = None,
primary_metal: int | str | None = None,
secondary_metal: int | str | None = None,
exit_metal: int | str | None = None,
x_origin: float = 0.0,
y_origin: float = 0.0,
which: str = "primary",
) -> Shape:
"""Build one coil of a 3D / planar balun (``Balun``, decomp ``0x0805bc74``).
The C builder is a 72-byte wrapper that calls
``cmd_symsq_build_geometry`` twice and applies ``cmd_flipv_apply``
to the secondary. Each coil is a *partial* SYMSQ — only
alternating rings are emitted, so the two coils together
interleave nicely in 3D::
Primary coil: rings 0, 2, 4, … (even k)
Secondary coil: rings 1, 3, 5, … (odd k)
The internal ILEN is derived from the build args (no explicit
ILEN parameter for BALUN) — decoded from gold
``balun_200x8x3x3_m3_m2``: ``ILEN = 2 · (W + S) = 2 · pitch``.
Use ``which="primary"`` (default) or ``which="secondary"`` to
select the coil to materialise.
"""
primary_arg: int | str | None = (
primary_metal if primary_metal is not None else metal
)
primary_idx: int = (
_resolve_metal(tech, primary_arg).index
if primary_arg is not None else 0
)
# In the 2D CIF, both BALUN coils land on the SAME metal layer
# (METAL=m3 in the canonical case). The METAL2 / secondary_metal
# parameter affects 3D inductance modelling but not the layout.
# The exit_metal parameter (if provided) is used for the centre-
# tap via cluster on the primary coil — defaults to METAL2 for
# backward compat.
exit_idx: int | None
if exit_metal is not None:
exit_idx = _resolve_metal(tech, exit_metal).index
else:
exit_metal_arg = secondary_metal if secondary_metal is not None else metal2
exit_idx = (
_resolve_metal(tech, exit_metal_arg).index
if exit_metal_arg is not None else None
)
return Shape(
name=name,
polygons=[],
width=width, length=length, spacing=spacing, turns=turns,
sides=4,
metal=primary_idx,
exit_metal=exit_idx,
x_origin=x_origin, y_origin=y_origin,
ilen=2.0 * (width + spacing),
kind="balun_primary" if which == "primary" else "balun_secondary",
)
def _sympoly_layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
"""CIF-equivalent polygons for a SYMPOLY centre-tapped polygon spiral.
Direct port of ``cmd_sympoly_build_geometry`` (decomp ``0x0805a45c``).
The C function runs ``2N`` half-turn iterations of a polygon spiral
that tracks four state variables in the polygon scratch record:
``angle`` (incremented by ``2π/sides`` per inner step),
``R_curr`` (radius, stepped by ``±(W+S)/cos(π/sides)`` between
rings), ``y_off`` (= ``ILEN/2``, sign-flipped between half-turns),
and ``metal_alt`` (toggled between primary and exit at every
via-cluster transition).
Per half-turn (``sides/2`` polygons each):
* Inner loop emits ``sides/2`` ring polygons at the current
``R_curr`` and ``y_off``.
* After the loop, ``y_off`` flips, the polygon's curr corners
collapse to prev, and one of three transitions runs:
- ``half == N``: centre-tap stub. The curr corner Y is shifted
by ``2 * y_off`` (= ``-ILEN``). Emits one M3 stub polygon.
- ``half != N`` (via cluster): the metal alternates and the
curr corner gets shifted by ``±(W+S)`` in X and ``±ILEN``
in Y per ``sympoly_emit_polygon_layers`` cases 4-7. Emits
one slant polygon on the alternating metal. When the
alternating metal differs from primary, two ``n_vias × n_vias``
via-cluster pads are emitted at the chamfer corner of the
slant's pre-shift and post-shift positions.
Final post-pass: bounding-box centre is translated to
``(x_origin, y_origin)`` (the binary's
``shape_translate_inplace_xy`` step).
"""
R = shape.radius
W = shape.width
S = shape.spacing
ILEN = shape.ilen if shape.ilen > 0 else (W + S)
N = round(shape.turns)
sides = shape.sides
if R <= 0 or W <= 0 or N < 1 or sides < 4 or sides % 2 != 0:
return []
XORG = shape.x_origin
YORG = shape.y_origin
primary_idx = shape.metal
exit_idx = shape.exit_metal if shape.exit_metal is not None else primary_idx
primary_rec = tech.metals[primary_idx]
exit_rec = tech.metals[exit_idx]
half_pi = math.pi / sides
cos_half = math.cos(half_pi)
side_step = 2.0 * math.pi / sides
radial_pitch = (W + S) / cos_half
angle = 0.0 # phase = 0, matches binary's default
R_curr = R
y_off = ILEN * 0.5
def end_corners(theta: float, R_: float, y_: float) -> tuple[
tuple[float, float], tuple[float, float], tuple[float, float],
]:
c = math.cos(theta)
s = math.sin(theta)
return (
(R_ * c, R_ * s + y_),
((R_ - W / (2.0 * cos_half)) * c, (R_ - W / (2.0 * cos_half)) * s + y_),
((R_ - W / cos_half) * c, (R_ - W / cos_half) * s + y_),
)
o_curr, ch_curr, i_curr = end_corners(angle, R_curr, y_off)
# Each tagged-poly tuple is (metal_idx, [(x, y), ...]).
tagged: list[tuple[int, list[tuple[float, float]]]] = []
# Via-cluster locations: list of (chamfer_x, chamfer_y, sign).
# NOTE: pad-width decoding is incomplete — lookup_via_for_metal_pair
# emits a 7.5 × 7.5 polygon (per geom_emit_polygon_at) but the gold
# has 10.82 × 8.5 (W/cos(π/N) wide) for narrow pads and even wider
# for the pre-shift cluster of OUTWARD transitions (38.96 for r120,
# 34.91 for r100). Decoded structurally below; the wide-pad width
# formula is recorded as outstanding in docs/asitic_geometry_c.md.
cluster_centres: list[tuple[float, float, int]] = []
# The C reads an uninitialised local_13c that's compared against
# iVar6 (= primary). With a typical zero-initialised stack, the
# comparison fails on the first via-cluster transition so the
# first slant lands on PRIMARY (M3); the alternation then flips
# to EXIT (M2) on the second via cluster. We model that with an
# initial sentinel that 'looks like' exit.
metal_alt = 1 # 1 == "last was exit/sentinel" -> first toggle gives 0 (primary)
for half in range(1, 2 * N + 1):
for _ in range(sides // 2):
o_prev, _ch_prev, i_prev = o_curr, ch_curr, i_curr
angle += side_step
o_curr, ch_curr, i_curr = end_corners(angle, R_curr, y_off)
tagged.append((primary_idx, [o_prev, o_curr, i_curr, i_prev]))
y_off = -y_off
# The C calls polygon_collapse_endpoints_2d after the flip;
# since prev was last set inside the loop, prev == curr is
# already the case here (no-op).
if half >= 2 * N:
break
if half == N:
# Centre-tap stub: shift end-side Y by 2 * (post-flip y_off).
shift_y = 2.0 * y_off
o_target = (o_curr[0], o_curr[1] + shift_y)
ch_target = (ch_curr[0], ch_curr[1] + shift_y)
i_target = (i_curr[0], i_curr[1] + shift_y)
tagged.append((primary_idx, [o_curr, o_target, i_target, i_curr]))
o_curr, ch_curr, i_curr = o_target, ch_target, i_target
continue
# Via-cluster transition. Toggle the alternating metal.
metal_alt = 1 - metal_alt
slant_metal = primary_idx if metal_alt == 0 else exit_idx
if half < N:
R_curr -= radial_pitch
case = 4 if y_off >= 0 else 7
else:
R_curr += radial_pitch
case = 6 if y_off < 0 else 5
# sympoly_emit_polygon_layers shifts curr corners. The four
# cases encode (sign_x, sign_y) for the X+Y shift of (W+S)
# and ILEN respectively.
if case == 4:
dx, dy = -(W + S), +ILEN
elif case == 5:
dx, dy = +(W + S), +ILEN
elif case == 6:
dx, dy = -(W + S), -ILEN
else: # case == 7
dx, dy = +(W + S), -ILEN
# Pre-shift chamfer (== ch_curr at this point) is the centre
# of the FIRST via cluster pad; post-shift chamfer is the
# SECOND. The vertical offset by ± pad_h/2 is added below
# only when the alternating metal differs from primary.
# Sign convention (decoded from the C transition state):
# case 4: (sign1, sign2) = (-1, +1) case 5: (-1, +1)
# case 6: (sign1, sign2) = (+1, -1) case 7: (+1, -1)
if case in (4, 5):
sign1, sign2 = -1, +1
else:
sign1, sign2 = +1, -1
ch_pre = ch_curr
o_target = (o_curr[0] + dx, o_curr[1] + dy)
ch_target = (ch_curr[0] + dx, ch_curr[1] + dy)
i_target = (i_curr[0] + dx, i_curr[1] + dy)
if slant_metal != primary_idx:
cluster_centres.append((ch_pre[0], ch_pre[1], sign1))
cluster_centres.append((ch_target[0], ch_target[1], sign2))
tagged.append((slant_metal, [o_curr, o_target, i_target, i_curr]))
o_curr, ch_curr, i_curr = o_target, ch_target, i_target
if not tagged:
return []
# Bbox-center to (XORG, YORG).
all_x = [v[0] for _, p in tagged for v in p]
all_y = [v[1] for _, p in tagged for v in p]
cx = (min(all_x) + max(all_x)) * 0.5
cy = (min(all_y) + max(all_y)) * 0.5
dx_shift = XORG - cx
dy_shift = YORG - cy
polys: list[Polygon] = []
for metal_idx, p in tagged:
rec = tech.metals[metal_idx]
polys.append(_polygon_record_to_poly(
[(v[0] + dx_shift, v[1] + dy_shift) for v in p],
rec, W,
))
# Via clusters: emit M2/M3 box pad + nx × ny via squares per
# via-cluster transition. The pad WIDTH/HEIGHT depend on the
# CONTAINING polygon's bbox per the C's CIF emitter logic
# (cif_check_via_has_metal + cif_emit_path_with_4_doubles).
#
# Algorithm decoded from the C:
#
# 1. The cluster's "base" polygon emitted by geom_emit_polygon_at
# has dims (n_vias·via_w + (n_vias-1)·via_s) ²
# (= 7.5 × 7.5 for BiCMOS via3 / W=10).
# 2. cif_check_via_has_metal walks the shape's polygon list and
# finds the FIRST polygon whose bbox CONTAINS the cluster's
# base bbox (in linked-list / emit order, regardless of which
# side of the cluster).
# 3. cif_emit_path_with_4_doubles computes:
# pad_w = 2 · min(|container.xmin - cluster.xmin|,
# |container.xmax - cluster.xmax|)
# + cluster.x_extent
# pad_h = 2 · min(|container.ymin - cluster.ymin|,
# |container.ymax - cluster.ymax|)
# + cluster.y_extent
#
# The pad reaches symmetrically from the cluster centre out to the
# container's nearest edge in each axis. M2 + M3 pads are emitted
# at the same dims; the via squares form an n_vias × n_vias grid
# at the cluster centre with pitch = via_w + via_s.
if exit_idx != primary_idx and cluster_centres:
via_rec, via_idx = None, -1
for i, v in enumerate(tech.vias):
if {v.top, v.bottom} == {primary_idx, exit_idx}:
via_rec, via_idx = v, i
break
if via_rec is not None:
overplot = max(via_rec.overplot1, via_rec.overplot2)
vp = via_rec.width + via_rec.space
n_vias = max(1, math.floor(
(W - 2.0 * overplot + via_rec.space) / vp,
))
grid_extent = n_vias * via_rec.width + (n_vias - 1) * via_rec.space
half_grid = grid_extent * 0.5
via_metal = len(tech.metals) + via_idx
def _bbox(poly: Polygon) -> tuple[float, float, float, float]:
xs = [v.x for v in poly.vertices[:-1]]
ys = [v.y for v in poly.vertices[:-1]]
return (min(xs), min(ys), max(xs), max(ys))
for raw_x, raw_y, sign in cluster_centres:
# Cluster centre = chamfer + (0, ± grid_extent/2 + overplot).
pad_h_default = grid_extent + 2.0 * overplot
cx_world = raw_x + dx_shift
cy_world = raw_y + dy_shift + sign * pad_h_default * 0.5
# Cluster's "base" bbox = grid_extent × grid_extent
# centred on (cx, cy). cif_check_via_has_metal compares
# against this size (NOT the final pad size).
cb_xmin = cx_world - half_grid
cb_ymin = cy_world - half_grid
cb_xmax = cx_world + half_grid
cb_ymax = cy_world + half_grid
# Find FIRST containing polygon among the spiral / slant
# / stub polys we've already built.
container_bbox: tuple[float, float, float, float] | None = None
for container_poly in polys:
bx0, by0, bx1, by1 = _bbox(container_poly)
if (bx0 <= cb_xmin and bx1 >= cb_xmax and
by0 <= cb_ymin and by1 >= cb_ymax):
container_bbox = (bx0, by0, bx1, by1)
break
if container_bbox is not None:
bx0, by0, bx1, by1 = container_bbox
min_x = min(abs(bx0 - cb_xmin), abs(bx1 - cb_xmax))
min_y = min(abs(by0 - cb_ymin), abs(by1 - cb_ymax))
pad_w = 2.0 * min_x + grid_extent
pad_h = 2.0 * min_y + grid_extent
else:
# No container: fall back to grid + overplot (shouldn't
# hit in valid layouts, since the C errors out then).
pad_w = grid_extent + 2.0 * overplot
pad_h = grid_extent + 2.0 * overplot
half_w = pad_w * 0.5
half_h = pad_h * 0.5
pad = [
(cx_world - half_w, cy_world - half_h),
(cx_world + half_w, cy_world - half_h),
(cx_world + half_w, cy_world + half_h),
(cx_world - half_w, cy_world + half_h),
]
polys.append(_polygon_record_to_poly(pad, exit_rec, W))
polys.append(_polygon_record_to_poly(pad, primary_rec, W))
# nx × ny via grid at cluster centre.
cell0_x = cx_world - half_grid
cell0_y = cy_world - half_grid
for i in range(n_vias):
for j in range(n_vias):
x0 = cell0_x + i * vp
y0 = cell0_y + j * vp
polys.append(_closed_poly(
[(x0, y0), (x0 + via_rec.width, y0),
(x0 + via_rec.width, y0 + via_rec.width),
(x0, y0 + via_rec.width)],
z=0.0, metal=via_metal,
width=via_rec.width, thickness=0.0,
))
return polys
def _balun_primary_layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
"""Primary coil of a BALUN — rings 0, 2, 4, ... + via clusters + M2 trace.
The primary BALUN coil is a partial SYMSQ that emits only the
EVEN-indexed rings (k = 0, 2, ...) plus the centre-tap routing.
Matches the ``balun_..._primary.cif`` gold output.
"""
L = shape.length
W = shape.width
S = shape.spacing
if L <= 0 or W <= 0:
return []
pitch = W + S
ILEN = shape.ilen if shape.ilen > 0 else 2.0 * pitch
X = shape.x_origin
Y = shape.y_origin
metal_rec = tech.metals[shape.metal]
n_int = round(shape.turns)
even_ks = [k for k in range(n_int) if k % 2 == 0]
polys: list[Polygon] = []
# Centre-U at top (always — it's ring k=0 top, full L width)
polys.extend(_symsq_centre_arm_polygons(L, W, ILEN, X, Y, metal_rec))
# Top rings k=2, 4, ... (even and ≥1)
for k in even_ks:
if k == 0:
continue
polys.extend(_symsq_u_ring_polygons(
outer_x_min=X + k * pitch, outer_x_max=X + L - k * pitch,
outer_top_y=Y + L + ILEN * 0.5 - k * pitch,
arm_bottom_y=Y + L * 0.5 + ILEN,
W=W, open_side="bottom", metal_rec=metal_rec,
))
# Bottom rings k=0, 2, 4, ...
for k in even_ks:
polys.extend(_symsq_u_ring_polygons(
outer_x_min=X + k * pitch, outer_x_max=X + L - k * pitch,
outer_top_y=Y + L * 0.5,
arm_bottom_y=Y + ILEN * 0.5 + k * pitch,
W=W, open_side="top", metal_rec=metal_rec,
))
# Stubs + slants for the INNERMOST even ring (= max even k)
if even_ks and ILEN > 0:
k_inner = max(even_ks)
if k_inner > 0:
# Left stub at offset k_inner from XORG
stub_x0 = X + k_inner * pitch
stub_x1 = stub_x0 + W
stub_mid = Y + L * 0.5 + ILEN * 0.5
for y0, y1 in [
(Y + L * 0.5, stub_mid), (stub_mid, Y + L * 0.5 + ILEN),
]:
polys.append(_polygon_record_to_poly(
[(stub_x0, y0), (stub_x0, y1), (stub_x1, y1), (stub_x1, y0)],
metal_rec, W,
))
# Right slant: top edge at the INNERMOST ring's outer-x
# (offset k_inner*pitch from right), bottom edge at the
# OUTERMOST ring's outer-x (offset 0 from right).
u_arm_bot = Y + L * 0.5 + ILEN
slant = [
(X + L - k_inner * pitch, u_arm_bot),
(X + L, Y + L * 0.5),
(X + L - W, Y + L * 0.5),
(X + L - k_inner * pitch - W, u_arm_bot),
]
polys.append(_polygon_record_to_poly(slant, metal_rec, W))
# Via clusters + M2 trace — only on the right side. Pad 1 is at
# the OUTERMOST ring's right vertical (offset 0 from right);
# pad 2 is at the INNERMOST ring's right vertical (offset
# k_inner*pitch). Decoded from gold balun_200x8x3x3:
# pad 1 cx = 196 = X+L-W/2 (offset 0 from right)
# pad 2 cx = 174 = X+L-2pitch-W/2 (offset k_inner=2)
exit_idx = shape.exit_metal
if exit_idx is not None and exit_idx != shape.metal and even_ks:
k_inner = max(even_ks)
via_rec, via_idx = None, -1
for i, v in enumerate(tech.vias):
if {v.top, v.bottom} == {shape.metal, exit_idx}:
via_rec, via_idx = v, i
break
if via_rec is not None:
overplot = max(via_rec.overplot1, via_rec.overplot2)
vp = via_rec.width + via_rec.space
n_vias = max(1, math.floor((W - 2.0 * overplot + via_rec.space) / vp))
cluster_span = n_vias * via_rec.width + (n_vias - 1) * via_rec.space
pad_h = cluster_span + 2.0 * overplot
u_arm_bot = Y + L * 0.5 + ILEN
exit_metal_rec = tech.metals[exit_idx]
via_metal = len(tech.metals) + via_idx
half_w = W * 0.5
half_h = pad_h * 0.5
pad_centers = [
(X + L - W * 0.5,
u_arm_bot + pad_h * 0.5),
(X + L - k_inner * pitch - W * 0.5,
Y + L * 0.5 - pad_h * 0.5),
]
for cx, cy in pad_centers:
pad_corners = [
(cx - half_w, cy - half_h), (cx + half_w, cy - half_h),
(cx + half_w, cy + half_h), (cx - half_w, cy + half_h),
]
polys.append(_polygon_record_to_poly(pad_corners, exit_metal_rec, W))
polys.append(_polygon_record_to_poly(pad_corners, metal_rec, W))
cell0_x = cx - cluster_span * 0.5
cell0_y = cy - cluster_span * 0.5
for i in range(n_vias):
for j in range(n_vias):
x0 = cell0_x + i * vp
y0 = cell0_y + j * vp
polys.append(_closed_poly(
[(x0, y0), (x0 + via_rec.width, y0),
(x0 + via_rec.width, y0 + via_rec.width),
(x0, y0 + via_rec.width)],
z=0.0, metal=via_metal,
width=via_rec.width, thickness=0.0,
))
# M2 chamfered transition trace from the outermost-ring
# pad to the innermost-ring pad.
trace = [
(X + L, u_arm_bot),
(X + L - k_inner * pitch, Y + L * 0.5),
(X + L - k_inner * pitch - W, Y + L * 0.5),
(X + L - W, u_arm_bot),
]
polys.append(_polygon_record_to_poly(trace, exit_metal_rec, W))
return polys
def _balun_secondary_layout_polygons(shape: Shape, tech: Tech) -> list[Polygon]:
"""Secondary coil of a BALUN — odd-indexed rings only.
No centre-U, no via clusters, no M2 trace — just the rings and
their centre-transition stubs.
"""
L = shape.length
W = shape.width
S = shape.spacing
if L <= 0 or W <= 0:
return []
pitch = W + S
ILEN = shape.ilen if shape.ilen > 0 else 2.0 * pitch
X = shape.x_origin
Y = shape.y_origin
metal_rec = tech.metals[shape.metal]
n_int = round(shape.turns)
odd_ks = [k for k in range(n_int) if k % 2 == 1]
polys: list[Polygon] = []
# Top rings k=1, 3, ...
for k in odd_ks:
polys.extend(_symsq_u_ring_polygons(
outer_x_min=X + k * pitch, outer_x_max=X + L - k * pitch,
outer_top_y=Y + L + ILEN * 0.5 - k * pitch,
arm_bottom_y=Y + L * 0.5 + ILEN,
W=W, open_side="bottom", metal_rec=metal_rec,
))
# Bottom rings k=1, 3, ...
for k in odd_ks:
polys.extend(_symsq_u_ring_polygons(
outer_x_min=X + k * pitch, outer_x_max=X + L - k * pitch,
outer_top_y=Y + L * 0.5,
arm_bottom_y=Y + ILEN * 0.5 + k * pitch,
W=W, open_side="top", metal_rec=metal_rec,
))
# Stubs for the OUTERMOST odd ring (= min odd k, typically 1)
if odd_ks and ILEN > 0:
k_outer_odd = min(odd_ks)
stub_x0 = X + k_outer_odd * pitch
stub_x1 = stub_x0 + W
stub_mid = Y + L * 0.5 + ILEN * 0.5
for y0, y1 in [
(Y + L * 0.5, stub_mid), (stub_mid, Y + L * 0.5 + ILEN),
]:
polys.append(_polygon_record_to_poly(
[(stub_x0, y0), (stub_x0, y1), (stub_x1, y1), (stub_x1, y0)],
metal_rec, W,
))
return polys