From eaf4f21ce65081da0490a41ee1829b4ec8319109 Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 3 Feb 2022 19:57:16 +0100 Subject: More doc --- docs/file-api.rst | 6 + docs/utilities.rst | 15 +++ gerbonara/NOTES | 42 ++++++ gerbonara/__init__.py | 17 +-- gerbonara/aperture_macros/parse.py | 3 +- gerbonara/apertures.py | 17 +++ gerbonara/cam.py | 134 +++++++++++++++---- gerbonara/excellon.py | 150 ++++++++++++++++------ gerbonara/graphic_objects.py | 17 +++ gerbonara/graphic_primitives.py | 17 +++ gerbonara/ipc356.py | 2 + gerbonara/layer_rules.py | 20 ++- gerbonara/layers.py | 3 +- gerbonara/rs274x.py | 57 +++++--- gerbonara/tests/image_support.py | 20 +++ gerbonara/tests/resources/fritzing/combined.gbs | 2 +- gerbonara/tests/resources/open_outline_altium.gbr | 2 +- gerbonara/tests/test_excellon.py | 19 ++- gerbonara/tests/test_ipc356.py | 19 ++- gerbonara/tests/test_layers.py | 19 ++- gerbonara/tests/test_rs274x.py | 21 ++- gerbonara/tests/test_utils.py | 18 ++- gerbonara/tests/utils.py | 17 +++ gerbonara/utils.py | 14 +- 24 files changed, 543 insertions(+), 108 deletions(-) create mode 100644 gerbonara/NOTES diff --git a/docs/file-api.rst b/docs/file-api.rst index ed23672..ba472d4 100644 --- a/docs/file-api.rst +++ b/docs/file-api.rst @@ -10,9 +10,15 @@ either a :py:class:`.GerberFile` or an :py:class:`.ExcellonFile`) is represented :py:class:`.LayerStack` contains logic to automatcally recognize a wide variety of CAD tools from file name and syntactic hints, and can automatically match all files in a folder to their appropriate layers. +:py:class:`.CamFile` is the common base class for all layer types. + + .. autoclass:: gerbonara.layers.LayerStack :members: +.. autoclass:: gerbonara.cam.CamFile + :members: + .. autoclass:: gerbonara.rs274x.GerberFile :members: diff --git a/docs/utilities.rst b/docs/utilities.rst index 80ce5ec..5e75df5 100644 --- a/docs/utilities.rst +++ b/docs/utilities.rst @@ -1,8 +1,23 @@ Utilities ========= +Physical units +~~~~~~~~~~~~~~ + +Gerbonara tracks length units using the :py:class:`.LengthUnit` class. :py:class:`.LengthUnit` contains a number of +conventient conversion functions. Everywhere where Gerbonara accepts units as a method argument, it automatically +converts a string ``'mm'`` or ``'inch'`` to the corresponding :py:class:`.LengthUnit`. + .. autoclass:: gerbonara.utils.LengthUnit :members: +Format settings +~~~~~~~~~~~~~~~ + +When reading or writing Gerber or Excellon, Gerbonara stores information about file format options such as zero +suppression or number of decimal places in a :py:class:`.FileSettings` instance. When you are writing a Gerber file, +Gerbonara picks reasonable defaults, but allows you to specify your own :py:class:`.FileSettings` to override these +defaults. + .. autoclass:: gerbonara.cam.FileSettings :members: diff --git a/gerbonara/NOTES b/gerbonara/NOTES new file mode 100644 index 0000000..efaa511 --- /dev/null +++ b/gerbonara/NOTES @@ -0,0 +1,42 @@ + +To do +===== + +[X] Actually use newly added gerber samples in test suite +[X] Make Gerber parser error out if no unit is set anywhere +[ ] Add test case for board outline / bounds with arcs (e.g. circle made up of four arcs, each with center line along + x/y axis) +[ ] Add "find outline" method +[X] Refactor layer identification logic, automatically detect Allegro NCPARAM files +[X] Add idempotence test: When reading, then reserializing the output of an earlier invocation of gerbonara, the output + should not change. That is, in f1 --gn-> f2 --gn-> f3, it should be f2 == f3 but not necessarily f1 == f2. +[X] Handle upverter output correctly: Upverter puts drils in a file called "design_export.xln" that actually contains + Gerber, not Excellon +[X] Add standard comment/attribute support for Gerber and Excellon +[X] Add file/lineno info to all warnings and syntax errors +[X] Make sure we handle arcs with co-inciding start/end points correctly (G74: no arc, G75: full circle) +[ ] Add allegro drill test files with different zero suppression settings +[ ] Add pcb-rnd to layer matching +[ ] Add librepcb to layer matching +[ ] On altium exports with multiple mech layers, use lowest-numbered one as board outline and raise a warning. +[ ] Assign layer rules based on allegro metadata instead of filenames for allegro files +[ ] Add more IPC-356 test files from github +[X] Add IPC netlist support to LayerStack +[ ] It seems the excellon generator never generates M16 (drill up) commands, only M15 (drill down) commands during + routing +[ ] Add standalone excellon SVG export test +[ ] In image difference tests, detect empty images. +[ ] Merge subsequent paths in gerbv svgs for less bad rendering performance +[ ] Add integrated zip handling to layerstack +[ ] Add GraphicObject.as(unit) method +[ ] Add methods to graphic_object Line, Arc, Flash to convert between gerber and excellon representations. +[ ] Add to_primitives to all *File classes +[ ] Add region cut-in API +[ ] Add radius- instead of center-based method of creating Arcs +[ ] Add warning when interpolating aperture that is not either a circle or a rectangle. +[ ] Maybe have Line and Arc just use a width instead of an aperture after all, and roll plating of excellon tool into + graphic object subclass. +[ ] Figure out whether to drop rectangular holes or whether to support them in polygon apertures as well. +[ ] Add "number of parameters" property to ApertureMacro +[ ] Aperture macro outline: Warn if first and last point are not the same. +[ ] Make sure incremental mode actually works for gerber import diff --git a/gerbonara/__init__.py b/gerbonara/__init__.py index 4076cc9..7a0475f 100644 --- a/gerbonara/__init__.py +++ b/gerbonara/__init__.py @@ -1,25 +1,26 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- - -# Copyright 2013-2014 Paulo Henrique Silva - +# +# Copyright 2022 Jan Götte +# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at - +# # http://www.apache.org/licenses/LICENSE-2.0 - +# # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# + """ Gerbonara ========= -gerbonara provides utilities for working with Gerber (RS-274X) and Excellon -files in python. +gerbonara provides utilities for working with Gerber (RS-274X) and Excellon files in python. """ from .rs274x import GerberFile diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py index 0fa936f..a679247 100644 --- a/gerbonara/aperture_macros/parse.py +++ b/gerbonara/aperture_macros/parse.py @@ -140,7 +140,7 @@ class ApertureMacro: return dup -cons, var = ConstantExpression, VariableExpression +var = VariableExpression deg_per_rad = 180 / math.pi class GenericMacros: @@ -179,3 +179,4 @@ if __name__ == '__main__': for primitive in parse_macro(sys.stdin.read(), 'mm'): print(primitive) + diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index d717d36..ec14f16 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -1,3 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# import math from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY diff --git a/gerbonara/cam.py b/gerbonara/cam.py index cf1d78a..a96a1eb 100644 --- a/gerbonara/cam.py +++ b/gerbonara/cam.py @@ -1,19 +1,21 @@ #! /usr/bin/env python # -*- coding: utf-8 -*- - -# copyright 2014 Hamilton Kibbe +# +# Copyright 2014 Hamilton Kibbe +# Copyright 2022 Jan Götte # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at - +# # http://www.apache.org/licenses/LICENSE-2.0 - +# # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# import math from dataclasses import dataclass @@ -27,20 +29,24 @@ from . import graphic_objects as go @dataclass class FileSettings: - ''' + ''' Format settings for Gerber/Excellon import/export. + .. note:: - Format and zero suppression are configurable. Note that the Excellon - and Gerber formats use opposite terminology with respect to leading - and trailing zeros. The Gerber format specifies which zeros are - suppressed, while the Excellon format specifies which zeros are - included. This function uses the Gerber-file convention, so an - Excellon file in LZ (leading zeros) mode would use - `zeros='trailing'` + Format and zero suppression are configurable. Note that the Excellon and Gerber formats use opposite terminology + with respect to leading and trailing zeros. The Gerber format specifies which zeros are suppressed, while the + Excellon format specifies which zeros are included. This function uses the Gerber-file convention, so an + Excellon file in LZ (leading zeros) mode would use ``zeros='trailing'`` ''' + #: Coordinate notation. ``'absolute'`` or ``'incremental'``. Absolute mode is universally used today. Incremental + #: (relative) mode is technically still supported, but exceedingly rare in the wild. notation : str = 'absolute' + #: Export unit. :py:attr:`~.utilities.MM` or :py:attr:`~.utilities.Inch` unit : LengthUnit = MM + #: Angle unit. Should be ``'degree'`` unless you really know what you're doing. angle_unit : str = 'degree' + #: Zero suppression settings. See note at :py:class:`.FileSettings` for meaning. zeros : bool = None + #: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec. number_format : tuple = (2, 5) # input validation @@ -64,6 +70,7 @@ class FileSettings: super().__setattr__(name, value) def to_radian(self, value): + """ Convert a given numeric string or a given float from file units into radians. """ value = float(value) return math.radians(value) if self.angle_unit == 'degree' else value @@ -113,14 +120,15 @@ class FileSettings: return f'' @property - def incremental(self): + def is_incremental(self): return self.notation == 'incremental' @property - def absolute(self): + def is_absolute(self): return not self.incremental # default to absolute def parse_gerber_value(self, value): + """ Parse a numeric string in gerber format using this file's settings. """ if not value: return None @@ -155,7 +163,7 @@ class FileSettings: return out def write_gerber_value(self, value, unit=None): - """ Convert a floating point number to a Gerber/Excellon-formatted string. """ + """ Convert a floating point number to a Gerber-formatted string. """ if unit is not None: value = self.unit(value, unit) @@ -186,6 +194,7 @@ class FileSettings: return sign + (num or '0') def write_excellon_value(self, value, unit=None): + """ Convert a floating point number to an Excellon-formatted string. """ if unit is not None: value = self.unit(value, unit) @@ -241,6 +250,10 @@ class Polyline: class CamFile: + """ Base class for all layer classes (:py:class:`.GerberFile`, :py:class:`.ExcellonFile`, and :py:class:`.Netlist`). + + Provides some common functions such as :py:meth:`~.CamFile.to_svg`. + """ def __init__(self, original_path=None, layer_name=None, import_settings=None): self.original_path = original_path self.layer_name = layer_name @@ -319,13 +332,29 @@ class CamFile: root=True) def size(self, unit=MM): + """ Get the dimensions of the file's axis-aligned bounding box, i.e. the difference in x- and y-direction + between the minimum x and y coordinates and the maximum x and y coordinates. + + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to return results in. Default: mm + :returns: ``(w, h)`` tuple of floats. + :rtype: tuple + """ + (x0, y0), (x1, y1) = self.bounding_box(unit, default=((0, 0), (0, 0))) return (x1 - x0, y1 - y0) def bounding_box(self, unit=MM, default=None): - """ Calculate bounding box of file. Returns value given by 'default' argument when there are no graphical - objects (default: None) + """ Calculate the axis-aligned bounding box of file. Returns value given by the ``default`` argument when the + file is empty. This file calculates the accurate bounding box, even for features such as arcs. + + .. note:: Gerbonara returns bounding boxes as a ``(bottom_left, top_right)`` tuple of points, not in the + ``((min_x, max_x), (min_y, max_y))`` format used by pcb-tools. + + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to return results in. Default: mm + :returns: ``((x_min, y_min), (x_max, y_max))`` tuple of floats. + :rtype: tuple """ + bounds = [ p.bounding_box(unit) for p in self.objects ] if not bounds: return default @@ -335,10 +364,71 @@ class CamFile: max_x = max(x1 for (x0, y0), (x1, y1) in bounds) max_y = max(y1 for (x0, y0), (x1, y1) in bounds) - #for p in self.objects: - # bb = (o_min_x, o_min_y), (o_max_x, o_max_y) = p.bounding_box(unit) - # if o_min_x == min_x or o_min_y == min_y or o_max_x == max_x or o_max_y == max_y: - # print('\033[91m bounds\033[0m', bb, p) - return ((min_x, min_y), (max_x, max_y)) + def to_excellon(self): + """ Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """ + raise NotImplementedError() + + def to_gerber(self): + """ Convert to a :py:class:`.GerberFile`. Returns ``self`` if it already is one. """ + raise NotImplementedError() + + def merge(self, other): + """ Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets + :py:attr:`.import_settings` and :py:attr:`~.CamFile.generator`. Units and other file-specific settings are + automatically handled. + """ + raise NotImplementedError() + + @property + def generator(self): + """ Return our best guess as to which software produced this file. + + :returns: a str like ``'kicad'`` or ``'allegro'`` + """ + raise NotImplementedError() + + def offset(self, x=0, y=0, unit=MM): + """ Add a coordinate offset to this file. The offset is given in Gerber/Excellon coordinates, so the Y axis + points upwards. Gerbonara does not use the poorly-supported Gerber file offset options, but instead actually + changes the coordinates of every object in the file. This means that you can load the generated file with any + Gerber viewer, and things should just work. + + :param float x: X offset + :param float y: Y offset + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit ``x`` and ``y`` are passed in. Default: mm + """ + raise NotImplementedError() + + def rotate(self, angle, cx=0, cy=0, unit=MM): + """ Apply a rotation to this file. The center of rotation is given in Gerber/Excellon coordinates, so the Y axis + points upwards. Gerbonara does not use the poorly-supported Gerber file rotation options, but instead actually + changes the coordinates and rotation of every object in the file. This means that you can load the generated + file with any Gerber viewer, and things should just work. + + Note that when rotating certain apertures, they will be automatically converted to aperture macros during export + since the standard apertures do not support rotation by spec. This is the same way most CAD packages deal with + this issue so it should work with most Gerber viewers. + + :param float angle: Rotation angle in radians, *clockwise*. + :param float cx: Center of rotation X coordinate + :param float cy: Center of rotation Y coordinate + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit ``cx`` and ``cy`` are passed in. Default: mm + """ + raise NotImplementedError() + + @property + def is_empty(self): + """ Check if there are any objects in this file. """ + raise NotImplementedError() + + def __len__(self): + """ Return the number of objects in this file. Note that a e.g. a long trace or a long slot consisting of + multiple segments is counted as one object per segment. Gerber regions are counted as only one object. """ + raise NotImplementedError() + + def __bool__(self): + """ Test if this file contains any objects """ + raise NotImplementedError() + diff --git a/gerbonara/excellon.py b/gerbonara/excellon.py index a6e9566..575e4a2 100755 --- a/gerbonara/excellon.py +++ b/gerbonara/excellon.py @@ -1,19 +1,21 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - +# # Copyright 2014 Hamilton Kibbe - +# Copyright 2022 Jan Götte +# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at - +# # http://www.apache.org/licenses/LICENSE-2.0 - +# # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# import math import operator @@ -32,6 +34,8 @@ from .apertures import ExcellonTool from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher class ExcellonContext: + """ Internal helper class used for tracking graphics state when writing Excellon. """ + def __init__(self, settings, tools): self.settings = settings self.tools = tools @@ -41,6 +45,7 @@ class ExcellonContext: self.drill_down = False def select_tool(self, tool): + """ Select the current tool. Retract drill first if necessary. """ if self.current_tool != tool: if self.drill_down: yield 'M16' # drill up @@ -50,6 +55,7 @@ class ExcellonContext: yield f'T{self.tools[id(tool)]:02d}' def drill_mode(self): + """ Enter drill mode. """ if self.mode != ProgramState.DRILLING: self.mode = ProgramState.DRILLING if self.drill_down: @@ -58,6 +64,7 @@ class ExcellonContext: yield 'G05' # drill mode def route_mode(self, unit, x, y): + """ Enter route mode and plunge tool at the given coordinates. """ x, y = self.settings.unit(x, unit), self.settings.unit(y, unit) if self.mode == ProgramState.ROUTING and (self.x, self.y) == (x, y): @@ -74,9 +81,12 @@ class ExcellonContext: self.x, self.y = x, y def set_current_point(self, unit, x, y): + """ Update internal last point """ self.x, self.y = self.settings.unit(x, unit), self.settings.unit(y, unit) def parse_allegro_ncparam(data, settings=None): + """ Internal function to parse Excellon format information out of Allegro's nonstandard textual parameter files that + it generates along with the Excellon file. """ # This function parses data from allegro's nc_param.txt and ncdrill.log files. We have to parse these files because # allegro Excellon files omit crucial information such as the *number format*. nc_param.txt really is the file we # want to parse, but sometimes due to user error it doesn't end up in the gerber package. In this case, we want to @@ -125,6 +135,8 @@ def parse_allegro_ncparam(data, settings=None): def parse_allegro_logfile(data): + """ Internal function to parse Excellon format information out of Allegro's nonstandard textual log files that it + generates along with the Excellon file. """ found_tools = {} unit = None @@ -150,6 +162,18 @@ def parse_allegro_logfile(data): return found_tools class ExcellonFile(CamFile): + """ Excellon drill file. + + An Excellon file can contain both drills and milled slots. Drills are represented by :py:class:`.Flash` instances + with their aperture set to the special :py:class:`.ExcellonDrill` aperture class. Drills can be plated or nonplated. + This information is stored in the :py:class:`.ExcellonTool`. Both can co-exist in the same file, and some CAD tools + even export files like this. :py:class:`.LayerStack` contains functions to convert between a single drill file with + mixed plated and nonplated holes and one with separate drill files for each. Best practice is to have separate drill + files for slots, nonplated holes, and plated holes, because the board house will produce all three in three separate + processes anyway, and also because there is no standardized way to represent plating in Excellon files. Gerbonara + uses Altium's convention for this, which uses a magic comment before the tool definition. + """ + def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None): super().__init__(original_path=original_path) self.objects = objects or [] @@ -177,21 +201,26 @@ class ExcellonFile(CamFile): @property def is_plated(self): + """ Test if *all* holes or slots in this file are plated. """ return all(obj.plated for obj in self.objects) @property def is_nonplated(self): + """ Test if *all* holes or slots in this file are non-plated. """ return all(obj.plated == False for obj in self.objects) # False, not None @property def is_plating_unknown(self): + """ Test if *all* holes or slots in this file have no known plating. """ return all(obj.plated is None for obj in self.objects) # False, not None @property def is_mixed_plating(self): + """ Test if there are multiple plating values used in this file. """ return len({obj.plated for obj in self.objects}) > 1 def append(self, obj_or_comment): + """ Add a :py:class:`.GraphicObject` or a comment (str) to this file. """ if isinstnace(obj_or_comment, str): self.comments.append(obj_or_comment) else: @@ -228,6 +257,23 @@ class ExcellonFile(CamFile): @classmethod def open(kls, filename, plated=None, settings=None): + """ Load an Excellon file from the file system. + + Certain CAD tools do not put any information on decimal points into the actual excellon file, and instead put + that information into a non-standard text file next to the excellon file. Using :py:meth:`~.ExcellonFile.open` + to open a file gives Gerbonara the opportunity to try to find this data. In contrast to pcb-tools, Gerbonara + will raise an exception instead of producing garbage parsing results if it cannot determine the file format + parameters with certainty. + + .. note:: This is preferred over loading Excellon from a str through :py:meth:`~.ExcellonFile.from_string`. + + :param filename: ``str`` or ``pathlib.Path``. + :param bool plated: If given, set plating status of any tools in this file that have undefined plating. This is + useful if you already know that this file contains only e.g. plated holes from contextual information + such as the file name. + :param FileSettings settings: Format settings to use. If None, try to auto-detect file settings. + """ + filename = Path(filename) logfile_tools = None @@ -250,13 +296,19 @@ class ExcellonFile(CamFile): @classmethod def from_string(kls, data, settings=None, filename=None, plated=None, logfile_tools=None): + """ Parse the given string as an Excellon file. Note that often, Excellon files do not contain any information + on which number format (integer/decimal places, zeros suppression) is used. In case Gerbonara cannot determine + this with certainty, this function *will* error out. Use :py:meth:`~.ExcellonFile.open` if you want Gerbonara to + parse this metadata from the non-standardized text files many CAD packages produce in addition to drill files. + """ + parser = ExcellonParser(settings, logfile_tools=logfile_tools) parser.do_parse(data, filename=filename) return kls(objects=parser.objects, comments=parser.comments, import_settings=settings, generator_hints=parser.generator_hints, original_path=filename) def _generate_statements(self, settings, drop_comments=True): - + """ Export this file as Excellon code, yields one str per line. """ yield '; XNC file generated by gerbonara' if self.comments and not drop_comments: yield '; Comments found in original file:' @@ -296,8 +348,17 @@ class ExcellonFile(CamFile): yield 'M30' def generate_excellon(self, settings=None, drop_comments=True): - ''' Export to Excellon format. This function always generates XNC, which is a well-defined subset of Excellon. - ''' + """ Export to Excellon format. This function always generates XNC, which is a well-defined subset of Excellon. + Uses sane default settings if you don't give any. + + + :param bool drop_comments: If true, do not write comments to output file. This defaults to true because + otherwise there is a risk that Gerbonara does not consider some obscure magic comment semantically + meaningful while some other Excellon viewer might still parse it. + + :rtype: str + """ + if settings is None: if self.import_settings: settings = self.import_settings.copy() @@ -308,6 +369,8 @@ class ExcellonFile(CamFile): return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)) def save(self, filename, settings=None, drop_comments=True): + """ Save this Excellon file to the file system. See :py:meth:`~.ExcellonFile.generate_excellon` for the meaning + of the arguments. """ with open(filename, 'w') as f: f.write(self.generate_excellon(settings, drop_comments=drop_comments)) @@ -322,18 +385,6 @@ class ExcellonFile(CamFile): for obj in self.objects: obj.rotate(angle, cx, cy, unit=unit) - @property - def has_mixed_plating(self): - return len(set(obj.plated for obj in self.objects)) > 1 - - @property - def is_plated(self): - return all(obj.plated for obj in self.objects) - - @property - def is_nonplated(self): - return not any(obj.plated for obj in self.objects) - @property def is_empty(self): return not self.objects @@ -342,6 +393,15 @@ class ExcellonFile(CamFile): return len(self.objects) def split_by_plating(self): + """ Split this file into two :py:class:`.ExcellonFile` instances, one containing all plated objects, and one + containing all nonplated objects. In this function, objects with undefined plating are considered nonplated. + + .. note:: This does not copy the objects, so modifications in either of the returned files may clobber the + original file. + + :returns: (nonplated_file, plated_file) + :rtype: tuple + """ plated = ExcellonFile( comments = self.comments.copy(), import_settings = self.import_settings.copy(), @@ -356,15 +416,18 @@ class ExcellonFile(CamFile): return nonplated, plated - def path_lengths(self, unit): + def path_lengths(self, unit=MM): """ Calculate path lengths per tool. - Returns: dict { tool: float(path length) } - This function only sums actual cut lengths, and ignores travel lengths that the tool is doing without cutting to get from one object to another. Travel lengths depend on the CAM program's path planning, which highly depends on panelization and other factors. Additionally, an EDA tool will not even attempt to minimize travel distance as that's not its job. + + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit to use for return value. Default: mm + + :returns: ``{ tool: float(path length) }`` + :rtype dict: """ lengths = {} tool = None @@ -377,31 +440,42 @@ class ExcellonFile(CamFile): return lengths def hit_count(self): + """ Calculate the number of objects per tool. + + :rtype: collections.Counter + """ return Counter(obj.tool for obj in self.objects) - def drill_sizes(self): - return sorted({ obj.tool.diameter for obj in self.objects }) + def drill_sizes(self, unit=MM): + """ Return a sorted list of all tool diameters found in this file. + + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit to use for return values. Default: mm + + :returns: list of floats, sorted smallest to largest diameter. + :rtype: list + """ + # use equivalent_width for unit conversion + return sorted({ obj.tool.equivalent_width(unit) for obj in self.objects }) def drills(self): + """ Return all drilled hole objects in this file. + + :returns: list of :py:class:`.Flash` instances + :rtype: list + """ return (obj for obj in self.objects if isinstance(obj, Flash)) def slots(self): - return (obj for obj in self.objects if not isinstance(obj, Flash)) + """ Return all milled slot objects in this file. - @property - def bounds(self): - if not self.objects: - return None - - (x_min, y_min), (x_max, y_max) = self.objects[0].bounding_box() - for obj in self.objects: - (obj_x_min, obj_y_min), (obj_x_max, obj_y_max) = self.objects[0].bounding_box() - x_min, y_min = min(x_min, obj_x_min), min(y_min, obj_y_min) - x_max, y_max = max(x_max, obj_x_max), max(y_max, obj_y_max) + :returns: list of :py:class:`~.graphic_objects.Line` or :py:class:`~.graphic_objects.Arc` instances + :rtype: list + """ + return (obj for obj in self.objects if not isinstance(obj, Flash)) - return ((x_min, y_min), (x_max, y_max)) class ProgramState(Enum): + """ Internal helper class used to track Excellon program state (i.e. G05/G06 command state). """ HEADER = 0 DRILLING = 1 ROUTING = 2 @@ -409,6 +483,8 @@ class ProgramState(Enum): class ExcellonParser(object): + """ Internal helper class that contains all the actual Excellon format parsing logic. """ + def __init__(self, settings=None, logfile_tools=None): # NOTE XNC files do not contain an explicit number format specification, but all values have decimal points. # Thus, we set the default number format to (None, None). If the file does not contain an explicit specification @@ -634,7 +710,7 @@ class ExcellonParser(object): old_pos = self.pos - if self.settings.absolute: + if self.settings.is_absolute: if x is not None: self.pos = (x, self.pos[1]) if y is not None: diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index c29db5c..cf4260f 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -1,3 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# import math import copy diff --git a/gerbonara/graphic_primitives.py b/gerbonara/graphic_primitives.py index 4d81792..6e785d9 100644 --- a/gerbonara/graphic_primitives.py +++ b/gerbonara/graphic_primitives.py @@ -1,3 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# import math import itertools diff --git a/gerbonara/ipc356.py b/gerbonara/ipc356.py index dc3773b..3e6998c 100644 --- a/gerbonara/ipc356.py +++ b/gerbonara/ipc356.py @@ -3,6 +3,7 @@ # # copyright 2014 Hamilton Kibbe # Modified from parser.py by Paulo Henrique Silva +# Copyright 2022 Jan Götte # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +16,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# from dataclasses import dataclass import math diff --git a/gerbonara/layer_rules.py b/gerbonara/layer_rules.py index a042050..7d61170 100644 --- a/gerbonara/layer_rules.py +++ b/gerbonara/layer_rules.py @@ -1,4 +1,22 @@ -# From https://github.com/tracespace/tracespace +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Based on https://github.com/tracespace/tracespace +# MATCH_RULES = { 'altium': { diff --git a/gerbonara/layers.py b/gerbonara/layers.py index fcaf0fc..970b214 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Hamilton Kibbe -# Copyright 2021 Jan Götte +# Copyright 2022 Jan Götte # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# import os import re diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index dbda955..3dd8bb7 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -4,7 +4,7 @@ # Modified from parser.py by Paulo Henrique Silva # Copyright 2014 Hamilton Kibbe # Copyright 2019 Hiroshi Murayama -# Copyright 2021 Jan Götte +# Copyright 2022 Jan Götte # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,8 +17,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -""" This module provides an RS-274-X class and parser. -""" +# import re import math @@ -46,9 +45,7 @@ def points_close(a, b): return math.isclose(a[0], b[0]) and math.isclose(a[1], b[1]) class GerberFile(CamFile): - """ A class representing a single gerber file - - The GerberFile class represents a single gerber file. + """ A single gerber file. """ def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None, @@ -81,7 +78,6 @@ class GerberFile(CamFile): return def merge(self, other): - """ Merge other GerberFile into this one """ if other is None: return @@ -127,6 +123,7 @@ class GerberFile(CamFile): seen_macro_names.add(new_name) def dilate(self, offset, unit=MM, polarity_dark=True): + # TODO add tests for this self.apertures = [ aperture.dilated(offset, unit) for aperture in self.apertures ] offset_circle = CircleAperture(offset, unit=unit) @@ -154,6 +151,17 @@ class GerberFile(CamFile): @classmethod def open(kls, filename, enable_includes=False, enable_include_dir=None): + """ Load a Gerber file from the file system. The Gerber standard contains this wonderful and totally not + insecure "include file" setting. We disable it by default and do not parse Gerber includes because a) nobody + actually uses them, and b) they're a bad idea from a security point of view. In case you actually want these, + you can enable them by setting ``enable_includes=True``. + + :param filename: str or :py:class:`pathlib.Path` + :param bool enable_includes: Enable Gerber ``IF`` statement includes (default *off*, recommended *off*) + :param enable_include_dir: str or :py:class:`pathlib.Path`. Override base dir for include files. + + :rtype: :py:class:`.GerberFile` + """ filename = Path(filename) with open(filename, "r") as f: if enable_includes and enable_include_dir is None: @@ -162,12 +170,15 @@ class GerberFile(CamFile): @classmethod def from_string(kls, data, enable_include_dir=None, filename=None): + """ Parse given string as Gerber file content. For the meaning of the parameters, see + :py:meth:`~.GerberFile.open`. """ # filename arg is for error messages obj = kls() GerberParser(obj, include_dir=enable_include_dir).parse(data, filename=filename) return obj - def generate_statements(self, settings, drop_comments=True): + def _generate_statements(self, settings, drop_comments=True): + """ Export this file as Gerber code, yields one str per line. """ yield 'G04 Gerber file generated by Gerbonara*' for name, value in self.file_attrs.items(): attrdef = ','.join([name, *map(str, value)]) @@ -222,17 +233,27 @@ class GerberFile(CamFile): return f'' def save(self, filename, settings=None, drop_comments=True): + """ Save this Gerber file to the file system. See :py:meth:`~.GerberFile.generate_gerber` for the meaning + of the arguments. """ with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec. f.write(self.generate_gerber(settings, drop_comments=drop_comments)) def generate_gerber(self, settings=None, drop_comments=True): - # Use given settings, or use same settings as original file if not given, or use defaults if not imported from a - # file + """ Export to Gerber format. Uses either the file's original settings or sane default settings if you don't give + any. + + :param FileSettings settings: override export settings. + :param bool drop_comments: If true, do not write comments to output file. This defaults to true because + otherwise there is a risk that Gerbonara does not consider some obscure magic comment semantically + meaningful while some other Excellon viewer might still parse it. + + :rtype: str + """ if settings is None: settings = self.import_settings.copy() or FileSettings() settings.zeros = None settings.number_format = (5,6) - return '\n'.join(self.generate_statements(settings, drop_comments=drop_comments)) + return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)) @property def is_empty(self): @@ -250,15 +271,6 @@ class GerberFile(CamFile): obj.with_offset(dx, dy, unit) def rotate(self, angle:'radian', center=(0,0), unit=MM): - """ Rotate file contents around given point. - - Arguments: - angle -- Rotation angle in radian clockwise. - center -- Center of rotation (default: document origin (0, 0)) - - Note that when rotating by odd angles other than 0, 90, 180 or 270 degree this method may replace standard - rect and oblong apertures by macro apertures. Existing macro apertures are re-written. - """ if math.isclose(angle % (2*math.pi), 0): return @@ -271,11 +283,14 @@ class GerberFile(CamFile): obj.rotate(angle, *center, unit) def invert_polarity(self): + """ Invert the polarity (color) of each object in this file. """ for obj in self.objects: obj.polarity_dark = not p.polarity_dark class GraphicsState: + """ Internal class used to track Gerber processing state during import and export. """ + def __init__(self, warn, file_settings=None, aperture_map=None): self.image_polarity = 'positive' # IP image polarity; deprecated self.polarity_dark = True @@ -502,6 +517,8 @@ class GraphicsState: class GerberParser: + """ Internal class that contains all of the actual Gerber parsing magic. """ + NUMBER = r"[\+-]?\d+" DECIMAL = r"[\+-]?\d+([.]?\d+)?" NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+" diff --git a/gerbonara/tests/image_support.py b/gerbonara/tests/image_support.py index 926e91d..33705f3 100644 --- a/gerbonara/tests/image_support.py +++ b/gerbonara/tests/image_support.py @@ -1,3 +1,23 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Based on https://github.com/tracespace/tracespace +# + import subprocess from pathlib import Path import tempfile diff --git a/gerbonara/tests/resources/fritzing/combined.gbs b/gerbonara/tests/resources/fritzing/combined.gbs index 72ac193..0f02f96 100644 --- a/gerbonara/tests/resources/fritzing/combined.gbs +++ b/gerbonara/tests/resources/fritzing/combined.gbs @@ -1,4 +1,4 @@ -G04 file was processed by a buggy GerberTools version. +G04 file was processed by a buggy GerberTools version.* G04 file manually fixed for GerberTools #86 / #143* %MOIN*% %OFA0B0*% diff --git a/gerbonara/tests/resources/open_outline_altium.gbr b/gerbonara/tests/resources/open_outline_altium.gbr index dc5a1d6..ce20053 100644 --- a/gerbonara/tests/resources/open_outline_altium.gbr +++ b/gerbonara/tests/resources/open_outline_altium.gbr @@ -1,4 +1,4 @@ -G04 From https://github.com/tracespace/tracespace/issues/365 +G04 From https://github.com/tracespace/tracespace/issues/365* G04 Generated by Cuprum (2.1.4) at 2021-06-09T12:46:32+02:00* %FSLAX66Y66*% %MOMM*% diff --git a/gerbonara/tests/test_excellon.py b/gerbonara/tests/test_excellon.py index 6267f65..2d2b32a 100644 --- a/gerbonara/tests/test_excellon.py +++ b/gerbonara/tests/test_excellon.py @@ -1,6 +1,21 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- -# Author: Jan Götte +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import math import pytest diff --git a/gerbonara/tests/test_ipc356.py b/gerbonara/tests/test_ipc356.py index a49b243..1090554 100644 --- a/gerbonara/tests/test_ipc356.py +++ b/gerbonara/tests/test_ipc356.py @@ -1,7 +1,22 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- +# +# Copyright 2015 Hamilton Kibbe +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# -# Author: Hamilton Kibbe import pytest from ..ipc356 import * diff --git a/gerbonara/tests/test_layers.py b/gerbonara/tests/test_layers.py index 3f72cd8..795af25 100644 --- a/gerbonara/tests/test_layers.py +++ b/gerbonara/tests/test_layers.py @@ -1,7 +1,7 @@ #! /usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright 2021 Jan Götte +# Copyright 2022 Jan Götte # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# from pathlib import Path @@ -274,6 +275,22 @@ REFERENCE_DIRS = { 'NCDrill/ThruHolePlated.ncd': 'drill plated', }, + 'zuken': { + '': 'mechanical outline', + 'Gerber/DrillDrawingThrough.gdo': None, + 'Gerber/EtchLayerBottom.gdo': 'bottom copper', + 'Gerber/EtchLayerTop.gdo': 'top copper', + 'Gerber/GerberPlot.gpf': None, + 'Gerber/PCB.dsn': None, + 'Gerber/SolderPasteBottom.gdo': 'bottom paste', + 'Gerber/SolderPasteTop.gdo': 'top paste', + 'Gerber/SoldermaskBottom.gdo': 'bottom mask', + 'Gerber/SoldermaskTop.gdo': 'top mask', + 'NCDrill/ContourPlated.ncd': 'mechanical outline', + 'NCDrill/ThruHoleNonPlated.ncd': 'drill nonplated', + 'NCDrill/ThruHolePlated.ncd': 'drill plated', + }, + 'upverter': { 'design_export.drl': 'drill unknown', 'design_export.gbl': 'bottom copper', diff --git a/gerbonara/tests/test_rs274x.py b/gerbonara/tests/test_rs274x.py index 9beaa7b..eb3ed48 100644 --- a/gerbonara/tests/test_rs274x.py +++ b/gerbonara/tests/test_rs274x.py @@ -1,6 +1,23 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- -# Author: Jan Götte +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Based on https://github.com/tracespace/tracespace +# + import math from PIL import Image diff --git a/gerbonara/tests/test_utils.py b/gerbonara/tests/test_utils.py index 6f243c8..eca58b1 100644 --- a/gerbonara/tests/test_utils.py +++ b/gerbonara/tests/test_utils.py @@ -1,8 +1,20 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- # -# Author: Hamilton Kibbe -# Author: Jan Götte +# Copyright 2015 Hamilton Kibbe +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. # import pytest diff --git a/gerbonara/tests/utils.py b/gerbonara/tests/utils.py index a3da40b..d78f257 100644 --- a/gerbonara/tests/utils.py +++ b/gerbonara/tests/utils.py @@ -1,3 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2022 Jan Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# import pytest import functools diff --git a/gerbonara/utils.py b/gerbonara/utils.py index d1f9d61..1a92116 100644 --- a/gerbonara/utils.py +++ b/gerbonara/utils.py @@ -1,26 +1,28 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - +# # Copyright 2014 Hamilton Kibbe - +# Copyright 2022 Jan Götte +# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at - +# # http://www.apache.org/licenses/LICENSE-2.0 - +# # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# + """ gerber.utils ============ **Gerber and Excellon file handling utilities** -This module provides utility functions for working with Gerber and Excellon -files. +This module provides utility functions for working with Gerber and Excellon files. """ import os -- cgit