1
0
mirror of https://github.com/whowechina/popn_pico.git synced 2025-03-03 16:43:49 +01:00
popn_pico/PCB/agg-kicad/scripts/stickerbom.py
2022-08-22 21:51:48 +08:00

590 lines
22 KiB
Python

#!/usr/bin/env python
"""
stickerbom.py
Copyright 2016 Adam Greig
Licensed under the MIT licence, see LICENSE file for details.
"""
from __future__ import print_function, division, unicode_literals
from collections import defaultdict
import argparse
import os
import math
import cairo
import xml.etree.ElementTree as ET
import sexp
class Module:
def __init__(self, mod):
self.fab_lines = []
self.fab_circs = []
self.silk_lines = []
self.silk_circs = []
self.rect_pads = []
self.circ_pads = []
# In each layer, store a dict with keys "lines,circs,rects,arcs",
# each with a list of tuples
# Note that *.Cu and *.Mask layers need to be handled on render
self.graphic_layers = defaultdict(lambda: defaultdict(list))
self._parse(mod)
def render(self, cr, layers, fallbacklayers=[]):
"""
Render the footprint in the board coordinate system.
"""
cr.save()
cr.translate(self.at[0], self.at[1])
cr.set_line_width(0.1)
if len(self.at) == 3:
cr.rotate(-self.at[2] * math.pi/180)
# Switch to drawing fallback layers if not all drawing layers are
# present in graphic_layers
if not all(layer in self.graphic_layers for layer in layers):
layers = fallbacklayers
for layer in layers:
if layer in self.graphic_layers:
for line in self.graphic_layers[layer]["lines"]:
cr.move_to(*line[0])
cr.line_to(*line[1])
cr.stroke()
for circ in self.graphic_layers[layer]["circs"]:
cr.new_sub_path()
cr.arc(circ[0][0], circ[0][1], circ[1], 0, 2*math.pi)
if layer.endswith(".Cu"):
cr.fill()
else:
cr.stroke()
for rect in self.graphic_layers[layer]["rects"]:
cr.rectangle(rect[0][0], rect[0][1], rect[1][0],
rect[1][1])
if layer.endswith(".Cu"):
cr.fill()
else:
cr.stroke()
cr.restore()
def render_highlight(self, cr):
"""
Render a highlight at the footprint's position and of its size.
"""
cr.save()
cr.translate(self.at[0], self.at[1])
if len(self.at) == 3:
cr.rotate(-self.at[2] * math.pi/180)
x1, y1, x2, y2 = self.bounds
a = 0.2
x1 -= a
y1 -= a
x2 += a
y2 += a
r = 0.5
pi2 = math.pi / 2.0
cr.new_sub_path()
cr.arc(x1+r, y1+r, r, 2*pi2, 3*pi2)
cr.arc(x2-r, y1+r, r, 3*pi2, 4*pi2)
cr.arc(x2-r, y2-r, r, 0*pi2, 1*pi2)
cr.arc(x1+r, y2-r, r, 1*pi2, 2*pi2)
cr.close_path()
cr.fill()
cr.restore()
def _parse(self, mod):
self.at = [float(x) for x in sexp.find(mod, "at")[1:]]
self.bounds = [0, 0, 0, 0]
self.layer = sexp.find(mod, "layer")[1]
for text in sexp.find_all(mod, "fp_text"):
if text[1] == "reference":
self.ref = text[2]
elif text[1] == "value":
self.val = text[2]
for graphic in sexp.find_all(mod, "fp_line", "fp_circle"):
self._parse_graphic(graphic)
for pad in sexp.find_all(mod, "pad"):
self._parse_pad(pad)
def _parse_graphic(self, graphic):
layer = sexp.find(graphic, "layer")[1]
end = [float(x) for x in sexp.find(graphic, "end")[1:]]
self._update_bounds(end)
if graphic[0] == "fp_line":
start = [float(x) for x in sexp.find(graphic, "start")[1:]]
self._update_bounds(start)
self.graphic_layers[layer]["lines"].append((start, end))
elif graphic[0] == "fp_circle":
center = [float(x) for x in sexp.find(graphic, "center")[1:]]
self._update_bounds(center)
r = math.sqrt((center[0] - end[0])**2 +
(center[1] - end[1])**2)
self.graphic_layers[layer]["circs"].append((center, r))
def _parse_pad(self, pad):
layers = sexp.find(pad, "layers")[1:]
pad_type = pad[2]
if pad_type not in ("smd", "thru_hole"):
return
at = [float(x) for x in sexp.find(pad, "at")[1:]]
size = [float(x) for x in sexp.find(pad, "size")[1:]]
drill = sexp.find(pad, "drill")
if drill:
offset = sexp.find(drill, "offset")
if offset:
at[0] += float(offset[1])
at[1] += float(offset[2])
topleft = at[0] - size[0]/2, at[1] - size[1]/2
shape = pad[3]
if shape in ("rect", "oval"):
for layer in layers:
self.graphic_layers[layer]["rects"].append((topleft, size))
self._update_bounds(at, dx=size[0]/2, dy=size[1]/2)
elif shape == "circle":
for layer in layers:
self.graphic_layers[layer]["circs"].append((at, size[0]/2))
self._update_bounds(at, dx=size[0]/2, dy=size[0]/2)
else:
self._update_bounds(at)
def _update_bounds(self, at, dx=0, dy=0):
self.bounds[0] = min(self.bounds[0], at[0] - dx)
self.bounds[1] = min(self.bounds[1], at[1] - dy)
self.bounds[2] = max(self.bounds[2], at[0] + dx)
self.bounds[3] = max(self.bounds[3], at[1] + dy)
class PCB:
def __init__(self, board):
self.modules = []
self.edge_lines = []
self.edge_arcs = []
self._parse(board)
def get_mod_sides(self, refs):
"""
Return a dictionary where the key is a layer name and the value is a
list of refs on that layer. Normal pcb files should only have modules
on F.Cu and B.Cu
"""
mod_sides = defaultdict(list)
for module in self.modules:
if module.ref not in refs:
continue
mod_sides[module.layer].append(module.ref)
return mod_sides
def render(self, cr, where, max_w, max_h, modlayers=[],
modfallbacklayers=[], highlights=None, flip=None):
"""
Render the PCB, with the top left corner at `where`,
occupying at most `max_w` width and `max_h` height,
and draw a highlight under parts whose reference is in `highlights`.
"""
cr.save()
cr.set_line_width(0.1)
# Set a clip to ensure we occupy at most max_w and max_h
cr.rectangle(where[0], where[1], max_w, max_h)
cr.clip()
# Find bounds on highlighted modules
hl_bounds = self._find_highlighted_bounds(highlights)
bound_width = hl_bounds[2] - hl_bounds[0]
bound_height = hl_bounds[3] - hl_bounds[1]
bound_centre_x = hl_bounds[0] + bound_width/2
bound_centre_y = hl_bounds[1] + bound_height/2
# Scale to either 1.5:1 or smaller if necessary to fit bounds
scale_x = max_w / max(bound_width, 0.01)
scale_y = max_h / max(bound_height, 0.01)
scale = min(min(1.5, scale_x), min(1.5, scale_y))
cr.scale(scale, scale)
# Can we shift the top edge of the PCB to the top and not cut off
# the bottom of the highlight?
if hl_bounds[3] - self.bounds[1] < max_h/scale:
shift_y = -self.bounds[1]
# Can we shift the bottom edge of the PCB to the bottom and not cut off
# the top of the highlight?
elif self.bounds[3] - hl_bounds[1] < max_h/scale:
shift_y = -self.bounds[3] + max_h/scale
# Otherwise centre the highlighted region vertically
else:
shift_y = (max_h/(2*scale))-bound_centre_y
# Can we shift the left edge of the PCB to the left and not cut off
# the right of the highlight?
if hl_bounds[2] - self.bounds[0] < max_w/scale:
shift_x = -self.bounds[0]
# Can we shift the right edge of the PCB to the right and not cut off
# the left of the highlight?
elif self.bounds[2] - hl_bounds[0] < max_w/scale:
shift_x = -self.bounds[2] + max_w/scale
# Otherwise centre the highlighted region horizontally
else:
shift_x = (max_w/(2*scale))-bound_centre_x
cr.translate(shift_x, shift_y)
# Setting "flip" at all will flip horizontally, specifying "v"
# specifically will flip vertically
flip_x = 1.0
flip_y = 1.0
if (flip):
if (flip == "v" or flip == "V"):
flip_y = -1.0
else:
flip_x = -1.0
cr.scale(flip_x, flip_y)
# Scale will flip around current origin, so shift back to TL corner
cr.translate((2*shift_x - (max_w/scale)) * (-flip_x/2 + 0.5),
(2*shift_y - (max_h/scale)) * (-flip_y/2 + 0.5))
# Translate our origin to desired position on page
cr.translate(where[0]/(scale*flip_x), where[1]/(scale*flip_y))
# Render highlights below everything else
cr.set_source_rgb(1.0, 0.5, 0.5)
for module in self.modules:
if module.ref in highlights:
module.render_highlight(cr)
# Render modules
cr.set_source_rgb(0, 0, 0)
for module in self.modules:
module.render(cr, modlayers, modfallbacklayers)
# Render edge lines
for line in self.edge_lines:
cr.move_to(*line[0])
cr.line_to(*line[1])
cr.stroke()
# Render edge arcs
for arc in self.edge_arcs:
cr.new_sub_path()
cr.arc(*arc)
cr.stroke()
cr.restore()
def _find_highlighted_bounds(self, highlights):
# Find bounds on highlighted modules
# TODO: Deal with rotation in modules in a more elegant fashion
# (Rotation includes bounds, so here we just take the biggest bound,
# which is both wasteful for high aspect ratio parts, and wrong for
# parts not on a 90' rotation).
hl_bounds = [self.bounds[2], self.bounds[3],
self.bounds[0], self.bounds[1]]
for module in self.modules:
if module.ref not in highlights:
continue
a = max(module.bounds) * 2
hl_bounds[0] = min(hl_bounds[0], module.at[0] - a)
hl_bounds[1] = min(hl_bounds[1], module.at[1] - a)
hl_bounds[2] = max(hl_bounds[2], module.at[0] + a)
hl_bounds[3] = max(hl_bounds[3], module.at[1] + a)
return hl_bounds
def _parse(self, board):
for module in sexp.find_all(board, "module"):
self.modules.append(Module(module))
# We compute the PCB bounds ourselves rather than relying on the file's
# area tag which seems to sometimes be wrong. First go based on module
# positions.
self.bounds = [
min(m.at[0] for m in self.modules),
min(m.at[1] for m in self.modules),
max(m.at[0] for m in self.modules),
max(m.at[1] for m in self.modules)
]
# We find all the board edges both for drawing and for bounds
self._parse_edges(board)
# Add a slight padding to ensure edge lines are properly drawn
self.bounds[0] -= 1
self.bounds[1] -= 1
self.bounds[2] += 1
self.bounds[3] += 1
self.width = self.bounds[2] - self.bounds[0]
self.height = self.bounds[3] - self.bounds[1]
def _parse_edges(self, board):
for graphic in sexp.find_all(board, "gr_line", "gr_arc", "gr_circle"):
layer = sexp.find(graphic, "layer")[1]
if layer != "Edge.Cuts":
continue
if graphic[0] == "gr_line":
start = [float(x) for x in sexp.find(graphic, "start")[1:]]
end = [float(x) for x in sexp.find(graphic, "end")[1:]]
self.edge_lines.append((start, end))
self._update_bounds(start)
self._update_bounds(end)
elif graphic[0] == "gr_arc":
center = [float(x) for x in sexp.find(graphic, "start")[1:]]
start = [float(x) for x in sexp.find(graphic, "end")[1:]]
r = math.sqrt((center[0] - start[0])**2 +
(center[1] - start[1])**2)
angle = float(sexp.find(graphic, "angle")[1]) * math.pi/180.0
dx = start[0] - center[0]
dy = start[1] - center[1]
start_angle = math.atan2(dy, dx)
end_angle = start_angle + angle
self.edge_arcs.append((center[0], center[1], r,
start_angle, end_angle))
self._update_bounds(center, dx=r, dy=r)
elif graphic[0] == "gr_circle":
center = [float(x) for x in sexp.find(graphic, "center")[1:]]
end = [float(x) for x in sexp.find(graphic, "end")[1:]]
r = math.sqrt((center[0] - end[0])**2 +
(center[1] - end[1])**2)
self.edge_arcs.append((center[0], center[1], r, 0, 2*math.pi))
self._update_bounds(center, dx=r, dy=r)
def _update_bounds(self, at, dx=0, dy=0):
self.bounds[0] = min(self.bounds[0], at[0] - dx)
self.bounds[1] = min(self.bounds[1], at[1] - dy)
self.bounds[2] = max(self.bounds[2], at[0] + dx)
self.bounds[3] = max(self.bounds[3], at[1] + dy)
class BOM:
def __init__(self, xmlpath, include=[], exclude=[]):
self.tree = ET.parse(xmlpath)
self.lines = []
self.suppliers = {}
self._find_parts(include, exclude)
self._generate_lines()
def _find_parts(self, include, exclude):
for comp in self.tree.getroot().iter('comp'):
ref = comp.get('ref')
if include and ref not in include:
continue
if exclude and ref in exclude:
continue
val = comp.findtext('value')
ftp = comp.findtext('footprint')
fields = {}
part = {"ref": ref, "val": val, "ftp": ftp, "fields": fields}
for field in comp.iter('field'):
supplier = field.get('name')
code = field.text
fields[supplier] = code
if supplier not in self.suppliers:
self.suppliers[supplier] = {}
if code not in self.suppliers[supplier]:
self.suppliers[supplier][code] = []
self.suppliers[supplier][code].append(part)
def _generate_lines(self):
for supplier in self.suppliers:
for code in self.suppliers[supplier]:
part = self.suppliers[supplier][code][0]
refs = []
for part in self.suppliers[supplier][code]:
refs.append(part['ref'])
line = Line(refs, part['val'], part['ftp'], supplier, code)
self.lines.append(line)
class Line:
def __init__(self, refs, value, footprint, supplier, code):
self.refs = refs
self.value = value
self.footprint = footprint
self.supplier = supplier
self.code = code
if self.footprint is not None:
self.footprint = self.footprint.split(":")[1]
else:
self.footprint = ""
def render(self, cr, where, w, h):
cr.save()
# Clip to permissible area
cr.rectangle(where[0], where[1], w, h)
cr.clip()
# Draw first line
cr.set_source_rgb(0, 0, 0)
cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL,
cairo.FONT_WEIGHT_BOLD)
cr.set_font_size(3.0)
cr.move_to(where[0]+3, where[1]+5)
cr.show_text(" ".join(self.refs))
# Draw second line
cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL,
cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(3.0)
cr.move_to(where[0]+3, where[1]+9)
cr.show_text("{}x {} {}"
.format(len(self.refs), self.value, self.footprint))
# Draw third line
cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL,
cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(3.0)
cr.move_to(where[0]+3, where[1]+12)
cr.show_text("{} {}".format(self.supplier, self.code))
cr.restore()
# Forever yields a new (x, y) of successive label top-left positions,
# calling cr.show_page() when the current page is exhausted.
def sheet_positions(cr, label_width, label_height, labels_x, labels_y,
margin_top, margin_left, spacing_x, spacing_y):
while True:
for x in range(labels_x):
for y in range(labels_y):
xx = margin_left + x*(label_width + spacing_x)
yy = margin_top + y*(label_height + spacing_y)
yield (xx, yy)
cr.show_page()
def xmlpath(path):
if os.path.exists(path):
return path
raise TypeError("XML file must exist.")
def pdfpath(path):
if path[-4:].lower() != ".pdf":
return path + ".pdf"
return path
def get_args():
parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("xmlpath", type=xmlpath,
help="Path to the xml BOM file to be parsed.")
parser.add_argument("pdfpath", type=pdfpath,
help="Path to the pdf file that will "
"contain the stickers.")
parser.add_argument("--label_width", type=float, default=72,
help="Width of a label (mm).")
parser.add_argument("--label_height", type=float, default=63.5,
help="Height of a label (mm).")
parser.add_argument("--labels-x", type=int, default=4,
help="Number of columns of labels on a page.")
parser.add_argument("--labels-y", type=int, default=3,
help="Number of rows of labels on a page.")
parser.add_argument("--margin-top", type=float, default=7.75,
help="Margin at the top of the page (mm).")
parser.add_argument("--margin-left", type=float, default=4.5,
help="Margin at the left side of the page (mm).")
parser.add_argument("--spacing-x", type=float, default=0.0,
help="Gap between columns of labels (mm).")
parser.add_argument("--spacing-y", type=float, default=2.0,
help="Gap between rows of labels (mm).")
parser.add_argument("--page-width", type=float, default=297,
help="Width of a page (mm).")
parser.add_argument("--page-height", type=float, default=210,
help="Height of a page (mm).")
parser.add_argument("--flip-vert", default="h",
action="store_const", const="v",
help="Flip the bottom view of the board vertically "
"rather than horizontally")
parser.add_argument("--suppliers",
default="Farnell,RS,DigiKey,Digikey,Mouser",
help="Comma seperated list of names of suppliers "
"to output stickers for (Note: this really "
"means 'custom schematic symbol property "
"field names' to output stickers for).")
parser.add_argument("--include-parts-without-footprint",
action="store_true",
help="Include parts that do not have a footprint.")
group = parser.add_mutually_exclusive_group()
group.add_argument("-i", "--include", nargs='+', help="parts to include")
group.add_argument("-e", "--exclude", nargs='+', help="parts to exclude")
return parser.parse_args()
def main():
args = get_args()
bom = BOM(args.xmlpath, include=args.include, exclude=args.exclude)
with open(args.xmlpath[:-3] + "kicad_pcb") as f:
pcb = PCB(sexp.parse(f.read()))
mm_to_pt = 2.835
ps = cairo.PDFSurface(args.pdfpath,
args.page_width*mm_to_pt,
args.page_height*mm_to_pt)
cr = cairo.Context(ps)
# Scale user units to millimetres
cr.scale(mm_to_pt, mm_to_pt)
labels = sheet_positions(cr,
args.label_width, args.label_height,
args.labels_x, args.labels_y,
args.margin_top, args.margin_left,
args.spacing_x, args.spacing_y)
suppliers = [name.strip() for name in args.suppliers.split(",")]
for line in bom.lines:
if line.supplier not in suppliers:
continue
if not line.footprint and not args.include_parts_without_footprint:
continue
label = next(labels)
line.render(cr,
(label[0]+1, label[1]),
args.label_width-2, 14)
sides = pcb.get_mod_sides(line.refs)
if "F.Cu" in sides and "B.Cu" in sides:
# if both sides present, split area and draw both
pcb.render(cr, (label[0]+1, label[1]+14),
(args.label_width-4)/2.0, args.label_height-14,
["F.Fab"], ["F.Cu", "*.Cu", "F.SilkS"], sides["F.Cu"])
pcb.render(cr, (label[0]+3+(args.label_width-3)/2.0, label[1]+14),
(args.label_width-4)/2.0, args.label_height-14,
["B.Fab"], ["B.Cu", "*.Cu", "B.SilkS"], sides["B.Cu"],
args.flip_vert)
elif "F.Cu" in sides:
pcb.render(cr, (label[0]+1, label[1]+14),
args.label_width-2, args.label_height-14,
["F.Fab"], ["F.Cu", "*.Cu", "F.SilkS"], sides["F.Cu"])
elif "B.Cu" in sides:
pcb.render(cr, (label[0]+1, label[1]+14),
args.label_width-2, args.label_height-14,
["B.Fab"], ["B.Cu", "*.Cu", "B.SilkS"], sides["B.Cu"],
args.flip_vert)
cr.show_page()
if __name__ == "__main__":
main()