diff --git a/.gitmodules b/.gitmodules index 3f055139..e3303f75 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "submodules/KiCost"] path = submodules/KiCost url = https://github.com/hildogjr/KiCost.git +[submodule "kibot/PcbDraw/resources/footprints"] + path = kibot/PcbDraw/resources/footprints + url = https://github.com/yaqwsx/PcbDraw-Lib.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1759ac7f..3cbb3959 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,6 +13,7 @@ exclude: experiments/.*| submodules/.*| kibot/PyPDF2/.*| + kibot/PcbDraw/.*| tests/yaml_samples/simple_position_csv_pre.kibot.yaml )$ repos: diff --git a/README.md b/README.md index 594ba593..128f554c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![PyPI version](https://img.shields.io/pypi/v/kibot?style=plastic)](https://pypi.org/project/kibot/) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?style=plastic)](https://www.paypal.com/donate/?hosted_button_id=K2T86GDTTMRPL) -# **This is the documentation for the current development KiBot, not yet released.** +# **This is the documentation for KiBot v1.4.0 for the current development read [here](https://github.com/INTI-CMNB/KiBot/tree/dev).** **Important for CI/CD**: @@ -142,18 +142,13 @@ Notes: - Mandatory for `kicost` - Optional to find components costs and specs for `bom` -[**PcbDraw**](https://github.com/INTI-CMNB/pcbdraw) v0.9.0.3 (<1.0) [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://github.com/INTI-CMNB/pcbdraw) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) -- Mandatory for `pcbdraw` -- Optional to create realistic solder masks for `pcb_print` -- Note: Currently the upstream version is broken, please use the mentioned fork - [**Interactive HTML BoM**](https://github.com/INTI-CMNB/InteractiveHtmlBom) v2.4.1.4 [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://github.com/INTI-CMNB/InteractiveHtmlBom) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) - Mandatory for `ibom` [**KiBoM**](https://github.com/INTI-CMNB/KiBoM) v1.8.0 [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://github.com/INTI-CMNB/KiBoM) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) - Mandatory for `kibom` -[**KiCad PCB/SCH Diff**](https://github.com/INTI-CMNB/KiDiff) v2.4.2 [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://github.com/INTI-CMNB/KiDiff) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) +[**KiCad PCB/SCH Diff**](https://github.com/INTI-CMNB/KiDiff) v2.4.3 [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://github.com/INTI-CMNB/KiDiff) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) - Mandatory for `diff` [**LXML**](https://pypi.org/project/LXML/) [![Python module](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/Python-logo-notext-22x22.png)](https://pypi.org/project/LXML/) [![Debian](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/debian-openlogo-22x22.png)](https://packages.debian.org/bullseye/python3-lxml) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) @@ -177,23 +172,25 @@ Notes: - Create outputs preview for `navigate_results` - Create PNG icons for `navigate_results` - Create PDF, PNG, PS and EPS formats for `pcb_print` - - Create PNG and JPG images for `pcbdraw` - -[**ImageMagick**](https://imagemagick.org/) [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://imagemagick.org/) [![Debian](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/debian-openlogo-22x22.png)](https://packages.debian.org/bullseye/imagemagick) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) -- Optional to: - - Create outputs preview for `navigate_results` - - Create monochrome prints and scaled PNG files for `pcb_print` - - Create JPG images for `pcbdraw` [**Ghostscript**](https://www.ghostscript.com/) [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://www.ghostscript.com/) [![Debian](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/debian-openlogo-22x22.png)](https://packages.debian.org/bullseye/ghostscript) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) - Optional to: - Create outputs preview for `navigate_results` - Create PNG, PS and EPS formats for `pcb_print` +[**ImageMagick**](https://imagemagick.org/) [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://imagemagick.org/) [![Debian](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/debian-openlogo-22x22.png)](https://packages.debian.org/bullseye/imagemagick) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) +- Optional to: + - Create outputs preview for `navigate_results` + - Create monochrome prints and scaled PNG files for `pcb_print` + [**Pandoc**](https://pandoc.org/) [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://pandoc.org/) [![Debian](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/debian-openlogo-22x22.png)](https://packages.debian.org/bullseye/pandoc) - Optional to create PDF/ODF/DOCX files for `report` - Note: In CI/CD environments: the `kicad_auto_test` docker image contains it. +[**PcbDraw**](https://github.com/INTI-CMNB/pcbdraw) v0.9.0.3 (<1.0) [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://github.com/INTI-CMNB/pcbdraw) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) +- Optional to create realistic solder masks for `pcb_print` +- Note: Currently the upstream version is broken, please use the mentioned fork + [**RAR**](https://www.rarlab.com/) [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://www.rarlab.com/) [![Debian](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/debian-openlogo-22x22.png)](https://packages.debian.org/bullseye/rar) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) - Optional to compress in RAR format for `compress` @@ -1746,6 +1743,7 @@ Notes: Use `multivar` to specify a reference file when `new_type` is also `multivar`. - `only_different`: [boolean=false] Only include the pages with differences in the output PDF. Note that when no differeces are found we get a page saying *No diff*. + - `only_first_sch_page`: [boolean=false] Compare only the main schematic page (root page). - `pcb`: [boolean=true] Compare the PCB, otherwise compare the schematic. - `threshold`: [number=0] [0,1000000] Error threshold for the `stats` mode, 0 is no error. When specified a difference bigger than the indicated value will make the diff fail. @@ -2566,7 +2564,7 @@ Notes: - **`mirror`**: [boolean=false] Mirror the board. - **`output`**: [string='%f-%i%I%v.%x'] Name for the generated file. Affected by global options. - **`show_components`**: [list(string)|string=none] [none,all] List of components to draw, can be also a string for none or all. - The default is none. + The default is none. IMPORTANT! This option is relevant only when no filters or variants are applied. - **`style`**: [string|dict] PCB style (colors). An internal name, the name of a JSON file or the style options. * Valid keys: - **`board`**: [string='#208b47'] Color for the board without copper (covered by solder mask). diff --git a/docs/samples/generic_plot.kibot.yaml b/docs/samples/generic_plot.kibot.yaml index 84a5abc1..d5ade69e 100644 --- a/docs/samples/generic_plot.kibot.yaml +++ b/docs/samples/generic_plot.kibot.yaml @@ -533,6 +533,8 @@ outputs: # [boolean=false] Only include the pages with differences in the output PDF. # Note that when no differeces are found we get a page saying *No diff* only_different: false + # [boolean=false] Compare only the main schematic page (root page) + only_first_sch_page: false # [string='%f-%i%I%v.%x'] Filename for the output (%i=diff_pcb/diff_sch, %x=pdf). Affected by global options output: '%f-%i%I%v.%x' # [boolean=true] Compare the PCB, otherwise compare the schematic @@ -1374,7 +1376,7 @@ outputs: # [dict|None] Replacements for PCB references using components (lib:component) remap: # [list(string)|string=none] [none,all] List of components to draw, can be also a string for none or all. - # The default is none + # The default is none. IMPORTANT! This option is relevant only when no filters or variants are applied show_components: none # [string|dict] PCB style (colors). An internal name, the name of a JSON file or the style options style: diff --git a/kibot/PcbDraw/README.md b/kibot/PcbDraw/README.md new file mode 100644 index 00000000..2d9a40fc --- /dev/null +++ b/kibot/PcbDraw/README.md @@ -0,0 +1,68 @@ +# PcbDraw code + +## Why? + +- Important dependency: PcbDraw is currently a core functionality of KiBot because its used for the `pcb_print` output +- Increased number of dependencies: The upstream code pulls too much dependencies, some of them optional, others that we don't need. + This is a constant problem. +- Incompatible interface and behavior: This should be fixed now that 1.0.0 is out, but I don't agree with the idea of doing small + changes just because they look more elegant. +- Now integrable: This is one of the changes in 1.0.0, now the code is easier to call as module. +- Repeated functionality: The `render` stuff is already implemented by KiAuto. + +## Details + +Currently only the `plot` module is included. + +### convert.py + +- Made the `pcbdraw` import relative + +### convert_common.py + +No current changes + +### convert_unix.py + +- Made the `pcbdraw` import relative + +### convert_windows.py + +- Made the `pcbdraw` import relative + +### unit.py + +No current changes + +### plot.py + +- Made the `pcbdraw` import relative +- Disabled `shrink_svg` + - Changes the old behavior, so this should be optional + - Pulls a problematic dependency: svgpathtool +- Changed calls to `ComputeBoundingBox()` to use `aBoardEdgesOnly=True` + - To get the same behavior as 0.9.0-5 + - This changes the size of the SVG to the size of the board + - `shrink_svg` must be disabled or it reverts the size to the detected +- Added `no_warn_back` option to disable warnings on the opposite side + +```diff +@@ -813,6 +813,7 @@ + highlight: Callable[[str], bool] = lambda x: False # References to highlight + remapping: Callable[[str, str, str], Tuple[str, str]] = lambda ref, lib, name: (lib, name) + resistor_values: Dict[str, ResistorValue] = field(default_factory=dict) ++ no_warn_back: bool = False + + def render(self, plotter: PcbPlotter) -> None: + self._plotter = plotter +@@ -848,7 +849,8 @@ + else: + ret = self._create_component(lib, name, ref, value) + if ret is None: +- self._plotter.yield_warning("component", f"Component {lib}:{name} has not footprint.") ++ if name[-5:] != '.back' or not self.no_warn_back: ++ self._plotter.yield_warning("component", f"Component {lib}:{name} has not footprint.") + return + component_element, component_info = ret + self._used_components[unique_name] = component_info +``` diff --git a/kibot/PcbDraw/convert.py b/kibot/PcbDraw/convert.py new file mode 100644 index 00000000..ba856a69 --- /dev/null +++ b/kibot/PcbDraw/convert.py @@ -0,0 +1,96 @@ +# Author: Jan Mrázek +# License: MIT +import platform +import subprocess +import textwrap +import os +from typing import Union +from tempfile import TemporaryDirectory +from PIL import Image +from lxml.etree import _ElementTree # type: ignore + +# Converting SVG to bitmap is a hard problem. We used Wand (and thus +# imagemagick) to do the conversion. However, imagemagick is really hard to +# configure properly and it breaks often. Therefore, we provide a custom module +# that has several conversion strategies that reflect the platform. We also try +# to provide descriptive enough message so the user can detect what is wrong. + +if platform.system() == "Windows": + from .convert_windows import detectInkscape +else: + from .convert_unix import detectInkscape, rsvgSvgToPng + +def inkscapeSvgToPng(inputFilename: str, outputFilename: str, dpi: int) -> None: + """ + A strategy to convert an SVG file into a PNG file using Inkscape + """ + command = [detectInkscape(), "--export-type=png", f"--export-dpi={dpi}", + f"--export-filename={outputFilename}", inputFilename] + def reportError(message: str) -> None: + raise RuntimeError(f"Cannot convert {inputFilename} to {outputFilename}. Inkscape failed with:\n" + + textwrap.indent(message, " ")) + try: + r = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output = r.stdout.decode("utf-8") + "\n" + r.stderr.decode("utf-8") + # Inkscape doesn't respect error codes + if "Can't open file" in output: + reportError(output) + except subprocess.CalledProcessError as e: + output = e.stdout.decode("utf-8") + "\n" + e.stderr.decode("utf-8") + reportError(output) + +def svgToPng(inputFilename: str, outputFilename: str, dpi: int=300) -> None: + """ + Convert SVG file into a PNG file based on platform-dependent strategies + """ + if platform.system() == "Windows": + strategies = { + "Inkscape": inkscapeSvgToPng + } + else: + strategies = { + "RSVG": rsvgSvgToPng, # We prefer it over Inkscape as it is much faster + "Inkscape": inkscapeSvgToPng + } + + errors = {} + for name, strategy in strategies.items(): + try: + strategy(inputFilename, outputFilename, dpi) + return + except Exception as e: + errors[name] = str(e) + message = "Cannot convert PNG to SVG; all strategies failed:\n" + for name, error in errors.items(): + m = f"- Strategy '{name}' failed with: {textwrap.indent(error, ' ')}\n" + message += textwrap.indent(m, " ") + raise RuntimeError(message) + +def save(image: Union[_ElementTree, Image.Image], filename: str, dpi: int=600) -> None: + """ + Given an SVG tree or an image, save to a filename. The format is deduced + from the extension. + """ + ftype = os.path.splitext(filename)[1][1:].lower() + if isinstance(image, Image.Image): + if ftype not in ["jpg", "jpeg", "png", "bmp"]: + raise TypeError(f"Cannot save bitmap image into {ftype}") + image.save(filename) + return + if isinstance(image, _ElementTree): + if ftype == "svg": + image.write(filename) + return + with TemporaryDirectory() as d: + svg_filename = os.path.join(d, "image.svg") + if ftype == "png": + png_filename = filename + else: + png_filename = os.path.join(d, "image.png") + image.write(svg_filename) + svgToPng(svg_filename, png_filename, dpi=dpi) + if ftype == "png": + return + Image.open(png_filename).convert("RGB").save(filename) + return + raise TypeError(f"Unknown image type: {type(image)}") diff --git a/kibot/PcbDraw/convert_common.py b/kibot/PcbDraw/convert_common.py new file mode 100644 index 00000000..253275c8 --- /dev/null +++ b/kibot/PcbDraw/convert_common.py @@ -0,0 +1,29 @@ +# Author: Jan Mrázek +# License: MIT +import subprocess +from typing import List + +def isValidInkscape(executable: str) -> bool: + try: + out = subprocess.check_output([executable, "--version"]).decode("utf-8") + parts = out.split(" ") + if parts[0] != "Inkscape": + return False + version = parts[1].split(".") + return int(version[0]) == 1 + except FileNotFoundError: + return False + except subprocess.CalledProcessError as e: + return False + +def chooseInkscapeCandidate(candidates: List[str]) -> str: + for candidate in candidates: + if isValidInkscape(candidate): + return candidate + raise RuntimeError("No Inkscape executable found. Please check:\n" + + "- if Inkscape is installed\n" + + "- if it is version at least 1.0\n" + + "If the conditions above are true, please ensure Inkscape is in PATH or\n" + + "ensure there is environmental variable 'PCBDRAW_INKSCAPE' pointing to the Inkscape executable\n\n" + + "Checked paths: \n" + + "\n".join([f"- {x}" for x in candidates])) diff --git a/kibot/PcbDraw/convert_unix.py b/kibot/PcbDraw/convert_unix.py new file mode 100644 index 00000000..1b656185 --- /dev/null +++ b/kibot/PcbDraw/convert_unix.py @@ -0,0 +1,37 @@ +# Author: Jan Mrázek +# License: MIT +import subprocess +import os +import textwrap +from .convert_common import chooseInkscapeCandidate + +def detectInkscape() -> str: + """ + Return path to working Inkscape >v1.0 executable + """ + candidates = [] + if "PCBDRAW_INKSCAPE" in os.environ: + # Ensure there is the .com extension needed for CLI interface + path = os.path.splitext(os.environ["PCBDRAW_INKSCAPE"])[0] + ".com" + candidates.append(path) + candidates.append("inkscape") # Inkscape in path + return chooseInkscapeCandidate(candidates) + +def rsvgSvgToPng(inputFilename: str, outputFilename: str, dpi: int) -> None: + tool = os.environ.get("PCBDRAW_RSVG", "rsvg-convert") + command = [tool, "--dpi-x", str(dpi), "--dpi-y", str(dpi), + "--output", outputFilename, "--format", "png", inputFilename] + def reportError(message: str) -> None: + raise RuntimeError(f"Cannot convert {inputFilename} to {outputFilename}. RSVG failed with:\n" + + textwrap.indent(message, " ")) + try: + r = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if r.returncode != 0: + output = r.stdout.decode("utf-8") + "\n" + r.stderr.decode("utf-8") + reportError(output) + except subprocess.CalledProcessError as e: + output = e.stdout.decode("utf-8") + "\n" + e.stderr.decode("utf-8") + reportError(output) + except FileNotFoundError: + reportError("rsvg-convert is not available. Please make sure it is installed.\n" + + f"It was executed via invoking '{tool}'") diff --git a/kibot/PcbDraw/convert_windows.py b/kibot/PcbDraw/convert_windows.py new file mode 100644 index 00000000..d3c130a1 --- /dev/null +++ b/kibot/PcbDraw/convert_windows.py @@ -0,0 +1,40 @@ +# Author: Jan Mrázek +# License: MIT +import os +# Reports false error on Linux as LnkParse3 is Windows-only dependency +import LnkParse3 # type: ignore +from typing import List +from .convert_common import chooseInkscapeCandidate + +def detectInkscape() -> str: + """ + Return path to working Inkscape >v1.0 executable + """ + candidates = [] + if "PCBDRAW_INKSCAPE" in os.environ: + # Ensure there is the .com extension needed for CLI interface + path = os.path.splitext(os.environ["PCBDRAW_INKSCAPE"])[0] + ".com" + candidates.append(path) + candidates.append("inkscape") # Inkscape in path + candidates += readInkscapeFromStartMenu() + + return chooseInkscapeCandidate(candidates) + +def readInkscapeFromStartMenu() -> List[str]: + candidates = [] + for profile in [os.environ.get("ALLUSERSPROFILE", ""), os.environ.get("USERPROFILE", "")]: + path = os.path.join(profile, "Microsoft", "Windows", "Start Menu", + "Programs", "Inkscape", "Inkscape.lnk") + try: + with open(path, "rb") as f: + lnk = LnkParse3.lnk_file(f) + abspath = os.path.realpath(lnk.string_data.relative_path()) + # The .com version provides CLI interface + abspath = os.path.splitext(abspath)[0] + ".com" + candidates.append(abspath) + except FileNotFoundError: + continue + return candidates + +if __name__ == "__main__": + print(detectInkscape()) diff --git a/kibot/PcbDraw/plot.py b/kibot/PcbDraw/plot.py new file mode 100644 index 00000000..af473cdb --- /dev/null +++ b/kibot/PcbDraw/plot.py @@ -0,0 +1,1266 @@ +#!/usr/bin/env python3 +# Author: Jan Mrázek +# License: MIT + +from __future__ import annotations + +import json +import math +import os +import re +import sysconfig +import tempfile +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Callable, Dict, List, Optional, Tuple, TypeVar, Union, Any + +import numpy as np +# We import the typing under try-catch to allow runtime for systems that have +# old Numpy that don't feature the numpy.typing module, but we want to preserve +# type checking. +try: + import numpy.typing + + # Note that we also have to define all the numpy-related types under the + # try-catch values as annotations can be ignored, but value can't + Matrix = np.typing.NDArray[np.float32] + +except ImportError: + pass +from .unit import read_resistance +# import svgpathtools # type: ignore +from lxml import etree, objectify # type: ignore +from pcbnewTransition import KICAD_VERSION, isV6, pcbnew # type: ignore + +T = TypeVar("T") +Numeric = Union[int, float] +Point = Tuple[Numeric, Numeric] +Box = Tuple[Numeric, Numeric, Numeric, Numeric] + + +PKG_BASE = os.path.dirname(__file__) + +etree.register_namespace("xlink", "http://www.w3.org/1999/xlink") + +default_style = { + "copper": "#417e5a", + "board": "#4ca06c", + "silk": "#f0f0f0", + "pads": "#b5ae30", + "outline": "#000000", + "clad": "#9c6b28", + "vcut": "#bf2600", + "paste": "#8a8a8a", + "highlight-on-top": False, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0, + "tht-resistor-band-colors": { + 0: '#000000', + 1: '#805500', + 2: '#ff0000', + 3: '#ff8000', + 4: '#ffff00', + 5: '#00cc11', + 6: '#0000cc', + 7: '#cc00cc', + 8: '#666666', + 9: '#cccccc', + '1%': '#805500', + '2%': '#ff0000', + '0.5%': '#00cc11', + '0.25%': '#0000cc', + '0.1%': '#cc00cc', + '0.05%': '#666666', + '5%': '#ffc800', + '10%': '#d9d9d9', + } +} + +float_re = r'([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)' + +class SvgPathItem: + def __init__(self, path: str) -> None: + path = re.sub(r"([MLA])(-?\d+)", r"\1 \2", path) + path_elems = re.split("[, ]", path) + path_elems = list(filter(lambda x: x, path_elems)) + if path_elems[0] != "M": + raise SyntaxError("Only paths with absolute position are supported") + self.start: Point = tuple(map(float, path_elems[1:3])) # type: ignore + self.end: Point = (0, 0) + self.args: Optional[List[Numeric]] = None + path_elems = path_elems[3:] + if path_elems[0] == "L": + x = float(path_elems[1]) + y = float(path_elems[2]) + self.end = (x, y) + self.type = path_elems[0] + self.args = None + elif path_elems[0] == "A": + args = list(map(float, path_elems[1:8])) + self.end = (args[5], args[6]) + self.args = args[0:5] + self.type = path_elems[0] + else: + raise SyntaxError("Unsupported path element " + path_elems[0]) + + @staticmethod + def is_same(p1: Point, p2: Point) -> bool: + dx = p1[0] - p2[0] + dy = p1[1] - p2[1] + return math.sqrt(dx*dx+dy*dy) < 100 + + def format(self, first: bool) -> str: + ret = "" + if first: + ret += " M {} {} ".format(*self.start) + ret += self.type + if self.args: + ret += " " + " ".join(map(lambda x: str(x).rstrip('0').rstrip('.'), self.args)) + ret += " {} {} ".format(*self.end) + return ret + + def flip(self) -> None: + self.start, self.end = self.end, self.start + if self.type == "A": + assert(self.args is not None) + self.args[4] = 1 if self.args[4] < 0.5 else 0 + +def matrix(data: List[List[Numeric]]) -> Matrix: + return np.array(data, dtype=np.float32) + +def pseudo_distance(a: Point, b: Point) -> Numeric: + return (a[0] - b[0])**2 + (a[1] - b[1])**2 + +def distance(a: Point, b: Point) -> Numeric: + return math.sqrt((a[0] - b[0])**2 + (a[1] - b[1])**2) + +def get_closest(reference: Point, elems: List[Point]) -> int: + distances = [pseudo_distance(reference, x) for x in elems] + return int(np.argmin(distances)) + +def extract_arg(args: List[Any], index: int, default: Any=None) -> Any: + """ + Return n-th element of array or default if out of range + """ + if index >= len(args): + return default + return args[index] + +def to_trans_matrix(transform: str) -> Matrix: + """ + Given SVG transformation string returns corresponding matrix + """ + m = matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + if transform is None: + return m + trans = re.findall(r'[a-z]+?\(.*?\)', transform) + for t in trans: + op, args = t.split('(') + args = [float(x) for x in re.findall(float_re, args)] + if op == 'matrix': + m = np.matmul(m, matrix([ + [args[0], args[2], args[4]], + [args[1], args[3], args[5]], + [0, 0, 1]])) + if op == 'translate': + x = args[0] + y = extract_arg(args, 1, 0) + m = np.matmul(m, matrix([ + [1, 0, x], + [0, 1, y], + [0, 0, 1]])) + if op == 'scale': + x = args[0] + y = extract_arg(args, 1, 1) + m = np.matmul(m, matrix([ + [x, 0, 0], + [0, y, 0], + [0, 0, 1]])) + if op == 'rotate': + cosa: float = math.cos(math.radians(args[0])) + sina: float = math.sin(math.radians(args[0])) + if len(args) != 1: + x, y = args[1:3] + m = np.matmul(m, matrix([ + [1, 0, x], + [0, 1, y], + [0, 0, 1]])) + m = np.matmul(m, matrix([ + [cosa, -sina, 0], + [sina, cosa, 0], + [0, 0, 1]])) + if len(args) != 1: + m = np.matmul(m, matrix([ + [1, 0, -x], + [0, 1, -y], + [0, 0, 1]])) + tana: float = math.tan(math.radians(args[0])) + if op == 'skewX': + m = np.matmul(m, matrix([ + [1, tana, 0], + [0, 1, 0], + [0, 0, 1]])) + if op == 'skewY': + m = np.matmul(m, matrix([ + [1, 0, 0], + [tana, 1, 0], + [0, 0, 1]])) + return m + +def collect_transformation(element: etree.Element, root: Optional[etree.Element]=None) -> Matrix: + """ + Collect all the transformation applied to an element and return it as matrix + """ + if root is None: + if element.getparent() is not None: + m = collect_transformation(element.getparent(), root) + else: + m = matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + else: + if element.getparent() != root: + m = collect_transformation(element.getparent(), root) + else: + m = matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + if "transform" not in element.attrib: + return m + trans = element.attrib["transform"] + # There is a strange typing behavior in CI, ignore it at the moment + return np.matmul(m, to_trans_matrix(trans)) # type: ignore + +def element_position(element: etree.Element, root: Optional[etree.Element]=None) -> Point: + position = matrix([ + [element.attrib["x"]], + [element.attrib["y"]], + [1]]) + r = root + trans = collect_transformation(element, root=r) + position = np.matmul(trans, position) + return position[0][0] / position[2][0], position[1][0] / position[2][0] + +def get_global_datapaths() -> List[str]: + paths = [] + share = os.path.join('share', 'pcbdraw') + scheme_names = sysconfig.get_scheme_names() + if os.name == 'posix': + if 'posix_user' in scheme_names: + paths.append(os.path.join(sysconfig.get_path('data', 'posix_user'), share)) + if 'posix_prefix' in scheme_names: + paths.append(os.path.join(sysconfig.get_path('data', 'posix_prefix'), share)) + elif os.name == 'nt': + if 'nt_user' in scheme_names: + paths.append(os.path.join(sysconfig.get_path('data', 'nt_user'), share)) + if 'nt' in scheme_names: + paths.append(os.path.join(sysconfig.get_path('data', 'nt'), share)) + if len(paths) == 0: + paths.append(os.path.join(sysconfig.get_path('data'), share)) + return paths + +def find_data_file(name: str, extension: str, data_paths: List[str], subdir: Optional[str]=None) -> Optional[str]: + if not name.endswith(extension): + name += extension + if os.path.isfile(name): + return name + for path in data_paths: + if subdir is not None: + fname = os.path.join(path, subdir, name) + if os.path.isfile(fname): + return fname + fname = os.path.join(path, name) + if os.path.isfile(fname): + return fname + return None + +def ki2dmil(val: int) -> float: + return val // 2540 + +def dmil2ki(val: float) -> int: + return int(val * 2540) + +def ki2mm(val: int) -> float: + return val / 1000000.0 + +def mm2ki(val: float) -> int: + return int(val * 1000000) + +# KiCAD 5 and KiCAD 6 use different units of the SVG +ki2svg: Callable[[int], float] = (lambda x: int(x)) if isV6(KICAD_VERSION) else ki2dmil +svg2ki: Callable[[float], int] = (lambda x: int(x)) if isV6(KICAD_VERSION) else dmil2ki + +def to_kicad_basic_units(val: str) -> int: + """ + Read string value and return it as KiCAD base units + """ + x = float_re + r'\s*(pt|pc|mm|cm|in)?' + value, unit = re.findall(x, val)[0] + value = float(value) + if unit == "" or unit == "px": + return mm2ki(value * 25.4 / 96) + if unit == "pt": + return mm2ki(value * 25.4 / 72) + if unit == "pc": + return mm2ki(value * 25.4 / 6) + if unit == "mm": + return mm2ki(value) + if unit == "cm": + return mm2ki(value * 10) + if unit == "in": + return mm2ki(25.4 * value) + raise RuntimeError(f"Unknown units in '{val}'") + +def to_user_units(val: str) -> float: + x = float_re + r'\s*(pt|pc|mm|cm|in)?' + value_str, unit = re.findall(x, val)[0] + value = float(value_str) + if unit == "" or unit == "px": + return value + if unit == "pt": + return 1.25 * value + if unit == "pc": + return 15 * value + if unit == "mm": + return 3.543307 * value + if unit == "cm": + return 35.43307 * value + if unit == "in": + return 90 + raise RuntimeError(f"Unknown units in '{val}'") + + +def make_XML_identifier(s: str) -> str: + """ + Given a name, strip invalid characters from XML identifier + """ + s = re.sub('[^0-9a-zA-Z_]', '', s) + s = re.sub('^[^a-zA-Z_]+', '', s) + return s + +def read_svg_unique(filename: str, prefix: str) -> etree.Element: + root, _ = read_svg_unique2(filename, prefix) + return root + +def read_svg_unique2(filename: str, prefix: str) -> etree.Element: + root = etree.parse(filename).getroot() + # We have to ensure all Ids in SVG are unique. Let's make it nasty by + # collecting all ids and doing search & replace + # Potentially dangerous (can break user text) + ids = [] + for el in root.getiterator(): + if "id" in el.attrib and el.attrib["id"] != "origin": + ids.append(el.attrib["id"]) + with open(filename) as f: + content = f.read() + for i in ids: + content = content.replace("#"+i, "#" + prefix + i) + root = etree.fromstring(str.encode(content)) + for el in root.getiterator(): + if "id" in el.attrib and el.attrib["id"] != "origin": + el.attrib["id"] = prefix + el.attrib["id"] + return root, prefix + +def extract_svg_content(root: etree.Element) -> List[etree.Element]: + # Remove SVG namespace to ease our lives and change ids + for el in root.getiterator(): + if '}' in str(el.tag): + el.tag = el.tag.split('}', 1)[1] + return [ x for x in root if x.tag and x.tag not in ["title", "desc"]] + +def strip_style_svg(root: etree.Element, keys: List[str], forbidden_colors: List[str]) -> bool: + elements_to_remove = [] + for el in root.getiterator(): + if "style" in el.attrib: + s = el.attrib["style"].strip().split(";") + styles = {} + for x in s: + if len(x) == 0: + continue + key, val = tuple(x.split(":")) + key = key.strip() + val = val.strip() + styles[key] = val + fill = styles.get("fill", "").lower() + stroke = styles.get("stroke", "").lower() + if fill in forbidden_colors or stroke in forbidden_colors: + elements_to_remove.append(el) + el.attrib["style"] = ";" \ + .join([f"{key}: {val}" for key, val in styles.items() if key not in keys]) \ + .replace(" ", " ") \ + .strip() + for el in elements_to_remove: + el.getparent().remove(el) + return root in elements_to_remove + +def empty_svg(**attrs: str) -> etree.ElementTree: + document = etree.ElementTree(etree.fromstring( + """ + + + Picture generated by PcbDraw + Picture generated by PcbDraw + """)) + root = document.getroot() + for key, value in attrs.items(): + root.attrib[key] = value + return document + +def get_board_polygon(svg_elements: etree.Element) -> etree.Element: + """ + Try to connect independents segments on Edge.Cuts and form a polygon + return SVG path element with the polygon + """ + elements = [] + path = "" + for group in svg_elements: + for svg_element in group: + if svg_element.tag == "path": + elements.append(SvgPathItem(svg_element.attrib["d"])) + elif svg_element.tag == "circle": + # Convert circle to path + att = svg_element.attrib + s = " M {0} {1} m-{2} 0 a {2} {2} 0 1 0 {3} 0 a {2} {2} 0 1 0 -{3} 0 ".format( + att["cx"], att["cy"], att["r"], 2 * float(att["r"])) + path += s + while len(elements) > 0: + # Initiate seed for the outline + outline = [elements[0]] + elements = elements[1:] + size = 0 + # Append new segments to the ends of outline until there is none to append. + while size != len(outline) and len(elements) > 0: + size = len(outline) + + i = get_closest(outline[0].start, [x.end for x in elements]) + if SvgPathItem.is_same(outline[0].start, elements[i].end): + outline.insert(0, elements[i]) + del elements[i] + continue + + i = get_closest(outline[0].start, [x.start for x in elements]) + if SvgPathItem.is_same(outline[0].start, elements[i].start): + e = elements[i] + e.flip() + outline.insert(0, e) + del elements[i] + continue + + i = get_closest(outline[-1].end, [x.start for x in elements]) + if SvgPathItem.is_same(outline[-1].end, elements[i].start): + outline.insert(0, elements[i]) + del elements[i] + continue + + i = get_closest(outline[-1].end, [x.end for x in elements]) + if SvgPathItem.is_same(outline[-1].end, elements[i].end): + e = elements[i] + e.flip() + outline.insert(0, e) + del elements[i] + continue + # ...then, append it to path. + first = True + for x in outline: + path += x.format(first) + first = False + e = etree.Element("path", d=path, style="fill-rule: evenodd;") + return e + +def component_to_board_scale_and_offset(svg: etree.Element) -> Tuple[float, float, float, float]: + width = ki2svg(to_kicad_basic_units(svg.attrib["width"])) + height = ki2svg(to_kicad_basic_units(svg.attrib["height"])) + x, y, vw, vh = [float(x) for x in svg.attrib["viewBox"].split()] + return width / vw, height / vh, x, y + +def load_style(style_file: str) -> Dict[str, Any]: + try: + with open(style_file, "r") as f: + style = json.load(f) + except IOError: + raise RuntimeError("Cannot open style " + style_file) + if not isinstance(style, dict): + raise RuntimeError("Stylesheet has to be a dictionary") + required = set(["copper", "board", "clad", "silk", "pads", "outline", + "vcut", "highlight-style", "highlight-offset", "highlight-on-top", + "highlight-padding"]) + missing = required - set(style.keys()) + if missing: + raise RuntimeError("Missing following keys in style {}: {}" + .format(style_file, ", ".join(missing))) + return style + +def load_remapping(remap_file: str) -> Dict[str, Tuple[str, str]]: + def readMapping(s: str) -> Tuple[str, str]: + x = s.split(":") + if len(x) != 2: + raise RuntimeError(f"Invalid remmaping value {s}") + return x[0], x[1] + if remap_file is None: + return {} + try: + with open(remap_file, "r") as f: + j = json.load(f) + if not isinstance(j, dict): + raise RuntimeError("Invalid format of remapping file") + return {ref: readMapping(val) for ref, val in j.items()} + except IOError: + raise RuntimeError("Cannot open remapping file " + remap_file) + +def merge_bbox(left: Box, right: Box) -> Box: + """ + Merge bounding boxes in format (xmin, xmax, ymin, ymax) + """ + return tuple([ + f(l, r) for l, r, f in zip(left, right, [min, max, min, max]) + ]) # type: ignore + +def hack_is_valid_bbox(box: Any): # type: ignore + return all(-1e15 < c < 1e15 for c in box) + +# def shrink_svg(svg: etree.ElementTree, margin: int) -> None: +# """ +# Shrink the SVG canvas to the size of the drawing. Add margin in +# KiCAD units. +# """ +# # We have to overcome the limitation of different base types between +# # PcbDraw and svgpathtools +# from xml.etree.ElementTree import fromstring as xmlParse +# +# from lxml.etree import tostring as serializeXml # type: ignore +# paths = svgpathtools.document.flattened_paths(xmlParse(serializeXml(svg))) +# +# if len(paths) == 0: +# return +# bbox = paths[0].bbox() +# for x in paths: +# b = x.bbox() +# if hack_is_valid_bbox(b): +# bbox = b +# break +# for x in paths: +# box = x.bbox() +# if not hack_is_valid_bbox(box): +# # This is a hack due to instability in svpathtools +# continue +# bbox = merge_bbox(bbox, box) +# bbox = list(bbox) +# bbox[0] -= ki2svg(margin) +# bbox[1] += ki2svg(margin) +# bbox[2] -= ki2svg(margin) +# bbox[3] += ki2svg(margin) +# +# root = svg.getroot() +# root.attrib["viewBox"] = "{} {} {} {}".format( +# bbox[0], bbox[2], +# bbox[1] - bbox[0], bbox[3] - bbox[2] +# ) +# root.attrib["width"] = str(ki2mm(svg2ki(bbox[1] - bbox[0]))) + "mm" +# root.attrib["height"] = str(ki2mm(svg2ki(bbox[3] - bbox[2]))) + "mm" + +def remove_empty_elems(tree: etree.Element) -> None: + """ + Given SVG tree, remove empty groups and defs + """ + for elem in tree: + remove_empty_elems(elem) + toDel = [] + for elem in tree: + if elem.tag in ["g", "defs"] and len(elem.getchildren()) == 0: + toDel.append(elem) + for elem in toDel: + tree.remove(elem) + +def remove_inkscape_annotation(tree: etree.Element) -> None: + for elem in tree: + remove_inkscape_annotation(elem) + for key in tree.attrib.keys(): + if "inkscape" in key: + tree.attrib.pop(key) + # Comments have callable tag... + if not callable(tree.tag): + objectify.deannotate(tree, cleanup_namespaces=True) + +@dataclass +class Hole: + position: Tuple[int, int] + orientation: int + drillsize: Tuple[int, int] + + def get_svg_path_d(self) -> str: + w, h = [ki2svg(x) for x in self.drillsize] + if w > h: + ew = w - h + eh = h + commands = f"M {-ew / 2} {-eh / 2} " + commands += f"A {eh / 2} {eh / 2} 0 1 1 {-ew / 2} {eh / 2} " + commands += f"L {ew / 2} {eh / 2} " + commands += f"A {eh / 2} {eh / 2} 0 1 1 {ew / 2} {-eh / 2} " + commands += f"Z" + return commands + else: + ew = w + eh = h - w + commands = f"M {-ew / 2} {eh / 2} " + commands += f"A {ew / 2} {ew / 2} 0 1 1 {ew / 2} {eh / 2} " + commands += f"L {ew / 2} {-eh / 2} " + commands += f"A {ew / 2} {ew / 2} 0 1 1 {-ew / 2} {-eh / 2} " + commands += f"Z" + return commands + +@dataclass +class PlotAction: + name: str + layers: List[int] + action: Callable[[str, str], None] + +@dataclass +class ResistorValue: + value: Optional[str] = None + flip_bands: bool=False + + +def collect_holes(board: pcbnew.BOARD) -> List[Hole]: + holes: List[Hole] = [] # Tuple: position, orientation, drillsize + for module in board.GetFootprints(): + if module.GetPadCount() == 0: + continue + for pad in module.Pads(): + pos = pad.GetPosition() + holes.append(Hole( + position=(pos[0], pos[1]), + orientation=pad.GetOrientation(), + drillsize=(pad.GetDrillSizeX(), pad.GetDrillSizeY()) + )) + for track in board.GetTracks(): + if not isinstance(track, pcbnew.PCB_VIA) or not isV6(KICAD_VERSION): + continue + pos = track.GetPosition() + holes.append(Hole( + position=(pos[0], pos[1]), + orientation=0, + drillsize=(track.GetDrillValue(), track.GetDrillValue()) + )) + return holes + + +class PlotInterface: + def render(self, plotter: PcbPlotter) -> None: + raise NotImplementedError("Plot interface wasn't implemented") + + +@dataclass +class PlotSubstrate(PlotInterface): + drill_holes: bool = True + outline_width: int = mm2ki(0.1) + + def render(self, plotter: PcbPlotter) -> None: + self._plotter = plotter # ...so we don't have to pass it explicitly + + to_plot: List[PlotAction] = [] + if plotter.render_back: + to_plot = [ + PlotAction("board", [pcbnew.Edge_Cuts], self._process_baselayer), + PlotAction("clad", [pcbnew.B_Mask], self._process_layer), + PlotAction("copper", [pcbnew.B_Cu], self._process_layer), + PlotAction("pads", [pcbnew.B_Cu], self._process_layer), + PlotAction("pads-mask", [pcbnew.B_Mask], self._process_mask), + PlotAction("silk", [pcbnew.B_SilkS], self._process_layer), + PlotAction("outline", [pcbnew.Edge_Cuts], self._process_outline) + ] + else: + to_plot = [ + PlotAction("board", [pcbnew.Edge_Cuts], self._process_baselayer), + PlotAction("clad", [pcbnew.F_Mask], self._process_layer), + PlotAction("copper", [pcbnew.F_Cu], self._process_layer), + PlotAction("pads", [pcbnew.F_Cu], self._process_layer), + PlotAction("pads-mask", [pcbnew.F_Mask], self._process_mask), + PlotAction("silk", [pcbnew.F_SilkS], self._process_layer), + PlotAction("outline", [pcbnew.Edge_Cuts], self._process_outline) + ] + + self._container = etree.Element("g", id="substrate") + self._container.attrib["clip-path"] = "url(#cut-off)" + self._boardsize = self._plotter.board.ComputeBoundingBox(aBoardEdgesOnly=True) + self._plotter.execute_plot_plan(to_plot) + + if self.drill_holes: + self._build_hole_mask() + self._container.attrib["mask"] = "url(#hole-mask)" + self._plotter.append_board_element(self._container) + + def _process_layer(self,name: str, source_filename: str) -> None: + layer = etree.SubElement(self._container, "g", id="substrate-" + name, + style="fill:{0}; stroke:{0};".format(self._plotter.get_style(name))) + if name == "pads": + layer.attrib["mask"] = "url(#pads-mask)" + if name == "silk": + layer.attrib["mask"] = "url(#pads-mask-silkscreen)" + for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())): + # Forbidden colors = workaround - KiCAD plots vias white + # See https://gitlab.com/kicad/code/kicad/-/issues/10491 + if not strip_style_svg(element, keys=["fill", "stroke"], + forbidden_colors=["#ffffff"]): + layer.append(element) + + def _process_outline(self, name: str, source_filename: str) -> None: + if self.outline_width == 0: + return + layer = etree.SubElement(self._container, "g", id="substrate-" + name, + style="fill:{0}; stroke:{0}; stroke-width: {1}".format( + self._plotter.get_style(name), + ki2svg(self.outline_width))) + if name == "pads": + layer.attrib["mask"] = "url(#pads-mask)" + if name == "silk": + layer.attrib["mask"] = "url(#pads-mask-silkscreen)" + for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())): + # Forbidden colors = workaround - KiCAD plots vias white + # See https://gitlab.com/kicad/code/kicad/-/issues/10491 + if not strip_style_svg(element, keys=["fill", "stroke", "stroke-width"], + forbidden_colors=["#ffffff"]): + layer.append(element) + for hole in collect_holes(self._plotter.board): + position = [ki2svg(coord) for coord in hole.position] + size = [ki2svg(coord) for coord in hole.drillsize] + if size[0] == 0 or size[1] == 0: + continue + el = etree.SubElement(layer, "path") + el.attrib["d"] = hole.get_svg_path_d() + el.attrib["transform"] = "translate({} {}) rotate({})".format( + position[0], position[1], -hole.orientation / 10) + + def _process_baselayer(self, name: str, source_filename: str) -> None: + clipPath = self._plotter.get_def_slot(tag_name="clipPath", id="cut-off") + clipPath.append( + get_board_polygon( + extract_svg_content( + read_svg_unique(source_filename, self._plotter.unique_prefix())))) + + layer = etree.SubElement(self._container, "g", id="substrate-"+name, + style="fill:{0}; stroke:{0};".format(self._plotter.get_style(name))) + layer.append( + get_board_polygon( + extract_svg_content( + read_svg_unique(source_filename, self._plotter.unique_prefix())))) + for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())): + # Forbidden colors = workaround - KiCAD plots vias white + # See https://gitlab.com/kicad/code/kicad/-/issues/10491 + if not strip_style_svg(element, keys=["fill", "stroke"], + forbidden_colors=["#ffffff"]): + layer.append(element) + + def _process_mask(self, name: str, source_filename: str) -> None: + mask = self._plotter.get_def_slot(tag_name="mask", id=name) + for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())): + for item in element.getiterator(): + if "style" in item.attrib: + # KiCAD plots in black, for mask we need white + item.attrib["style"] = item.attrib["style"].replace("#000000", "#ffffff") + mask.append(element) + silkMask = self._plotter.get_def_slot(tag_name="mask", id=f"{name}-silkscreen") + bg = etree.SubElement(silkMask, "rect", attrib={ + "x": str(ki2svg(self._boardsize.GetX())), + "y": str(ki2svg(self._boardsize.GetY())), + "width": str(ki2svg(self._boardsize.GetWidth())), + "height": str(ki2svg(self._boardsize.GetHeight())), + "fill": "white" + }) + for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())): + # KiCAD plots black, no need to change fill + silkMask.append(element) + + def _build_hole_mask(self) -> None: + mask = self._plotter.get_def_slot(tag_name="mask", id="hole-mask") + container = etree.SubElement(mask, "g") + + bb = self._plotter.board.ComputeBoundingBox(aBoardEdgesOnly=True) + bg = etree.SubElement(container, "rect", x="0", y="0", fill="white") + bg.attrib["x"] = str(ki2svg(bb.GetX())) + bg.attrib["y"] = str(ki2svg(bb.GetY())) + bg.attrib["width"] = str(ki2svg(bb.GetWidth())) + bg.attrib["height"] = str(ki2svg(bb.GetHeight())) + + for hole in collect_holes(self._plotter.board): + position = list(map(ki2svg, hole.position)) + size = list(map(ki2svg, hole.drillsize)) + if size[0] > 0 and size[1] > 0: + if size[0] < size[1]: + stroke = size[0] + length = size[1] - size[0] + points = "{} {} {} {}".format(0, -length / 2, 0, length / 2) + else: + stroke = size[1] + length = size[0] - size[1] + points = "{} {} {} {}".format(-length / 2, 0, length / 2, 0) + el = etree.SubElement(container, "polyline") + el.attrib["stroke-linecap"] = "round" + el.attrib["stroke"] = "black" + el.attrib["stroke-width"] = str(stroke) + el.attrib["points"] = points + el.attrib["transform"] = "translate({} {}) rotate({})".format( + position[0], position[1], -hole.orientation / 10) + +@dataclass +class PlacedComponentInfo: + id: str + origin: Tuple[float, float] + svg_offset: Tuple[float, float] + scale: Tuple[float, float] + size: Tuple[float, float] + +@dataclass +class PlotComponents(PlotInterface): + filter: Callable[[str], bool] = lambda x: True # Components to show + highlight: Callable[[str], bool] = lambda x: False # References to highlight + remapping: Callable[[str, str, str], Tuple[str, str]] = lambda ref, lib, name: (lib, name) + resistor_values: Dict[str, ResistorValue] = field(default_factory=dict) + no_warn_back: bool = False + + def render(self, plotter: PcbPlotter) -> None: + self._plotter = plotter + self._prefix = plotter.unique_prefix() + self._used_components: Dict[str, PlacedComponentInfo] = {} + plotter.walk_components(invert_side=False, callback=self._append_component) + plotter.walk_components(invert_side=True, callback=self._append_back_component) + + def _get_unique_name(self, lib: str, name: str, value: str) -> str: + return f"{self._prefix}_{lib}__{name}_{value}" + + def _append_back_component(self, lib: str, name: str, ref: str, value: str, + position: Tuple[int, int, float]) -> None: + return self._append_component(lib, name + ".back", ref, value, position) + + def _append_component(self, lib: str, name: str, ref: str, value: str, + position: Tuple[int, int, float]) -> None: + if not self.filter(ref) or name == "": + return + # Override resistor values + if ref in self.resistor_values: + v = self.resistor_values[ref].value + if v is not None: + value = v + + lib, name = self.remapping(ref, lib, name) + + unique_name = self._get_unique_name(lib, name, value) + if unique_name in self._used_components: + component_info = self._used_components[unique_name] + component_element = etree.Element("use", + attrib={"{http://www.w3.org/1999/xlink}href": "#" + component_info.id}) + else: + ret = self._create_component(lib, name, ref, value) + if ret is None: + if name[-5:] != '.back' or not self.no_warn_back: + self._plotter.yield_warning("component", f"Component {lib}:{name} has not footprint.") + return + component_element, component_info = ret + self._used_components[unique_name] = component_info + + self._plotter.append_component_element(etree.Comment(f"{lib}:{name}:{ref}")) + group = etree.Element("g") + group.append(component_element) + ci = component_info + group.attrib["transform"] = \ + f"translate({ki2svg(position[0])} {ki2svg(position[1])}) " + \ + f"scale({ci.scale[0]}, {ci.scale[1]}) " + \ + f"rotate({-math.degrees(position[2])}) " + \ + f"translate({-ci.origin[0]} {-ci.origin[1]})" + self._plotter.append_component_element(group) + + if self.highlight(ref): + self._build_highlight(ref, component_info, position) + + def _create_component(self, lib: str, name: str, ref: str, value: str) \ + -> Optional[Tuple[etree.Element, PlacedComponentInfo]]: + f = self._plotter._get_model_file(lib, name) + if f is None: + return None + xml_id = make_XML_identifier(self._get_unique_name(lib, name, value)) + component_element = etree.Element("g", attrib={"id": xml_id}) + + svg_tree, id_prefix = read_svg_unique2(f, self._plotter.unique_prefix()) + for x in extract_svg_content(svg_tree): + if x.tag in ["namedview", "metadata"]: + continue + component_element.append(x) + origin_x: Numeric = 0 + origin_y: Numeric = 0 + origin = component_element.find(".//*[@id='origin']") + if origin is not None: + origin_x, origin_y = element_position(origin, root=component_element) + origin.getparent().remove(origin) + else: + self._plotter.yield_warning("origin", f"component: Component {lib}:{name} has not origin") + svg_scale_x, svg_scale_y, svg_offset_x, svg_offset_y = component_to_board_scale_and_offset(svg_tree) + component_info = PlacedComponentInfo( + id=xml_id, + origin=(origin_x, origin_y), + svg_offset=(svg_offset_x, svg_offset_y), + scale=(svg_scale_x, svg_scale_y), + size=(to_kicad_basic_units(svg_tree.attrib["width"]), to_kicad_basic_units(svg_tree.attrib["height"])) + ) + self._apply_resistor_code(component_element, id_prefix, ref, value) + return component_element, component_info + + def _build_highlight(self, ref: str, info: PlacedComponentInfo, + position: Tuple[int, int, float]) -> None: + padding = mm2ki(self._plotter.get_style("highlight-padding")) + h = etree.Element("rect", id=f"h_{ref}", + x=str(ki2svg(-padding)), + y=str(ki2svg(-padding)), + width=str(ki2svg(int(info.size[0] + 2 * padding))), + height=str(ki2svg(int(info.size[1] + 2 * padding))), + style=self._plotter.get_style("highlight-style")) + h.attrib["transform"] = \ + f"translate({ki2svg(position[0])} {ki2svg(position[1])}) " + \ + f"rotate({-math.degrees(position[2])}) " + \ + f"translate({-(info.origin[0] - info.svg_offset[0]) * info.scale[0]}, {-(info.origin[1] - info.svg_offset[1]) * info.scale[1]})" + self._plotter.append_highlight_element(h) + + def _apply_resistor_code(self, root: etree.Element, id_prefix: str, ref: str, value: str) -> None: + if root.find(f".//*[@id='{id_prefix}res_band1']") is None: + return + try: + res, tolerance = self._get_resistance_from_value(value) + power = math.floor(res.log10()) - 1 + res = Decimal(int(res / 10 ** power)) + resistor_colors = [ + self._plotter.get_style("tht-resistor-band-colors", int(str(res)[0])), + self._plotter.get_style("tht-resistor-band-colors", int(str(res)[1])), + self._plotter.get_style("tht-resistor-band-colors", int(power)), + self._plotter.get_style("tht-resistor-band-colors", tolerance) + ] + + if ref in self.resistor_values: + if self.resistor_values[ref].flip_bands: + resistor_colors.reverse() + + for res_i, res_c in enumerate(resistor_colors): + band = root.find(f".//*[@id='{id_prefix}res_band{res_i+1}']") + s = band.attrib["style"].split(";") + for i in range(len(s)): + if s[i].startswith('fill:'): + s_split = s[i].split(':') + s_split[1] = res_c + s[i] = ':'.join(s_split) + elif s[i].startswith('display:'): + s_split = s[i].split(':') + s_split[1] = 'inline' + s[i] = ':'.join(s_split) + band.attrib["style"] = ";".join(s) + except UserWarning as e: + self._plotter.yield_warning("resistor", f"Cannot color-code resistor {ref}: {e}") + return + + def _get_resistance_from_value(self, value: str) -> Tuple[Decimal, str]: + res, tolerance = None, "5%" + value_l = value.split(" ", maxsplit=1) + try: + res = read_resistance(value_l[0]) + except ValueError: + raise UserWarning(f"Invalid resistor value {value_l[0]}") + if len(value_l) > 1: + t_string = value_l[1].strip().replace(" ", "") + if "%" in t_string: + s = self._plotter.get_style("tht-resistor-band-colors") + if not isinstance(s, dict): + raise RuntimeError(f"Invalid style specified, tht-resistor-band-colors should be dictionary, got {type(s)}") + if t_string.strip() not in s: + raise UserWarning(f"Invalid resistor tolerance {value_l[1]}") + tolerance = t_string + return res, tolerance + + +@dataclass +class PlotPlaceholders(PlotInterface): + def render(self, plotter: PcbPlotter) -> None: + self._plotter = plotter + plotter.walk_components(invert_side=False, callback=self._append_placeholder) + + def _append_placeholder(self, lib: str, name: str, ref: str, value: str, + position: Tuple[int, int, float]) -> None: + p = etree.Element("rect", + x=str(ki2svg(position[0] - mm2ki(0.5))), + y=str(ki2svg(position[1] - mm2ki(0.5))), + width=str(ki2svg(mm2ki(1))), height=str(ki2svg(mm2ki(1))), style="fill:red;") + self._plotter.append_component_element(p) + +@dataclass +class PlotVCuts(PlotInterface): + layer: int = pcbnew.Cmts_User + + def render(self, plotter: PcbPlotter) -> None: + self._plotter = plotter + self._plotter.execute_plot_plan([ + PlotAction("vcuts", [self.layer], self._process_vcuts) + ]) + + def _process_vcuts(self, name: str, source_filename: str) -> None: + layer = etree.Element("g", id="substrate-vcuts", + style="fill:{0}; stroke:{0};".format(self._plotter.get_style("vcut"))) + for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())): + # Forbidden colors = workaround - KiCAD plots vias white + # See https://gitlab.com/kicad/code/kicad/-/issues/10491 + if not strip_style_svg(element, keys=["fill", "stroke"], + forbidden_colors=["#ffffff"]): + layer.append(element) + self._plotter.append_board_element(layer) + +@dataclass +class PlotPaste(PlotInterface): + def render(self, plotter: PcbPlotter) -> None: + plan: List[PlotAction] = [] + if plotter.render_back: + plan = [PlotAction("paste", [pcbnew.B_Paste], self._process_paste)] + else: + plan = [PlotAction("paste", [pcbnew.F_Paste], self._process_paste)] + self._plotter = plotter + self._plotter.execute_plot_plan(plan) + + def _process_paste(self, name: str, source_filename: str) -> None: + layer = etree.Element("g", id="substrate-paste", + style="fill:{0}; stroke:{0};".format(self._plotter.get_style("paste"))) + for element in extract_svg_content(read_svg_unique(source_filename, self._plotter.unique_prefix())): + if not strip_style_svg(element, keys=["fill", "stroke"], + forbidden_colors=["#ffffff"]): + layer.append(element) + self._plotter.append_board_element(layer) + + +class PcbPlotter(): + """ + PcbPlotter encapsulates all the machinery with PcbDraw plotting of SVG. It + mainly serves as a builder (to step-by-step specify all options) and also to + avoid passing many arguments between auxiliary functions + """ + def __init__(self, boardFile: str): + self._unique_counter: int = 1 + try: + self.board: pcbnew.BOARD = pcbnew.LoadBoard(boardFile) + except IOError: + raise IOError(f"Cannot open board '{boardFile}'") from None + self.render_back: bool = False + self.mirror: bool = False + self.plot_plan: List[PlotInterface] = [ + PlotSubstrate(), + PlotComponents(), + ] + + self.data_path: List[str] = [] # Base paths for libraries lookup + self.libs: List[str] = [] # Names of available libraries + self._libs_path: List[str] = [] + self.style: Any = {} # Color scheme + self.margin: int = 0 # Margin of the resulting document + + self.yield_warning: Callable[[str, str], None] = lambda tag, msg: None # Handle warnings + + def plot(self) -> etree.ElementTree: + """ + Plot the board based on the arguments stored in this class. Returns + SVG tree that you can either save or post-process as you wish. + """ + self._build_libs_path() + self._setup_document(self.render_back, self.mirror) + for plotter in self.plot_plan: + plotter.render(self) + remove_empty_elems(self._document.getroot()) + remove_inkscape_annotation(self._document.getroot()) + # shrink_svg(self._document, self.margin) + return self._document + + + def walk_components(self, invert_side: bool, + callback: Callable[[str, str, str, str, Tuple[int, int, float]], None]) -> None: + """ + Invokes callback on all components in the board. The callback takes: + - library name of the component + - footprint name of the component + - reference of the component + - value of the component + - position of the component + + The position is adjusted based on what side we are rendering + """ + render_back = not self.render_back if invert_side else self.render_back + for footprint in self.board.GetFootprints(): + if (str(footprint.GetLayerName()) in ["Back", "B.Cu"] and not render_back) or \ + (str(footprint.GetLayerName()) in ["Top", "F.Cu"] and render_back): + continue + lib = str(footprint.GetFPID().GetLibNickname()).strip() + name = str(footprint.GetFPID().GetLibItemName()).strip() + value = footprint.GetValue().strip() + ref = footprint.GetReference().strip() + center = footprint.GetPosition() + orient = math.radians(footprint.GetOrientation() / 10) + pos = (center.x, center.y, orient) + callback(lib, name, ref, value, pos) + + def get_def_slot(self, tag_name: str, id: str) -> etree.SubElement: + """ + Creates a new definition slot and returns the tag + """ + return etree.SubElement(self._defs, tag_name, id=id) + + def append_board_element(self, element: etree.Element) -> None: + """ + Add new element into the board container + """ + self._board_cont.append(element) + + def append_component_element(self, element: etree.Element) -> None: + """ + Add new element into board container + """ + self._comp_cont.append(element) + + def append_highlight_element(self, element: etree.Element) -> None: + """ + Add new element into highlight container + """ + self._high_cont.append(element) + + def setup_builtin_data_path(self) -> None: + """ + Add PcbDraw built-in libraries to the search path for libraries + """ + self.data_path.append(os.path.join(PKG_BASE, "resources")) + + def setup_global_data_path(self) -> None: + """ + Add global installation paths to the search path for libraries. + """ + self.data_path += get_global_datapaths() + + def setup_arbitrary_data_path(self, path: str) -> None: + """ + Add an arbitrary data path + """ + self.data_path.append(os.path.realpath(path)) + + def setup_env_data_path(self) -> None: + """ + Add search paths from the env variable PCBDRAW_LIB_PATH + """ + paths = os.environ.get("PCBDRAW_LIB_PATH", "").split(":") + self.data_path += filter(lambda x: len(x) > 0, paths) + + def resolve_style(self, name: str) -> None: + """ + Given a name of style, find the corresponding file and load it + """ + path = self._find_data_file(name, ".json", "styles") + if path is None: + raise RuntimeError(f"Cannot locate resource {name}; explored paths:\n" + + "\n".join([f"- {x}" for x in self.data_path])) + self.style = load_style(path) + + def unique_prefix(self) -> str: + pref = f"pref_{self._unique_counter}" + self._unique_counter += 1 + return pref + + def _find_data_file(self, name: str, extension: str, subdir: str) -> Optional[str]: + return find_data_file(name, extension, self.data_path, subdir) + + def _build_libs_path(self) -> None: + self._libs_path = [] + for l in self.libs: + self._libs_path += [os.path.join(p, l) for p in self.data_path] + for l in self.libs: + self._libs_path += [os.path.join(p, "footprints", l) for p in self.data_path] + self._libs_path = [x for x in self._libs_path if os.path.exists(x)] + + def _get_model_file(self, lib: str, name: str) -> Optional[str]: + """ + Find model file in the configured libraries. If it doesn't exists, + return None. + """ + for path in self._libs_path: + f = os.path.join(path, lib, name + ".svg") + if os.path.isfile(f): + return f + return None + + def get_style(self, *args: Union[str, int]) -> Any: + try: + value = self.style + for key in args: + value = value[key] + return value + except KeyError: + try: + value = default_style + for key in args: + value = value[key] + return value + except KeyError as e: + raise e from None + + def execute_plot_plan(self, to_plot: List[PlotAction]) -> None: + """ + Given a plotting plan, plots the layers and invokes a post-processing + callback on the generated files + """ + with tempfile.TemporaryDirectory() as tmp: + pctl = pcbnew.PLOT_CONTROLLER(self.board) + popt = pctl.GetPlotOptions() + popt.SetOutputDirectory(tmp) + popt.SetScale(1) + popt.SetMirror(False) + popt.SetSubtractMaskFromSilk(True) + popt.SetDrillMarksType(0) # NO_DRILL_SHAPE + try: + popt.SetPlotOutlineMode(False) + except: + # Method does not exist in older versions of KiCad + pass + popt.SetTextMode(pcbnew.PLOT_TEXT_MODE_STROKE) + for action in to_plot: + if len(action.layers) == 0: + continue + # Set the filename before opening the file as KiCAD 6.0.8 + # requires it even for the SVG format + pctl.SetLayer(action.layers[0]) + pctl.OpenPlotfile(action.name, pcbnew.PLOT_FORMAT_SVG, action.name) + for l in action.layers: + pctl.SetColorMode(False) + pctl.SetLayer(l) + pctl.PlotLayer() + pctl.ClosePlot() + for action in to_plot: + for svg_file in os.listdir(tmp): + if svg_file.endswith(f"-{action.name}.svg"): + action.action(action.name, os.path.join(tmp, svg_file)) + + def _setup_document(self, render_back: bool, mirror: bool) -> None: + bb = self.board.ComputeBoundingBox(aBoardEdgesOnly=True) + transform_string = "" + # Let me briefly explain what's going on. KiCAD outputs SVG in user units, + # where 1 unit is 1/10 of an inch (in v5) or KiCAD native unit (v6). So to + # make our life easy, we respect it and make our document also in the + # corresponding units. Therefore we specify the outer dimensions in + # millimeters and specify the board area. + if(render_back ^ mirror): + transform_string = "scale(-1,1)" + self._document = empty_svg( + width=f"{ki2mm(bb.GetWidth())}mm", + height=f"{ki2mm(bb.GetHeight())}mm", + viewBox=f"{ki2svg(-bb.GetWidth() - bb.GetX())} {ki2svg(bb.GetY())} {ki2svg(bb.GetWidth())} {ki2svg(bb.GetHeight())}") + else: + self._document = empty_svg( + width=f"{ki2mm(bb.GetWidth())}mm", + height=f"{ki2mm(bb.GetHeight())}mm", + viewBox=f"{ki2svg(bb.GetX())} {ki2svg(bb.GetY())} {ki2svg(bb.GetWidth())} {ki2svg(bb.GetHeight())}") + + self._defs = etree.SubElement(self._document.getroot(), "defs") + self._board_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string) + if self.get_style("highlight-on-top"): + self._comp_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string) + self._high_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string) + else: + self._high_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string) + self._comp_cont = etree.SubElement(self._document.getroot(), "g", transform=transform_string) + + self._board_cont.attrib["id"] = "boardContainer" + self._comp_cont.attrib["id"] = "componentContainer" + self._high_cont.attrib["id"] = "highlightContainer" diff --git a/kibot/PcbDraw/resources/footprints b/kibot/PcbDraw/resources/footprints new file mode 160000 index 00000000..d582bc72 --- /dev/null +++ b/kibot/PcbDraw/resources/footprints @@ -0,0 +1 @@ +Subproject commit d582bc725fe987e35f291bc06e318cf6d3b263d2 diff --git a/kibot/PcbDraw/resources/styles/gatema-green.json b/kibot/PcbDraw/resources/styles/gatema-green.json new file mode 100644 index 00000000..c84623d6 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/gatema-green.json @@ -0,0 +1,13 @@ +{ + "copper": "#417e5a", + "board": "#4ca06c", + "silk": "#f0f0f0", + "pads": "#b5ae30", + "clad": "#9c6b28", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/jlcpcb-green-enig.json b/kibot/PcbDraw/resources/styles/jlcpcb-green-enig.json new file mode 100644 index 00000000..1e8719d0 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/jlcpcb-green-enig.json @@ -0,0 +1,13 @@ +{ + "copper": "#208b47", + "board": "#285e3a", + "silk": "#f0f0f0", + "pads": "#b5ae30", + "clad": "#9c6b28", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/jlcpcb-green-hasl.json b/kibot/PcbDraw/resources/styles/jlcpcb-green-hasl.json new file mode 100644 index 00000000..3b899751 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/jlcpcb-green-hasl.json @@ -0,0 +1,13 @@ +{ + "copper": "#208b47", + "board": "#285e3a", + "silk": "#f0f0f0", + "pads": "#bfba9e", + "clad": "#9c6b28", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/mayer-yellow-hasl.json b/kibot/PcbDraw/resources/styles/mayer-yellow-hasl.json new file mode 100644 index 00000000..e8ef4a93 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/mayer-yellow-hasl.json @@ -0,0 +1,13 @@ +{ + "board": "#6c651d", + "copper": "#f2a756", + "silk": "#d5dce4", + "pads": "#8b898c", + "clad": "#656e5b", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/oshpark-afterdark.json b/kibot/PcbDraw/resources/styles/oshpark-afterdark.json new file mode 100644 index 00000000..22d31870 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/oshpark-afterdark.json @@ -0,0 +1,13 @@ +{ + "copper": "#af6640", + "board": "#323232", + "silk": "#d8dae7", + "pads": "#dec500", + "clad": "#5d4e44", + "outline": "#000000", + "vcut": "#f0f0f0", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/oshpark-purple.json b/kibot/PcbDraw/resources/styles/oshpark-purple.json new file mode 100644 index 00000000..c247bfdc --- /dev/null +++ b/kibot/PcbDraw/resources/styles/oshpark-purple.json @@ -0,0 +1,13 @@ +{ + "copper": "#451d70", + "board": "#30234a", + "silk": "#d8dae7", + "pads": "#ede8b9", + "clad": "#5d4e44", + "outline": "#000000", + "vcut": "#f0f0f0", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-black-cu.json b/kibot/PcbDraw/resources/styles/set-black-cu.json new file mode 100644 index 00000000..f5cbf0da --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-black-cu.json @@ -0,0 +1,13 @@ +{ + "board": "#1d1918", + "copper": "#2d2522", + "silk": "#d5dce4", + "pads": "#d39751", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-black-enig.json b/kibot/PcbDraw/resources/styles/set-black-enig.json new file mode 100644 index 00000000..cba642f5 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-black-enig.json @@ -0,0 +1,13 @@ +{ + "board": "#1d1918", + "copper": "#2d2522", + "silk": "#d5dce4", + "pads": "#cfb96e", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-black-hasl.json b/kibot/PcbDraw/resources/styles/set-black-hasl.json new file mode 100644 index 00000000..30677f45 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-black-hasl.json @@ -0,0 +1,13 @@ +{ + "board": "#1d1918", + "copper": "#2d2522", + "silk": "#d5dce4", + "pads": "#8b898c", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-blue-cu.json b/kibot/PcbDraw/resources/styles/set-blue-cu.json new file mode 100644 index 00000000..ceff2768 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-blue-cu.json @@ -0,0 +1,13 @@ +{ + "board": "#1b1f44", + "copper": "#00406a", + "silk": "#d5dce4", + "pads": "#d39751", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-blue-enig.json b/kibot/PcbDraw/resources/styles/set-blue-enig.json new file mode 100644 index 00000000..379c774e --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-blue-enig.json @@ -0,0 +1,13 @@ +{ + "board": "#1b1f44", + "copper": "#00406a", + "silk": "#d5dce4", + "pads": "#cfb96e", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-blue-hasl.json b/kibot/PcbDraw/resources/styles/set-blue-hasl.json new file mode 100644 index 00000000..feadebb2 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-blue-hasl.json @@ -0,0 +1,13 @@ +{ + "board": "#1b1f44", + "copper": "#00406a", + "silk": "#d5dce4", + "pads": "#8b898c", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-red-cu.json b/kibot/PcbDraw/resources/styles/set-red-cu.json new file mode 100644 index 00000000..6b33ae8d --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-red-cu.json @@ -0,0 +1,13 @@ +{ + "board": "#812e2a", + "copper": "#be352b", + "silk": "#d5dce4", + "pads": "#d39751", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-red-enig.json b/kibot/PcbDraw/resources/styles/set-red-enig.json new file mode 100644 index 00000000..a7239f94 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-red-enig.json @@ -0,0 +1,13 @@ +{ + "board": "#812e2a", + "copper": "#be352b", + "silk": "#d5dce4", + "pads": "#cfb96e", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-red-hasl.json b/kibot/PcbDraw/resources/styles/set-red-hasl.json new file mode 100644 index 00000000..57d9f83f --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-red-hasl.json @@ -0,0 +1,13 @@ +{ + "board": "#812e2a", + "copper": "#be352b", + "silk": "#d5dce4", + "pads": "#8b898c", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-white-cu.json b/kibot/PcbDraw/resources/styles/set-white-cu.json new file mode 100644 index 00000000..2d8a0b5f --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-white-cu.json @@ -0,0 +1,13 @@ +{ + "board": "#bdccc7", + "copper": "#b7b7ad", + "silk": "#0b1013", + "pads": "#d39751", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-white-enig.json b/kibot/PcbDraw/resources/styles/set-white-enig.json new file mode 100644 index 00000000..07914be5 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-white-enig.json @@ -0,0 +1,13 @@ +{ + "board": "#bdccc7", + "copper": "#b7b7ad", + "silk": "#0b1013", + "pads": "#cfb96e", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-white-hasl.json b/kibot/PcbDraw/resources/styles/set-white-hasl.json new file mode 100644 index 00000000..bd563443 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-white-hasl.json @@ -0,0 +1,13 @@ +{ + "board": "#bdccc7", + "copper": "#b7b7ad", + "silk": "#0b1013", + "pads": "#cfcfd7", + "clad": "#72786c", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-yellow-cu.json b/kibot/PcbDraw/resources/styles/set-yellow-cu.json new file mode 100644 index 00000000..3021a285 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-yellow-cu.json @@ -0,0 +1,13 @@ +{ + "board": "#73823d", + "copper": "#f79e64", + "silk": "#d5dce4", + "pads": "#d39751", + "clad": "#525341", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-yellow-enig.json b/kibot/PcbDraw/resources/styles/set-yellow-enig.json new file mode 100644 index 00000000..caa2df7f --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-yellow-enig.json @@ -0,0 +1,13 @@ +{ + "board": "#73823d", + "copper": "#f79e64", + "silk": "#d5dce4", + "pads": "#cfb96e", + "clad": "#525341", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/resources/styles/set-yellow-hasl.json b/kibot/PcbDraw/resources/styles/set-yellow-hasl.json new file mode 100644 index 00000000..36783190 --- /dev/null +++ b/kibot/PcbDraw/resources/styles/set-yellow-hasl.json @@ -0,0 +1,13 @@ +{ + "board": "#73823d", + "copper": "#f79e64", + "silk": "#d5dce4", + "pads": "#8b898c", + "clad": "#525341", + "outline": "#000000", + "vcut": "#bf2600", + "highlight-on-top": false, + "highlight-style": "stroke:none;fill:#ff0000;opacity:0.5;", + "highlight-padding": 1.5, + "highlight-offset": 0 +} \ No newline at end of file diff --git a/kibot/PcbDraw/unit.py b/kibot/PcbDraw/unit.py new file mode 100644 index 00000000..81e44a4a --- /dev/null +++ b/kibot/PcbDraw/unit.py @@ -0,0 +1,51 @@ +# Author: Jan Mrázek +# License: MIT +from decimal import Decimal +from typing import List + + +def erase(string: str, what: List[str]) -> str: + """ + Given a string and a list of string, removes all occurrences of items from + what in the string + """ + for x in what: + string = string.replace(x, "") + return string + + +def read_resistance(value: str) -> Decimal: + """ + Given a string, try to parse resistance and return it as Ohms (Decimal) + + This function can raise a ValueError if the value is invalid + """ + p_value = erase(value, ["Ω", "Ohms", "Ohm"]).strip() + p_value = p_value.replace(" ", "") # Sometimes there are spaces after decimal place + unit_prefixes = { + "m": Decimal('1e-3'), + "R": Decimal('1'), + "K": Decimal('1e3'), + "k": Decimal('1e3'), + "M": Decimal('1e6'), + "G": Decimal('1e9') + } + try: + numerical_value = None + for prefix, table in unit_prefixes.items(): + if prefix in p_value: + # Example: 4k7 will have the 4 converted to Decimal(4) and 7 to Decimal(0.7) + # Then each gets multiplied by the factor and added, so 4000 + 700 + # This method ensures that 4k7 and 4k700 for example yields the same result + split = p_value.split(prefix) + n_whole = Decimal(split[0]) if split[0] != "" else Decimal(0) + n_dec = Decimal('.'+split[1]) if split[1] != "" else Decimal(0) + numerical_value = n_whole * table + n_dec * table + break + if numerical_value is None: + # If this fails, a decimal.InvalidOperation is raised which is handled by the Exception catch + numerical_value = Decimal(p_value) + return numerical_value + except Exception: + pass + raise ValueError(f"Cannot parse '{value}' to resistance") diff --git a/kibot/misc.py b/kibot/misc.py index b06cf00d..cfde8272 100644 --- a/kibot/misc.py +++ b/kibot/misc.py @@ -238,6 +238,7 @@ W_MISSREF = '(W099) ' W_COPYOVER = '(W100) ' W_PARITY = '(W101) ' W_MISSFPINFO = '(W102) ' +W_PCBDRAW = '(W103) ' # Somehow arbitrary, the colors are real, but can be different PCB_MAT_COLORS = {'fr1': "937042", 'fr2': "949d70", 'fr3': "adacb4", 'fr4': "332B16", 'fr5': "6cc290"} PCB_FINISH_COLORS = {'hal': "8b898c", 'hasl': "8b898c", 'imag': "8b898c", 'enig': "cfb96e", 'enepig': "cfb96e", diff --git a/kibot/out_pcbdraw.py b/kibot/out_pcbdraw.py index bff95bd2..64792517 100644 --- a/kibot/out_pcbdraw.py +++ b/kibot/out_pcbdraw.py @@ -3,31 +3,39 @@ # Copyright (c) 2020-2022 Instituto Nacional de Tecnología Industrial # License: GPL-3.0 # Project: KiBot (formerly KiPlot) -""" -Dependencies: - - from: RSVG - role: Create PNG and JPG images - - from: ImageMagick - role: Create JPG images - - from: PcbDraw - role: mandatory -""" +# TODO: PIL dependency? pcbnewTransition? numpy? +# TODO: Package resources +# """ +# Dependencies: +# - from: RSVG +# role: Create PNG and JPG images +# - from: ImageMagick +# role: Create JPG images +# - from: PcbDraw +# role: mandatory +# """ import os from tempfile import NamedTemporaryFile -import shlex # Here we import the whole module to make monkeypatch work -import subprocess -from .misc import (PCBDRAW_ERR, W_AMBLIST, W_UNRETOOL, W_USESVG2, W_USEIMAGICK, PCB_MAT_COLORS, - PCB_FINISH_COLORS, SOLDER_COLORS, SILK_COLORS) +from .error import KiPlotConfigurationError +from .misc import (PCBDRAW_ERR, W_AMBLIST, PCB_MAT_COLORS, PCB_FINISH_COLORS, SOLDER_COLORS, SILK_COLORS, + W_PCBDRAW) from .gs import GS from .optionable import Optionable from .out_base import VariantOptions from .macros import macros, document, output_class # noqa: F401 from . import log +from .PcbDraw.plot import (PcbPlotter, PlotPaste, PlotPlaceholders, PlotSubstrate, PlotVCuts, mm2ki, PlotComponents) +from .PcbDraw.convert import save + logger = log.get_logger() +def pcbdraw_warnings(tag, msg): + logger.warning('{}({}) {}'.format(W_PCBDRAW, tag, msg)) + + class PcbDrawStyle(Optionable): def __init__(self): super().__init__() @@ -102,31 +110,6 @@ class PcbDrawRemap(Optionable): pass -def _get_tmp_name(ext): - with NamedTemporaryFile(mode='w', suffix=ext, delete=False) as f: - f.close() - return f.name - - -def _run_command(cmd, tmp_remap=False, tmp_style=False): - logger.debug('Executing: '+shlex.join(cmd)) - try: - cmd_output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as e: - logger.error('Failed to run %s, error %d', cmd[0], e.returncode) - if e.output: - logger.debug('Output from command: '+e.output.decode()) - exit(PCBDRAW_ERR) - finally: - if tmp_remap: - os.remove(tmp_remap) - if tmp_style: - os.remove(tmp_style) - out = cmd_output.decode() - if out.strip(): - logger.debug('Output from command:\n'+out) - - class PcbDrawOptions(VariantOptions): def __init__(self): with document: @@ -148,7 +131,7 @@ class PcbDrawOptions(VariantOptions): """ [list(string)=[]] List of components to highlight """ self.show_components = Optionable """ *[list(string)|string=none] [none,all] List of components to draw, can be also a string for none or all. - The default is none """ + The default is none. IMPORTANT! This option is relevant only when no filters or variants are applied """ self.vcuts = False """ Render V-CUTS on the Cmts.User layer """ self.warnings = 'visible' @@ -170,32 +153,39 @@ class PcbDrawOptions(VariantOptions): super().config(parent) # Libs if isinstance(self.libs, type): - self.libs = None + self.libs = ['KiCAD-base'] else: self.libs = ','.join(self.libs) # Highlight if isinstance(self.highlight, type): self.highlight = None - else: - self.highlight = ','.join(self.highlight) # Filter if isinstance(self.show_components, type): - self.show_components = '' + self.show_components = None elif isinstance(self.show_components, str): if self.variant or self.dnf_filter: logger.warning(W_AMBLIST + 'Ambiguous list of components to show `{}` vs variant/filter'. format(self.show_components)) if self.show_components == 'none': - self.show_components = '' - else: self.show_components = None - else: - self.show_components = ','.join(self.show_components) + else: + self.show_components = [] + # Remap + # TODO: Better remap option, like - ref: xxx\nlib: xxxx\ncomponent: xxxx if isinstance(self.remap, type): - self.remap = None + self.remap = {} elif isinstance(self.remap, PcbDrawRemap): - self.remap = self.remap._tree + parsed_remap = {} + for ref, v in self.remap._tree.items(): + if not isinstance(v, str): + raise KiPlotConfigurationError("Wrong PcbDraw remap, must be `ref: lib:component` ({}: {})".format(ref, v)) + lib_comp = v.split(':') + if len(lib_comp) == 2: + parsed_remap[ref] = lib_comp + else: + raise KiPlotConfigurationError("Wrong PcbDraw remap, must be `ref: lib:component` ({}: {})".format(ref, v)) + self.remap = parsed_remap # Style if isinstance(self.style, type): # Apply the global defaults @@ -207,20 +197,6 @@ class PcbDrawOptions(VariantOptions): self._expand_id = 'bottom' if self.bottom else 'top' self._expand_ext = self.format - def _create_remap(self): - with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - f.write('{\n') - first = True - for k, v in self.remap.items(): - if first: - first = False - else: - f.write(',\n') - f.write(' "{}": "{}"'.format(k, v)) - f.write('\n}\n') - f.close() - return f.name - def _create_style(self): with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: f.write('{\n') @@ -240,89 +216,99 @@ class PcbDrawOptions(VariantOptions): f.close() return f.name - def _append_output(self, cmd, output): - svg = None - if self.format == 'svg': - cmd.append(output) - else: - # PNG and JPG outputs are unreliable - self.rsvg_command = self.check_tool('RSVG') - if self.rsvg_command is None: - logger.warning(W_UNRETOOL + '`RSVG` not installed, using unreliable PNG/JPG conversion') - logger.warning(W_USESVG2 + 'If you experiment problems install it') - cmd.append(output) - else: - self.convert_command = self.check_tool('ImageMagick') - if self.convert_command is None: - logger.warning(W_UNRETOOL + '`ImageMagick` not installed, using unreliable PNG/JPG conversion') - logger.warning(W_USEIMAGICK + 'If you experiment problems install it') - cmd.append(output) - else: - svg = _get_tmp_name('.svg') - cmd.append(svg) - return svg - def get_targets(self, out_dir): return [self._parent.expand_filename(out_dir, self.output)] + def build_plot_components(self): + remapping = self.remap + + def remapping_fun(ref, lib, name): + if ref in remapping: + remapped_lib, remapped_name = remapping[ref] + if name.endswith('.back'): + return remapped_lib, remapped_name + '.back' + else: + return remapped_lib, remapped_name + return lib, name + + resistor_values = {} + # TODO: Implement resistor_values_input and resistor_flip +# for mapping in resistor_values_input: +# key, value = tuple(mapping.split(":")) +# resistor_values[key] = ResistorValue(value=value) +# for ref in resistor_flip: +# field = resistor_values.get(ref, ResistorValue()) +# field.flip_bands = True +# resistor_values[ref] = field + + plot_components = PlotComponents(remapping=remapping_fun, + resistor_values=resistor_values, + no_warn_back=self.warnings == 'visible') + + if self._comps or self.show_components: + comps = self.get_fitted_refs() + if self.show_components: + comps += self.show_components + filter_set = set(comps) + plot_components.filter = lambda ref: ref in filter_set + + if self.highlight is not None: + highlight_set = set(self.highlight) + plot_components.highlight = lambda ref: ref in highlight_set + return plot_components + def run(self, name): super().run(name) - pcbdraw_command = self.ensure_tool('PcbDraw') - # Base command with overwrite - cmd = [pcbdraw_command] - # Add user options - tmp_style = None - if self.style: - if isinstance(self.style, str): - cmd.extend(['-s', self.style]) - else: - tmp_style = self._create_style() - cmd.extend(['-s', tmp_style]) - if self.libs: - cmd.extend(['-l', self.libs]) - if self.placeholder: - cmd.append('--placeholder') - if self.no_drillholes: - cmd.append('--no-drillholes') - if self.bottom: - cmd.append('-b') - if self.mirror: - cmd.append('--mirror') - if self.highlight: - cmd.extend(['-a', self.highlight]) - if self.show_components is not None: - to_add = ','.join(self.get_fitted_refs()) - if self.show_components and to_add: - self.show_components += ',' - self.show_components += to_add - cmd.extend(['-f', self.show_components]) - if self.vcuts: - cmd.append('-v') - if self.warnings == 'visible': - cmd.append('--no-warn-back') - elif self.warnings == 'none': - cmd.append('--silent') - if self.dpi: - cmd.extend(['--dpi', str(self.dpi)]) - if self.remap: - tmp_remap = self._create_remap() - cmd.extend(['-m', tmp_remap]) - else: - tmp_remap = None - # The board & output - cmd.append(GS.pcb_file) - svg = self._append_output(cmd, name) - # Execute and inform is successful - _run_command(cmd, tmp_remap, tmp_style) - if svg is not None: - # Manually convert the SVG to PNG - png = _get_tmp_name('.png') - _run_command([self.rsvg_command, '-d', str(self.dpi), '-p', str(self.dpi), svg, '-o', png], svg) - cmd = [self.convert_command, '-trim', png] - if self.format == 'jpg': - cmd += ['-quality', '85%'] - cmd.append(name) - _run_command(cmd, png) + + try: + # TODO: Avoid loading the PCB again + plotter = PcbPlotter(GS.pcb_file) + # TODO: Review the paths, most probably add the system KiBot dir + # Read libs from current dir + # plotter.setup_arbitrary_data_path(".") + # Libs indicated by PCBDRAW_LIB_PATH + plotter.setup_env_data_path() + # Libs from resources relative to the script + plotter.setup_builtin_data_path() + # Libs from the user HOME and the system + plotter.setup_global_data_path() + plotter.yield_warning = pcbdraw_warnings + plotter.libs = self.libs + plotter.render_back = self.bottom + plotter.mirror = self.mirror + # TODO: Allow margin configuration + plotter.margin = mm2ki(1.5) + # TODO: Pass it directly? If no: remove file? + tmp_style = None + if self.style: + if isinstance(self.style, str): + plotter.resolve_style(self.style) + else: + tmp_style = self._create_style() + plotter.resolve_style(tmp_style) + # TODO: Make aoutline_width configurable + plotter.plot_plan = [PlotSubstrate(drill_holes=not self.no_drillholes, outline_width=mm2ki(0.15))] + # TODO: Make paste optional + plotter.plot_plan.append(PlotPaste()) + if self.vcuts: + # TODO: Make layer configurable + plotter.plot_plan.append(PlotVCuts(layer=41)) + # Two filtering mechanism: 1) Specified list and 2) KiBot filters and variants + if self.show_components is not None or self._comps: + plotter.plot_plan.append(self.build_plot_components()) + if self.placeholder: + plotter.plot_plan.append(PlotPlaceholders()) + + image = plotter.plot() + # Most errors are reported as RuntimeError + # When the PCB can't be loaded we get IOError + # When the SVG contains errors we get SyntaxError + except (RuntimeError, SyntaxError, IOError) as e: + logger.error('PcbDraw error: '+str(e)) + exit(PCBDRAW_ERR) + + save(image, name, self.dpi) + return @output_class diff --git a/setup.cfg b/setup.cfg index 83a69e2c..a7f063c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ max-complexity = 21 exclude = experiments/kicad/v6/ experiments/JLC/ kibot/mcpyrate/ + kibot/PcbDraw/ kibot/PyPDF2/ submodules/ pp/ diff --git a/src/kibot-check b/src/kibot-check index daa1f280..6dac566e 100755 --- a/src/kibot-check +++ b/src/kibot-check @@ -159,7 +159,7 @@ deps = '{\ ],\ "extra_deb": null,\ "help_option": "--version",\ - "importance": 3,\ + "importance": 2,\ "in_debian": true,\ "is_kicad_plugin": false,\ "is_python": false,\ @@ -183,13 +183,6 @@ deps = '{\ "max_version": null,\ "output": "pcb_print",\ "version": null\ - },\ - {\ - "desc": "Create JPG images",\ - "mandatory": false,\ - "max_version": null,\ - "output": "pcbdraw",\ - "version": null\ }\ ],\ "tests": [],\ @@ -485,7 +478,7 @@ deps = '{\ "version": [\ 2,\ 4,\ - 2\ + 3\ ]\ }\ ],\ @@ -627,7 +620,7 @@ deps = '{\ "extra_arch": null,\ "extra_deb": null,\ "help_option": "--version",\ - "importance": 10001,\ + "importance": 1,\ "in_debian": false,\ "is_kicad_plugin": false,\ "is_python": false,\ @@ -652,21 +645,6 @@ deps = '{\ 0,\ 3\ ]\ - },\ - {\ - "desc": null,\ - "mandatory": true,\ - "max_version": [\ - 1,\ - 0\ - ],\ - "output": "pcbdraw",\ - "version": [\ - 0,\ - 9,\ - 0,\ - 3\ - ]\ }\ ],\ "tests": [],\ @@ -784,7 +762,7 @@ deps = '{\ "extra_arch": null,\ "extra_deb": null,\ "help_option": "--version",\ - "importance": 4,\ + "importance": 3,\ "in_debian": true,\ "is_kicad_plugin": false,\ "is_python": false,\ @@ -815,13 +793,6 @@ deps = '{\ "max_version": null,\ "output": "pcb_print",\ "version": null\ - },\ - {\ - "desc": "Create PNG and JPG images",\ - "mandatory": false,\ - "max_version": null,\ - "output": "pcbdraw",\ - "version": null\ }\ ],\ "tests": [\ diff --git a/tests/test_plot/test_misc.py b/tests/test_plot/test_misc.py index f9c14a13..28eed149 100644 --- a/tests/test_plot/test_misc.py +++ b/tests/test_plot/test_misc.py @@ -468,7 +468,7 @@ def test_pcbdraw_fail(test_dir): prj = 'bom' ctx = context.TestContext(test_dir, prj, 'pcbdraw_fail') ctx.run(PCBDRAW_ERR) - assert ctx.search_err('Failed to run') + assert ctx.search_err('Cannot locate resource bogus') ctx.clean_up() diff --git a/tests/test_plot/test_pcbdraw.py b/tests/test_plot/test_pcbdraw.py index 58218374..1cd1be45 100644 --- a/tests/test_plot/test_pcbdraw.py +++ b/tests/test_plot/test_pcbdraw.py @@ -8,11 +8,8 @@ import coverage import logging from shutil import which from os import access -from importlib import reload from . import context from kibot.mcpyrate import activate # noqa: F401 -from kibot.out_pcbdraw import PcbDrawOptions -import kibot.log OUT_DIR = 'PcbDraw' cov = coverage.Coverage() @@ -86,63 +83,63 @@ DEPS = {'Dependencies': [{'name': 'pcbdraw', 'command': 'pcbdraw', 'role': 'mand {'name': 'ImageMagick', 'command': 'convert', 'role': 'XXXX', 'debian': 'imagemagick'}]} -def test_pcbdraw_miss_rsvg(caplog, monkeypatch): - """ Check missing rsvg-convert """ - with monkeypatch.context() as m: - # Make which('rsvg-convert') fail - m.setattr("shutil.which", no_rsvg_convert) - # Make the call to determine the version fail - m.setattr("subprocess.check_output", no_run) - # Make os.access(...rsvg-convert', EXEC) fail - m.setattr("os.access", no_rsvg_convert_access) - # Make platform.system() return a bogus OS - m.setattr("platform.system", platform_system_bogus) - # Reload the module so we get the above patches - mod = reload(kibot.dep_downloader) - mod.register_deps('pcbdraw', DEPS) - logging.error(mod.used_deps) - old_lev = kibot.log.debug_level - kibot.log.debug_level = 2 - o = PcbDrawOptions() - o.style = '' - o.remap = None - o.format = 'jpg' - o.config(None) - o._parent = DummyPcbDraw() - cov.load() - cov.start() - o.run('') - cov.stop() - cov.save() - kibot.log.debug_level = old_lev - assert 'using unreliable PNG/JPG' in caplog.text, caplog.text - assert 'librsvg2-bin' in caplog.text, caplog.text - - -def test_pcbdraw_miss_convert(caplog, monkeypatch): - """ Check missing convert """ - with monkeypatch.context() as m: - m.setattr("shutil.which", no_convert) - m.setattr("subprocess.check_output", no_run) - m.setattr("os.access", no_convert_access) - # Make platform.system() return a bogus OS - m.setattr("platform.system", platform_system_bogus) - # Reload the module so we get the above patches - mod = reload(kibot.dep_downloader) - mod.register_deps('pcbdraw', DEPS) - o = PcbDrawOptions() - o.style = '' - o.remap = None - o.format = 'jpg' - o.config(None) - o._parent = DummyPcbDraw() - cov.load() - cov.start() - o.run('') - cov.stop() - cov.save() - assert 'using unreliable PNG/JPG' in caplog.text, caplog.text - assert 'imagemagick' in caplog.text, caplog.text +# def test_pcbdraw_miss_rsvg(caplog, monkeypatch): +# """ Check missing rsvg-convert """ +# with monkeypatch.context() as m: +# # Make which('rsvg-convert') fail +# m.setattr("shutil.which", no_rsvg_convert) +# # Make the call to determine the version fail +# m.setattr("subprocess.check_output", no_run) +# # Make os.access(...rsvg-convert', EXEC) fail +# m.setattr("os.access", no_rsvg_convert_access) +# # Make platform.system() return a bogus OS +# m.setattr("platform.system", platform_system_bogus) +# # Reload the module so we get the above patches +# mod = reload(kibot.dep_downloader) +# mod.register_deps('pcbdraw', DEPS) +# logging.error(mod.used_deps) +# old_lev = kibot.log.debug_level +# kibot.log.debug_level = 2 +# o = PcbDrawOptions() +# o.style = '' +# o.remap = None +# o.format = 'jpg' +# o.config(None) +# o._parent = DummyPcbDraw() +# cov.load() +# cov.start() +# o.run('') +# cov.stop() +# cov.save() +# kibot.log.debug_level = old_lev +# assert 'using unreliable PNG/JPG' in caplog.text, caplog.text +# assert 'librsvg2-bin' in caplog.text, caplog.text +# +# +# def test_pcbdraw_miss_convert(caplog, monkeypatch): +# """ Check missing convert """ +# with monkeypatch.context() as m: +# m.setattr("shutil.which", no_convert) +# m.setattr("subprocess.check_output", no_run) +# m.setattr("os.access", no_convert_access) +# # Make platform.system() return a bogus OS +# m.setattr("platform.system", platform_system_bogus) +# # Reload the module so we get the above patches +# mod = reload(kibot.dep_downloader) +# mod.register_deps('pcbdraw', DEPS) +# o = PcbDrawOptions() +# o.style = '' +# o.remap = None +# o.format = 'jpg' +# o.config(None) +# o._parent = DummyPcbDraw() +# cov.load() +# cov.start() +# o.run('') +# cov.stop() +# cov.save() +# assert 'using unreliable PNG/JPG' in caplog.text, caplog.text +# assert 'imagemagick' in caplog.text, caplog.text def test_pcbdraw_variant_1(test_dir):