app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233
"""The EDA Bridge Standard board model from Fusion's own EAGLE board XML.
Embedded by routing.rs beside routing_script.py and run on Fusion's main thread; stdlib only, so
tests/test_board_model.py runs the same code offline on .brd files. Schema:
wiki.adom.inc/adom/eda-bridge-standard (schema/board.schema.json, schema/companions.schema.json).
Millimetres, Fusion's board frame (y up), absolute. Copper layers carry KiCad's names (F.Cu,
In<n>.Cu by stack order, B.Cu, *.Cu); other layers keep EAGLE's names.
"""
import hashlib
import json
import math
import re
MODEL = 'eda-bridge-standard/board 0.1'
# EAGLE's fixed layer numbers for everything that is not copper (copper is 1..16)
EAGLE_LAYERS = {17: 'Pads', 18: 'Vias', 19: 'Unrouted', 20: 'Dimension', 21: 'tPlace', 22: 'bPlace',
23: 'tOrigins', 24: 'bOrigins', 25: 'tNames', 26: 'bNames', 27: 'tValues', 28: 'bValues',
29: 'tStop', 30: 'bStop', 31: 'tCream', 32: 'bCream', 33: 'tFinish', 34: 'bFinish',
35: 'tGlue', 36: 'bGlue', 37: 'tTest', 38: 'bTest', 39: 'tKeepout', 40: 'bKeepout',
41: 'tRestrict', 42: 'bRestrict', 43: 'vRestrict', 44: 'Drills', 45: 'Holes', 46: 'Milling',
47: 'Measures', 48: 'Document', 49: 'Reference', 51: 'tDocu', 52: 'bDocu'}
COPPER = set(range(1, 17))
EPS = 0.001 # copper closer than this touches
ARC_STEP = 2.0 # degrees: sample arcs this finely before measuring (the standard's rule)
def _f(node, key, default=0.0):
try:
return float(node.get(key, default))
except (TypeError, ValueError):
return default
def _deg(rot):
m = re.search(r'R(-?[\d.]+)', rot or 'R0')
return float(m.group(1)) if m else 0.0
def _norm(a):
a = a % 360.0
return 0.0 if abs(a - 360.0) < 1e-9 else round(a, 6)
# ---------------------------------------------------------------- layers
def copper_stack(board):
"""Copper layer numbers top to bottom. Fusion's layer stack (layerstackup) when the board has
one, else designrules layerSetup ("(1*16)", "(1+2*15+16)", "[2:1+2*15+16:15]")."""
ls = board.find('.//layerstackup')
if ls is not None:
order = [int(d.get('layer')) for d in ls.findall('layerdef') if d.get('type') in ('Signal', 'Plane')]
if order:
return order
node = board.find("designrules/param[@name='layerSetup']")
setup = node.get('value', '(1*16)') if node is not None else '(1*16)'
setup = re.sub(r'\[\d+:', '[', re.sub(r':\d+\]', ']', setup)) # blind/buried bounds are not layers
order = []
for n in re.findall(r'\d+', setup):
if int(n) not in order:
order.append(int(n))
return order or [1, 16]
class Layers:
def __init__(self, board):
self.order = copper_stack(board)
inner = [n for n in self.order if n not in (1, 16)]
self.names = {1: 'F.Cu', 16: 'B.Cu'}
for i, n in enumerate(inner):
self.names[n] = 'In%d.Cu' % (i + 1)
def name(self, n):
n = int(n)
if n in self.names:
return self.names[n]
if n in COPPER: # a copper layer the stack does not list (unused): by its number
return 'In%d.Cu' % (n - 1)
return EAGLE_LAYERS.get(n, 'L%d' % n)
def span(self, extent):
"""The copper layers a via of this extent ("1-16", "1-2") passes through, as numbers."""
try:
a, b = (int(x) for x in (extent or '1-16').split('-'))
except ValueError:
a, b = 1, 16
order = self.order if a in self.order and b in self.order else sorted(COPPER)
i, j = sorted((order.index(a), order.index(b)))
return order[i:j + 1]
# ---------------------------------------------------------------- transforms and rules
def placement(el):
"""The element's transform: local (x, y) -> board (x, y), its angle and whether it is mirrored.
Rotate, then mirror x: the same order the route planner's pad model has always used."""
rot = el.get('rot', 'R0')
mirrored = 'M' in rot
a = math.radians(_deg(rot))
ex, ey = _f(el, 'x'), _f(el, 'y')
c, s = math.cos(a), math.sin(a)
def xf(x, y):
x, y = x * c - y * s, x * s + y * c
if mirrored:
x = -x
return (x + ex, y + ey)
return xf, _deg(rot), mirrored
def abs_rotation(el_angle, mirrored, local):
return _norm(-(el_angle + local) if mirrored else el_angle + local)
def _rule_mm(board, name, default):
node = board.find("designrules/param[@name='%s']" % name)
m = re.fullmatch(r'([0-9.]+)(mm|mil|in|mic)', (node.get('value', '') if node is not None else '').strip())
if not m:
return default
return float(m[1]) * {'mm': 1, 'mil': 0.0254, 'in': 25.4, 'mic': 0.001}[m[2]]
def _rule_num(board, name, default):
node = board.find("designrules/param[@name='%s']" % name)
try:
return float(node.get('value')) if node is not None else default
except (TypeError, ValueError):
return default
def pad_diameter(board, drill, lib_diameter):
"""A through pad's copper: its library diameter or the drill plus the board's restring,
whichever is larger (Fusion's rule; the route planner uses the same)."""
ring = min(max(drill * _rule_num(board, 'rvPadTop', 0.25), _rule_mm(board, 'rlMinPadTop', 0.254)),
_rule_mm(board, 'rlMaxPadTop', 0.508))
return max(lib_diameter, drill + 2 * ring)
def via_diameter(board, drill, diameter):
ring = min(max(drill * _rule_num(board, 'rvViaOuter', 0.25), _rule_mm(board, 'rlMinViaOuter', 0.2032)),
_rule_mm(board, 'rlMaxViaOuter', 0.508))
return max(diameter, drill + 2 * ring)
# ---------------------------------------------------------------- geometry
def arc_of(p1, p2, curve):
"""An EAGLE wire p1 -> p2 sweeping `curve` degrees (positive counterclockwise) as center,
radius and the counterclockwise angle range (angle1 -> angle2, angle2 - angle1 = |curve|)."""
c = math.radians(curve)
dx, dy = p2[0] - p1[0], p2[1] - p1[1]
L = math.hypot(dx, dy)
r = L / (2 * abs(math.sin(c / 2)))
d = (L / 2) / math.tan(c / 2)
cx, cy = (p1[0] + p2[0]) / 2 - dy / L * d, (p1[1] + p2[1]) / 2 + dx / L * d
a1 = math.degrees(math.atan2(p1[1] - cy, p1[0] - cx))
start = a1 if curve > 0 else a1 + curve
start = _norm(start)
return (cx, cy), r, start, round(start + abs(curve), 6)
def arc_samples(center, r, a_from, sweep, step=ARC_STEP):
"""Points along an arc from angle a_from sweeping `sweep` degrees (signed), at most `step` apart."""
n = max(1, int(math.ceil(abs(sweep) / step)))
return [[center[0] + r * math.cos(math.radians(a_from + sweep * k / n)),
center[1] + r * math.sin(math.radians(a_from + sweep * k / n))] for k in range(n + 1)]
def wire_points(p1, p2, curve):
if not curve:
return [list(p1), list(p2)]
center, r, _, _ = arc_of(p1, p2, curve)
a = math.degrees(math.atan2(p1[1] - center[1], p1[0] - center[0]))
pts = arc_samples(center, r, a, curve)
pts[0], pts[-1] = list(p1), list(p2)
return pts
def vertex_ring(vertices):
"""A closed EAGLE vertex list (a vertex's curve bends the edge to the next) as points."""
vs = [((_f(v, 'x'), _f(v, 'y')), _f(v, 'curve')) for v in vertices]
out = []
for i, (p, curve) in enumerate(vs):
q = vs[(i + 1) % len(vs)][0]
out += wire_points(p, q, curve)[:-1] if curve and p != q else [list(p)]
return out
def shape_vertices(node):
"""A polygon's own vertices: <polygon> holds them directly, Fusion's <polygonshape> and
<polygonpour> inside <polygonoutlineobjects>."""
inner = node.find('polygonoutlineobjects')
return (inner if inner is not None else node).findall('vertex')
def area(pts):
return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(pts, pts[1:] + pts[:1]))) / 2
def _r(v):
return round(v, 6)
def pt_seg(p, a, b):
dx, dy = b[0] - a[0], b[1] - a[1]
t = max(0, min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy))) if dx or dy else 0
return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
def seg_seg(a, b, c, d):
def cross(p, q, r):
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
if cross(a, b, c) * cross(a, b, d) < 0 and cross(c, d, a) * cross(c, d, b) < 0:
return 0.0
return min(pt_seg(a, c, d), pt_seg(b, c, d), pt_seg(c, a, b), pt_seg(d, a, b))
class Region:
"""A polygon with holes, indexed for touch tests: its edges bucketed in a grid (distance) and
in horizontal bands (point in polygon), so a 3000-vertex pour costs a few edges per query."""
def __init__(self, outline, holes=()):
self.outline = [tuple(v) for v in outline]
self.rings = [self.outline] + [[tuple(v) for v in h] for h in holes]
self.edges = [(a, b) for ring in self.rings for a, b in zip(ring, ring[1:] + ring[:1])]
xs = [v[0] for v in self.outline]
ys = [v[1] for v in self.outline]
self.bbox = (min(xs), min(ys), max(xs), max(ys))
span = max(self.bbox[2] - self.bbox[0], self.bbox[3] - self.bbox[1], 1e-6)
self.cell = max(span / 64.0, 0.25) if len(self.edges) > 32 else span * 2
self.grid, self.bands = {}, {}
for k, (a, b) in enumerate(self.edges):
i0, i1 = sorted((self._c(a[0]), self._c(b[0])))
j0, j1 = sorted((self._c(a[1]), self._c(b[1])))
for j in range(j0, j1 + 1):
self.bands.setdefault(j, []).append(k)
for i in range(i0, i1 + 1):
self.grid.setdefault((i, j), []).append(k)
def _c(self, v):
return int(math.floor(v / self.cell))
def contains(self, p):
"""Inside the outline and outside every hole: an odd number of crossings over all rings."""
if not (self.bbox[0] <= p[0] <= self.bbox[2] and self.bbox[1] <= p[1] <= self.bbox[3]):
return False
inside = False
for k in self.bands.get(self._c(p[1]), ()):
a, b = self.edges[k]
if (a[1] > p[1]) != (b[1] > p[1]) and p[0] < a[0] + (p[1] - a[1]) * (b[0] - a[0]) / (b[1] - a[1]):
inside = not inside
return inside
def near(self, box):
"""The edges whose grid cells meet a bounding box."""
out = set()
for i in range(self._c(box[0]), self._c(box[2]) + 1):
for j in range(self._c(box[1]), self._c(box[3]) + 1):
out.update(self.grid.get((i, j), ()))
return [self.edges[k] for k in out]
def vertices_in(self, box):
return [a for a, _ in self.near(box) if box[0] <= a[0] <= box[2] and box[1] <= a[1] <= box[3]]
def poly(outline, holes=()):
return ('poly', Region(outline, holes))
FAR = 1e9
def prim_distance(p, q):
"""Edge-to-edge distance of two primitives, a fat segment (seg, a, b, radius) or a polygon
(poly, Region): 0 or less when they overlap. Exact up to EPS; past that it may answer FAR
(a region's index only looks at nearby edges), which is all a touch test needs."""
if p[0] == 'poly' and q[0] == 'seg':
p, q = q, p
if p[0] == 'seg' and q[0] == 'seg':
return seg_seg(p[1], p[2], q[1], q[2]) - p[3] - q[3]
if p[0] == 'seg':
_, a, b, r = p
reg = q[1]
if reg.contains(a) or reg.contains(b):
return -r
box = (min(a[0], b[0]) - r - EPS, min(a[1], b[1]) - r - EPS, max(a[0], b[0]) + r + EPS, max(a[1], b[1]) + r + EPS)
near = reg.near(box)
return (min(seg_seg(a, b, c, d) for c, d in near) - r) if near else FAR
r1, r2 = p[1], q[1]
box = (max(r1.bbox[0], r2.bbox[0]) - EPS, max(r1.bbox[1], r2.bbox[1]) - EPS,
min(r1.bbox[2], r2.bbox[2]) + EPS, min(r1.bbox[3], r2.bbox[3]) + EPS)
if box[0] > box[2] or box[1] > box[3]:
return FAR
if any(r2.contains(v) for v in r1.vertices_in(box)) or any(r1.contains(v) for v in r2.vertices_in(box)):
return 0.0
e1, e2 = r1.near(box), r2.near(box)
return min((seg_seg(a, b, c, d) for a, b in e1 for c, d in e2), default=FAR)
def prim_bbox(p):
if p[0] == 'seg':
_, a, b, r = p
return (min(a[0], b[0]) - r, min(a[1], b[1]) - r, max(a[0], b[0]) + r, max(a[1], b[1]) + r)
return p[1].bbox
def rect_corners(cx, cy, sx, sy, rot):
a = math.radians(rot)
c, s = math.cos(a), math.sin(a)
return [[cx + x * c - y * s, cy + x * s + y * c] for x, y in
((-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2))]
def pad_prims(pad):
"""A pad as touch primitives: a circle or stadium is a fat segment, everything else its polygon."""
x, y, sx, sy, rot = pad['x'], pad['y'], pad['sizeX'], pad['sizeY'], pad['rotation']
if pad['shape'] == 'round':
long_ = max(sx, sy) - min(sx, sy)
a = math.radians(rot + (0 if sx >= sy else 90))
dx, dy = math.cos(a) * long_ / 2, math.sin(a) * long_ / 2
return [('seg', (x - dx, y - dy), (x + dx, y + dy), min(sx, sy) / 2)]
if pad['shape'] == 'octagonal':
k = min(sx, sy) * (1 - 1 / (1 + math.sqrt(2))) # an octagon's corner cut
a = math.radians(rot)
c, s = math.cos(a), math.sin(a)
pts = [(-sx / 2 + k / 2, -sy / 2), (sx / 2 - k / 2, -sy / 2), (sx / 2, -sy / 2 + k / 2), (sx / 2, sy / 2 - k / 2),
(sx / 2 - k / 2, sy / 2), (-sx / 2 + k / 2, sy / 2), (-sx / 2, sy / 2 - k / 2), (-sx / 2, -sy / 2 + k / 2)]
return [poly([[x + px * c - py * s, y + px * s + py * c] for px, py in pts])]
return [poly(rect_corners(x, y, sx, sy, rot))]
# ---------------------------------------------------------------- the model
def pads_of(board, layers, contacts):
"""Every pad: the standard row (ref, name, x, y, sizeX, sizeY, rotation, layer, drill, net,
shape) keyed by 'REF.PAD', plus the footprint copper that is not a pad (custom pad shapes are
folded into their pad as its bounding rectangle, the rest is net-less copper)."""
packages = {(l.get('name'), p.get('name')): p for l in board.findall('libraries/library')
for p in l.findall('packages/package')}
pads, extra_tracks, extra_polys, holes = {}, [], [], []
for el in board.findall('elements/element'):
pkg = packages.get((el.get('library'), el.get('package')))
if pkg is None:
continue
ref = el.get('name')
xf, angle, mirrored = placement(el)
own = []
for pad in pkg.findall('pad'):
drill = _f(pad, 'drill')
d = pad_diameter(board, drill, _f(pad, 'diameter'))
shape = pad.get('shape', 'round')
prot = _deg(pad.get('rot'))
lx, ly = _f(pad, 'x'), _f(pad, 'y')
sx = sy = d
kind = {'square': 'rect', 'octagon': 'octagonal'}.get(shape, 'round')
if shape in ('long', 'offset'):
sx = d * (1 + _f(pad, 'elongation', 100) / 100.0)
if shape == 'offset': # the drill sits at one end: the copper's center moves
lx += math.cos(math.radians(prot)) * (sx - d) / 2
ly += math.sin(math.radians(prot)) * (sx - d) / 2
cx, cy = xf(lx, ly)
hx, hy = xf(_f(pad, 'x'), _f(pad, 'y'))
row = {'ref': ref, 'name': pad.get('name'), 'x': _r(cx), 'y': _r(cy), 'sizeX': _r(sx), 'sizeY': _r(sy),
'rotation': abs_rotation(angle, mirrored, prot), 'layer': '*.Cu', 'drill': _r(drill),
'net': contacts.get((ref, pad.get('name')), ''), 'shape': kind}
if (cx, cy) != (hx, hy):
row['holeAt'] = [_r(hx), _r(hy)]
pads[ref + '.' + pad.get('name')] = row
own.append(row)
for smd in pkg.findall('smd'):
cx, cy = xf(_f(smd, 'x'), _f(smd, 'y'))
dx, dy, round_ = _f(smd, 'dx'), _f(smd, 'dy'), _f(smd, 'roundness')
layer = int(smd.get('layer', '1'))
layer = 17 - layer if mirrored and layer in (1, 16) else layer
kind = 'rect' if round_ <= 0 else ('round' if round_ >= 100 else 'roundrect')
row = {'ref': ref, 'name': smd.get('name'), 'x': _r(cx), 'y': _r(cy), 'sizeX': _r(dx), 'sizeY': _r(dy),
'rotation': abs_rotation(angle, mirrored, _deg(smd.get('rot'))), 'layer': layers.name(layer),
'layerId': layer, 'drill': 0, 'net': contacts.get((ref, smd.get('name')), ''), 'shape': kind}
if kind == 'roundrect':
row['roundness'] = round_
pads[ref + '.' + smd.get('name')] = row
own.append(row)
# footprint copper that is not a pad: a polygon or rectangle on a pad is that pad's custom
# shape (reported as the bounding rectangle of both); anything else is free copper
for node in pkg:
if node.tag not in ('polygon', 'polygonshape', 'rectangle', 'wire', 'circle') or int(node.get('layer', '0')) not in (1, 16):
continue
layer = int(node.get('layer'))
layer = 17 - layer if mirrored else layer
if node.tag == 'wire':
pts = wire_points((_f(node, 'x1'), _f(node, 'y1')), (_f(node, 'x2'), _f(node, 'y2')), _f(node, 'curve'))
pts = [list(xf(*p)) for p in pts]
net = next((p['net'] for p in own if p['layer'] in (layers.name(layer), '*.Cu') and
any(prim_distance(('seg', a, b, _f(node, 'width') / 2), q) <= EPS
for a, b in zip(pts, pts[1:]) for q in pad_prims(p))), '')
for a, b in zip(pts, pts[1:]):
extra_tracks.append({'start': {'x': _r(a[0]), 'y': _r(a[1])}, 'end': {'x': _r(b[0]), 'y': _r(b[1])},
'width': _f(node, 'width'), 'layer': layers.name(layer), 'layerId': layer,
'net': net, 'ref': ref})
continue
if node.tag in ('polygon', 'polygonshape'):
ring = [list(xf(*p)) for p in vertex_ring(shape_vertices(node))]
elif node.tag == 'rectangle':
x1, y1, x2, y2 = _f(node, 'x1'), _f(node, 'y1'), _f(node, 'x2'), _f(node, 'y2')
cx0, cy0 = (x1 + x2) / 2, (y1 + y2) / 2
ring = [list(xf(*p)) for p in rect_corners(cx0, cy0, abs(x2 - x1), abs(y2 - y1), _deg(node.get('rot')))]
else:
ring = [list(xf(*p)) for p in arc_samples((_f(node, 'x'), _f(node, 'y')), _f(node, 'radius'), 0, 360)[:-1]]
hit = [p for p in own if p['layer'] == layers.name(layer) and
any(prim_distance(poly(ring), q) <= EPS for q in pad_prims(p))]
# a shape shared by several pads (a power FET's drain: pads 5..9 joined by one
# polygon) becomes part of each of them
for p in hit:
corners = rect_corners(p['x'], p['y'], p['sizeX'], p['sizeY'], p['rotation'])
xs = [v[0] for v in ring] + [c[0] for c in corners]
ys = [v[1] for v in ring] + [c[1] for c in corners]
p.update({'x': _r((min(xs) + max(xs)) / 2), 'y': _r((min(ys) + max(ys)) / 2), 'sizeX': _r(max(xs) - min(xs)),
'sizeY': _r(max(ys) - min(ys)), 'rotation': 0, 'shape': 'rect', 'customShape': True})
ring_r = [[_r(a), _r(b)] for a, b in ring]
# the standard's copperPoints is one ring: the largest drawn polygon on the pad; a
# pad that gathers several also lists them all under copperOutlines
if 'copperPoints' not in p or area(ring_r) > area(p['copperPoints']):
if 'copperPoints' in p:
p.setdefault('copperOutlines', [p['copperPoints']])
p['copperPoints'] = ring_r
if 'copperOutlines' in p:
p['copperOutlines'].append(ring_r)
if not hit:
extra_polys.append({'net': '', 'layer': layers.name(layer), 'layerId': layer,
'points': [[_r(a), _r(b)] for a, b in ring], 'ref': ref, 'kind': 'graphic', 'source': 'footprint', 'pour': 'drawn'})
for h in pkg.findall('hole'):
x, y = xf(_f(h, 'x'), _f(h, 'y'))
holes.append({'ref': ref, 'x': _r(x), 'y': _r(y), 'drill': _f(h, 'drill')})
return pads, extra_tracks, extra_polys, holes
def outline_edges(board):
"""The board outline (layer 20 Dimension: the board's own lines, arcs and circles, and those
inside footprints) as closed loops of edges, largest first. Returns (loops, open_ends)."""
raw = []
def add(p1, p2, curve):
if curve:
center, r, a1, a2 = arc_of(p1, p2, curve)
raw.append({'kind': 'arc', 'start': [_r(p1[0]), _r(p1[1])], 'end': [_r(p2[0]), _r(p2[1])],
'center': [_r(center[0]), _r(center[1])], 'radius': _r(r), 'angle1': a1, 'angle2': a2,
'_ccw': curve > 0})
else:
raw.append({'kind': 'line', 'start': [_r(p1[0]), _r(p1[1])], 'end': [_r(p2[0]), _r(p2[1])]})
def circle(c, r):
raw.append({'kind': 'arc', 'start': [_r(c[0] + r), _r(c[1])], 'end': [_r(c[0] + r), _r(c[1])],
'center': [_r(c[0]), _r(c[1])], 'radius': _r(r), 'angle1': 0, 'angle2': 360, '_ccw': True})
for w in board.findall("plain/wire[@layer='20']"):
add((_f(w, 'x1'), _f(w, 'y1')), (_f(w, 'x2'), _f(w, 'y2')), _f(w, 'curve'))
for c in board.findall("plain/circle[@layer='20']"):
circle((_f(c, 'x'), _f(c, 'y')), _f(c, 'radius'))
packages = {(l.get('name'), p.get('name')): p for l in board.findall('libraries/library')
for p in l.findall('packages/package')}
for el in board.findall('elements/element'):
pkg = packages.get((el.get('library'), el.get('package')))
if pkg is None:
continue
xf, angle, mirrored = placement(el)
for w in pkg.findall("wire[@layer='20']"):
curve = _f(w, 'curve')
add(xf(_f(w, 'x1'), _f(w, 'y1')), xf(_f(w, 'x2'), _f(w, 'y2')), -curve if mirrored else curve)
for c in pkg.findall("circle[@layer='20']"):
circle(xf(_f(c, 'x'), _f(c, 'y')), _f(c, 'radius'))
near = lambda p, q: abs(p[0] - q[0]) < 1e-3 and abs(p[1] - q[1]) < 1e-3
def flip(e):
e = dict(e)
e['start'], e['end'] = e['end'], e['start']
if e['kind'] == 'arc':
e['_ccw'] = not e['_ccw']
return e
loops, open_ends = [], []
while raw:
loop = [raw.pop(0)]
while not near(loop[0]['start'], loop[-1]['end']):
for i, e in enumerate(raw):
if near(e['start'], loop[-1]['end']):
loop.append(raw.pop(i))
break
if near(e['end'], loop[-1]['end']):
loop.append(flip(raw.pop(i)))
break
else:
open_ends.append(loop[-1]['end'])
break
loops.append(loop)
loops.sort(key=lambda l: area(loop_points(l)), reverse=True)
return loops, open_ends
def loop_points(loop):
"""A loop's vertices in order, arcs sampled every 2 degrees."""
pts = []
for e in loop:
if e['kind'] == 'line':
pts.append(list(e['start']))
continue
sweep = e['angle2'] - e['angle1']
a_from = math.degrees(math.atan2(e['start'][1] - e['center'][1], e['start'][0] - e['center'][0]))
pts += [[_r(x), _r(y)] for x, y in arc_samples(e['center'], e['radius'], a_from, sweep if e['_ccw'] else -sweep)[:-1]]
return pts
def public_edge(e):
return {k: v for k, v in e.items() if not k.startswith('_')}
def board_model(board, meta=None):
"""The board model as the standard's schema/board.schema.json defines it."""
layers = Layers(board)
contacts = {(c.get('element'), c.get('pad')): s.get('name')
for s in board.findall('signals/signal') for c in s.findall('contactref')}
pads, fp_tracks, fp_polys, holes = pads_of(board, layers, contacts)
tracks, arcs, vias, polygons = [], [], [], []
def wire(w, net):
layer = int(w.get('layer'))
p1, p2, curve = (_f(w, 'x1'), _f(w, 'y1')), (_f(w, 'x2'), _f(w, 'y2')), _f(w, 'curve')
if curve and p1 != p2:
center, r, a1, a2 = arc_of(p1, p2, curve)
arcs.append({'center': {'x': _r(center[0]), 'y': _r(center[1])}, 'radius': _r(r), 'startAngle': a1,
'endAngle': a2, 'width': _f(w, 'width'), 'layer': layers.name(layer), 'layerId': layer, 'net': net})
else:
# the handle edit_tracks takes: Fusion stores no ids, so it is the track's own net, layer and ends
tracks.append({'id': 'track:%s:%d:%g,%g,%g,%g' % (net, layer, _r(p1[0]), _r(p1[1]), _r(p2[0]), _r(p2[1])),
'start': {'x': _r(p1[0]), 'y': _r(p1[1])}, 'end': {'x': _r(p2[0]), 'y': _r(p2[1])},
'width': _f(w, 'width'), 'layer': layers.name(layer), 'layerId': layer, 'net': net})
def pour(p, net):
layer = int(p.get('layer', '1'))
ring = vertex_ring(shape_vertices(p))
polygons.append({'net': net, 'layer': layers.name(layer), 'layerId': layer, 'points': [[_r(a), _r(b)] for a, b in ring],
'kind': 'zone', 'pour': p.get('pour', 'solid'), 'isolate': _f(p, 'isolate'), 'rank': int(p.get('rank', '1')),
'width': _f(p, 'width')})
for s in board.findall('signals/signal'):
net = s.get('name')
for w in s.findall('wire'):
if int(w.get('layer')) in COPPER:
wire(w, net)
for v in s.findall('via'):
drill = _f(v, 'drill')
span = layers.span(v.get('extent'))
vias.append({'position': {'x': _f(v, 'x'), 'y': _f(v, 'y')}, 'size': _r(via_diameter(board, drill, _f(v, 'diameter'))),
'drill': drill, 'layers': [layers.name(span[0]), layers.name(span[-1])], 'net': net})
for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):
if int(p.get('layer', '1')) in COPPER:
pour(p, net)
# free copper drawn on the board itself has no net: kept, with an empty net
for w in board.findall('plain/wire'):
if int(w.get('layer', '0')) in COPPER:
wire(w, '')
for p in board.findall('plain/polygon') + board.findall('plain/polygonshape'):
if int(p.get('layer', '0')) in COPPER:
pour(p, '')
polygons[-1]['pour'] = 'drawn'
polygons[-1]['kind'] = 'graphic'
for rct in board.findall('plain/rectangle'):
layer = int(rct.get('layer', '0'))
if layer in COPPER:
x1, y1, x2, y2 = _f(rct, 'x1'), _f(rct, 'y1'), _f(rct, 'x2'), _f(rct, 'y2')
ring = rect_corners((x1 + x2) / 2, (y1 + y2) / 2, abs(x2 - x1), abs(y2 - y1), _deg(rct.get('rot')))
polygons.append({'net': '', 'layer': layers.name(layer), 'layerId': layer, 'points': [[_r(a), _r(b)] for a, b in ring], 'kind': 'graphic', 'pour': 'drawn'})
for h in board.findall('plain/hole'):
holes.append({'ref': '', 'x': _f(h, 'x'), 'y': _f(h, 'y'), 'drill': _f(h, 'drill')})
tracks += fp_tracks
polygons += fp_polys
components = []
for el in board.findall('elements/element'):
rot = el.get('rot', 'R0')
components.append({'ref': el.get('name'), 'x': _f(el, 'x'), 'y': _f(el, 'y'), 'rotation': _norm(_deg(rot)),
'side': 'bottom' if 'M' in rot else 'top', 'pattern': el.get('package'),
'has3d': bool(el.get('package3d_urn')), 'value': el.get('value'), 'library': el.get('library'),
'locked': el.get('locked') == 'yes'})
loops, open_ends = outline_edges(board)
outer = loops[0] if loops else []
model = {
'model': MODEL, 'units': 'mm', 'file': (meta or {}).get('file'), 'boardId': (meta or {}).get('boardId'),
'revision': (meta or {}).get('revision') or board_revision(board),
'copperLayers': [layers.name(n) for n in layers.order],
'tracks': tracks, 'arcs': arcs, 'vias': vias, 'pads': list(pads.values()), 'components': components,
'polygons': polygons, 'holes': holes,
'outline': loop_points(outer), 'outlineEdges': [public_edge(e) for e in outer],
'outlineCutouts': [{'outline': loop_points(l), 'edges': [public_edge(e) for e in l]} for l in loops[1:]],
'outlineClosed': not open_ends,
}
if open_ends:
model['outlineProblem'] = 'the layer-20 outline is not closed: an edge ends at %s with nothing joined to it' % open_ends
model['nets'] = sorted({s.get('name') for s in board.findall('signals/signal') if s.get('name')})
unconnected, split, floating = connectivity(board, model, layers)
model['unconnected'] = unconnected
model['splitNets'] = split
model['isolatedCopper'] = floating[:200] # the standard's key (0.1.1): poured copper reaching nothing else
if len(floating) > 200:
model['isolatedCopperCount'] = len(floating)
if floating: # the older name, kept for callers that read it
model['floatingPourIslandCount'] = len(floating)
model['floatingPourIslands'] = [{'net': f['net'], 'layer': f['layer'], 'at': [f['at']['x'], f['at']['y']]} for f in floating[:50]]
model['counts'] = {k: len(model[k]) for k in ('tracks', 'arcs', 'vias', 'pads', 'components', 'polygons', 'nets')}
return model, pads
def pour_islands(board, layers):
"""Fusion's own fill, island by island: (net, layer number, outline, holes)."""
out = []
for s in board.findall('signals/signal'):
for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):
fd = p.find('polygonfilldetails')
if fd is None:
continue
for sh in fd.findall('polygonshape'):
seg = sh.find('polygonoutlinesegments')
if seg is None:
continue
holes = [vertex_ring(h.findall('vertex')) for h in sh.iter('polygonholesegments')]
out.append((s.get('name'), int(sh.get('layer', p.get('layer', '1'))), vertex_ring(seg.findall('vertex')), holes))
return out
def connectivity(board, model, layers):
"""`unconnected` from the copper itself: per net, the pieces of copper (pads, tracks, arcs, vias,
Fusion's poured fill; a pour Fusion never filled counts as its drawn outline) joined where they
touch on a shared layer. Every group beyond the first is a connection still to make, as on
Altium. A group that is only poured copper (an island, or a pour that reaches no pad) is dead
copper, not a missing connection: it is listed under isolatedCopper instead (the EDA Bridge
Standard 0.1.1 rule, KiCad's name); a stray track or via still counts."""
name_to_num = {v: k for k, v in layers.names.items()}
all_cu = set(layers.order)
def lset(name):
if name == '*.Cu':
return all_cu
return {name_to_num.get(name, -1)}
items = {}
def add(net, prims, ls, kind):
if net:
items.setdefault(net, []).append({'prims': prims, 'layers': ls, 'kind': kind,
'bbox': tuple(f(prim_bbox(p)[i] for p in prims) for i, f in enumerate((min, min, max, max)))})
for p in model['pads']:
add(p['net'], pad_prims(p), lset(p['layer']), 'pad')
for t in model['tracks']:
add(t['net'], [('seg', (t['start']['x'], t['start']['y']), (t['end']['x'], t['end']['y']), t['width'] / 2)], {t['layerId']}, 'track')
for a in model['arcs']:
pts = arc_samples((a['center']['x'], a['center']['y']), a['radius'], a['startAngle'], a['endAngle'] - a['startAngle'])
add(a['net'], [('seg', p, q, a['width'] / 2) for p, q in zip(pts, pts[1:])], {a['layerId']}, 'arc')
for v in model['vias']:
c = (v['position']['x'], v['position']['y'])
span = set(layers.order[layers.order.index(name_to_num[v['layers'][0]]):layers.order.index(name_to_num[v['layers'][1]]) + 1]) \
if all(l in name_to_num for l in v['layers']) else all_cu
add(v['net'], [('seg', c, c, v['size'] / 2)], span, 'via')
filled = set()
for net, layer, outline, holes in pour_islands(board, layers):
if len(outline) >= 3:
add(net, [poly(outline, holes)], {layer}, 'fill')
filled.add((net, layer))
for g in model['polygons']:
if g['net'] and (g['net'], g['layerId']) not in filled and len(g['points']) >= 3 and g.get('pour') != 'drawn':
add(g['net'], [poly(g['points'])], {g['layerId']}, 'pour outline')
elif g['net'] and g.get('pour') == 'drawn':
add(g['net'], [poly(g['points'])], {g['layerId']}, 'copper')
total, split, floating = 0, [], []
for net, its in sorted(items.items()):
n = len(its)
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for i in range(n):
a = its[i]
for j in range(i + 1, n):
b = its[j]
if not (a['layers'] & b['layers']):
continue
if a['bbox'][0] > b['bbox'][2] + EPS or b['bbox'][0] > a['bbox'][2] + EPS or \
a['bbox'][1] > b['bbox'][3] + EPS or b['bbox'][1] > a['bbox'][3] + EPS:
continue
if find(i) == find(j):
continue
if any(prim_distance(p, q) <= EPS for p in a['prims'] for q in b['prims']):
parent[find(i)] = find(j)
groups = {}
for i in range(n):
groups.setdefault(find(i), []).append(its[i])
live = []
for g in groups.values():
# only poured copper (an island, or a pour that reaches no pad): isolated copper, listed
# and not counted (EDA Bridge Standard 0.1.1); a stray track or via still counts
if all(x['kind'] in ('fill', 'pour outline') for x in g):
region = g[0]['prims'][0][1]
area_ = sum(area(list(x['prims'][0][1].rings[0])) - sum(area(list(h)) for h in x['prims'][0][1].rings[1:]) for x in g)
floating.append({'net': net, 'layer': layers.name(min(g[0]['layers'])),
'at': {'x': _r(region.outline[0][0]), 'y': _r(region.outline[0][1])}, 'areaMm2': round(area_, 4)})
else:
live.append(g)
if len(live) > 1:
total += len(live) - 1
split.append({'net': net, 'groups': len(live)})
return total, split, floating
def board_revision(board):
"""A hash of the copper, the parts and the outline, and nothing else: the edit verbs' guard.
Silkscreen text, rules, views and Fusion's own ratsnest (layer 19) leave it alone."""
def canonical(node):
if node.tag == 'wire' and node.get('layer') == '19':
return None
if node.tag == 'attribute': # a part's name/value text placement is silkscreen
return None
return [node.tag, sorted(node.attrib.items()), (node.text or '').strip(),
[v for ch in node if (v := canonical(ch)) is not None]]
used = {(e.get('library'), e.get('package')) for e in board.findall('elements/element')}
parts = [canonical(s) for s in board.findall('signals/signal')]
parts += [canonical(e) for e in board.findall('elements/element')]
parts += [canonical(n) for n in board.findall('plain/*')
if n.tag == 'hole' or (n.get('layer') or '').isdigit() and (int(n.get('layer')) in COPPER or n.get('layer') in ('20', '46'))]
parts += [canonical(p) for l in board.findall('libraries/library') for p in l.findall('packages/package')
if (l.get('name'), p.get('name')) in used]
return hashlib.sha256(json.dumps(parts, separators=(',', ':')).encode()).hexdigest()[:16]
# ---------------------------------------------------------------- companions
def layer_stack(board):
"""The stack-up as the standard's companions schema (stack) defines it: copper top to bottom,
each with the dielectric below it. From Fusion's Layer Stack Manager (layerstackup) when the
board has one, else the design rules' layerSetup, mtCopper and mtIsolate."""
layers = Layers(board)
ls = board.find('.//layerstackup')
rows, notes = [], []
def um(v):
return round(_len(v) * 1000, 3)
if ls is not None:
cu, dielectric = None, []
defs = ls.findall('layerdef')
for d in defs + [None]:
if d is None or d.get('type') in ('Signal', 'Plane'):
if cu is not None:
if dielectric:
t = sum(x[0] for x in dielectric)
cu['dielectricUm'] = round(t, 3)
dks = [(x[0], x[1]) for x in dielectric if x[1]]
if dks:
cu['dk1000'] = round(1000 * sum(t_ * k for t_, k in dks) / sum(t_ for t_, _ in dks))
cu['dielectric'] = [{'kind': x[2], 'um': x[0], 'dk': x[1], 'material': x[3]} for x in dielectric]
rows.append(cu)
if d is None:
break
m = d.find('material')
n = int(d.get('layer'))
cu = {'name': layers.name(n), 'layerId': n, 'kind': 'plane' if d.get('type') == 'Plane' else 'signal',
'copperUm': um(m.get('thickness')) if m is not None else None, 'fusionName': d.get('name')}
if n not in (1, 16):
cu['mid'] = int(layers.name(n)[2:-3])
dielectric = []
else:
m = d.find('material')
dk = m.get('dielectric_constant_1g') if m is not None else None
dielectric.append((um(m.get('thickness')) if m is not None else 0.0, float(dk) if dk else None,
d.get('type', '').lower(), m.get('name') if m is not None else None))
source = 'Fusion Layer Stack Manager (layerstackup)'
else:
node = lambda k: board.find("designrules/param[@name='%s']" % k)
cu_t = (node('mtCopper').get('value').split() if node('mtCopper') is not None else [])
iso = (node('mtIsolate').get('value').split() if node('mtIsolate') is not None else [])
for i, n in enumerate(layers.order):
row = {'name': layers.name(n), 'layerId': n, 'kind': 'signal',
'copperUm': um(cu_t[n - 1]) if len(cu_t) >= n else 35.0}
if n not in (1, 16):
row['mid'] = int(layers.name(n)[2:-3])
if i < len(layers.order) - 1 and i < len(iso):
row['dielectricUm'] = um(iso[i])
rows.append(row)
notes.append('No Layer Stack Manager data in this board: thicknesses come from the design rules '
'(mtCopper, mtIsolate), and the dielectric constant is unknown (dk1000 absent). '
'Set the stack in Fusion (Layer Stack Manager) for impedance work.')
source = 'design rules (layerSetup, mtCopper, mtIsolate)'
total = sum((r.get('copperUm') or 0) + (r.get('dielectricUm') or 0) for r in rows)
return {'copperLayers': len(rows), 'totalUm': round(total, 3), 'layers': rows, 'source': source, 'notes': notes}
def _len(v):
m = re.fullmatch(r'\s*(-?[0-9.]+)\s*(mm|mil|mic|um|in)?\s*', v or '0')
if not m:
return 0.0
return float(m[1]) * {'mm': 1, None: 1, 'mil': 0.0254, 'mic': 0.001, 'um': 0.001, 'in': 25.4}[m[2]]
def pour_id(net, layer, outline):
"""A pour's handle. Fusion stores no id on a polygon, so it's made from what the pour is: its net, layer and
outline, whichever vertex the outline starts at. The same pour keeps it across saves and when other pours come
and go; a new outline is a new pour."""
pts = sorted((round(float(a), 3), round(float(b), 3)) for a, b in outline)
return 'pour:%s:%d:%s' % (net, int(layer), hashlib.sha1(repr(pts).encode()).hexdigest()[:12])
def zones(board):
"""The pours model (companions schema `pours`): each pour as drawn and the copper Fusion
actually poured, island by island with its holes."""
layers = Layers(board)
out = []
for s in board.findall('signals/signal'):
for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):
layer = int(p.get('layer', '1'))
fd = p.find('polygonfilldetails')
fills = []
if fd is not None:
for sh in fd.findall('polygonshape'):
seg = sh.find('polygonoutlinesegments')
if seg is None:
continue
fills.append({'outline': [[_r(a), _r(b)] for a, b in vertex_ring(seg.findall('vertex'))],
'holes': [[[_r(a), _r(b)] for a, b in vertex_ring(h.findall('vertex'))] for h in sh.iter('polygonholesegments')]})
filled = sum(area(f['outline']) - sum(area(h) for h in f['holes']) for f in fills)
out.append({'id': pour_id(s.get('name'), layer, vertex_ring(shape_vertices(p))), 'net': s.get('name'), 'layer': layers.name(layer), 'layerId': layer,
'outline': [[_r(a), _r(b)] for a, b in vertex_ring(shape_vertices(p))],
'filledMm2': round(filled, 3), 'filled': fd is not None, 'fills': fills,
'pour': p.get('pour', 'solid'), 'isolate': _f(p, 'isolate'), 'rank': int(p.get('rank', '1'))})
return out
def netlist(board):
"""{net: ["U1.3", ...]}: every signal with its pads as Designator.Pad, from the board (Fusion
keeps board and schematic in sync, so this is the compiled netlist by pad)."""
nets = {}
for s in board.findall('signals/signal'):
refs = sorted('%s.%s' % (c.get('element'), c.get('pad')) for c in s.findall('contactref'))
if refs:
nets[s.get('name')] = refs
return nets
# ---------------------------------------------------------------- part identifiers
# The attribute names libraries put part numbers under: Adom's own (adom-lbr: MPN, MANUFACTURER,
# MOUSER_PN, DIGIKEY_PN, LCSC_PN), LCSC/JLCPCB libraries (LCSC), SnapEDA/SamacSys
# (MANUFACTURER_PART_NUMBER, MANUFACTURER_NAME) and common hand-made variants. First match wins.
PART_ID_ATTRS = {
'mpn': ('MPN', 'MANUFACTURER_PART_NUMBER', 'MFR_PN', 'MPN#', 'PART_NUMBER', 'PARTNUMBER', 'MP'),
'manufacturer': ('MANUFACTURER', 'MANUFACTURER_NAME', 'MFR', 'MF', 'MANUF'),
'lcsc': ('LCSC_PN', 'LCSC', 'LCSC_PART', 'LCSC#', 'JLCPCB', 'JLC_PN', 'JLCPCB_PN'),
'mouser': ('MOUSER_PN', 'MOUSER', 'MOUSER_PART_NUMBER', 'MOUSER#'),
'digikey': ('DIGIKEY_PN', 'DIGIKEY', 'DIGI-KEY_PN', 'DIGI-KEY_PART_NUMBER', 'DIGIKEY_PART_NUMBER', 'DIGIKEY#'),
}
def part_ids(element):
"""A placed part's identifiers from its attributes, by the names above (case-insensitive)."""
attrs = {(a.get('name') or '').upper(): (a.get('value') or '').strip() for a in element.findall('attribute')}
return {k: next((attrs[n] for n in names if attrs.get(n)), '') for k, names in PART_ID_ATTRS.items()}
# ---------------------------------------------------------------- reads served from the same export
# Each takes (root, board, args) and answers the EDA Bridge Standard's verb of the same name, in mm.
def _natural(s):
return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', s or '')]
def _packages(board):
return {(l.get('name'), p.get('name')): p for l in board.findall('libraries/library') for p in l.findall('packages/package')}
def part_box(el, pkg):
"""A part's extent on the board: every footprint primitive but text, transformed."""
xf, _, _ = placement(el)
pts = []
for n in pkg:
if n.tag in ('smd',):
pts += [xf(*c) for c in rect_corners(_f(n, 'x'), _f(n, 'y'), _f(n, 'dx'), _f(n, 'dy'), _deg(n.get('rot')))]
elif n.tag == 'pad':
d = max(_f(n, 'diameter'), _f(n, 'drill') * 1.5)
pts += [xf(_f(n, 'x') + dx, _f(n, 'y') + dy) for dx in (-d / 2, d / 2) for dy in (-d / 2, d / 2)]
elif n.tag == 'wire':
w = _f(n, 'width') / 2
pts += [xf(_f(n, 'x1') + s * w, _f(n, 'y1') + t * w) for s in (-1, 1) for t in (-1, 1)]
pts += [xf(_f(n, 'x2') + s * w, _f(n, 'y2') + t * w) for s in (-1, 1) for t in (-1, 1)]
elif n.tag == 'rectangle':
pts += [xf(_f(n, 'x1'), _f(n, 'y1')), xf(_f(n, 'x2'), _f(n, 'y2')), xf(_f(n, 'x1'), _f(n, 'y2')), xf(_f(n, 'x2'), _f(n, 'y1'))]
elif n.tag == 'circle':
r = _f(n, 'radius') + _f(n, 'width') / 2
pts += [xf(_f(n, 'x') + s * r, _f(n, 'y') + t * r) for s in (-1, 1) for t in (-1, 1)]
elif n.tag in ('polygon', 'polygonshape'):
pts += [xf(_f(v, 'x'), _f(v, 'y')) for v in shape_vertices(n)]
if not pts:
return None
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
return [_r(min(xs)), _r(min(ys)), _r(max(xs)), _r(max(ys))]
def rd_component_data(root, board, args):
model, _ = board_model(board)
pkgs = _packages(board)
els = {e.get('name'): e for e in board.findall('elements/element')}
want = args.get('designator')
if want is not None and want not in els:
fail('designator_not_found', 'No part %s on the board' % want)
rows = []
for c in sorted(model['components'], key=lambda c: _natural(c['ref'])):
if want is not None and c['ref'] != want:
continue
el = els[c['ref']]
mine = [p for p in model['pads'] if p['ref'] == c['ref']]
box = part_box(el, pkgs.get((el.get('library'), el.get('package')))) if pkgs.get((el.get('library'), el.get('package'))) is not None else None
ids = part_ids(el)
rows.append({'designator': c['ref'], 'pattern': c['pattern'], 'comment': c['value'], 'library': c['library'], 'side': c['side'],
'layer': 'B.Cu' if c['side'] == 'bottom' else 'F.Cu', 'xMm': c['x'], 'yMm': c['y'], 'rotation': c['rotation'],
'box': box, 'wMm': _r(box[2] - box[0]) if box else None, 'hMm': _r(box[3] - box[1]) if box else None,
'pads': len(mine), 'nets': sorted({p['net'] for p in mine if p['net']}), 'has3d': c['has3d'], 'locked': c['locked'],
'partNumber': ids['mpn'] or None, 'manufacturer': ids['manufacturer'] or None, 'lcsc': ids['lcsc'] or None,
'mouser': ids['mouser'] or None, 'digikey': ids['digikey'] or None})
limit = int(args.get('limit', 500))
if want is not None:
return {'component': rows[0]}
return {'count': len(rows), 'components': rows[:limit], 'truncated': len(rows) > limit}
def rd_component_pins(root, board, args):
want = args.get('designator')
if not want:
fail('bad_args', 'designator is required (U1)')
model, _ = board_model(board)
pins = [p for p in model['pads'] if p['ref'] == want]
if not pins and want not in {c['ref'] for c in model['components']}:
fail('designator_not_found', 'No part %s on the board' % want)
pins.sort(key=lambda p: _natural(p['name']))
return {'designator': want, 'count': len(pins), 'pins': pins}
def rd_list_nets(root, board, args):
model, _ = board_model(board)
classes = {c.get('number'): c.get('name') for c in board.findall('classes/class')}
by = {n: {'name': n, 'pads': 0, 'tracks': 0, 'arcs': 0, 'vias': 0, 'pours': 0} for n in model['nets']}
for kind, key in (('pads', 'pads'), ('tracks', 'tracks'), ('arcs', 'arcs'), ('vias', 'vias'), ('polygons', 'pours')):
for x in model[kind]:
if x['net'] in by:
by[x['net']][key] += 1
for s in board.findall('signals/signal'):
if s.get('name') in by:
by[s.get('name')]['class'] = classes.get(s.get('class', '0'), s.get('class', '0'))
nets = sorted(by.values(), key=lambda n: _natural(n['name']))
return {'count': len(nets), 'nets': nets}
def airline(points):
"""The minimum spanning tree's length over points (Prim): the shortest copper that could join them."""
if len(points) < 2:
return 0.0
inside = [False] * len(points)
best = [float('inf')] * len(points)
best[0] = 0.0
total = 0.0
for _ in points:
i = min((k for k in range(len(points)) if not inside[k]), key=lambda k: best[k])
inside[i] = True
total += best[i]
for k in range(len(points)):
if not inside[k]:
d = math.hypot(points[i][0] - points[k][0], points[i][1] - points[k][1])
if d < best[k]:
best[k] = d
return total
def rd_net_connections(root, board, args):
model, _ = board_model(board)
one = args.get('net')
names = {n.lower(): n for n in model['nets']}
if one is not None and one.lower() not in names:
fail('no_such_net', 'No net named %s' % one)
pads = [{'net': p['net'], 'pad': '%s.%s' % (p['ref'], p['name']), 'x': p['x'], 'y': p['y'], 'layer': p['layer']}
for p in model['pads'] if p['net'] and (one is None or p['net'] == names[one.lower()])]
nets = {}
for p in pads:
nets.setdefault(p['net'], []).append((p['x'], p['y']))
rows = [{'net': n, 'pads': len(v), 'span': [_r(min(x for x, _ in v)), _r(min(y for _, y in v)), _r(max(x for x, _ in v)), _r(max(y for _, y in v))],
'airlineMm': round(airline(v), 3)} for n, v in sorted(nets.items(), key=lambda kv: _natural(kv[0]))]
limit = int(args.get('limit', 4000))
return {'count': len(pads), 'pads': pads[:limit], 'nets': rows, 'truncated': len(pads) > limit}
def rd_list_designators(root, board, args):
refs = sorted((e.get('name') for e in board.findall('elements/element')), key=_natural)
return {'count': len(refs), 'designators': refs}
def _layer_kind(n, name):
if n in COPPER:
return 'copper'
return {20: 'outline', 46: 'outline', 21: 'silkscreen', 22: 'silkscreen', 25: 'silkscreen', 26: 'silkscreen', 27: 'silkscreen', 28: 'silkscreen',
29: 'mask', 30: 'mask', 31: 'paste', 32: 'paste', 41: 'keepout', 42: 'keepout', 43: 'keepout', 39: 'keepout', 40: 'keepout',
44: 'drill', 45: 'drill', 17: 'copper (pads)', 18: 'copper (vias)', 19: 'ratsnest', 51: 'documentation', 52: 'documentation',
47: 'documentation', 48: 'documentation', 49: 'documentation'}.get(n, 'mechanical' if n >= 100 else 'other')
def _usage(board):
"""Primitives per EAGLE layer: the board's own, every placed footprint's (mirrored parts on the
other side), and the signals' copper. Vias count on every copper layer they pass through."""
layers = Layers(board)
used = {}
def add(n, k=1):
used[n] = used.get(n, 0) + k
flip = {1: 16, 16: 1, 21: 22, 22: 21, 25: 26, 26: 25, 27: 28, 28: 27, 29: 30, 30: 29, 31: 32, 32: 31, 35: 36, 36: 35,
39: 40, 40: 39, 41: 42, 42: 41, 51: 52, 52: 51}
for n in board.findall('plain/*'):
if (n.get('layer') or '').isdigit():
add(int(n.get('layer')))
pkgs = _packages(board)
for el in board.findall('elements/element'):
pkg = pkgs.get((el.get('library'), el.get('package')))
if pkg is None:
continue
mirrored = 'M' in el.get('rot', 'R0')
for n in pkg:
if n.tag == 'pad':
for c in layers.order:
add(c)
elif (n.get('layer') or '').isdigit():
k = int(n.get('layer'))
add(flip.get(k, k) if mirrored else k)
for s in board.findall('signals/signal'):
for w in s.findall('wire'):
add(int(w.get('layer')))
for v in s.findall('via'):
for c in layers.span(v.get('extent')):
add(c)
for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):
add(int(p.get('layer', '1')))
return layers, used
def rd_layers(root, board, args):
layers, used = _usage(board)
out = []
for l in (root.find('drawing/layers') if root is not None and root.find('drawing/layers') is not None else []):
n = int(l.get('number'))
if n in COPPER and n not in layers.order:
continue # a copper layer this board's stack does not have
out.append({'id': n, 'name': layers.name(n) if n in COPPER else l.get('name'), 'fusionName': l.get('name'), 'kind': _layer_kind(n, l.get('name')),
'displayed': l.get('visible') == 'yes', 'primitives': used.get(n, 0)})
return {'count': len(out), 'layers': out}
def rd_copper_layers(root, board, args):
layers, used = _usage(board)
shown = {int(l.get('number')): l.get('visible') == 'yes' for l in (root.find('drawing/layers') if root is not None and root.find('drawing/layers') is not None else [])}
ls = board.find('.//layerstackup')
kinds = {int(d.get('layer')): ('plane' if d.get('type') == 'Plane' else 'signal') for d in (ls.findall('layerdef') if ls is not None else []) if d.get('type') in ('Signal', 'Plane')}
rows = [{'name': layers.name(n), 'id': n, 'kind': kinds.get(n, 'signal'), 'displayed': shown.get(n, True), 'primitives': used.get(n, 0)} for n in layers.order]
return {'count': len(rows), 'layers': rows}
def rd_layers_in_use(root, board, args):
layers, used = _usage(board)
names = {int(l.get('number')): l.get('name') for l in (root.find('drawing/layers') if root is not None and root.find('drawing/layers') is not None else [])}
rows = [{'layerId': n, 'name': layers.name(n) if n in COPPER else names.get(n, EAGLE_LAYERS.get(n, 'L%d' % n)), 'primitives': k}
for n, k in sorted(used.items(), key=lambda kv: -kv[1]) if k]
return {'copperLayersInUse': [r for r in rows if r['layerId'] in COPPER], 'layersWithPrimitives': rows}
def _rules(board):
dr = board.find('designrules')
rows = []
# <rules> holds one <rulecategory> per kind (whether live and manual DRC check it) and, beside
# them, the <rule> elements themselves
cats = {c.get('type'): c for c in (dr.findall('rules/rulecategory') if dr is not None else [])}
for r in (dr.findall('rules/rule') + dr.findall('rules/rulecategory/rule') if dr is not None else []):
a = dict(r.attrib)
cat = cats.get(a.get('type'))
rows.append({'name': a.pop('name', ''), 'kind': a.pop('type', ''), 'enabled': a.pop('enabled', 'yes') == 'yes',
'priority': int(a.pop('priority', '0') or 0), 'scope1': a.pop('onescope', ''), 'scope2': a.pop('twoscope', ''),
'value': a.pop('value', None), 'preferredValue': a.pop('preferredvalue', None), 'attrs': a,
'liveDrc': cat is not None and cat.get('livedrc') == 'yes', 'manualDrc': cat is not None and cat.get('manualdrc') == 'yes'})
params = {p.get('name'): p.get('value') for p in (dr.findall('param') if dr is not None else [])}
return (dr.get('name') if dr is not None else None), rows, params
def rd_read_rules(root, board, args):
name, rows, params = _rules(board)
return {'ruleSet': name, 'count': len(rows), 'rules': rows, 'params': params,
'note': 'rules are Fusion\'s rule manager (type, scope, value); params are the classic EAGLE design-rule values the same checks also read'}
def rd_rules_by_kind(root, board, args):
name, rows, _ = _rules(board)
by = {}
for r in sorted((r for r in rows if r['enabled']), key=lambda r: r['priority']):
by.setdefault(r['kind'], []).append(r)
return {'ruleSet': name, 'rulesByKind': by}
def _text(node, xf=None, mirrored=False, layer_flip=False):
x, y = (_f(node, 'x'), _f(node, 'y'))
if xf:
x, y = xf(x, y)
layer = int(node.get('layer', '0') or 0)
return {'x': _r(x), 'y': _r(y), 'size': _f(node, 'size'), 'rotation': _deg(node.get('rot')), 'layer': layer,
'side': 'bottom' if layer in (22, 26, 28, 52) or 'M' in (node.get('rot') or '') else 'top'}
def rd_silk_state(root, board, args):
"""Each part's body box, designator and value text as placed (a smashed part's own text, else
the footprint's >NAME / >VALUE placeholder, transformed), and the board's free silkscreen."""
pkgs = _packages(board)
comps = []
for el in sorted(board.findall('elements/element'), key=lambda e: _natural(e.get('name'))):
pkg = pkgs.get((el.get('library'), el.get('package')))
xf, angle, mirrored = placement(el)
texts = {}
for a in el.findall('attribute'):
if a.get('name') in ('NAME', 'VALUE') and a.get('x') is not None:
t = _text(a)
t['shown'] = a.get('display', 'value') != 'off'
texts[a.get('name')] = t
if pkg is not None:
for t in pkg.findall('text'):
key = {'>NAME': 'NAME', '>VALUE': 'VALUE'}.get((t.text or '').strip().upper())
if key and key not in texts:
row = _text(t, xf)
row['rotation'] = abs_rotation(angle, mirrored, _deg(t.get('rot')))
if mirrored:
row['side'] = 'bottom'
row['shown'] = True
texts[key] = row
name = dict(texts.get('NAME', {}), text=el.get('name')) if 'NAME' in texts else {'text': el.get('name'), 'shown': False}
value = dict(texts.get('VALUE', {}), text=el.get('value')) if 'VALUE' in texts else {'text': el.get('value'), 'shown': False}
comps.append({'ref': el.get('name'), 'pattern': el.get('package'), 'x': _f(el, 'x'), 'y': _f(el, 'y'), 'rotation': _norm(_deg(el.get('rot'))),
'side': 'bottom' if 'M' in el.get('rot', '') else 'top', 'body': part_box(el, pkg) if pkg is not None else None,
'name': name, 'comment': value, 'smashed': el.get('smashed') == 'yes'})
# raw: the handle apply_silkscreen's remove takes back (what the item is and where it sits)
lines = [{'side': 'bottom' if w.get('layer') == '22' else 'top', 'x1': _f(w, 'x1'), 'y1': _f(w, 'y1'), 'x2': _f(w, 'x2'), 'y2': _f(w, 'y2'), 'width': _f(w, 'width'),
'raw': 'wire|%s|%s|%s|%s|%s' % (w.get('layer'), w.get('x1'), w.get('y1'), w.get('x2'), w.get('y2'))}
for w in board.findall('plain/wire') if w.get('layer') in ('21', '22')]
texts = [dict(_text(t), text=t.text or '', raw='text|%s|%s|%s|%s' % (t.get('layer'), t.get('x'), t.get('y'), t.text or ''))
for t in board.findall('plain/text') if t.get('layer') in ('21', '22', '25', '26', '27', '28')]
return {'components': comps, 'lines': lines, 'texts': texts}
def rd_net_classes(root, board, args):
nets = {}
for s in board.findall('signals/signal'):
nets.setdefault(s.get('class', '0'), []).append(s.get('name'))
rows = []
for c in board.findall('classes/class'):
rows.append({'name': c.get('name'), 'number': int(c.get('number')), 'widthMm': _f(c, 'width'), 'drillMm': _f(c, 'drill'),
'clearances': [{'toClass': int(k.get('class')), 'mm': _f(k, 'value')} for k in c.findall('clearance')],
'nets': sorted(nets.get(c.get('number'), []), key=_natural)})
return {'count': len(rows), 'classes': rows}
def rd_diff_pairs(root, board, args):
"""Differential pairs as Fusion keeps them: by the nets' names. A pair is two nets that differ
only in a final _P/_N, P/N, +/- or DP/DN (Fusion's differential-pair routing pairs NAME_P with
NAME_N). There is no pair object to define or remove: rename the nets in the schematic."""
if args.get('pos') or args.get('neg') or args.get('remove'):
fail('not_supported', "Fusion pairs nets by their names (NAME_P / NAME_N), not by a pair object: to make or break a pair, rename the nets in the schematic (fusion_sch_edit_label or fusion_sch_edit)")
names = sorted({s_.get('name') for s_ in board.findall('signals/signal')}, key=_natural)
have = set(names)
pairs, seen = [], set()
for n in names:
for p_, q in (('_P', '_N'), ('P', 'N'), ('+', '-'), ('DP', 'DN'), ('_DP', '_DN')):
if n.upper().endswith(p_) and n not in seen:
stem = n[:len(n) - len(p_)]
twin = next((m for m in have if m.upper() == (stem + q).upper()), None)
if twin and twin not in seen and stem.strip('_'):
pairs.append({'name': stem.rstrip('_') or stem, 'pos': n, 'neg': twin, 'by': '%s/%s' % (p_, q)})
seen |= {n, twin}
break
if args.get('name'):
pairs = [x for x in pairs if x['name'].lower() == str(args['name']).lower()]
return {'count': len(pairs), 'pairs': pairs,
'_hint': 'Pairs come from the nets\' names. Route a pair with Fusion\'s differential-pair router; set its width and gap with fusion_add_rule or the net classes.'}
READS = {'component_data': rd_component_data, 'component_pins': rd_component_pins, 'list_nets': rd_list_nets,
'net_connections': rd_net_connections, 'list_designators': rd_list_designators, 'layers': rd_layers,
'copper_layers': rd_copper_layers, 'layers_in_use': rd_layers_in_use, 'read_rules': rd_read_rules,
'rules_by_kind': rd_rules_by_kind, 'silk_state': rd_silk_state, 'net_classes': rd_net_classes, 'diff_pairs': rd_diff_pairs}
def export_bom_csv(board, args):
"""fusion_export_bom: the BOM CSV from the live board, with each part's MPN, manufacturer, LCSC,
Mouser and DigiKey numbers from its attributes (blank when a part has none). Grouped (default):
one line per value, footprint, library and MPN; else one line per part. The columns the export
always had come first, in their old order, so readers of the old file keep working."""
import csv, io
els = sorted(board.findall('elements/element'), key=lambda e: _natural(e.get('name')))
rows = []
for e in els:
ids = part_ids(e)
rows.append({'name': e.get('name'), 'value': e.get('value') or '', 'package': e.get('package') or '',
'library': e.get('library') or '', 'side': 'Bottom' if 'M' in e.get('rot', 'R0') else 'Top', **ids})
grouped = args.get('grouped', True) is not False
ID_COLS = ['MPN', 'Manufacturer', 'LCSC', 'Mouser', 'DigiKey']
ids_of = lambda r: [r['mpn'], r['manufacturer'], r['lcsc'], r['mouser'], r['digikey']]
out = io.StringIO()
w = csv.writer(out, lineterminator='\n')
if grouped:
groups = {}
for r in rows:
g = groups.setdefault((r['value'], r['package'], r['library'], r['mpn']), dict(r, designators=[]))
g['designators'].append(r['name'])
lines = sorted(groups.values(), key=lambda g: _natural(g['designators'][0]))
w.writerow(['Comment', 'Designator', 'Footprint', 'Quantity', 'Library'] + ID_COLS)
for g in lines:
w.writerow([g['value'], ' '.join(g['designators']), g['package'], len(g['designators']), g['library']] + ids_of(g))
unique = len(lines)
else:
w.writerow(['Designator', 'Comment', 'Footprint', 'Library', 'Side'] + ID_COLS)
for r in rows:
w.writerow([r['name'], r['value'], r['package'], r['library'], r['side']] + ids_of(r))
unique = len({(r['value'], r['package'], r['library'], r['mpn']) for r in rows})
missing = {k: [r['name'] for r in rows if not r[k]] for k in ('mpn', 'lcsc')}
return out.getvalue(), {'componentCount': len(rows), 'uniquePartCount': unique, 'grouped': grouped,
'withMpn': len(rows) - len(missing['mpn']), 'withLcsc': len(rows) - len(missing['lcsc']),
'missingMpn': missing['mpn'], 'missingLcsc': missing['lcsc']}