409 lines
18 KiB
Python
409 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2020-2022 Salvador E. Tropea
|
|
# Copyright (c) 2020-2022 Instituto Nacional de Tecnología Industrial
|
|
# License: GPL-3.0
|
|
# Project: KiBot (formerly KiPlot)
|
|
# Note: the algorithm used to detect the PCB outline is adapted from KiKit project.
|
|
from itertools import chain
|
|
import os
|
|
from tempfile import TemporaryDirectory
|
|
from .registrable import RegVariant
|
|
from .optionable import Optionable, PanelOptions
|
|
from .fil_base import apply_exclude_filter, apply_fitted_filter, apply_fixed_filter, apply_pre_transform
|
|
from .error import KiPlotConfigurationError
|
|
from .misc import KIKIT_UNIT_ALIASES
|
|
from .gs import GS
|
|
from .kiplot import run_command
|
|
from .kicad.pcb import PCB
|
|
from .macros import macros, document # noqa: F401
|
|
from . import log
|
|
|
|
logger = log.get_logger()
|
|
|
|
|
|
def round_point(point, precision=-4):
|
|
return (round(point[0], precision), round(point[1], precision))
|
|
|
|
|
|
def point_str(point):
|
|
if isinstance(point, tuple):
|
|
return '({} mm, {} mm)'.format(GS.to_mm(point[0]), GS.to_mm(point[1]))
|
|
return '({} mm, {} mm)'.format(GS.to_mm(point.x), GS.to_mm(point.y))
|
|
|
|
|
|
def bbox_str(bbox):
|
|
return point_str(bbox.GetPosition())+'-'+point_str(bbox.GetEnd())
|
|
|
|
|
|
class Edge(object):
|
|
def __init__(self, shape):
|
|
super().__init__()
|
|
self.start = GS.get_start_point(shape)
|
|
self.r_start = round_point(self.start)
|
|
self.end = GS.get_end_point(shape)
|
|
self.r_end = round_point(self.end)
|
|
self.shape = shape
|
|
self.cls = GS.get_shape(shape)
|
|
self.used = False
|
|
|
|
def get_other_end(self, point):
|
|
if self.r_start != point:
|
|
return self.start, self.r_start
|
|
return self.end, self.r_end
|
|
|
|
def get_bbox(self):
|
|
""" Get the Bounding Box for the shape, without its line width.
|
|
KiKit uses the value in this way. """
|
|
return GS.get_shape_bbox(self.shape)
|
|
|
|
def __str__(self):
|
|
return '{} {}-{}'.format(self.cls, point_str(self.start), point_str(self.end))
|
|
|
|
|
|
class SubPCBOptions(PanelOptions):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._unknown_is_error = True
|
|
with document:
|
|
self.name = ''
|
|
""" *Name for this sub-pcb """
|
|
self.reference = ''
|
|
""" *Use it for the annotations method.
|
|
This is the reference for the `kikit:Board` footprint used to identify the sub-PCB.
|
|
Note that you can use any footprint as long as its position is inside the PCB outline.
|
|
When empty the sub-PCB is specified using a rectangle """
|
|
self.ref = None
|
|
""" {reference} """
|
|
self.tlx = 0
|
|
""" [number|string] The X position of the top left corner for the rectangle that contains the sub-PCB """
|
|
self.top_left_x = None
|
|
""" {tlx} """
|
|
self.tly = 0
|
|
""" [number|string] The Y position of the top left corner for the rectangle that contains the sub-PCB """
|
|
self.top_left_y = None
|
|
""" {tly} """
|
|
self.brx = 0
|
|
""" [number|string] The X position of the bottom right corner for the rectangle that contains the sub-PCB """
|
|
self.bottom_right_x = None
|
|
""" {brx} """
|
|
self.bry = 0
|
|
""" [number|string] The Y position of the bottom right corner for the rectangle that contains the sub-PCB """
|
|
self.bottom_right_y = None
|
|
""" {bry} """
|
|
self.units = 'mm'
|
|
""" [millimeters,inches,mils,mm,cm,dm,m,mil,inch,in] Units used when omitted """
|
|
self.file_id = ''
|
|
""" Text to use as the replacement for %v expansion.
|
|
When empty we use the parent `file_id` plus the `name` of the sub-PCB """
|
|
self.tool = 'internal'
|
|
""" [internal,kikit] Tool used to extract the sub-PCB. """
|
|
self.tolerance = 0
|
|
""" [number|string] Used to enlarge the selected rectangle to include elements outside the board.
|
|
KiCad 5: To avoid rounding issues this value is set to 0.000002 mm when 0 is specified """
|
|
self.strip_annotation = False
|
|
""" Remove the annotation footprint. Note that KiKit will remove all annotations,
|
|
but the internal implementation just the one indicated by `ref`.
|
|
If you need to remove other annotations use an exclude filter """
|
|
self.center_result = True
|
|
""" Move the resulting PCB to the center of the page.
|
|
You can disable it only for the internal tool, KiKit should always do it """
|
|
|
|
def is_zero(self, val):
|
|
return isinstance(val, (int, float)) and val == 0
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
if not self.name:
|
|
raise KiPlotConfigurationError('Sub-PCB without a name')
|
|
self.units = KIKIT_UNIT_ALIASES.get(self.units, self.units)
|
|
if (not self.reference and self.is_zero(self.tlx) and self.is_zero(self.tly) and self.is_zero(self.brx) and
|
|
self.is_zero(self.bry)):
|
|
raise KiPlotConfigurationError('No reference or rectangle specified for {} sub-PCB'.format(self.name))
|
|
self.add_units(('tlx', 'tly', 'brx', 'bry', 'tolerance'), self.units, convert=True)
|
|
self.board_rect = GS.create_eda_rect(self._tlx, self._tly, self._brx, self._bry)
|
|
if not self._tolerance and GS.ki5:
|
|
# KiCad 5 workaround: rounding issues generate 1 fm of error. So we change to 2 fm tolerance.
|
|
self._tolerance = 2
|
|
self.board_rect.Inflate(int(self._tolerance))
|
|
|
|
def get_separate_source(self):
|
|
if self.reference:
|
|
src = "annotation; ref: {}".format(self.reference)
|
|
else:
|
|
src = "rectangle; tlx: {}; tly: {}; brx: {}; bry: {}".format(self.tlx, self.tly, self.brx, self.bry)
|
|
if self._tolerance:
|
|
src += "; tolerance: {}".format(self.tolerance)
|
|
return src
|
|
|
|
def separate_board(self, comps_hash):
|
|
""" Apply the sub-PCB using an external tool and load it into memory """
|
|
# Make sure kikit is available
|
|
command = GS.ensure_tool('global', 'KiKit')
|
|
with TemporaryDirectory(prefix='kibot-separate') as d:
|
|
dest = os.path.join(d, os.path.basename(GS.pcb_file))
|
|
if comps_hash:
|
|
# Memorize the used modules
|
|
old_modules = {m.GetReference() for m in GS.get_modules()}
|
|
# Now do the separation
|
|
cmd = [command, 'separate', '--preserveArcs', '-s', self.get_separate_source()]
|
|
if self.strip_annotation:
|
|
cmd.append('--stripAnnotations')
|
|
cmd.extend([GS.pcb_file, dest])
|
|
# Execute the separate
|
|
run_command(cmd)
|
|
# Load this board
|
|
GS.load_board(dest, forced=True)
|
|
# Now reflect the changes in the list of components
|
|
if comps_hash:
|
|
logger.debug('Removing components outside the sub-PCB')
|
|
# Memorize the used modules
|
|
new_modules = {m.GetReference() for m in GS.get_modules()}
|
|
# Compute the modules we removed
|
|
diff = old_modules - new_modules
|
|
logger.debugl(3, diff)
|
|
# Exclude them from _comps
|
|
for c in diff:
|
|
cmp = comps_hash[c]
|
|
if cmp.included:
|
|
cmp.included = False
|
|
self._excl_by_sub_pcb.add(c)
|
|
logger.debugl(2, '- Removing '+c)
|
|
|
|
def _remove_items(self, iter):
|
|
""" Remove items outside the rectangle.
|
|
If the item has width (shapes and tracks) we discard it.
|
|
This produces something closer to KiKit. """
|
|
for m in iter:
|
|
with_width = hasattr(m, 'GetWidth')
|
|
if with_width:
|
|
width = m.GetWidth()
|
|
m.SetWidth(0)
|
|
if not self.board_rect.Contains(m.GetBoundingBox()):
|
|
GS.board.Remove(m)
|
|
self._removed.append(m)
|
|
if with_width:
|
|
m.SetWidth(width)
|
|
|
|
def _remove_modules(self, iter, comps_hash):
|
|
""" Remove modules outside the rectangle.
|
|
Footprints are added to the list of references to exclude.
|
|
We also check their position, not their BBox. """
|
|
for m in iter:
|
|
ref = m.GetReference()
|
|
if not self.board_rect.Contains(m.GetPosition()) or (self.strip_annotation and ref == self.reference):
|
|
GS.board.Remove(m)
|
|
self._removed.append(m)
|
|
if comps_hash:
|
|
self._excl_by_sub_pcb.add(ref)
|
|
|
|
def remove_outside(self, comps_hash):
|
|
""" Remove footprints, drawings, text and zones outside `board_rect` rectangle.
|
|
Keep them in a list to restore later. """
|
|
self._removed = []
|
|
self._remove_modules(GS.get_modules(), comps_hash)
|
|
self._remove_items(GS.board.GetDrawings())
|
|
self._remove_items(GS.board.GetTracks())
|
|
self._remove_items(list(GS.board.Zones()))
|
|
|
|
def get_pcb_edges(self):
|
|
""" Get a list of PCB shapes from the Edge.Cuts layer.
|
|
Only useful elements are returned. """
|
|
edges = []
|
|
layer_cuts = GS.board.GetLayerID('Edge.Cuts')
|
|
for edge in chain(GS.board.GetDrawings(), *[m.GraphicalItems() for m in GS.get_modules()]):
|
|
if edge.GetLayer() != layer_cuts or edge.GetClass().startswith('PCB_DIM_') or not GS.is_valid_pcb_shape(edge):
|
|
continue
|
|
edges.append(Edge(edge))
|
|
return edges
|
|
|
|
def inform_unconnected(self, edge, point):
|
|
raise KiPlotConfigurationError('Discontinuous PCB outline: {} not connected at {}'.format(edge, point_str(point)))
|
|
|
|
def inform_multiple_connect(self, edges, point):
|
|
raise KiPlotConfigurationError('PCB outline error: {}, {} and {} are connected at {}'.
|
|
format(edges[0], edges[1], edges[2], point_str(point)))
|
|
|
|
def find_contour(self, initial_edge, edges):
|
|
""" Find a list of edges that creates a closed contour and contains initial_edge """
|
|
# Classify the points according to its rounded coordinates
|
|
points = {}
|
|
for e in edges:
|
|
points.setdefault(e.r_start, []).append(e)
|
|
points.setdefault(e.r_end, []).append(e)
|
|
# Look for a closed loop that contains initial_edge
|
|
r_start = initial_edge.r_start
|
|
start = initial_edge.start
|
|
r_end = initial_edge.r_end
|
|
contour = [initial_edge]
|
|
cur_edge = initial_edge
|
|
cur_edge.used = True
|
|
bbox = cur_edge.get_bbox()
|
|
while r_start != r_end:
|
|
e = points.get(r_start, None)
|
|
# We should get 2 points, the one we are using and its connected point
|
|
if e is None or len(e) == 1:
|
|
self.inform_unconnected(cur_edge, start)
|
|
if len(e) > 2:
|
|
self.inform_multiple_connect(e, start)
|
|
cur_edge = e[0] if e[0] != cur_edge else e[1]
|
|
# Sanity check
|
|
assert not cur_edge.used
|
|
# Change to the new segment
|
|
contour.append(cur_edge)
|
|
start, r_start = cur_edge.get_other_end(r_start)
|
|
cur_edge.used = True
|
|
bbox.Merge(cur_edge.get_bbox())
|
|
return contour, bbox
|
|
|
|
def search_reference_rect(self, ref):
|
|
""" Search the rectangle that contains the outline pointed by `ref` footprint """
|
|
logger.debug('Looking for the rectangle pointed by `{}`'.format(ref))
|
|
extra_debug = GS.debug_level > 2
|
|
# Find the annotation component
|
|
r = next(filter(lambda x: x.GetReference() == ref, GS.get_modules()), None)
|
|
if r is None:
|
|
raise KiPlotConfigurationError('Missing `{}` component in PCB, used for sub-PCB `{}`'.format(ref, self.name))
|
|
# Find the point it indicates
|
|
point = r.GetPosition()
|
|
if extra_debug:
|
|
logger.debug('- Points to '+point_str(point))
|
|
# Look for the PCB edges
|
|
edges = self.get_pcb_edges()
|
|
# Detect which edge is selected
|
|
sel_edge = next(filter(lambda x: x.shape.HitTest(point), edges), None)
|
|
if sel_edge is None:
|
|
raise KiPlotConfigurationError("The `{}` component doesn't select an object in the PCB edge".format(ref))
|
|
if extra_debug:
|
|
logger.debug('- Segment '+str(sel_edge))
|
|
# Detect a contour containing this edge
|
|
contour, bbox = self.find_contour(sel_edge, edges)
|
|
if extra_debug:
|
|
logger.debug('- BBox '+bbox_str(bbox))
|
|
logger.debug('- Elements:')
|
|
for e in contour:
|
|
logger.debug(' - '+str(e))
|
|
return bbox
|
|
|
|
def move_objects(self):
|
|
""" Move all objects by self._moved """
|
|
logger.debug('Moving all PCB elements by '+point_str(self._moved))
|
|
any((x.Move(self._moved) for x in GS.get_modules()))
|
|
any((x.Move(self._moved) for x in GS.board.GetDrawings()))
|
|
any((x.Move(self._moved) for x in GS.board.GetTracks()))
|
|
any((x.Move(self._moved) for x in GS.board.Zones()))
|
|
|
|
def center_objects(self):
|
|
""" Move all objects in the PCB so it gets centered """
|
|
# Look for the PCB size
|
|
pcb = PCB.load(GS.pcb_file)
|
|
paper_center_x = GS.from_mm(pcb.paper_w/2)
|
|
paper_center_y = GS.from_mm(pcb.paper_h/2)
|
|
# Compute the offset to make it centered
|
|
self._moved = self.board_rect.GetCenter()
|
|
self._moved.x = paper_center_x-self._moved.x
|
|
self._moved.y = paper_center_y-self._moved.y
|
|
self.move_objects()
|
|
|
|
def apply(self, comps_hash):
|
|
""" Apply the sub-PCB selection. """
|
|
self._excl_by_sub_pcb = set()
|
|
if self.tool == 'internal':
|
|
if self.reference:
|
|
# Get the rectangle containing the board edge pointed by the reference
|
|
self.board_rect = self.search_reference_rect(self.reference)
|
|
self.board_rect.Inflate(int(self._tolerance))
|
|
# Using a rectangle
|
|
self.remove_outside(comps_hash)
|
|
# Center the PCB
|
|
self.center_objects()
|
|
else:
|
|
# Using KiKit:
|
|
self.separate_board(comps_hash)
|
|
|
|
def unload_board(self, comps_hash):
|
|
# Undo the sub-PCB: just reload the PCB
|
|
GS.load_board(forced=True)
|
|
|
|
def restore_removed(self):
|
|
""" Restore the stuff we removed from the board """
|
|
for o in self._removed:
|
|
GS.board.Add(o)
|
|
|
|
def restore_moved(self):
|
|
""" Move objects back to their original place """
|
|
self._moved.x = -self._moved.x
|
|
self._moved.y = -self._moved.y
|
|
self.move_objects()
|
|
|
|
def revert(self, comps_hash):
|
|
""" Restore the sub-PCB selection. """
|
|
if self.tool == 'internal':
|
|
self.restore_moved()
|
|
self.restore_removed()
|
|
else:
|
|
# Using KiKit:
|
|
self.unload_board(comps_hash)
|
|
# Restore excluded components
|
|
logger.debug('Restoring components outside the sub-PCB')
|
|
for c in self._excl_by_sub_pcb:
|
|
comps_hash[c].included = True
|
|
|
|
|
|
class BaseVariant(RegVariant):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._unknown_is_error = True
|
|
with document:
|
|
self.name = ''
|
|
""" Used to identify this particular variant definition """
|
|
self.type = ''
|
|
""" Type of variant """
|
|
self.comment = ''
|
|
""" A comment for documentation purposes """
|
|
self.file_id = ''
|
|
""" Text to use as the replacement for %v expansion """
|
|
# * Filters
|
|
self.pre_transform = Optionable
|
|
""" [string|list(string)=''] Name of the filter to transform fields before applying other filters.
|
|
Use '_var_rename' to transform VARIANT:FIELD fields.
|
|
Use '_var_rename_kicost' to transform kicost.VARIANT:FIELD fields.
|
|
Use '_kicost_rename' to apply KiCost field rename rules """
|
|
self.exclude_filter = Optionable
|
|
""" [string|list(string)=''] Name of the filter to exclude components from BoM processing.
|
|
Use '_mechanical' for the default KiBoM behavior """
|
|
self.dnf_filter = Optionable
|
|
""" [string|list(string)=''] Name of the filter to mark components as 'Do Not Fit'.
|
|
Use '_kibom_dnf' for the default KiBoM behavior.
|
|
Use '_kicost_dnp'' for the default KiCost behavior """
|
|
self.dnc_filter = Optionable
|
|
""" [string|list(string)=''] Name of the filter to mark components as 'Do Not Change'.
|
|
Use '_kibom_dnc' for the default KiBoM behavior """
|
|
self.sub_pcbs = SubPCBOptions
|
|
""" [list(dict)] Used for multi-board workflows as defined by KiKit.
|
|
I don't recommend using it, for detail read
|
|
[this](https://github.com/INTI-CMNB/KiBot/tree/master/docs/1_SCH_2_part_PCBs).
|
|
But if you really need it you can define the sub-PCBs here.
|
|
Then you just use *VARIANT[SUB_PCB_NAME]* instead of just *VARIANT* """
|
|
self._sub_pcb = None
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
if isinstance(self.sub_pcbs, type):
|
|
self.sub_pcbs = []
|
|
|
|
def get_variant_field(self):
|
|
""" Returns the name of the field used to determine if the component belongs to the variant """
|
|
return None
|
|
|
|
def matches_variant(self, text):
|
|
""" This is a generic match mechanism used by variants that doesn't really have a matching mechanism """
|
|
return self.name.lower() == text.lower()
|
|
|
|
def filter(self, comps):
|
|
# Apply all the filters
|
|
comps = apply_pre_transform(comps, self.pre_transform)
|
|
apply_exclude_filter(comps, self.exclude_filter)
|
|
apply_fitted_filter(comps, self.dnf_filter)
|
|
apply_fixed_filter(comps, self.dnc_filter)
|
|
return comps
|