summaryrefslogtreecommitdiff
path: root/gerbonara/cam.py
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
committerjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
commitc3ca4f95bd59f69d45e582a4149327f57a360760 (patch)
tree5f43c61a261698e2f671b5238a7aa9a71a0f6d23 /gerbonara/cam.py
parent259a56186820923c78a5688f59bd8249cf958b5f (diff)
downloadgerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.gz
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.bz2
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.zip
Rename gerbonara/gerber package to just gerbonara
Diffstat (limited to 'gerbonara/cam.py')
-rw-r--r--gerbonara/cam.py302
1 files changed, 302 insertions, 0 deletions
diff --git a/gerbonara/cam.py b/gerbonara/cam.py
new file mode 100644
index 0000000..ccf1d2a
--- /dev/null
+++ b/gerbonara/cam.py
@@ -0,0 +1,302 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
+#
+# 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
+from copy import deepcopy
+from enum import Enum
+import string
+
+from .utils import LengthUnit, MM, Inch, Tag
+from . import graphic_primitives as gp
+from . import graphic_objects as go
+
+@dataclass
+class FileSettings:
+ '''
+ .. 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'`
+ '''
+ notation : str = 'absolute'
+ unit : LengthUnit = MM
+ angle_unit : str = 'degree'
+ zeros : bool = None
+ number_format : tuple = (2, 5)
+
+ # input validation
+ def __setattr__(self, name, value):
+ if name == 'unit' and value not in [MM, Inch]:
+ raise ValueError(f'Unit must be either Inch or MM, not {value}')
+ elif name == 'notation' and value not in ['absolute', 'incremental']:
+ raise ValueError(f'Notation must be either "absolute" or "incremental", not {value}')
+ elif name == 'angle_unit' and value not in ('degree', 'radian'):
+ raise ValueError(f'Angle unit may be "degree" or "radian", not {value}')
+ elif name == 'zeros' and value not in [None, 'leading', 'trailing']:
+ raise ValueError(f'zeros must be either "leading" or "trailing" or None, not {value}')
+ elif name == 'number_format':
+ if len(value) != 2:
+ raise ValueError(f'Number format must be a (integer, fractional) tuple of integers, not {value}')
+
+ if value != (None, None) and (value[0] > 6 or value[1] > 7):
+ raise ValueError(f'Requested precision of {value} is too high. Only up to 6.7 digits are supported by spec.')
+
+
+ super().__setattr__(name, value)
+
+ def to_radian(self, value):
+ value = float(value)
+ return math.radians(value) if self.angle_unit == 'degree' else value
+
+ def parse_ipc_length(self, value, default=None):
+ if value is None or not str(value).strip():
+ return default
+
+ if isinstance(value, str) and value[0].isalpha():
+ value = value[1:]
+
+ value = int(value)
+ value *= 0.0001 if self.is_inch else 0.001
+ return value
+
+ def format_ipc_number(self, value, digits, key='', sign=False):
+ if value is None:
+ return ' ' * (digits + int(bool(sign)) + len(key))
+
+ if isinstance(value, Enum):
+ value = value.value
+ num = format(round(value), f'{"+" if sign else ""}0{digits+int(bool(sign))}d')
+
+ if len(num) > digits + int(bool(sign)):
+ raise ValueError('Error: Number {num} to wide for IPC-356 field of width {digits}')
+
+ return key + num
+
+ def format_ipc_length(self, value, digits, key='', unit=None, sign=False):
+ if value is not None:
+ value = self.unit(value, unit)
+ value /= 0.0001 if self.is_inch else 0.001
+
+ return self.format_ipc_number(value, digits, key, sign=sign)
+
+ @property
+ def is_metric(self):
+ return self.unit == MM
+
+ @property
+ def is_inch(self):
+ return self.unit == Inch
+
+ def copy(self):
+ return deepcopy(self)
+
+ def __str__(self):
+ return f'<File settings: unit={self.unit}/{self.angle_unit} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
+
+ @property
+ def incremental(self):
+ return self.notation == 'incremental'
+
+ @property
+ def absolute(self):
+ return not self.incremental # default to absolute
+
+ def parse_gerber_value(self, value):
+ if not value:
+ return None
+
+ # Handle excellon edge case with explicit decimal. "That was easy!"
+ if '.' in value:
+ return float(value)
+
+ # TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
+ if int(value) == 0:
+ return 0
+
+ # Format precision
+ integer_digits, decimal_digits = self.number_format
+ if integer_digits is None or decimal_digits is None:
+ raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
+ 'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
+ 'it, because Allegro does not include this critical information in their Excellon output. If you '
+ 'call this through ExcellonFile.from_string, you must manually supply from_string with a '
+ 'FileSettings object from excellon.parse_allegro_ncparam.')
+
+ # Remove extraneous information
+ sign = '-' if value[0] == '-' else ''
+ value = value.lstrip('+-')
+
+ if self.zeros == 'leading':
+ value = '0'*decimal_digits + value # pad with zeros to ensure we have enough decimals
+ out = float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:])
+
+ else: # no or trailing zero suppression
+ value = value + '0'*integer_digits
+ out = float(sign + value[:integer_digits] + '.' + value[integer_digits:])
+ return out
+
+ def write_gerber_value(self, value, unit=None):
+ """ Convert a floating point number to a Gerber/Excellon-formatted string. """
+
+ if unit is not None:
+ value = self.unit(value, unit)
+
+ integer_digits, decimal_digits = self.number_format
+ if integer_digits is None:
+ integer_digits = 3
+ if decimal_digits is None:
+ decimal_digits = 3
+
+ # negative sign affects padding, so deal with it at the end...
+ sign = '-' if value < 0 else ''
+
+ # FIXME never use exponential notation here
+ num = format(abs(value), f'0{integer_digits+decimal_digits+1}.{decimal_digits}f').replace('.', '')
+
+ # Suppression...
+ if self.zeros == 'trailing':
+ num = num.rstrip('0')
+
+ elif self.zeros == 'leading':
+ num = num.lstrip('0')
+
+ # Edge case. Per Gerber spec if the value is 0 we should return a single '0' in all cases, see page 77.
+ elif not num.strip('0'):
+ num = '0'
+
+ return sign + (num or '0')
+
+ def write_excellon_value(self, value, unit=None):
+ if unit is not None:
+ value = self.unit(value, unit)
+
+ integer_digits, decimal_digits = self.number_format
+ if integer_digits is None:
+ integer_digits = 2
+ if decimal_digits is None:
+ decimal_digits = 6
+
+ return format(value, f'0{integer_digits+decimal_digits+1}.{decimal_digits}f')
+
+
+class CamFile:
+ def __init__(self, original_path=None, layer_name=None, import_settings=None):
+ self.original_path = original_path
+ self.layer_name = layer_name
+ self.import_settings = import_settings
+
+ def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white'):
+
+ if force_bounds is None:
+ (min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
+ else:
+ (min_x, min_y), (max_x, max_y) = force_bounds
+ min_x = svg_unit(min_x, arg_unit)
+ min_y = svg_unit(min_y, arg_unit)
+ max_x = svg_unit(max_x, arg_unit)
+ max_y = svg_unit(max_y, arg_unit)
+
+ content_min_x, content_min_y = min_x, min_y
+ content_w, content_h = max_x - min_x, max_y - min_y
+ if margin:
+ margin = svg_unit(margin, arg_unit)
+ min_x -= margin
+ min_y -= margin
+ max_x += margin
+ max_y += margin
+
+ w, h = max_x - min_x, max_y - min_y
+ w = 1.0 if math.isclose(w, 0.0) else w
+ h = 1.0 if math.isclose(h, 0.0) else h
+
+ view = tag('sodipodi:namedview', [], id='namedview1', pagecolor=bg,
+ inkscape__document_units=svg_unit.shorthand)
+
+ tags = []
+ polyline = None
+ for i, obj in enumerate(self.objects):
+ #if isinstance(obj, go.Flash):
+ # if polyline:
+ # tags.append(polyline.to_svg(tag, fg, bg))
+ # polyline = None
+
+ # mask_tags = [ prim.to_svg(tag, 'white', 'black') for prim in obj.to_primitives(unit=svg_unit) ]
+ # mask_tags.insert(0, tag('rect', width='100%', height='100%', fill='black'))
+ # mask_id = f'mask{i}'
+ # tag('mask', mask_tags, id=mask_id)
+ # tag('rect', width='100%', height='100%', mask='url(#{mask_id})', fill=fg)
+
+ #else:
+ for primitive in obj.to_primitives(unit=svg_unit):
+ if isinstance(primitive, gp.Line):
+ if not polyline:
+ polyline = gp.Polyline(primitive)
+ else:
+ if not polyline.append(primitive):
+ tags.append(polyline.to_svg(tag, fg, bg))
+ polyline = gp.Polyline(primitive)
+ else:
+ if polyline:
+ tags.append(polyline.to_svg(tag, fg, bg))
+ polyline = None
+ tags.append(primitive.to_svg(tag, fg, bg))
+ if polyline:
+ tags.append(polyline.to_svg(tag, fg, bg))
+
+ # setup viewport transform flipping y axis
+ xform = f'translate({content_min_x} {content_min_y+content_h}) scale(1 -1) translate({-content_min_x} {-content_min_y})'
+
+ svg_unit = 'in' if svg_unit == 'inch' else 'mm'
+ # TODO export apertures as <uses> where reasonable.
+ return tag('svg', [view, tag('g', tags, transform=xform)],
+ width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
+ viewBox=f'{min_x} {min_y} {w} {h}',
+ xmlns="http://www.w3.org/2000/svg",
+ xmlns__xlink="http://www.w3.org/1999/xlink",
+ xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
+ xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape',
+ root=True)
+
+ def size(self, unit=MM):
+ (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)
+ """
+ bounds = [ p.bounding_box(unit) for p in self.objects ]
+ if not bounds:
+ return default
+
+ min_x = min(x0 for (x0, y0), (x1, y1) in bounds)
+ min_y = min(y0 for (x0, y0), (x1, y1) in bounds)
+ 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))
+