842 lines
35 KiB
Python
842 lines
35 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2022 Salvador E. Tropea
|
|
# Copyright (c) 2022 Instituto Nacional de Tecnología Industrial
|
|
# License: GPL-3.0
|
|
# Project: KiBot (formerly KiPlot)
|
|
"""
|
|
Dependencies:
|
|
- name: Pandoc
|
|
role: Create PDF/ODF/DOCX files
|
|
url: https://pandoc.org/
|
|
url_down: https://github.com/jgm/pandoc/releases
|
|
debian: pandoc
|
|
extra_deb: ['texlive-latex-base', 'texlive-latex-recommended']
|
|
comments: 'In CI/CD environments: the `kicad_auto_test` docker image contains it.'
|
|
"""
|
|
import os
|
|
import re
|
|
import pcbnew
|
|
from subprocess import check_output, STDOUT, CalledProcessError
|
|
|
|
from .gs import GS
|
|
from .misc import (UI_SMD, UI_VIRTUAL, MOD_THROUGH_HOLE, MOD_SMD, MOD_EXCLUDE_FROM_POS_FILES,
|
|
FAILED_EXECUTE, W_WRONGEXT, W_WRONGOAR, W_ECCLASST)
|
|
from .registrable import RegOutput
|
|
from .out_base import BaseOptions
|
|
from .error import KiPlotConfigurationError
|
|
from .kiplot import config_output
|
|
from .dep_downloader import get_dep_data
|
|
from .macros import macros, document, output_class # noqa: F401
|
|
from . import log
|
|
|
|
logger = log.get_logger()
|
|
INF = float('inf')
|
|
|
|
|
|
def do_round(v, dig):
|
|
v = round(v+1e-9, dig)
|
|
return v if dig else int(v)
|
|
|
|
|
|
def to_mm(iu, dig=2):
|
|
""" KiCad Internal Units to millimeters """
|
|
if isinstance(iu, pcbnew.wxPoint):
|
|
return (do_round(iu.x/pcbnew.IU_PER_MM, dig), do_round(iu.y/pcbnew.IU_PER_MM, dig))
|
|
if isinstance(iu, pcbnew.wxSize):
|
|
return (do_round(iu.x/pcbnew.IU_PER_MM, dig), do_round(iu.y/pcbnew.IU_PER_MM, dig))
|
|
return do_round(iu/pcbnew.IU_PER_MM, dig)
|
|
|
|
|
|
def to_mils(iu, dig=0):
|
|
""" KiCad Internal Units to mils (1/1000 inch) """
|
|
return do_round(iu/pcbnew.IU_PER_MILS, dig)
|
|
|
|
|
|
def to_inches(iu, dig=2):
|
|
""" KiCad Internal Units to inches """
|
|
return do_round(iu/(pcbnew.IU_PER_MILS*1000), dig)
|
|
|
|
|
|
def get_class_index(val, lst):
|
|
""" Used to search in an Eurocircuits class vector.
|
|
Returns the first match that is >= to val. """
|
|
val = to_mm(val, 3)
|
|
for c, v in enumerate(lst):
|
|
if val >= v:
|
|
return c
|
|
return c+1
|
|
|
|
|
|
def get_pattern_class(track, clearance, oar, case, target=None):
|
|
""" Returns the Eurocircuits Pattern class for a track width, clearance and OAR """
|
|
c1 = (0.25, 0.2, 0.175, 0.150, 0.125, 0.1, 0.09)
|
|
c2 = (0.2, 0.15, 0.15, 0.125, 0.125, 0.1, 0.1)
|
|
ct = get_class_index(track, c1)
|
|
cc = get_class_index(clearance, c1)
|
|
co = get_class_index(oar, c2)
|
|
cf = max(ct, max(cc, co))+3
|
|
if target is not None and cf > target:
|
|
if ct+3 > target:
|
|
logger.warning(W_ECCLASST+"Track too narrow {} mm, should be ≥ {} mm".format(to_mm(track), c1[target-3]))
|
|
if cc+3 > target:
|
|
logger.warning(W_ECCLASST+"Clearance too small {} mm, should be ≥ {} mm".
|
|
format(to_mm(clearance), c1[target-3]))
|
|
if co+3 > target:
|
|
logger.warning(W_ECCLASST+"OAR too small {} mm, should be ≥ {} mm".format(to_mm(oar), c2[target-3]))
|
|
logger.debug('Eurocircuits Pattern class for `{}` is {} because the clearance is {}, track is {} and OAR is {}'.
|
|
format(case, cf, to_mm(clearance), to_mm(track), to_mm(oar)))
|
|
return cf
|
|
|
|
|
|
def get_drill_class(drill, case, target=None):
|
|
""" Returns the Eurocircuits Drill class for a drill size.
|
|
This is the real (tool) size. """
|
|
c3 = (0.6, 0.45, 0.35, 0.25, 0.2)
|
|
cd = get_class_index(drill, c3)
|
|
res = chr(ord('A') + cd)
|
|
if target is not None and cd > target:
|
|
logger.warning(W_ECCLASST+"Drill too small {} mm, should be ≥ {} mm".format(to_mm(drill), c3[target]))
|
|
logger.debug('Eurocircuits Drill class for `{}` is {} because the drill is {}'.format(case, res, to_mm(drill)))
|
|
return res
|
|
|
|
|
|
def to_top_bottom(front, bottom):
|
|
""" Returns a text indicating if the feature is in top/bottom layers """
|
|
if front and bottom:
|
|
return "TOP / BOTTOM"
|
|
elif front:
|
|
return "TOP"
|
|
elif bottom:
|
|
return "BOTTOM"
|
|
return "NONE"
|
|
|
|
|
|
def to_smd_tht(smd, tht):
|
|
""" Returns a text indicating if the components are SMD/THT """
|
|
if smd and tht:
|
|
return "SMD + THT"
|
|
elif smd:
|
|
return "SMD"
|
|
elif tht:
|
|
return "THT"
|
|
return "NONE"
|
|
|
|
|
|
def to_top_bottom_color(front, bottom):
|
|
""" Returns a text indicating the top/bottom colors """
|
|
f = front.strip().lower()
|
|
b = bottom.strip().lower()
|
|
if f == b:
|
|
return front.capitalize()
|
|
return "Top: "+front.capitalize()+" / Bottom: "+bottom.capitalize()
|
|
|
|
|
|
def solve_edge_connector(val):
|
|
if val == 'no':
|
|
return ''
|
|
if val == 'bevelled':
|
|
return 'yes, bevelled'
|
|
return val
|
|
|
|
|
|
def get_pad_info(pad):
|
|
return ("Position {} on layer {}, size {} drill size {}".
|
|
format(to_mm(pad.GetPosition()), GS.board.GetLayerName(pad.GetLayer()),
|
|
to_mm(pad.GetSize(), 4), to_mm(pad.GetDrillSize(), 4)))
|
|
|
|
|
|
def adjust_drill(val, is_pth=True, pad=None):
|
|
""" Add drill_size_increment if this is a PTH hole and round it to global_extra_pth_drill """
|
|
if val == INF:
|
|
return val
|
|
step = GS.global_drill_size_increment*pcbnew.IU_PER_MM
|
|
if is_pth:
|
|
val += GS.global_extra_pth_drill*pcbnew.IU_PER_MM
|
|
res = int((val+step/2)/step)*step
|
|
# if pad:
|
|
# logger.error(f"{to_mm(val)} -> {to_mm(res)} {get_pad_info(pad)}")
|
|
# else:
|
|
# logger.error(f"{to_mm(val)} -> {to_mm(res)}")
|
|
return res
|
|
|
|
|
|
def list_nice(names):
|
|
if len(names) == 1:
|
|
return '`{}`'.format(names[0])
|
|
res = ''
|
|
for n in names[:-1]:
|
|
res += ', `{}`'.format(n)
|
|
res += ' and `{}`'.format(names[-1])
|
|
return res[2:]
|
|
|
|
|
|
class ReportOptions(BaseOptions):
|
|
def __init__(self):
|
|
with document:
|
|
self.output = GS.def_global_output
|
|
""" *Output file name (%i='report', %x='txt') """
|
|
self.template = 'full'
|
|
""" *Name for one of the internal templates (full, full_svg, simple) or a custom template file.
|
|
Environment variables and ~ are allowed.
|
|
Note: when converting to PDF PanDoc can fail on some Unicode values (use `simple_ASCII`) """
|
|
self.convert_from = 'markdown'
|
|
""" Original format for the report conversion. Current templates are `markdown`. See `do_convert` """
|
|
self.convert_to = 'pdf'
|
|
""" *Target format for the report conversion. See `do_convert` """
|
|
self.do_convert = False
|
|
""" *Run `Pandoc` to convert the report. Note that Pandoc must be installed.
|
|
The conversion is done assuming the report is in `convert_from` format.
|
|
The output file will be in `convert_to` format.
|
|
The available formats depends on the `Pandoc` installation """
|
|
self.converted_output = GS.def_global_output
|
|
""" Converted output file name (%i='report', %x=`convert_to`).
|
|
Note that the extension should match the `convert_to` value """
|
|
self.eurocircuits_class_target = '10F'
|
|
""" Which Eurocircuits class are we aiming at """
|
|
super().__init__()
|
|
self._expand_id = 'report'
|
|
self._expand_ext = 'txt'
|
|
self._mm_digits = 2
|
|
self._mils_digits = 0
|
|
self._in_digits = 2
|
|
# Extra help for PanDoc
|
|
dep = get_dep_data('report', 'PanDoc')
|
|
deb_text = 'In Debian/Ubuntu environments: install '+list_nice([dep.deb_package]+dep.extra_deb)
|
|
self._help_do_convert += ".\n"+'\n'.join(dep.comments)+'\n'+deb_text
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
self.to_ascii = False
|
|
if self.template.endswith('_ASCII'):
|
|
self.template = self.template[:-6]
|
|
self.to_ascii = True
|
|
if self.template.lower() in ('full', 'simple', 'full_svg'):
|
|
self.template = os.path.abspath(os.path.join(os.path.dirname(__file__), 'report_templates',
|
|
'report_'+self.template.lower()+'.txt'))
|
|
if not os.path.isabs(self.template):
|
|
self.template = os.path.expandvars(os.path.expanduser(self.template))
|
|
if not os.path.isfile(self.template):
|
|
raise KiPlotConfigurationError("Missing report template: `{}`".format(self.template))
|
|
m = re.match(r'(\d+)([A-F])', self.eurocircuits_class_target)
|
|
if not m:
|
|
raise KiPlotConfigurationError("Malformed Eurocircuits class, must be a number and a letter (<=10F)")
|
|
self._ec_pat = int(m.group(1))
|
|
if self._ec_pat < 3 or self._ec_pat > 10:
|
|
raise KiPlotConfigurationError("Eurocircuits Pattern class out of range [3,10]")
|
|
self._ec_drl = ord(m.group(2))-ord('A')
|
|
|
|
def do_replacements(self, line, defined):
|
|
""" Replace ${VAR} patterns """
|
|
for var in re.findall(r'\$\{([^\s\}]+)\}', line):
|
|
if var[0] == '_':
|
|
# Prevent access to internal data
|
|
continue
|
|
units = None
|
|
var_ori = var
|
|
m = re.match(r'^(%[^,]+),(.*)$', var)
|
|
pattern = None
|
|
if m:
|
|
pattern = m.group(1)
|
|
var = m.group(2)
|
|
if var.endswith('_mm'):
|
|
units = to_mm
|
|
digits = self._mm_digits
|
|
var = var[:-3]
|
|
elif var.endswith('_in'):
|
|
units = to_inches
|
|
digits = self._in_digits
|
|
var = var[:-3]
|
|
elif var.endswith('_mils'):
|
|
units = to_mils
|
|
digits = self._mils_digits
|
|
var = var[:-5]
|
|
if var in defined:
|
|
val = defined[var]
|
|
if val == INF:
|
|
val = 'N/A'
|
|
elif units is not None and isinstance(val, (int, float)):
|
|
val = units(val, digits)
|
|
if pattern is not None:
|
|
clear = False
|
|
if 's' in pattern:
|
|
val = str(val)
|
|
else:
|
|
try:
|
|
val = float(val)
|
|
except ValueError:
|
|
val = 0
|
|
clear = True
|
|
rep = pattern % val
|
|
if clear:
|
|
rep = ' '*len(rep)
|
|
else:
|
|
rep = str(val)
|
|
line = line.replace('${'+var_ori+'}', rep)
|
|
else:
|
|
print('Error: Unable to expand `{}`'.format(var))
|
|
return line
|
|
|
|
def context_defined_tracks(self, line):
|
|
""" Replace iterator for the `defined_tracks` context """
|
|
text = ''
|
|
for t in sorted(self._track_sizes):
|
|
if not t:
|
|
continue # KiCad 6
|
|
text += self.do_replacements(line, {'track': t})
|
|
return text
|
|
|
|
def context_used_tracks(self, line):
|
|
""" Replace iterator for the `used_tracks` context """
|
|
text = ''
|
|
for t in sorted(self._tracks_m.keys()):
|
|
text += self.do_replacements(line, {'track': t, 'count': self._tracks_m[t],
|
|
'defined': 'yes' if t in self._tracks_defined else 'no'})
|
|
return text
|
|
|
|
def context_defined_vias(self, line):
|
|
""" Replace iterator for the `defined_vias` context """
|
|
text = ''
|
|
for v in self._via_sizes_sorted:
|
|
text += self.do_replacements(line, {'pad': v[1], 'drill': v[0]})
|
|
return text
|
|
|
|
def context_used_vias(self, line):
|
|
""" Replace iterator for the `used_vias` context """
|
|
text = ''
|
|
if not self._vias_m:
|
|
return text
|
|
for v in self._vias_m:
|
|
d = v[1]
|
|
h = v[0]
|
|
aspect = round(self.thickness/d, 1)
|
|
# IPC-2222 Table 9.4
|
|
producibility_level = 'C'
|
|
if aspect < 9:
|
|
if aspect < 5:
|
|
producibility_level = 'A'
|
|
else:
|
|
producibility_level = 'B'
|
|
defined = {'pad': v[1], 'drill': v[0]}
|
|
defined['count'] = self._vias[v]
|
|
defined['aspect'] = aspect
|
|
defined['producibility_level'] = producibility_level
|
|
defined['defined'] = 'yes' if (h, d) in self._vias_defined else 'no'
|
|
text += self.do_replacements(line, defined)
|
|
return text
|
|
|
|
def context_hole_sizes_no_vias(self, line):
|
|
""" Replace iterator for the `hole_sizes_no_vias` context """
|
|
text = ''
|
|
for d in sorted(self._drills.keys()):
|
|
text += self.do_replacements(line, {'drill': d, 'count': self._drills[d]})
|
|
return text
|
|
|
|
def context_oval_hole_sizes(self, line):
|
|
""" Replace iterator for the `oval_hole_sizes` context """
|
|
text = ''
|
|
for d in sorted(self._drills_oval.keys()):
|
|
text += self.do_replacements(line, {'drill_1': d[0], 'drill_2': d[1], 'count': self._drills_oval[d]})
|
|
return text
|
|
|
|
def context_drill_tools(self, line):
|
|
""" Replace iterator for the `drill_tools` context """
|
|
text = ''
|
|
for d in sorted(self._drills_real.keys()):
|
|
text += self.do_replacements(line, {'drill': d, 'count': self._drills_real[d]})
|
|
return text
|
|
|
|
def context_stackup(self, line):
|
|
""" Replace iterator for the `stackup` context """
|
|
text = ''
|
|
for s in self._stackup:
|
|
context = {}
|
|
for k in dir(s):
|
|
val = getattr(s, k)
|
|
if k[0] != '_' and not callable(val):
|
|
context[k] = val if val is not None else ''
|
|
text += self.do_replacements(line, context)
|
|
return text
|
|
|
|
def _context_images(self, line, images):
|
|
""" Replace iterator for the various contexts that expands images """
|
|
text = ''
|
|
for s in images:
|
|
context = {'path': s[0], 'comment': s[1], 'new_line': '\n'}
|
|
text += self.do_replacements(line, context)
|
|
return text
|
|
|
|
def context_layer_pdfs(self, line):
|
|
""" Replace iterator for the `layer_pdfs` context """
|
|
return self._context_images(line, self._layer_pdfs)
|
|
|
|
def context_layer_svgs(self, line):
|
|
""" Replace iterator for the `layer_svgs` context """
|
|
return self._context_images(line, self._layer_svgs)
|
|
|
|
def context_schematic_pdfs(self, line):
|
|
""" Replace iterator for the `schematic_pdfs` context """
|
|
return self._context_images(line, self._schematic_pdfs)
|
|
|
|
def context_schematic_svgs(self, line):
|
|
""" Replace iterator for the `schematic_svgs` context """
|
|
return self._context_images(line, self._schematic_svgs)
|
|
|
|
def _context_individual_images(self, line, images):
|
|
""" Replace iterator for the various contexts that expands one image """
|
|
text = ''
|
|
context = {'new_line': '\n'}
|
|
for s in images:
|
|
context['path_'+s[2]] = s[0]
|
|
context['comment_'+s[2]] = s[1]
|
|
text += self.do_replacements(line, context)
|
|
return text
|
|
|
|
def context_layer_pdf(self, line):
|
|
""" Replace iterator for the `layer_pdf` context """
|
|
return self._context_individual_images(line, self._layer_pdfs)
|
|
|
|
def context_layer_svg(self, line):
|
|
""" Replace iterator for the `layer_svg` context """
|
|
return self._context_individual_images(line, self._layer_svgs)
|
|
|
|
def context_schematic_pdf(self, line):
|
|
""" Replace iterator for the `schematic_pdf` context """
|
|
return self._context_individual_images(line, self._schematic_pdfs)
|
|
|
|
def context_schematic_svg(self, line):
|
|
""" Replace iterator for the `schematic_svg` context """
|
|
return self._context_individual_images(line, self._schematic_svgs)
|
|
|
|
@staticmethod
|
|
def is_pure_smd_5(m):
|
|
return m.GetAttributes() == UI_SMD
|
|
|
|
@staticmethod
|
|
def is_pure_smd_6(m):
|
|
return m.GetAttributes() & (MOD_THROUGH_HOLE | MOD_SMD) == MOD_SMD
|
|
|
|
@staticmethod
|
|
def is_not_virtual_5(m):
|
|
return m.GetAttributes() != UI_VIRTUAL
|
|
|
|
@staticmethod
|
|
def is_not_virtual_6(m):
|
|
return not (m.GetAttributes() & MOD_EXCLUDE_FROM_POS_FILES)
|
|
|
|
def get_attr_tests(self):
|
|
if GS.ki5:
|
|
return self.is_pure_smd_5, self.is_not_virtual_5
|
|
return self.is_pure_smd_6, self.is_not_virtual_6
|
|
|
|
def measure_pcb(self, board):
|
|
edge_layer = board.GetLayerID('Edge.Cuts')
|
|
x1 = y1 = x2 = y2 = None
|
|
draw_type = 'DRAWSEGMENT' if GS.ki5 else 'PCB_SHAPE'
|
|
for d in board.GetDrawings():
|
|
if d.GetClass() == draw_type and d.GetLayer() == edge_layer:
|
|
if x1 is None:
|
|
p = d.GetStart()
|
|
x1 = x2 = p.x
|
|
y1 = y2 = p.y
|
|
for p in [d.GetStart(), d.GetEnd()]:
|
|
x2 = max(x2, p.x)
|
|
y2 = max(y2, p.y)
|
|
x1 = min(x1, p.x)
|
|
y1 = min(y1, p.y)
|
|
# This is a special case: the PCB edges are in a footprint
|
|
for m in GS.get_modules():
|
|
for gi in m.GraphicalItems():
|
|
if gi.GetClass() == 'MGRAPHIC' and gi.GetLayer() == edge_layer:
|
|
if x1 is None:
|
|
p = gi.GetStart()
|
|
x1 = x2 = p.x
|
|
y1 = y2 = p.y
|
|
for p in [gi.GetStart(), gi.GetEnd()]:
|
|
x2 = max(x2, p.x)
|
|
y2 = max(y2, p.y)
|
|
x1 = min(x1, p.x)
|
|
y1 = min(y1, p.y)
|
|
if x1 is None:
|
|
self.bb_w = self.bb_h = INF
|
|
else:
|
|
self.bb_w = x2-x1
|
|
self.bb_h = y2-y1
|
|
|
|
def collect_data(self, board):
|
|
ds = board.GetDesignSettings()
|
|
self.extra_pth_drill = GS.global_extra_pth_drill*pcbnew.IU_PER_MM
|
|
###########################################################
|
|
# Board size
|
|
###########################################################
|
|
# The value returned by ComputeBoundingBox(True) adds the drawing width!
|
|
bb = board.ComputeBoundingBox(True)
|
|
self.bb_w_d = bb.GetWidth()
|
|
self.bb_h_d = bb.GetHeight()
|
|
self.measure_pcb(board)
|
|
###########################################################
|
|
# Board thickness
|
|
###########################################################
|
|
self.thickness = ds.GetBoardThickness()
|
|
###########################################################
|
|
# Number of layers
|
|
###########################################################
|
|
self.layers = ds.GetCopperLayerCount()
|
|
###########################################################
|
|
# Solder mask layers
|
|
###########################################################
|
|
fmask = board.IsLayerEnabled(board.GetLayerID('F.Mask'))
|
|
bmask = board.IsLayerEnabled(board.GetLayerID('B.Mask'))
|
|
self.solder_mask = to_top_bottom(fmask, bmask)
|
|
###########################################################
|
|
# Silk screen
|
|
###########################################################
|
|
fsilk = board.IsLayerEnabled(board.GetLayerID('F.SilkS'))
|
|
bsilk = board.IsLayerEnabled(board.GetLayerID('B.SilkS'))
|
|
self.silk_screen = to_top_bottom(fsilk, bsilk)
|
|
###########################################################
|
|
# Clearance
|
|
###########################################################
|
|
self.clearance = ds.GetSmallestClearanceValue()
|
|
# This seems to be bogus:
|
|
# h2h = ds.m_HoleToHoleMin
|
|
###########################################################
|
|
# Track width (min)
|
|
###########################################################
|
|
self.track_d = ds.m_TrackMinWidth
|
|
tracks = board.GetTracks()
|
|
self.oar_vias = self.track = INF
|
|
self._vias = {}
|
|
self._tracks_m = {}
|
|
self._drills_real = {}
|
|
track_type = 'TRACK' if GS.ki5 else 'PCB_TRACK'
|
|
via_type = 'VIA' if GS.ki5 else 'PCB_VIA'
|
|
for t in tracks:
|
|
tclass = t.GetClass()
|
|
if tclass == track_type:
|
|
w = t.GetWidth()
|
|
self.track = min(w, self.track)
|
|
self._tracks_m[w] = self._tracks_m.get(w, 0) + 1
|
|
elif tclass == via_type:
|
|
via = t.Cast()
|
|
via_id = (via.GetDrill(), via.GetWidth())
|
|
self._vias[via_id] = self._vias.get(via_id, 0) + 1
|
|
d = adjust_drill(via_id[0])
|
|
self.oar_vias = min(self.oar_vias, (via_id[1] - d) / 2)
|
|
self._drills_real[d] = self._drills_real.get(d, 0) + 1
|
|
self.track_min = min(self.track_d, self.track)
|
|
###########################################################
|
|
# Drill (min)
|
|
###########################################################
|
|
modules = board.GetModules() if GS.ki5 else board.GetFootprints()
|
|
self._drills = {}
|
|
self._drills_oval = {}
|
|
self.oar_pads = self.pad_drill = self.pad_drill_real = INF
|
|
self.slot = INF
|
|
self.top_smd = self.top_tht = self.bot_smd = self.bot_tht = 0
|
|
top_layer = board.GetLayerID('F.Cu')
|
|
bottom_layer = board.GetLayerID('B.Cu')
|
|
is_pure_smd, is_not_virtual = self.get_attr_tests()
|
|
npth_attrib = 3 if GS.ki5 else pcbnew.PAD_ATTRIB_NPTH
|
|
min_oar = 0.1*pcbnew.IU_PER_MM
|
|
for m in modules:
|
|
layer = m.GetLayer()
|
|
if layer == top_layer:
|
|
if is_pure_smd(m):
|
|
self.top_smd += 1
|
|
elif is_not_virtual(m):
|
|
self.top_tht += 1
|
|
elif layer == bottom_layer:
|
|
if is_pure_smd(m):
|
|
self.bot_smd += 1
|
|
elif is_not_virtual(m):
|
|
self.bot_tht += 1
|
|
pads = m.Pads()
|
|
for pad in pads:
|
|
dr = pad.GetDrillSize()
|
|
if not dr.x:
|
|
continue
|
|
self.pad_drill = min(dr.x, self.pad_drill)
|
|
self.pad_drill = min(dr.y, self.pad_drill)
|
|
# Compute the drill size to get it after plating
|
|
is_pth = pad.GetAttribute() != npth_attrib
|
|
dr_x_real = adjust_drill(dr.x, is_pth, pad)
|
|
dr_y_real = adjust_drill(dr.y, is_pth, pad)
|
|
self.pad_drill_real = min(dr_x_real, self.pad_drill_real)
|
|
self.pad_drill_real = min(dr_y_real, self.pad_drill_real)
|
|
if dr.x == dr.y:
|
|
self._drills[dr.x] = self._drills.get(dr.x, 0) + 1
|
|
self._drills_real[dr_x_real] = self._drills_real.get(dr_x_real, 0) + 1
|
|
else:
|
|
if dr.x < dr.y:
|
|
m = (dr.x, dr.y)
|
|
d = dr.x
|
|
d_r = dr_x_real
|
|
else:
|
|
m = (dr.y, dr.x)
|
|
d = dr.y
|
|
d_r = dr_y_real
|
|
self._drills_oval[m] = self._drills_oval.get(m, 0) + 1
|
|
self.slot = min(self.slot, m[0])
|
|
# print('{} @ {}'.format(dr, pad.GetPosition()))
|
|
self._drills_real[d_r] = self._drills_real.get(d_r, 0) + 1
|
|
pad_sz = pad.GetSize()
|
|
oar_x = (pad_sz.x - dr_x_real) / 2
|
|
oar_y = (pad_sz.y - dr_y_real) / 2
|
|
oar_t = min(oar_x, oar_y)
|
|
if oar_t > 0:
|
|
self.oar_pads = min(self.oar_pads, oar_t)
|
|
if oar_t < min_oar:
|
|
logger.warning(W_WRONGOAR+"Really small OAR detected ({} mm) for pad {}".
|
|
format(to_mm(oar_t, 4), get_pad_info(pad)))
|
|
elif oar_t < 0:
|
|
logger.warning(W_WRONGOAR+"Negative OAR detected for pad "+get_pad_info(pad))
|
|
elif oar_t == 0 and is_pth:
|
|
logger.warning(W_WRONGOAR+"Plated pad without copper "+get_pad_info(pad))
|
|
self._vias_m = sorted(self._vias.keys())
|
|
# Via Pad size
|
|
self.via_pad_d = ds.m_ViasMinSize
|
|
self.via_pad = self._vias_m[0][1] if self._vias_m else INF
|
|
self.via_pad_min = min(self.via_pad_d, self.via_pad)
|
|
# Via Drill size
|
|
self._vias_m = sorted(self._vias.keys())
|
|
self.via_drill_d = ds.m_ViasMinDrill if GS.ki5 else ds.m_MinThroughDrill
|
|
self.via_drill = self._vias_m[0][0] if self._vias_m else INF
|
|
self.via_drill_min = min(self.via_drill_d, self.via_drill)
|
|
# Via Drill size before platting
|
|
self.via_drill_real_d = adjust_drill(self.via_drill_d)
|
|
self.via_drill_real = adjust_drill(self.via_drill)
|
|
self.via_drill_real_min = adjust_drill(self.via_drill_min)
|
|
# Pad Drill
|
|
# No minimum defined (so no _d)
|
|
self.pad_drill_min = self.pad_drill if GS.ki5 else ds.m_MinThroughDrill
|
|
self.pad_drill_real_min = self.pad_drill_real if GS.ki5 else adjust_drill(ds.m_MinThroughDrill, False)
|
|
# Drill overall
|
|
self.drill_d = min(self.via_drill_d, self.pad_drill)
|
|
self.drill = min(self.via_drill, self.pad_drill)
|
|
self.drill_min = min(self.via_drill_min, self.pad_drill_min)
|
|
# Drill overall size minus 0.1 mm
|
|
self.drill_real_d = min(self.via_drill_real_d, self.pad_drill_real)
|
|
self.drill_real = min(self.via_drill_real, self.pad_drill_real)
|
|
self.drill_real_min = min(self.via_drill_real_min, self.pad_drill_real_min)
|
|
self.top_comp_type = to_smd_tht(self.top_smd, self.top_tht)
|
|
self.bot_comp_type = to_smd_tht(self.bot_smd, self.bot_tht)
|
|
###########################################################
|
|
# Vias
|
|
###########################################################
|
|
self.micro_vias = 'yes' if ds.m_MicroViasAllowed else 'no'
|
|
self.blind_vias = 'yes' if ds.m_BlindBuriedViaAllowed else 'no'
|
|
self.uvia_pad = ds.m_MicroViasMinSize
|
|
self.uvia_drill = ds.m_MicroViasMinDrill
|
|
via_sizes = board.GetViasDimensionsList()
|
|
self._vias_defined = set()
|
|
self._via_sizes_sorted = []
|
|
self.oar_vias_d = INF
|
|
for v in sorted(via_sizes, key=lambda x: (x.m_Diameter, x.m_Drill)):
|
|
d = v.m_Diameter
|
|
h = v.m_Drill
|
|
if not d and not h:
|
|
continue # KiCad 6
|
|
self.oar_vias_d = min(self.oar_vias_d, (d - adjust_drill(h)) / 2)
|
|
self._vias_defined.add((h, d))
|
|
self._via_sizes_sorted.append((h, d))
|
|
###########################################################
|
|
# Outer Annular Ring
|
|
###########################################################
|
|
self.oar_pads_min = self.oar_pads
|
|
self.oar_d = min(self.oar_vias_d, self.oar_pads)
|
|
self.oar = min(self.oar_vias, self.oar_pads)
|
|
self.oar_min = min(self.oar_d, self.oar)
|
|
self.oar_vias_min = min(self.oar_vias_d, self.oar_vias)
|
|
###########################################################
|
|
# Eurocircuits class
|
|
# https://www.eurocircuits.com/pcb-design-guidelines-classification/
|
|
###########################################################
|
|
# Pattern class
|
|
self.pattern_class_min = get_pattern_class(self.track_min, self.clearance, self.oar_min, 'minimum')
|
|
self.pattern_class = get_pattern_class(self.track, self.clearance, self.oar, 'measured', self._ec_pat)
|
|
self.pattern_class_d = get_pattern_class(self.track_d, self.clearance, self.oar_d, 'defined')
|
|
# Drill class
|
|
self.drill_class_min = get_drill_class(self.drill_real_min, 'minimum')
|
|
self.drill_class = get_drill_class(self.drill_real, 'measured', self._ec_drl)
|
|
self.drill_class_d = get_drill_class(self.drill_real_d, 'defined')
|
|
###########################################################
|
|
# General stats
|
|
###########################################################
|
|
self._track_sizes = board.GetTrackWidthList()
|
|
self._tracks_defined = set(self._track_sizes)
|
|
|
|
def eval_conditional(self, line):
|
|
context = {k: getattr(self, k) for k in dir(self) if k[0] != '_' and not callable(getattr(self, k))}
|
|
res = None
|
|
text = line[2:].strip()
|
|
logger.debug('- Evaluating `{}`'.format(text))
|
|
try:
|
|
res = eval(text, {}, context)
|
|
except Exception as e:
|
|
raise KiPlotConfigurationError('wrong conditional: `{}`\nPython says: `{}`'.format(text, str(e)))
|
|
logger.debug('- Result `{}`'.format(res))
|
|
return res
|
|
|
|
def do_template(self, template_file, output_file):
|
|
text = ''
|
|
logger.debug("Report template: `{}`".format(template_file))
|
|
with open(template_file, "rt") as f:
|
|
skip_next = False
|
|
for line in f:
|
|
if skip_next:
|
|
skip_next = False
|
|
continue
|
|
done = False
|
|
if line[0] == '#':
|
|
if line.startswith('#?'):
|
|
skip_next = not self.eval_conditional(line)
|
|
done = True
|
|
line = ''
|
|
elif ':' in line:
|
|
context = line[1:].split(':')[0]
|
|
logger.debug("- Report context: `{}`".format(context))
|
|
name = 'context_'+context
|
|
if hasattr(self, name):
|
|
# Contexts are members called context_*
|
|
line = getattr(self, name)(line[len(context)+2:])
|
|
done = True
|
|
else:
|
|
raise KiPlotConfigurationError("Unknown context: `{}`".format(context))
|
|
if not done:
|
|
# Just replace using any data member (_* excluded)
|
|
line = self.do_replacements(line, self.__dict__)
|
|
text += line
|
|
logger.debug("Report output: `{}`".format(output_file))
|
|
if self.to_ascii:
|
|
# PanDoc has problems with this Unicode
|
|
text = text.replace('≥', '>=')
|
|
with open(output_file, "wt") as f:
|
|
f.write(text)
|
|
|
|
def expand_converted_output(self, out_dir):
|
|
aux = self._expand_ext
|
|
self._expand_ext = str(self.convert_to)
|
|
res = self._parent.expand_filename(out_dir, self.converted_output)
|
|
self._expand_ext = aux
|
|
return res
|
|
|
|
def get_targets(self, out_dir):
|
|
files = [self._parent.expand_filename(out_dir, self.output)]
|
|
if self.do_convert:
|
|
files.append(self.expand_converted_output(out_dir))
|
|
return files
|
|
|
|
def convert(self, fname):
|
|
if not self.do_convert:
|
|
return
|
|
command = self.ensure_tool('PanDoc')
|
|
dir_out = os.path.dirname(os.path.abspath(fname))
|
|
out = self.expand_converted_output(dir_out)
|
|
logger.debug('Converting the report to: {}'.format(out))
|
|
resources = '--resource-path='+dir_out
|
|
# Pandoc 2.2.1 doesn't support "--to pdf"
|
|
if not out.endswith('.'+self.convert_to):
|
|
logger.warning(W_WRONGEXT+'The conversion tool detects the output format using the extension')
|
|
cmd = [command, '--from', self.convert_from, resources, fname, '-o', out]
|
|
logger.debug('Executing {}'.format(cmd))
|
|
try:
|
|
check_output(cmd, stderr=STDOUT)
|
|
except CalledProcessError as e:
|
|
logger.error('{} error: {}'.format(command, e.returncode))
|
|
if e.output:
|
|
logger.debug('Output from command: '+e.output.decode())
|
|
exit(FAILED_EXECUTE)
|
|
|
|
def run(self, fname):
|
|
self.pcb_material = GS.global_pcb_material
|
|
self.solder_mask_color = GS.global_solder_mask_color
|
|
self.solder_mask_color_top = GS.global_solder_mask_color_top
|
|
self.solder_mask_color_bottom = GS.global_solder_mask_color_bottom
|
|
self.solder_mask_color_text = to_top_bottom_color(GS.global_solder_mask_color_top, GS.global_solder_mask_color_bottom)
|
|
self.silk_screen_color = GS.global_silk_screen_color
|
|
self.silk_screen_color_top = GS.global_silk_screen_color_top
|
|
self.silk_screen_color_bottom = GS.global_silk_screen_color_bottom
|
|
self.silk_screen_color_text = to_top_bottom_color(GS.global_silk_screen_color_top, GS.global_silk_screen_color_bottom)
|
|
self.pcb_finish = GS.global_pcb_finish
|
|
self.edge_connector = solve_edge_connector(GS.global_edge_connector)
|
|
self.castellated_pads = GS.global_castellated_pads
|
|
self.edge_plating = GS.global_edge_plating
|
|
self.copper_thickness = GS.global_copper_thickness
|
|
self.impedance_controlled = GS.global_impedance_controlled
|
|
self.stackup = 'yes' if GS.stackup else ''
|
|
self._stackup = GS.stackup if GS.stackup else []
|
|
self.collect_data(GS.board)
|
|
base_dir = os.path.dirname(fname)
|
|
# Collect the PCB layers and schematic prints
|
|
self._layer_pdfs = []
|
|
self._layer_svgs = []
|
|
self._schematic_pdfs = []
|
|
self._schematic_svgs = []
|
|
for o in RegOutput.get_outputs():
|
|
dest = None
|
|
if o.type == 'pdf_pcb_print' or o.type == 'pcb_print':
|
|
dest = self._layer_pdfs
|
|
elif o.type == 'svg_pcb_print':
|
|
dest = self._layer_svgs
|
|
elif o.type == 'pdf_sch_print':
|
|
dest = self._schematic_pdfs
|
|
elif o.type == 'svg_sch_print':
|
|
dest = self._schematic_svgs
|
|
if dest is not None:
|
|
if not o._configured:
|
|
config_output(o)
|
|
if o.type == 'pcb_print' and o.options.format != 'PDF':
|
|
if o.options.format == 'SVG':
|
|
dest = self._layer_svgs
|
|
else:
|
|
continue
|
|
out_files = o.get_targets(o.expand_dirname(os.path.join(GS.out_dir, o.dir)))
|
|
is_pcb_print_svg = o.type == 'pcb_print' and o.options.format == 'SVG'
|
|
for n, of in enumerate(out_files):
|
|
rel_path = os.path.relpath(of, base_dir)
|
|
comment = o.comment
|
|
if is_pcb_print_svg and o.options.pages[n].sheet:
|
|
comment += ' '+o.options.pages[n].sheet
|
|
dest.append((rel_path, comment, o.name))
|
|
self.layer_pdfs = len(self._layer_pdfs) > 0
|
|
self.layer_svgs = len(self._layer_svgs) > 0
|
|
self.schematic_pdfs = len(self._schematic_pdfs) > 0
|
|
self.schematic_svgs = len(self._schematic_svgs) > 0
|
|
self.do_template(self.template, fname)
|
|
self.convert(fname)
|
|
|
|
|
|
@output_class
|
|
class Report(BaseOutput): # noqa: F821
|
|
""" Design report
|
|
Generates a report about the design.
|
|
Mainly oriented to be sent to the manufacturer or check PCB details. """
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
self.options = ReportOptions
|
|
""" *[dict] Options for the `report` output """
|
|
self._category = 'PCB/docs'
|
|
|
|
@staticmethod
|
|
def get_conf_examples(name, layers, templates):
|
|
pandoc = GS.check_tool(name, 'PanDoc')
|
|
gb = {}
|
|
outs = [gb]
|
|
gb['name'] = 'report_simple'
|
|
gb['comment'] = 'Simple design report'
|
|
gb['type'] = name
|
|
gb['output_id'] = '_simple'
|
|
gb['options'] = {'template': 'simple_ASCII'}
|
|
if pandoc:
|
|
gb['options']['do_convert'] = True
|
|
gb = {}
|
|
gb['name'] = 'report_full'
|
|
gb['comment'] = 'Full design report'
|
|
gb['type'] = name
|
|
gb['options'] = {'template': 'full_SVG'}
|
|
if pandoc:
|
|
gb['options']['do_convert'] = True
|
|
outs.append(gb)
|
|
return outs
|