From 0b1ad20ba3c34e25ad92367bc96be39ebb8c61b0 Mon Sep 17 00:00:00 2001 From: pong Date: Sat, 13 Jun 2026 00:31:22 +0300 Subject: [PATCH 1/2] Add KiCad IPC API backend (runs on KiCad 9+, incl. v11 nightlies) KiCad removed the SWIG `pcbnew` Python module in development builds (v11/nightly), so the existing ActionPlugin no longer loads there. This adds an alternative backend that talks to KiCad over the IPC API via kicad-python (kipy), running as a separate process. - ecad/kicad_ipc.py: IpcApiParser(EcadParser) producing the same pcbdata structure as PcbnewParser, so the HTML renderer is unchanged. Output was diffed against the SWIG parser on KiCad 10 and matches to <0.01mm for edges, footprints, pads, tracks, zones, nets and BOM components. - plugin.json / requirements.txt / ipc_entrypoint.py: IPC plugin manifest, per-plugin venv deps, and the separate-process entry point. Puts the toolbar button back on v11 where SWIG plugins are unavailable. - ecad/__init__.py: get_kicad_ipc_parser() factory (kipy imported lazily, so nothing changes for SWIG installs / CI). - ecad/kicad_extra/__init__.py: make `import pcbnew` optional so the package imports under the IPC runtime where pcbnew is absent. Co-Authored-By: Claude Fable 5 --- InteractiveHtmlBom/ecad/__init__.py | 5 + .../ecad/kicad_extra/__init__.py | 11 +- InteractiveHtmlBom/ecad/kicad_ipc.py | 932 ++++++++++++++++++ ipc_entrypoint.py | 75 ++ plugin.json | 28 + requirements.txt | 12 + 6 files changed, 1061 insertions(+), 2 deletions(-) create mode 100644 InteractiveHtmlBom/ecad/kicad_ipc.py create mode 100644 ipc_entrypoint.py create mode 100644 plugin.json create mode 100644 requirements.txt diff --git a/InteractiveHtmlBom/ecad/__init__.py b/InteractiveHtmlBom/ecad/__init__.py index ecaef5ee..ba56a057 100644 --- a/InteractiveHtmlBom/ecad/__init__.py +++ b/InteractiveHtmlBom/ecad/__init__.py @@ -26,6 +26,11 @@ def get_kicad_parser(file_name, config, logger, board=None): return PcbnewParser(file_name, config, logger, board) +def get_kicad_ipc_parser(file_name, config, logger, kicad=None, board=None): + from .kicad_ipc import IpcApiParser + return IpcApiParser(file_name, config, logger, kicad, board) + + def get_easyeda_parser(file_name, config, logger): from .easyeda import EasyEdaParser return EasyEdaParser(file_name, config, logger) diff --git a/InteractiveHtmlBom/ecad/kicad_extra/__init__.py b/InteractiveHtmlBom/ecad/kicad_extra/__init__.py index f4e59bf1..654a86d5 100644 --- a/InteractiveHtmlBom/ecad/kicad_extra/__init__.py +++ b/InteractiveHtmlBom/ecad/kicad_extra/__init__.py @@ -1,5 +1,4 @@ import os -import pcbnew from .xmlparser import XmlParser from .netlistparser import NetlistParser @@ -10,7 +9,15 @@ } -if hasattr(pcbnew, 'FOOTPRINT'): +try: + import pcbnew + _pcb_fields_supported = hasattr(pcbnew, 'FOOTPRINT') +except ImportError: + # IPC API parser reads fields from the board itself, + # no pcbnew module required. + _pcb_fields_supported = True + +if _pcb_fields_supported: PARSERS['.kicad_pcb'] = None diff --git a/InteractiveHtmlBom/ecad/kicad_ipc.py b/InteractiveHtmlBom/ecad/kicad_ipc.py new file mode 100644 index 00000000..4b5cc38e --- /dev/null +++ b/InteractiveHtmlBom/ecad/kicad_ipc.py @@ -0,0 +1,932 @@ +"""KiCad IPC API parser. + +Talks to a running KiCad instance through the IPC API using the kipy +(kicad-python) bindings instead of the legacy SWIG pcbnew module. +Produces the same pcbdata structure as the legacy PcbnewParser so the +rest of iBOM (and the html renderer) is unaffected. + +Requires KiCad 9.0.3+ with the API server enabled and kicad-python 0.7+. +""" + +import math +import os +from datetime import datetime + +from kipy import KiCad +from kipy.board_types import ( + ArcTrack, + BoardArc, + BoardBezier, + BoardCircle, + BoardPolygon, + BoardRectangle, + BoardSegment, + BoardText, + BoardTextBox, + Field, + Track, + Via, +) +from kipy.proto.board import board_types_pb2 + +from .common import EcadParser, Component, ExtraFieldData +from .kicad_extra import find_latest_schematic_data, parse_schematic_data +from .svgpath import create_path + +BL = board_types_pb2.BoardLayer +PAD_TYPE = board_types_pb2.PadType +PAD_STACK_TYPE = board_types_pb2.PadStackType +PAD_STACK_SHAPE = board_types_pb2.PadStackShape +DRILL_SHAPE = board_types_pb2.DrillShape +ULR = board_types_pb2.UnconnectedLayerRemoval +ZONE_TYPE = board_types_pb2.ZoneType + +OUTER_COPPER_LAYERS = [(BL.BL_F_Cu, "F"), (BL.BL_B_Cu, "B")] + +# Bitmask values matching pcbnew's RECT_CHAMFER_POSITIONS, which is what +# the html renderer expects in the "chamfpos" field. +CHAMFER_TOP_LEFT = 1 +CHAMFER_TOP_RIGHT = 2 +CHAMFER_BOTTOM_LEFT = 4 +CHAMFER_BOTTOM_RIGHT = 8 + + +class IpcApiParser(EcadParser): + + def __init__(self, file_name, config, logger, kicad=None, board=None): + self.kicad = kicad if kicad is not None else KiCad() + self.board = board if board is not None else self.kicad.get_board() + if not file_name: + project = self.board.get_project() + file_name = os.path.join(project.path, self.board.name) + super(IpcApiParser, self).__init__(file_name, config, logger) + self.footprints = list(self.board.get_footprints()) + self._text_render_cache = {} + + @staticmethod + def normalize(point): + return [point.x * 1e-6, point.y * 1e-6] + + @staticmethod + def rotate(point, center, angle_degrees): + # Rotation in kicad's coordinate system (y axis down, + # positive angle is counterclockwise). + a = -math.radians(angle_degrees) + dx = point[0] - center[0] + dy = point[1] - center[1] + return [center[0] + dx * math.cos(a) - dy * math.sin(a), + center[1] + dx * math.sin(a) + dy * math.cos(a)] + + @staticmethod + def layer_letter(layer): + return {BL.BL_F_Cu: "F", BL.BL_B_Cu: "B"}.get(layer) + + def parse_poly_line(self, poly_line): + # type: (...) -> list + # PolyLine nodes are either points or arcs. Arcs are approximated + # with their start/mid/end points; filled polygons coming from + # KiCad are already flattened so this is rarely hit. + result = [] + for node in poly_line.nodes: + if node.has_point: + result.append(self.normalize(node.point)) + elif node.has_arc: + arc = node.arc + result.append(self.normalize(arc.start)) + result.append(self.normalize(arc.mid)) + result.append(self.normalize(arc.end)) + return result + + def parse_polygons(self, polygons_with_holes): + # Holes are not supported by the legacy data format either: + # SWIG parser warned about them and used outlines only. + result = [] + for poly in polygons_with_holes: + if poly.holes: + self.logger.warn('Detected holes in polygon, ignoring them') + result.append(self.parse_poly_line(poly.outline)) + return result + + @staticmethod + def arc_angles_degrees(shape): + """Computes (startangle, endangle) the way the legacy parser did. + + Angles are in kicad's screen coordinate system (y axis down, + angles grow clockwise on screen). kipy's start_angle()/end_angle() + use the math convention (y up) so we compute from the arc's + start/mid/end points directly. + """ + center = shape.center() + if center is None: + return 0, 0 + + def angle_to(p): + return math.degrees(math.atan2(p.y - center.y, p.x - center.x)) + + a1 = angle_to(shape.start) + am = angle_to(shape.mid) + a2 = angle_to(shape.end) + if a1 >= 180 - 1e-3: + a1 -= 360 # match pcbnew's (-180, 180] -> [-180, 180) edge + # Determine sweep direction: the mid point must lie on the arc. + ccw_mid = (am - a1) % 360 + ccw_end = (a2 - a1) % 360 + if ccw_mid <= ccw_end: + sweep = ccw_end + else: + sweep = ccw_end - 360 + a2 = a1 + sweep + if a2 < a1: + a1, a2 = a2, a1 + return round(a1, 2), round(a2, 2) + + def parse_shape(self, d): + if isinstance(d, BoardSegment): + return { + "type": "segment", + "start": self.normalize(d.start), + "end": self.normalize(d.end), + "width": d.attributes.stroke.width * 1e-6, + } + if isinstance(d, BoardRectangle): + start = self.normalize(d.top_left) + end = self.normalize(d.bottom_right) + return { + "type": "polygon", + "pos": [0, 0], + "angle": 0, + "polygons": [[ + start, + [end[0], start[1]], + end, + [start[0], end[1]], + ]], + "width": d.attributes.stroke.width * 1e-6, + "filled": 1 if d.attributes.fill.filled else 0, + } + if isinstance(d, BoardCircle): + radius = (d.radius_point - d.center).length() + shape_dict = { + "type": "circle", + "start": self.normalize(d.center), + "radius": radius * 1e-6, + "width": d.attributes.stroke.width * 1e-6, + } + if d.attributes.fill.filled: + shape_dict["filled"] = 1 + return shape_dict + if isinstance(d, BoardArc): + a1, a2 = self.arc_angles_degrees(d) + center = d.center() + if center is None: + # Degenerate arc, draw as segment. + return { + "type": "segment", + "start": self.normalize(d.start), + "end": self.normalize(d.end), + "width": d.attributes.stroke.width * 1e-6, + } + return { + "type": "arc", + "start": self.normalize(center), + "radius": d.radius() * 1e-6, + "startangle": a1, + "endangle": a2, + "width": d.attributes.stroke.width * 1e-6, + } + if isinstance(d, BoardPolygon): + shape_dict = { + "type": "polygon", + "pos": [0.0, 0.0], + "angle": 0, + "polygons": self.parse_polygons(d.polygons), + } + if not d.attributes.fill.filled: + shape_dict["filled"] = 0 + shape_dict["width"] = d.attributes.stroke.width * 1e-6 + return shape_dict + if isinstance(d, BoardBezier): + return { + "type": "curve", + "start": self.normalize(d.start), + "cpa": self.normalize(d.control1), + "cpb": self.normalize(d.control2), + "end": self.normalize(d.end), + "width": d.attributes.stroke.width * 1e-6, + } + self.logger.info("Unsupported shape %s, skipping", type(d).__name__) + return None + + def _resolve_text_value(self, value, footprint): + """Expands text variables. Footprint-context variables are + substituted locally, the rest is expanded by KiCad.""" + if '${' not in value: + return value + if footprint is not None: + subs = { + 'REFERENCE': footprint.reference_field.text.value, + 'VALUE': footprint.value_field.text.value, + 'FOOTPRINT_NAME': footprint.definition.id.name, + 'FOOTPRINT_LIBRARY': footprint.definition.id.library, + } + for key, sub in subs.items(): + value = value.replace('${%s}' % key, sub) + if '${' in value: + value = self.board.expand_text_variables(value) + return value + + def _as_common_text(self, d, footprint=None): + from kipy.common_types import Text + + if isinstance(d, Field): + text = d.text.as_text() + elif isinstance(d, BoardText): + text = d.as_text() + elif isinstance(d, BoardTextBox): + return d.as_textbox() + else: + text = d # already a common Text (e.g. dimension text) + + modified = False + + # GetTextAsShapes renders the stored string as-is, with text + # variables like ${REFERENCE} unexpanded. Resolve them first. + value = self._resolve_text_value(text.value, footprint) + if value != text.value: + text = Text(text.proto) # copy, do not mutate the board + text.value = value + modified = True + + # GetTextAsShapes renders the stored angle as-is, but pcbnew + # draws keep-upright text rotated back into readable orientation + # (see EDA_TEXT::GetDrawRotation). Replicate that here, otherwise + # left/right justified text on rotated footprints renders on the + # wrong side of its anchor. + attrs = text.proto.attributes + if attrs.keep_upright: + angle = attrs.angle.value_degrees % 360 + if 90 < angle <= 270: + if not modified: + text = Text(text.proto) + text.proto.attributes.angle.value_degrees = angle - 180 + return text + + def prerender_text_items(self, items): + """Renders all text items in one IPC round trip. + + KiCad renders text to polygonal shapes server side, which replaces + the SWIG GetEffectiveTextShape() call. Results are cached keyed by + the text item's python object id. + + :param items: list of (text_item, parent_footprint_or_None) + """ + items = [(t, f) for (t, f) in items + if id(t) not in self._text_render_cache] + if not items: + return + shapes = self.kicad.get_text_as_shapes( + [self._as_common_text(t, f) for (t, f) in items]) + for (item, _), compound in zip(items, shapes): + self._text_render_cache[id(item)] = compound + + def parse_text(self, d, footprint=None): + common_text = self._as_common_text(d, footprint) + thickness = common_text.attributes.stroke_width * 1e-6 + compound = self._text_render_cache.get(id(d)) + if compound is None: + compound = self.kicad.get_text_as_shapes(common_text)[0] + segments = [] + polygons = [] + from kipy.common_types import Segment, Polygon + for s in compound.shapes: + if isinstance(s, Segment): + segments.append( + [self.normalize(s.start), self.normalize(s.end)]) + elif isinstance(s, Polygon): + polygons.extend(self.parse_polygons(s.polygons)) + else: + self.logger.warn( + "Unsupported subshape in text: %s" % type(s).__name__) + if segments: + return { + "thickness": thickness, + "svgpath": create_path(segments) + } + else: + return { + "polygons": polygons + } + + def parse_drawing(self, d, footprint=None): + if isinstance(d, (BoardSegment, BoardRectangle, BoardCircle, + BoardArc, BoardPolygon, BoardBezier)): + s = self.parse_shape(d) + return [s] if s else [] + if isinstance(d, (BoardText, BoardTextBox, Field)): + s = self.parse_text(d, footprint) + return [s] if s else [] + self.logger.info("Unsupported drawing type %s, skipping", + type(d).__name__) + return [] + + def _field_visible(self, field): + # Note: BoardText.attributes.visible is deprecated and always + # True over the IPC API; Field.visible is the reliable flag. + return field.visible + + def get_all_drawings(self): + """Returns a list of (kind, item, parent_footprint) tuples for all + silk/fab drawings. + + kind is "ref"/"val" for reference and value fields so they can be + tagged in the output, mirroring the SWIG parser. + """ + drawings = [] + for d in self.board.get_shapes(): + drawings.append(("shape", d, None)) + for t in self.board.get_text(): + if isinstance(t, BoardText) and not t.attributes.visible: + continue + drawings.append(("text", t, None)) + for f in self.footprints: + if self._field_visible(f.reference_field): + drawings.append(("ref", f.reference_field, f)) + if self._field_visible(f.value_field): + drawings.append(("val", f.value_field, f)) + for d in f.definition.shapes: + drawings.append(("shape", d, f)) + ref_val_ids = (f.reference_field.field_id, + f.value_field.field_id) + for t in f.texts_and_fields: + if isinstance(t, Field): + if t.field_id in ref_val_ids: + continue + if not self._field_visible(t): + continue + elif isinstance(t, BoardText): + if not t.attributes.visible: + continue + drawings.append(("text", t, f)) + return drawings + + def parse_drawings_on_layers(self, drawings, f_layer, b_layer): + front = [] + back = [] + + text_items = [(d, f) for (kind, d, f) in drawings + if d.layer in [f_layer, b_layer] and + not isinstance(d, (BoardSegment, BoardRectangle, + BoardCircle, BoardArc, + BoardPolygon, BoardBezier))] + self.prerender_text_items(text_items) + + for kind, d, f in drawings: + if d.layer not in [f_layer, b_layer]: + continue + for drawing in self.parse_drawing(d, f): + if kind in ["ref", "val"]: + drawing[kind] = 1 + if d.layer == f_layer: + front.append(drawing) + else: + back.append(drawing) + + return { + "F": front, + "B": back, + } + + def _shape_points_with_extents(self, d): + """Returns points describing the extents of a shape, used for + bounding box computation on the client side.""" + width = d.attributes.stroke.width + points = [] + if isinstance(d, BoardSegment): + points = [d.start, d.end] + elif isinstance(d, BoardRectangle): + points = [d.top_left, d.bottom_right] + elif isinstance(d, (BoardCircle, BoardArc, BoardPolygon, + BoardBezier)): + try: + box = d.bounding_box() + except Exception: + return [] + points = [box.pos, + type(box.pos).from_xy(box.pos.x + box.size.x, + box.pos.y + box.size.y)] + result = [] + for p in points: + x, y = p.x, p.y + result.append([(x - width / 2) * 1e-6, (y - width / 2) * 1e-6]) + result.append([(x + width / 2) * 1e-6, (y + width / 2) * 1e-6]) + return result + + def parse_edges(self): + edges = [] + bbox = None # [minx, miny, maxx, maxy] in mm + + items = list(self.board.get_shapes()) + for f in self.footprints: + items.extend(f.definition.shapes) + + for d in items: + if d.layer != BL.BL_Edge_Cuts: + continue + for parsed_drawing in self.parse_drawing(d): + edges.append(parsed_drawing) + for p in self._shape_points_with_extents(d): + if bbox is None: + bbox = [p[0], p[1], p[0], p[1]] + else: + bbox = [min(bbox[0], p[0]), min(bbox[1], p[1]), + max(bbox[2], p[0]), max(bbox[3], p[1])] + return edges, bbox + + def _pad_is_through_hole(self, pad): + return pad.pad_type in [PAD_TYPE.PT_PTH, PAD_TYPE.PT_NPTH] + + def parse_pad(self, pad): + padstack = pad.padstack + layers_set = list(padstack.layers) + layers = [] + for layer, letter in OUTER_COPPER_LAYERS: + if layer in layers_set: + layers.append(letter) + if not layers and not self._pad_is_through_hole(pad): + return [] + + custom_padstack = ( + padstack.type != PAD_STACK_TYPE.PST_NORMAL or + padstack.unconnected_layer_removal != ULR.ULR_KEEP + ) + + if custom_padstack: + presence = self.board.check_padstack_presence_on_layers( + pad, [layer for layer, _ in OUTER_COPPER_LAYERS]) + presence = presence.get(pad, {}) + pads = [] + for layer, letter in OUTER_COPPER_LAYERS: + if layer in layers_set and presence.get(layer, True): + pad_dict = self.parse_pad_layer(pad, layer) + if pad_dict is not None: + pad_dict["layers"] = [letter] + pads.append(pad_dict) + return pads + else: + pad_layer = layers_set[0] if layers_set else BL.BL_F_Cu + pad_dict = self.parse_pad_layer(pad, pad_layer) + if pad_dict is None: + return [] + pad_dict["layers"] = layers + return [pad_dict] + + def parse_pad_layer(self, pad, layer): + padstack = pad.padstack + psl = padstack.copper_layer(layer) + if psl is None: + cls = padstack.copper_layers + psl = cls[0] if cls else None + if psl is None: + self.logger.info("Pad %s has no copper layers, skipping.", + pad.number) + return None + pos = self.normalize(pad.position) + size = self.normalize(psl.size) + angle = padstack.angle.degrees + shape = { + PAD_STACK_SHAPE.PSS_CIRCLE: "circle", + PAD_STACK_SHAPE.PSS_RECTANGLE: "rect", + PAD_STACK_SHAPE.PSS_OVAL: "oval", + PAD_STACK_SHAPE.PSS_TRAPEZOID: "trapezoid", + PAD_STACK_SHAPE.PSS_ROUNDRECT: "roundrect", + PAD_STACK_SHAPE.PSS_CHAMFEREDRECT: "chamfrect", + PAD_STACK_SHAPE.PSS_CUSTOM: "custom", + }.get(psl.shape, "") + if shape == "": + self.logger.info("Unsupported pad shape %s, skipping.", psl.shape) + return None + pad_dict = { + "pos": pos, + "size": size, + "angle": angle, + "shape": shape + } + if shape == "custom": + polygon = self.board.get_pad_shapes_as_polygons(pad, layer) + if polygon is None: + self.logger.info( + "Failed to get polygon for custom pad %s", pad.number) + return None + # Polygon comes in absolute board coordinates, convert it to + # pad-relative coordinates that the renderer expects. + points = self.parse_poly_line(polygon.outline) + if polygon.holes: + self.logger.warn('Detected holes in custom pad polygons') + points = [self.rotate(p, pos, -angle) for p in points] + points = [[p[0] - pos[0], p[1] - pos[1]] for p in points] + pad_dict["polygons"] = [points] + if shape == "trapezoid": + # treat trapezoid as custom shape + pad_dict["shape"] = "custom" + delta = self.normalize(psl.trapezoid_delta) + size = pad_dict["size"] + pad_dict["polygons"] = [[ + [size[0] / 2 + delta[1] / 2, size[1] / 2 - delta[0] / 2], + [-size[0] / 2 - delta[1] / 2, size[1] / 2 + delta[0] / 2], + [-size[0] / 2 + delta[1] / 2, -size[1] / 2 - delta[0] / 2], + [size[0] / 2 - delta[1] / 2, -size[1] / 2 + delta[0] / 2], + ]] + + if shape in ["roundrect", "chamfrect"]: + pad_dict["radius"] = (psl.corner_rounding_ratio * + min(psl.size.x, psl.size.y) * 1e-6) + if shape == "chamfrect": + corners = psl.chamfered_corners + chamfpos = 0 + if corners.top_left: + chamfpos |= CHAMFER_TOP_LEFT + if corners.top_right: + chamfpos |= CHAMFER_TOP_RIGHT + if corners.bottom_left: + chamfpos |= CHAMFER_BOTTOM_LEFT + if corners.bottom_right: + chamfpos |= CHAMFER_BOTTOM_RIGHT + pad_dict["chamfpos"] = chamfpos + pad_dict["chamfratio"] = psl.chamfer_ratio + + if self._pad_is_through_hole(pad): + drill = padstack.drill + pad_dict["type"] = "th" + pad_dict["drillshape"] = { + DRILL_SHAPE.DS_CIRCLE: "circle", + DRILL_SHAPE.DS_OBLONG: "oblong", + }.get(drill.shape, "circle") + pad_dict["drillsize"] = self.normalize(drill.diameter) + else: + pad_dict["type"] = "smd" + + pad_dict["offset"] = self.normalize(psl.offset) + if self.config.include_nets: + pad_dict["net"] = pad.net.name + + return pad_dict + + def _footprint_bbox(self, f): + """Computes the footprint bounding box at orientation 0, relative + to the footprint position, like the SWIG parser did by copying the + footprint and resetting its orientation. + """ + pos = self.normalize(f.position) + angle = f.orientation.degrees + points = [] + + for d in f.definition.shapes: + if d.layer == BL.BL_Edge_Cuts: + continue + points.extend(self._shape_points_with_extents(d)) + + for pad in f.definition.pads: + pad_pos = self.normalize(pad.position) + psl_list = pad.padstack.copper_layers + half_w = half_h = 0 + for psl in psl_list: + half_w = max(half_w, psl.size.x * 1e-6 / 2) + half_h = max(half_h, psl.size.y * 1e-6 / 2) + drill = pad.padstack.drill + half_w = max(half_w, drill.diameter.x * 1e-6 / 2) + half_h = max(half_h, drill.diameter.y * 1e-6 / 2) + pad_angle = pad.padstack.angle.degrees + for sx in (-1, 1): + for sy in (-1, 1): + corner = [pad_pos[0] + sx * half_w, + pad_pos[1] + sy * half_h] + points.append(self.rotate(corner, pad_pos, pad_angle)) + + if not points: + points = [pos] + + # Transform from board coordinates to footprint-local unrotated + # coordinates. + local = [self.rotate(p, pos, -angle) for p in points] + minx = min(p[0] for p in local) + miny = min(p[1] for p in local) + maxx = max(p[0] for p in local) + maxy = max(p[1] for p in local) + + return { + "pos": pos, + "relpos": [minx - pos[0], miny - pos[1]], + "size": [maxx - minx, maxy - miny], + "angle": angle, + } + + def parse_footprints(self): + footprints = [] + for f in self.footprints: + ref = f.reference_field.text.value + + # graphical drawings on copper layers + drawings = [] + for d in f.definition.shapes: + if d.layer not in [BL.BL_F_Cu, BL.BL_B_Cu]: + continue + for drawing in self.parse_drawing(d): + drawings.append({ + "layer": "F" if d.layer == BL.BL_F_Cu else "B", + "drawing": drawing, + }) + + # footprint pads + pads = [] + for p in f.definition.pads: + for pad_dict in self.parse_pad(p): + pads.append((p.number, pad_dict)) + + if pads: + # Try to guess first pin name. + pads = sorted(pads, key=lambda el: el[0]) + pin1_pads = [p for p in pads if p[0] in + ['1', 'A', 'A1', 'P1', 'PAD1']] + if pin1_pads: + pin1_pad_name = pin1_pads[0][0] + else: + # No pads have common first pin name, + # pick lexicographically smallest. + pin1_pad_name = pads[0][0] + for pad_name, pad_dict in pads: + if pad_name == pin1_pad_name: + pad_dict['pin1'] = 1 + + pads = [p[1] for p in pads] + + footprints.append({ + "ref": ref, + "bbox": self._footprint_bbox(f), + "pads": pads, + "drawings": drawings, + "layer": self.layer_letter(f.layer), + }) + + return footprints + + def parse_tracks(self, tracks_and_vias): + result = {BL.BL_F_Cu: [], BL.BL_B_Cu: []} + for track in tracks_and_vias: + if isinstance(track, Via): + track_dict = { + "start": self.normalize(track.position), + "end": self.normalize(track.position), + "width": track.diameter * 1e-6, + "net": track.net.name, + } + for layer, _ in OUTER_COPPER_LAYERS: + if layer in track.padstack.layers: + result[layer].append(track_dict) + else: + if track.layer in [BL.BL_F_Cu, BL.BL_B_Cu]: + if isinstance(track, ArcTrack): + a1, a2 = self.arc_angles_degrees(track) + center = track.center() + if center is None: + continue + track_dict = { + "center": self.normalize(center), + "startangle": a1, + "endangle": a2, + "radius": track.radius() * 1e-6, + "width": track.width * 1e-6, + } + else: + track_dict = { + "start": self.normalize(track.start), + "end": self.normalize(track.end), + "width": track.width * 1e-6, + } + if self.config.include_nets: + track_dict["net"] = track.net.name + result[track.layer].append(track_dict) + + return { + 'F': result[BL.BL_F_Cu], + 'B': result[BL.BL_B_Cu], + } + + def parse_zones(self, zones): + result = {BL.BL_F_Cu: [], BL.BL_B_Cu: []} + for zone in zones: + if not zone.filled or zone.is_rule_area(): + continue + filled = zone.filled_polygons + for layer, polys in filled.items(): + if layer not in result: + continue + zone_dict = { + "polygons": self.parse_polygons(polys), + "width": 0, + } + if self.config.include_nets: + net = getattr(zone, 'net', None) + zone_dict["net"] = net.name if net is not None else "" + result[layer].append(zone_dict) + + return { + 'F': result[BL.BL_F_Cu], + 'B': result[BL.BL_B_Cu], + } + + def parse_netlist(self): + nets = [n.name for n in self.board.get_nets()] + return sorted(nets) + + def get_footprint_fields(self, f): + props = {} + for t in f.texts_and_fields: + if isinstance(t, Field): + value = t.text.value + props[t.name] = value + # Reference/value/etc are Fields too, keep parity with + # GetFieldsShownText which includes them. + expanded = self.board.expand_text_variables(list(props.values())) + props = dict(zip(props.keys(), expanded)) + if "dnp" in props and props["dnp"] == "": + del props["dnp"] + props["kicad_dnp"] = "DNP" + if f.attributes.do_not_populate: + props["kicad_dnp"] = "DNP" + return props + + def parse_extra_data_from_pcb(self): + field_set = set() + by_ref = {} + by_index = {} + + for (i, f) in enumerate(self.footprints): + props = self.get_footprint_fields(f) + by_index[i] = props + ref = f.reference_field.text.value + ref_fields = by_ref.setdefault(ref, {}) + + for k, v in props.items(): + field_set.add(k) + ref_fields[k] = v + + return ExtraFieldData(list(field_set), by_ref, by_index) + + def get_extra_field_data(self, file_name): + if os.path.abspath(file_name) == os.path.abspath(self.file_name): + return self.parse_extra_data_from_pcb() + if os.path.splitext(file_name)[1] == '.kicad_pcb': + return None + + data = parse_schematic_data(file_name) + + return ExtraFieldData(data[0], data[1]) + + def latest_extra_data(self, extra_dirs=None): + base_name = os.path.splitext(os.path.basename(self.file_name))[0] + file_dir_name = os.path.dirname(self.file_name) + directories = [file_dir_name] + for dir in (extra_dirs or []): + if not os.path.isabs(dir): + dir = os.path.join(file_dir_name, dir) + if os.path.exists(dir): + directories.append(dir) + return find_latest_schematic_data(base_name, directories) + + def extra_data_file_filter(self): + return ("Netlist, xml and pcb files (*.net; *.xml; *.kicad_pcb)|" + "*.net;*.xml;*.kicad_pcb") + + def footprint_to_component(self, footprint, extra_fields): + footprint_name = str(footprint.definition.id.name) + + attr = 'Normal' + if footprint.attributes.exclude_from_bill_of_materials: + attr = 'Virtual' + + return Component(footprint.reference_field.text.value, + footprint.value_field.text.value, + footprint_name, + self.layer_letter(footprint.layer), + attr, + extra_fields) + + def parse(self): + from ..errors import ParsingException + + # Get extra field data + field_set = set(self.config.show_fields) + field_set.discard("Value") + field_set.discard("Footprint") + need_extra_fields = (field_set or + self.config.board_variant_whitelist or + self.config.board_variant_blacklist or + self.config.dnp_field) + + if not self.config.extra_data_file and need_extra_fields: + self.config.extra_data_file = self.file_name + self.logger.warn('Assuming extra data file to be the pcb file ' + 'since --extra-data-file was not specified.') + + extra_field_data = None + if (self.config.extra_data_file and + os.path.isfile(self.config.extra_data_file)): + extra_field_data = self.parse_extra_data( + self.config.extra_data_file, self.config.normalize_field_case) + + if extra_field_data is None and need_extra_fields: + raise ParsingException( + 'Failed parsing %s' % self.config.extra_data_file) + + title_block = self.board.get_title_block_info() + texts = self.board.expand_text_variables([ + title_block.title, + title_block.revision, + title_block.company, + title_block.date, + ]) + title, revision, company, file_date = texts + + if not file_date: + file_mtime = os.path.getmtime(self.file_name) + file_date = datetime.fromtimestamp(file_mtime).strftime( + '%Y-%m-%d %H:%M:%S') + pcb_file_name = os.path.basename(self.file_name) + if not title: + # remove .kicad_pcb extension + title = os.path.splitext(pcb_file_name)[0] + + edges, bbox = self.parse_edges() + if bbox is None: + self.logger.error('Please draw pcb outline on the edges ' + 'layer on sheet or any footprint before ' + 'generating BOM.') + return None, None + bbox = { + "minx": bbox[0], + "miny": bbox[1], + "maxx": bbox[2], + "maxy": bbox[3], + } + + drawings = self.get_all_drawings() + + pcbdata = { + "edges_bbox": bbox, + "edges": edges, + "drawings": { + "silkscreen": self.parse_drawings_on_layers( + drawings, BL.BL_F_SilkS, BL.BL_B_SilkS), + "fabrication": self.parse_drawings_on_layers( + drawings, BL.BL_F_Fab, BL.BL_B_Fab), + }, + "footprints": self.parse_footprints(), + "metadata": { + "title": title, + "revision": revision, + "company": company, + "date": file_date, + "variant": getattr(self.config, 'kicad_variant', ''), + }, + "bom": {}, + "font_data": {} + } + if self.config.include_tracks: + tracks_and_vias = (list(self.board.get_tracks()) + + list(self.board.get_vias())) + pcbdata["tracks"] = self.parse_tracks(tracks_and_vias) + pcbdata["zones"] = self.parse_zones(self.board.get_zones()) + if self.config.include_nets: + pcbdata["nets"] = self.parse_netlist() + + if extra_field_data and need_extra_fields: + extra_fields = extra_field_data.fields_by_index + if extra_fields: + extra_fields = extra_fields.values() + + if extra_fields is None: + extra_fields = [] + field_map = extra_field_data.fields_by_ref + warning_shown = False + + for f in self.footprints: + ref = f.reference_field.text.value + extra_fields.append(field_map.get(ref, {})) + if ref not in field_map: + # Some components are on pcb but not in schematic + # data. Show a warning about outdated extra data + # file. + self.logger.warn( + 'Component %s is missing from schematic data.' + % ref) + warning_shown = True + + if warning_shown: + self.logger.warn('Netlist/xml file is likely out of date.') + else: + extra_fields = [{}] * len(self.footprints) + + components = [self.footprint_to_component(f, e) + for (f, e) in zip(self.footprints, extra_fields)] + + return pcbdata, components diff --git a/ipc_entrypoint.py b/ipc_entrypoint.py new file mode 100644 index 00000000..d72d4aca --- /dev/null +++ b/ipc_entrypoint.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Entry point for KiCad's IPC plugin system. + +KiCad launches this script in its own process (inside the plugin's +virtual environment) when the user clicks the iBOM action button. +Connection parameters are passed via the KICAD_API_SOCKET and +KICAD_API_TOKEN environment variables, which kipy picks up +automatically. + +With wxPython available the regular settings dialog is shown; +otherwise the BOM is generated headlessly with the saved +(or default) settings. +""" + +import os +import sys + +# Prevent InteractiveHtmlBom/__init__.py from trying to register the +# legacy SWIG action plugin when the package is imported. +os.environ['INTERACTIVE_HTML_BOM_CLI_MODE'] = '1' + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from InteractiveHtmlBom.core import ibom # noqa: E402 +from InteractiveHtmlBom.core.config import Config # noqa: E402 +from InteractiveHtmlBom.ecad import get_kicad_ipc_parser # noqa: E402 +from InteractiveHtmlBom.errors import ParsingException # noqa: E402 +from InteractiveHtmlBom.version import version # noqa: E402 +from InteractiveHtmlBom.compat import get_wx # noqa: E402 + + +def main(): + wx = get_wx() + app = None + if wx is not None: + app = wx.App() + if hasattr(wx, "APP_ASSERT_SUPPRESS"): + app.SetAssertMode(wx.APP_ASSERT_SUPPRESS) + + logger = ibom.Logger(cli=(wx is None)) + + try: + from kipy import KiCad + kicad = KiCad() + board = kicad.get_board() + except Exception as e: + logger.error("Cannot connect to KiCad via the IPC API: %s" % e) + return 1 + + file_name = board.name + if not file_name: + logger.error('Please save the board file before generating BOM.') + return 1 + + project = board.get_project() + pcb_file_name = os.path.join(project.path, file_name) + + config = Config(version, os.path.dirname(pcb_file_name)) + parser = get_kicad_ipc_parser(pcb_file_name, config, logger, kicad, board) + + try: + if wx is not None: + ibom.run_with_dialog(parser, config, logger) + else: + config.load_from_ini() + ibom.main(parser, config, logger) + except ParsingException as e: + logger.error(str(e)) + return 1 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/plugin.json b/plugin.json new file mode 100644 index 00000000..9191f360 --- /dev/null +++ b/plugin.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://go.kicad.org/api/schemas/v1", + "identifier": "org.openscopeproject.InteractiveHtmlBom", + "name": "Interactive HTML BOM", + "description": "Generates an interactive HTML page with a BOM table and pcb drawing.", + "runtime": { + "type": "python", + "min_version": "3.9" + }, + "actions": [ + { + "identifier": "generate-bom", + "name": "Generate Interactive HTML BOM", + "description": "Opens the iBOM settings dialog and generates an interactive HTML BOM.", + "show-button": true, + "scopes": [ + "pcb" + ], + "entrypoint": "ipc_entrypoint.py", + "icons-light": [ + "InteractiveHtmlBom/icon.png" + ], + "icons-dark": [ + "InteractiveHtmlBom/icon.png" + ] + } + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..e9c6b394 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# Dependencies for the KiCad IPC plugin runtime. +# KiCad installs these into a per-plugin virtual environment created +# with --system-site-packages. +kicad-python>=0.7.1 +# The interactive settings dialog needs wxPython. It is intentionally +# NOT listed here: pip has no prebuilt wheels for many Linux distros +# and building it from source inside KiCad's plugin venv can take a +# very long time or fail. The venv inherits system site-packages, so +# installing wxPython through the system package manager (e.g. +# python3-wxgtk4.0 on Debian/Ubuntu, it usually comes with KiCad +# itself) makes the dialog available. Without wxPython the plugin +# falls back to headless generation with saved/default settings. From aee86ca00b45390b45d8db3d35223f86bc3e62c8 Mon Sep 17 00:00:00 2001 From: pong Date: Mon, 15 Jun 2026 16:06:35 +0300 Subject: [PATCH 2/2] Make IPC API the primary kicad parser, demote SWIG to kicad_swig - Move legacy SWIG parser (PcbnewParser + the ActionPlugin) to ecad/kicad_swig.py and promote the IPC API parser into ecad/kicad.py so the IPC backend is the default going forward. - generate_interactive_bom.py: route .kicad_pcb through the IPC parser when an API server context is present (KICAD_API_SOCKET), otherwise fall back to the SWIG parser which loads the board file from disk. - Skip registering the legacy SWIG ActionPlugin on KiCad 9+ (where the IPC plugin manifest provides the iBOM action) to avoid a duplicate toolbar button. I hate work, and may the Claude be with you. Co-Authored-By: Claude Opus 4.8 --- InteractiveHtmlBom/__init__.py | 23 +- InteractiveHtmlBom/ecad/__init__.py | 30 +- InteractiveHtmlBom/ecad/kicad.py | 1319 ++++++++++++------------- InteractiveHtmlBom/ecad/kicad_ipc.py | 932 ----------------- InteractiveHtmlBom/ecad/kicad_swig.py | 955 ++++++++++++++++++ 5 files changed, 1647 insertions(+), 1612 deletions(-) delete mode 100644 InteractiveHtmlBom/ecad/kicad_ipc.py create mode 100644 InteractiveHtmlBom/ecad/kicad_swig.py diff --git a/InteractiveHtmlBom/__init__.py b/InteractiveHtmlBom/__init__.py index f171a41f..1af48b32 100644 --- a/InteractiveHtmlBom/__init__.py +++ b/InteractiveHtmlBom/__init__.py @@ -43,9 +43,28 @@ def callback(_): top_tb.Realize() +def _supports_ipc_api(): + # KiCad 9+ ships the IPC API plugin system; the iBOM action is then + # provided through the plugin manifest (ipc_entrypoint.py) instead of + # the legacy SWIG ActionPlugin, so registering the SWIG one would just + # duplicate the toolbar button. + try: + import pcbnew + except ImportError: + return False + if not hasattr(pcbnew, 'Version'): + return False + try: + major = int(pcbnew.Version().split('.')[0].split('-')[0]) + except (ValueError, IndexError): + return False + return major >= 9 + + if (not os.environ.get('INTERACTIVE_HTML_BOM_CLI_MODE', False) and - not os.path.basename(sys.argv[0]).startswith('generate_interactive_bom')): - from .ecad.kicad import InteractiveHtmlBomPlugin + not os.path.basename(sys.argv[0]).startswith('generate_interactive_bom') + and not _supports_ipc_api()): + from .ecad.kicad_swig import InteractiveHtmlBomPlugin plugin = InteractiveHtmlBomPlugin() plugin.register() diff --git a/InteractiveHtmlBom/ecad/__init__.py b/InteractiveHtmlBom/ecad/__init__.py index ba56a057..12fd455f 100644 --- a/InteractiveHtmlBom/ecad/__init__.py +++ b/InteractiveHtmlBom/ecad/__init__.py @@ -4,7 +4,19 @@ def get_parser_by_extension(file_name, config, logger): ext = os.path.splitext(file_name)[1] if ext == '.kicad_pcb': - return get_kicad_parser(file_name, config, logger) + # Prefer the IPC API parser, but only when an API server context is + # actually present (KICAD_API_SOCKET is set by a running KiCad or by + # kicad-cli's server mode, and kipy picks it up automatically). + # Without it we must not connect to some unrelated running KiCad and + # parse its open board; we fall back to the legacy SWIG parser, which + # is the only one that loads the board file straight from disk. + if os.environ.get('KICAD_API_SOCKET'): + try: + return get_kicad_parser(file_name, config, logger) + except Exception as e: + logger.info("IPC API unavailable (%s), " + "falling back to SWIG parser." % e) + return get_kicad_swig_parser(file_name, config, logger) elif ext == '.json': """.json file may be from EasyEDA or a generic json format""" import io @@ -21,14 +33,18 @@ def get_parser_by_extension(file_name, config, logger): return None -def get_kicad_parser(file_name, config, logger, board=None): - from .kicad import PcbnewParser - return PcbnewParser(file_name, config, logger, board) +def get_kicad_parser(file_name, config, logger, kicad=None, board=None): + from .kicad import IpcApiParser + return IpcApiParser(file_name, config, logger, kicad, board) -def get_kicad_ipc_parser(file_name, config, logger, kicad=None, board=None): - from .kicad_ipc import IpcApiParser - return IpcApiParser(file_name, config, logger, kicad, board) +# Backwards compatible alias for the IPC parser. +get_kicad_ipc_parser = get_kicad_parser + + +def get_kicad_swig_parser(file_name, config, logger, board=None): + from .kicad_swig import PcbnewParser + return PcbnewParser(file_name, config, logger, board) def get_easyeda_parser(file_name, config, logger): diff --git a/InteractiveHtmlBom/ecad/kicad.py b/InteractiveHtmlBom/ecad/kicad.py index bef0b30b..4b5cc38e 100644 --- a/InteractiveHtmlBom/ecad/kicad.py +++ b/InteractiveHtmlBom/ecad/kicad.py @@ -1,531 +1,509 @@ +"""KiCad IPC API parser. + +Talks to a running KiCad instance through the IPC API using the kipy +(kicad-python) bindings instead of the legacy SWIG pcbnew module. +Produces the same pcbdata structure as the legacy PcbnewParser so the +rest of iBOM (and the html renderer) is unaffected. + +Requires KiCad 9.0.3+ with the API server enabled and kicad-python 0.7+. +""" + +import math import os from datetime import datetime -import pcbnew +from kipy import KiCad +from kipy.board_types import ( + ArcTrack, + BoardArc, + BoardBezier, + BoardCircle, + BoardPolygon, + BoardRectangle, + BoardSegment, + BoardText, + BoardTextBox, + Field, + Track, + Via, +) +from kipy.proto.board import board_types_pb2 from .common import EcadParser, Component, ExtraFieldData from .kicad_extra import find_latest_schematic_data, parse_schematic_data from .svgpath import create_path -from ..core import ibom -from ..core.config import Config -from ..core.fontparser import FontParser - - -KICAD_VERSION = [5, 1, 0] - -if hasattr(pcbnew, 'Version'): - version = pcbnew.Version().split('.') - try: - for i in range(len(version)): - version[i] = int(version[i].split('-')[0]) - except ValueError: - pass - KICAD_VERSION = version - - -class PcbnewParser(EcadParser): - - def __init__(self, file_name, config, logger, board=None): - super(PcbnewParser, self).__init__(file_name, config, logger) - self.board = board - if self.board is None: - self.board = pcbnew.LoadBoard(self.file_name) # type: pcbnew.BOARD - if not self.board: - raise Exception('Failed to load board file') - if hasattr(self.board, "SetCurrentVariant"): - self.board.SetCurrentVariant(config.kicad_variant) - if hasattr(self.board, 'GetModules'): - # type: list[pcbnew.MODULE] - self.footprints = list(self.board.GetModules()) - else: - # type: list[pcbnew.FOOTPRINT] - self.footprints = list(self.board.GetFootprints()) - self.font_parser = FontParser() - def get_extra_field_data(self, file_name): - if os.path.abspath(file_name) == os.path.abspath(self.file_name): - return self.parse_extra_data_from_pcb() - if os.path.splitext(file_name)[1] == '.kicad_pcb': - return None +BL = board_types_pb2.BoardLayer +PAD_TYPE = board_types_pb2.PadType +PAD_STACK_TYPE = board_types_pb2.PadStackType +PAD_STACK_SHAPE = board_types_pb2.PadStackShape +DRILL_SHAPE = board_types_pb2.DrillShape +ULR = board_types_pb2.UnconnectedLayerRemoval +ZONE_TYPE = board_types_pb2.ZoneType - data = parse_schematic_data(file_name) +OUTER_COPPER_LAYERS = [(BL.BL_F_Cu, "F"), (BL.BL_B_Cu, "B")] - return ExtraFieldData(data[0], data[1]) +# Bitmask values matching pcbnew's RECT_CHAMFER_POSITIONS, which is what +# the html renderer expects in the "chamfpos" field. +CHAMFER_TOP_LEFT = 1 +CHAMFER_TOP_RIGHT = 2 +CHAMFER_BOTTOM_LEFT = 4 +CHAMFER_BOTTOM_RIGHT = 8 - def get_footprint_fields(self, f): - # type: (pcbnew.FOOTPRINT) -> dict - props = {} - if hasattr(f, "GetProperties"): - props = f.GetProperties() - if hasattr(f, "GetFields"): - props = f.GetFieldsShownText() - if "dnp" in props and props["dnp"] == "": - del props["dnp"] - props["kicad_dnp"] = "DNP" - if hasattr(f, "IsDNP"): - if f.IsDNP(): - props["kicad_dnp"] = "DNP" - if hasattr(f, 'GetVariant'): - variant = f.GetVariant(self.config.kicad_variant) - if variant: - var_fields = variant.GetFields() - for k in var_fields.keys(): - props[str(k)] = str(f.GetFieldShownText(str(k))) - props["kicad_dnp"] = "DNP" if variant.GetDNP() else "" - return props +class IpcApiParser(EcadParser): - def parse_extra_data_from_pcb(self): - field_set = set() - by_ref = {} - by_index = {} - - for (i, f) in enumerate(self.footprints): - props = self.get_footprint_fields(f) - by_index[i] = props - ref = f.GetReference() - ref_fields = by_ref.setdefault(ref, {}) - - for k, v in props.items(): - field_set.add(k) - ref_fields[k] = v - - return ExtraFieldData(list(field_set), by_ref, by_index) - - def latest_extra_data(self, extra_dirs=None): - base_name = os.path.splitext(os.path.basename(self.file_name))[0] - extra_dirs.append(self.board.GetPlotOptions().GetOutputDirectory()) - file_dir_name = os.path.dirname(self.file_name) - directories = [file_dir_name] - for dir in extra_dirs: - if not os.path.isabs(dir): - dir = os.path.join(file_dir_name, dir) - if os.path.exists(dir): - directories.append(dir) - return find_latest_schematic_data(base_name, directories) - - def extra_data_file_filter(self): - if hasattr(self.board, 'GetModules'): - return "Netlist and xml files (*.net; *.xml)|*.net;*.xml" - else: - return ("Netlist, xml and pcb files (*.net; *.xml; *.kicad_pcb)|" - "*.net;*.xml;*.kicad_pcb") + def __init__(self, file_name, config, logger, kicad=None, board=None): + self.kicad = kicad if kicad is not None else KiCad() + self.board = board if board is not None else self.kicad.get_board() + if not file_name: + project = self.board.get_project() + file_name = os.path.join(project.path, self.board.name) + super(IpcApiParser, self).__init__(file_name, config, logger) + self.footprints = list(self.board.get_footprints()) + self._text_render_cache = {} @staticmethod def normalize(point): return [point.x * 1e-6, point.y * 1e-6] @staticmethod - def normalize_angle(angle): - if isinstance(angle, int) or isinstance(angle, float): - return angle * 0.1 - else: - return angle.AsDegrees() + def rotate(point, center, angle_degrees): + # Rotation in kicad's coordinate system (y axis down, + # positive angle is counterclockwise). + a = -math.radians(angle_degrees) + dx = point[0] - center[0] + dy = point[1] - center[1] + return [center[0] + dx * math.cos(a) - dy * math.sin(a), + center[1] + dx * math.sin(a) + dy * math.cos(a)] - def get_arc_angles(self, d): - # type: (pcbnew.PCB_SHAPE) -> tuple - a1 = self.normalize_angle(d.GetArcAngleStart()) - if hasattr(d, "GetAngle"): - a2 = a1 + self.normalize_angle(d.GetAngle()) + @staticmethod + def layer_letter(layer): + return {BL.BL_F_Cu: "F", BL.BL_B_Cu: "B"}.get(layer) + + def parse_poly_line(self, poly_line): + # type: (...) -> list + # PolyLine nodes are either points or arcs. Arcs are approximated + # with their start/mid/end points; filled polygons coming from + # KiCad are already flattened so this is rarely hit. + result = [] + for node in poly_line.nodes: + if node.has_point: + result.append(self.normalize(node.point)) + elif node.has_arc: + arc = node.arc + result.append(self.normalize(arc.start)) + result.append(self.normalize(arc.mid)) + result.append(self.normalize(arc.end)) + return result + + def parse_polygons(self, polygons_with_holes): + # Holes are not supported by the legacy data format either: + # SWIG parser warned about them and used outlines only. + result = [] + for poly in polygons_with_holes: + if poly.holes: + self.logger.warn('Detected holes in polygon, ignoring them') + result.append(self.parse_poly_line(poly.outline)) + return result + + @staticmethod + def arc_angles_degrees(shape): + """Computes (startangle, endangle) the way the legacy parser did. + + Angles are in kicad's screen coordinate system (y axis down, + angles grow clockwise on screen). kipy's start_angle()/end_angle() + use the math convention (y up) so we compute from the arc's + start/mid/end points directly. + """ + center = shape.center() + if center is None: + return 0, 0 + + def angle_to(p): + return math.degrees(math.atan2(p.y - center.y, p.x - center.x)) + + a1 = angle_to(shape.start) + am = angle_to(shape.mid) + a2 = angle_to(shape.end) + if a1 >= 180 - 1e-3: + a1 -= 360 # match pcbnew's (-180, 180] -> [-180, 180) edge + # Determine sweep direction: the mid point must lie on the arc. + ccw_mid = (am - a1) % 360 + ccw_end = (a2 - a1) % 360 + if ccw_mid <= ccw_end: + sweep = ccw_end else: - a2 = a1 + self.normalize_angle(d.GetArcAngle()) + sweep = ccw_end - 360 + a2 = a1 + sweep if a2 < a1: a1, a2 = a2, a1 return round(a1, 2), round(a2, 2) def parse_shape(self, d): - # type: (pcbnew.PCB_SHAPE) -> dict | None - shape = { - pcbnew.S_SEGMENT: "segment", - pcbnew.S_CIRCLE: "circle", - pcbnew.S_ARC: "arc", - pcbnew.S_POLYGON: "polygon", - pcbnew.S_CURVE: "curve", - pcbnew.S_RECT: "rect", - }.get(d.GetShape(), "") - if shape == "": - self.logger.info("Unsupported shape %s, skipping", d.GetShape()) - return None - start = self.normalize(d.GetStart()) - end = self.normalize(d.GetEnd()) - if shape == "segment": + if isinstance(d, BoardSegment): return { - "type": shape, - "start": start, - "end": end, - "width": d.GetWidth() * 1e-6 + "type": "segment", + "start": self.normalize(d.start), + "end": self.normalize(d.end), + "width": d.attributes.stroke.width * 1e-6, } - - if shape == "rect": - if hasattr(d, "GetRectCorners"): - points = list(map(self.normalize, d.GetRectCorners())) - else: - points = [ - start, - [end[0], start[1]], - end, - [start[0], end[1]] - ] - shape_dict = { + if isinstance(d, BoardRectangle): + start = self.normalize(d.top_left) + end = self.normalize(d.bottom_right) + return { "type": "polygon", "pos": [0, 0], "angle": 0, - "polygons": [points], - "width": d.GetWidth() * 1e-6, - "filled": 0 + "polygons": [[ + start, + [end[0], start[1]], + end, + [start[0], end[1]], + ]], + "width": d.attributes.stroke.width * 1e-6, + "filled": 1 if d.attributes.fill.filled else 0, } - if hasattr(d, "IsFilled") and d.IsFilled(): - shape_dict["filled"] = 1 - return shape_dict - - if shape == "circle": + if isinstance(d, BoardCircle): + radius = (d.radius_point - d.center).length() shape_dict = { - "type": shape, - "start": start, - "radius": d.GetRadius() * 1e-6, - "width": d.GetWidth() * 1e-6 + "type": "circle", + "start": self.normalize(d.center), + "radius": radius * 1e-6, + "width": d.attributes.stroke.width * 1e-6, } - if hasattr(d, "IsFilled") and d.IsFilled(): + if d.attributes.fill.filled: shape_dict["filled"] = 1 return shape_dict - - if shape == "arc": - a1, a2 = self.get_arc_angles(d) - if hasattr(d, "GetCenter"): - start = self.normalize(d.GetCenter()) + if isinstance(d, BoardArc): + a1, a2 = self.arc_angles_degrees(d) + center = d.center() + if center is None: + # Degenerate arc, draw as segment. + return { + "type": "segment", + "start": self.normalize(d.start), + "end": self.normalize(d.end), + "width": d.attributes.stroke.width * 1e-6, + } return { - "type": shape, - "start": start, - "radius": d.GetRadius() * 1e-6, + "type": "arc", + "start": self.normalize(center), + "radius": d.radius() * 1e-6, "startangle": a1, "endangle": a2, - "width": d.GetWidth() * 1e-6 + "width": d.attributes.stroke.width * 1e-6, } - - if shape == "polygon": - if hasattr(d, "GetPolyShape"): - polygons = self.parse_poly_set(d.GetPolyShape()) - else: - self.logger.info( - "Polygons not supported for KiCad 4, skipping") - return None - angle = 0 - if hasattr(d, 'GetParentModule'): - parent_footprint = d.GetParentModule() - else: - parent_footprint = d.GetParentFootprint() - if parent_footprint is not None and KICAD_VERSION[0] < 8: - angle = self.normalize_angle(parent_footprint.GetOrientation()) + if isinstance(d, BoardPolygon): shape_dict = { - "type": shape, - "pos": start, - "angle": angle, - "polygons": polygons + "type": "polygon", + "pos": [0.0, 0.0], + "angle": 0, + "polygons": self.parse_polygons(d.polygons), } - if ((hasattr(d, "IsFilled") and not d.IsFilled()) or - (hasattr(d, "IsSolidFill") and not d.IsSolidFill())): + if not d.attributes.fill.filled: shape_dict["filled"] = 0 - shape_dict["width"] = d.GetWidth() * 1e-6 + shape_dict["width"] = d.attributes.stroke.width * 1e-6 return shape_dict - if shape == "curve": - if hasattr(d, "GetBezierC1"): - c1 = self.normalize(d.GetBezierC1()) - c2 = self.normalize(d.GetBezierC2()) - else: - c1 = self.normalize(d.GetBezControl1()) - c2 = self.normalize(d.GetBezControl2()) + if isinstance(d, BoardBezier): return { - "type": shape, - "start": start, - "cpa": c1, - "cpb": c2, - "end": end, - "width": d.GetWidth() * 1e-6 + "type": "curve", + "start": self.normalize(d.start), + "cpa": self.normalize(d.control1), + "cpb": self.normalize(d.control2), + "end": self.normalize(d.end), + "width": d.attributes.stroke.width * 1e-6, } - - def parse_line_chain(self, shape): - # type: (pcbnew.SHAPE_LINE_CHAIN) -> list - result = [] - if not hasattr(shape, "PointCount"): - self.logger.warn("No PointCount method on outline object. " - "Unpatched kicad version?") - return result - - for point_index in range(shape.PointCount()): - result.append( - self.normalize(shape.CPoint(point_index))) - - return result - - def parse_poly_set(self, poly): - # type: (pcbnew.SHAPE_POLY_SET) -> list - result = [] - - for i in range(poly.OutlineCount()): - result.append(self.parse_line_chain(poly.Outline(i))) - - return result - - def parse_text(self, d): - # type: (pcbnew.PCB_TEXT) -> dict - if not d.IsVisible() and d.GetClass() not in ["PTEXT", "PCB_TEXT"]: - return None - pos = self.normalize(d.GetPosition()) - if hasattr(d, "GetTextThickness"): - thickness = d.GetTextThickness() * 1e-6 - else: - thickness = d.GetThickness() * 1e-6 - if hasattr(d, 'TransformToSegmentList'): - segments = [self.normalize(p) for p in d.TransformToSegmentList()] - lines = [] - for i in range(0, len(segments), 2): - if i == 0 or segments[i - 1] != segments[i]: - lines.append([segments[i]]) - lines[-1].append(segments[i + 1]) - return { - "thickness": thickness, - "svgpath": create_path(lines) + self.logger.info("Unsupported shape %s, skipping", type(d).__name__) + return None + + def _resolve_text_value(self, value, footprint): + """Expands text variables. Footprint-context variables are + substituted locally, the rest is expanded by KiCad.""" + if '${' not in value: + return value + if footprint is not None: + subs = { + 'REFERENCE': footprint.reference_field.text.value, + 'VALUE': footprint.value_field.text.value, + 'FOOTPRINT_NAME': footprint.definition.id.name, + 'FOOTPRINT_LIBRARY': footprint.definition.id.library, } - elif hasattr(d, 'GetEffectiveTextShape'): - # type: pcbnew.SHAPE_COMPOUND - shape = d.GetEffectiveTextShape(False) - segments = [] - polygons = [] - for s in shape.GetSubshapes(): - if s.Type() == pcbnew.SH_LINE_CHAIN: - polygons.append(self.parse_line_chain(s)) - elif s.Type() == pcbnew.SH_SEGMENT: - seg = s.GetSeg() - segments.append( - [self.normalize(seg.A), self.normalize(seg.B)]) - else: - self.logger.warn( - "Unsupported subshape in text: %s" % s.Type()) - if segments: - return { - "thickness": thickness, - "svgpath": create_path(segments) - } - else: - return { - "polygons": polygons - } - - if d.GetClass() == "MTEXT": - angle = self.normalize_angle(d.GetDrawRotation()) - else: - if hasattr(d, "GetTextAngle"): - angle = self.normalize_angle(d.GetTextAngle()) - else: - angle = self.normalize_angle(d.GetOrientation()) - if hasattr(d, "GetTextHeight"): - height = d.GetTextHeight() * 1e-6 - width = d.GetTextWidth() * 1e-6 - else: - height = d.GetHeight() * 1e-6 - width = d.GetWidth() * 1e-6 - if hasattr(d, "GetShownText"): - text = d.GetShownText() + for key, sub in subs.items(): + value = value.replace('${%s}' % key, sub) + if '${' in value: + value = self.board.expand_text_variables(value) + return value + + def _as_common_text(self, d, footprint=None): + from kipy.common_types import Text + + if isinstance(d, Field): + text = d.text.as_text() + elif isinstance(d, BoardText): + text = d.as_text() + elif isinstance(d, BoardTextBox): + return d.as_textbox() else: - text = d.GetText() - self.font_parser.parse_font_for_string(text) - attributes = [] - if d.IsMirrored(): - attributes.append("mirrored") - if d.IsItalic(): - attributes.append("italic") - if d.IsBold(): - attributes.append("bold") - - return { - "pos": pos, - "text": text, - "height": height, - "width": width, - "justify": [d.GetHorizJustify(), d.GetVertJustify()], - "thickness": thickness, - "attr": attributes, - "angle": angle - } - - def parse_dimension(self, d): - # type: (pcbnew.PCB_DIMENSION_BASE) -> dict + text = d # already a common Text (e.g. dimension text) + + modified = False + + # GetTextAsShapes renders the stored string as-is, with text + # variables like ${REFERENCE} unexpanded. Resolve them first. + value = self._resolve_text_value(text.value, footprint) + if value != text.value: + text = Text(text.proto) # copy, do not mutate the board + text.value = value + modified = True + + # GetTextAsShapes renders the stored angle as-is, but pcbnew + # draws keep-upright text rotated back into readable orientation + # (see EDA_TEXT::GetDrawRotation). Replicate that here, otherwise + # left/right justified text on rotated footprints renders on the + # wrong side of its anchor. + attrs = text.proto.attributes + if attrs.keep_upright: + angle = attrs.angle.value_degrees % 360 + if 90 < angle <= 270: + if not modified: + text = Text(text.proto) + text.proto.attributes.angle.value_degrees = angle - 180 + return text + + def prerender_text_items(self, items): + """Renders all text items in one IPC round trip. + + KiCad renders text to polygonal shapes server side, which replaces + the SWIG GetEffectiveTextShape() call. Results are cached keyed by + the text item's python object id. + + :param items: list of (text_item, parent_footprint_or_None) + """ + items = [(t, f) for (t, f) in items + if id(t) not in self._text_render_cache] + if not items: + return + shapes = self.kicad.get_text_as_shapes( + [self._as_common_text(t, f) for (t, f) in items]) + for (item, _), compound in zip(items, shapes): + self._text_render_cache[id(item)] = compound + + def parse_text(self, d, footprint=None): + common_text = self._as_common_text(d, footprint) + thickness = common_text.attributes.stroke_width * 1e-6 + compound = self._text_render_cache.get(id(d)) + if compound is None: + compound = self.kicad.get_text_as_shapes(common_text)[0] segments = [] - circles = [] - for s in d.GetShapes(): - s = s.Cast() - if s.Type() == pcbnew.SH_SEGMENT: - seg = s.GetSeg() + polygons = [] + from kipy.common_types import Segment, Polygon + for s in compound.shapes: + if isinstance(s, Segment): segments.append( - [self.normalize(seg.A), self.normalize(seg.B)]) - elif s.Type() == pcbnew.SH_CIRCLE: - circles.append( - [self.normalize(s.GetCenter()), s.GetRadius() * 1e-6]) + [self.normalize(s.start), self.normalize(s.end)]) + elif isinstance(s, Polygon): + polygons.extend(self.parse_polygons(s.polygons)) else: - self.logger.info( - "Unsupported shape type in dimension object: %s", s.Type()) - - svgpath = create_path(segments, circles=circles) - - return { - "thickness": d.GetLineThickness() * 1e-6, - "svgpath": svgpath - } + self.logger.warn( + "Unsupported subshape in text: %s" % type(s).__name__) + if segments: + return { + "thickness": thickness, + "svgpath": create_path(segments) + } + else: + return { + "polygons": polygons + } - def parse_drawing(self, d): - # type: (pcbnew.BOARD_ITEM) -> list - result = [] - s = None - if d.GetClass() in ["DRAWSEGMENT", "MGRAPHIC", "PCB_SHAPE"]: + def parse_drawing(self, d, footprint=None): + if isinstance(d, (BoardSegment, BoardRectangle, BoardCircle, + BoardArc, BoardPolygon, BoardBezier)): s = self.parse_shape(d) - elif d.GetClass() in ["PTEXT", "MTEXT", "FP_TEXT", "PCB_TEXT", "PCB_FIELD"]: - s = self.parse_text(d) - elif (d.GetClass().startswith("PCB_DIM") - and hasattr(pcbnew, "VECTOR_SHAPEPTR")): - result.append(self.parse_dimension(d)) - if hasattr(d, "Text"): - s = self.parse_text(d.Text()) - else: - s = self.parse_text(d) - else: - self.logger.info("Unsupported drawing class %s, skipping", - d.GetClass()) - if s: - result.append(s) - return result + return [s] if s else [] + if isinstance(d, (BoardText, BoardTextBox, Field)): + s = self.parse_text(d, footprint) + return [s] if s else [] + self.logger.info("Unsupported drawing type %s, skipping", + type(d).__name__) + return [] + + def _field_visible(self, field): + # Note: BoardText.attributes.visible is deprecated and always + # True over the IPC API; Field.visible is the reliable flag. + return field.visible - def parse_edges(self, pcb): - edges = [] - drawings = list(pcb.GetDrawings()) - bbox = None + def get_all_drawings(self): + """Returns a list of (kind, item, parent_footprint) tuples for all + silk/fab drawings. + + kind is "ref"/"val" for reference and value fields so they can be + tagged in the output, mirroring the SWIG parser. + """ + drawings = [] + for d in self.board.get_shapes(): + drawings.append(("shape", d, None)) + for t in self.board.get_text(): + if isinstance(t, BoardText) and not t.attributes.visible: + continue + drawings.append(("text", t, None)) for f in self.footprints: - for g in f.GraphicalItems(): - drawings.append(g) - for d in drawings: - if d.GetLayer() == pcbnew.Edge_Cuts: - for parsed_drawing in self.parse_drawing(d): - edges.append(parsed_drawing) - if bbox is None: - bbox = d.GetBoundingBox() - else: - bbox.Merge(d.GetBoundingBox()) - if bbox: - bbox.Normalize() - return edges, bbox + if self._field_visible(f.reference_field): + drawings.append(("ref", f.reference_field, f)) + if self._field_visible(f.value_field): + drawings.append(("val", f.value_field, f)) + for d in f.definition.shapes: + drawings.append(("shape", d, f)) + ref_val_ids = (f.reference_field.field_id, + f.value_field.field_id) + for t in f.texts_and_fields: + if isinstance(t, Field): + if t.field_id in ref_val_ids: + continue + if not self._field_visible(t): + continue + elif isinstance(t, BoardText): + if not t.attributes.visible: + continue + drawings.append(("text", t, f)) + return drawings def parse_drawings_on_layers(self, drawings, f_layer, b_layer): front = [] back = [] - for d in drawings: - if d[1].GetLayer() not in [f_layer, b_layer]: + text_items = [(d, f) for (kind, d, f) in drawings + if d.layer in [f_layer, b_layer] and + not isinstance(d, (BoardSegment, BoardRectangle, + BoardCircle, BoardArc, + BoardPolygon, BoardBezier))] + self.prerender_text_items(text_items) + + for kind, d, f in drawings: + if d.layer not in [f_layer, b_layer]: continue - for drawing in self.parse_drawing(d[1]): - if d[0] in ["ref", "val"]: - drawing[d[0]] = 1 - if d[1].GetLayer() == f_layer: + for drawing in self.parse_drawing(d, f): + if kind in ["ref", "val"]: + drawing[kind] = 1 + if d.layer == f_layer: front.append(drawing) else: back.append(drawing) return { "F": front, - "B": back + "B": back, } - def get_all_drawings(self): - drawings = [(d.GetClass(), d) for d in list(self.board.GetDrawings())] + def _shape_points_with_extents(self, d): + """Returns points describing the extents of a shape, used for + bounding box computation on the client side.""" + width = d.attributes.stroke.width + points = [] + if isinstance(d, BoardSegment): + points = [d.start, d.end] + elif isinstance(d, BoardRectangle): + points = [d.top_left, d.bottom_right] + elif isinstance(d, (BoardCircle, BoardArc, BoardPolygon, + BoardBezier)): + try: + box = d.bounding_box() + except Exception: + return [] + points = [box.pos, + type(box.pos).from_xy(box.pos.x + box.size.x, + box.pos.y + box.size.y)] + result = [] + for p in points: + x, y = p.x, p.y + result.append([(x - width / 2) * 1e-6, (y - width / 2) * 1e-6]) + result.append([(x + width / 2) * 1e-6, (y + width / 2) * 1e-6]) + return result + + def parse_edges(self): + edges = [] + bbox = None # [minx, miny, maxx, maxy] in mm + + items = list(self.board.get_shapes()) for f in self.footprints: - drawings.append(("ref", f.Reference())) - drawings.append(("val", f.Value())) - for d in f.GraphicalItems(): - drawings.append((d.GetClass(), d)) - if hasattr(f, "GetFields"): - fields = f.GetFields() # type: list[pcbnew.PCB_FIELD] - for field in fields: - if field.IsReference() or field.IsValue(): - continue - drawings.append((field.GetClass(), field)) + items.extend(f.definition.shapes) - return drawings + for d in items: + if d.layer != BL.BL_Edge_Cuts: + continue + for parsed_drawing in self.parse_drawing(d): + edges.append(parsed_drawing) + for p in self._shape_points_with_extents(d): + if bbox is None: + bbox = [p[0], p[1], p[0], p[1]] + else: + bbox = [min(bbox[0], p[0]), min(bbox[1], p[1]), + max(bbox[2], p[0]), max(bbox[3], p[1])] + return edges, bbox - @staticmethod - def _pad_is_through_hole(pad): - # type: (pcbnew.PAD) -> bool - if hasattr(pcbnew, 'PAD_ATTRIB_PTH'): - through_hole_attributes = [pcbnew.PAD_ATTRIB_PTH, - pcbnew.PAD_ATTRIB_NPTH] - else: - through_hole_attributes = [pcbnew.PAD_ATTRIB_STANDARD, - pcbnew.PAD_ATTRIB_HOLE_NOT_PLATED] - return pad.GetAttribute() in through_hole_attributes + def _pad_is_through_hole(self, pad): + return pad.pad_type in [PAD_TYPE.PT_PTH, PAD_TYPE.PT_NPTH] def parse_pad(self, pad): - # type: (pcbnew.PAD) -> list[dict] - custom_padstack = False - outer_layers = [(pcbnew.F_Cu, "F"), (pcbnew.B_Cu, "B")] - if hasattr(pad, 'Padstack'): - padstack = pad.Padstack() # type: pcbnew.PADSTACK - layers_set = list(padstack.LayerSet().Seq()) - if hasattr(pcbnew, "UNCONNECTED_LAYER_MODE_REMOVE_ALL"): - ULMRA = pcbnew.UNCONNECTED_LAYER_MODE_REMOVE_ALL - else: - ULMRA = padstack.UNCONNECTED_LAYER_MODE_REMOVE_ALL - custom_padstack = ( - padstack.Mode() != padstack.MODE_NORMAL or - padstack.UnconnectedLayerMode() == ULMRA - ) - else: - layers_set = list(pad.GetLayerSet().Seq()) + padstack = pad.padstack + layers_set = list(padstack.layers) layers = [] - for layer, letter in outer_layers: + for layer, letter in OUTER_COPPER_LAYERS: if layer in layers_set: layers.append(letter) if not layers and not self._pad_is_through_hole(pad): return [] + custom_padstack = ( + padstack.type != PAD_STACK_TYPE.PST_NORMAL or + padstack.unconnected_layer_removal != ULR.ULR_KEEP + ) + if custom_padstack: + presence = self.board.check_padstack_presence_on_layers( + pad, [layer for layer, _ in OUTER_COPPER_LAYERS]) + presence = presence.get(pad, {}) pads = [] - for layer, letter in outer_layers: - if layer in layers_set and pad.FlashLayer(layer): + for layer, letter in OUTER_COPPER_LAYERS: + if layer in layers_set and presence.get(layer, True): pad_dict = self.parse_pad_layer(pad, layer) - pad_dict["layers"] = [letter] - pads.append(pad_dict) + if pad_dict is not None: + pad_dict["layers"] = [letter] + pads.append(pad_dict) return pads else: - pad_layer = layers_set[0] if layers_set else pcbnew.F_Cu + pad_layer = layers_set[0] if layers_set else BL.BL_F_Cu pad_dict = self.parse_pad_layer(pad, pad_layer) + if pad_dict is None: + return [] pad_dict["layers"] = layers return [pad_dict] def parse_pad_layer(self, pad, layer): - # type: (pcbnew.PAD, int) -> dict | None - pos = self.normalize(pad.GetPosition()) - try: - size = self.normalize(pad.GetSize(layer)) - except TypeError: - size = self.normalize(pad.GetSize()) - angle = self.normalize_angle(pad.GetOrientation()) - shape_lookup = { - pcbnew.PAD_SHAPE_RECT: "rect", - pcbnew.PAD_SHAPE_OVAL: "oval", - pcbnew.PAD_SHAPE_CIRCLE: "circle", - } - if hasattr(pcbnew, "PAD_SHAPE_TRAPEZOID"): - shape_lookup[pcbnew.PAD_SHAPE_TRAPEZOID] = "trapezoid" - if hasattr(pcbnew, "PAD_SHAPE_ROUNDRECT"): - shape_lookup[pcbnew.PAD_SHAPE_ROUNDRECT] = "roundrect" - if hasattr(pcbnew, "PAD_SHAPE_CUSTOM"): - shape_lookup[pcbnew.PAD_SHAPE_CUSTOM] = "custom" - if hasattr(pcbnew, "PAD_SHAPE_CHAMFERED_RECT"): - shape_lookup[pcbnew.PAD_SHAPE_CHAMFERED_RECT] = "chamfrect" - try: - pad_shape = pad.GetShape(layer) - except TypeError: - pad_shape = pad.GetShape() - shape = shape_lookup.get(pad_shape, "") + padstack = pad.padstack + psl = padstack.copper_layer(layer) + if psl is None: + cls = padstack.copper_layers + psl = cls[0] if cls else None + if psl is None: + self.logger.info("Pad %s has no copper layers, skipping.", + pad.number) + return None + pos = self.normalize(pad.position) + size = self.normalize(psl.size) + angle = padstack.angle.degrees + shape = { + PAD_STACK_SHAPE.PSS_CIRCLE: "circle", + PAD_STACK_SHAPE.PSS_RECTANGLE: "rect", + PAD_STACK_SHAPE.PSS_OVAL: "oval", + PAD_STACK_SHAPE.PSS_TRAPEZOID: "trapezoid", + PAD_STACK_SHAPE.PSS_ROUNDRECT: "roundrect", + PAD_STACK_SHAPE.PSS_CHAMFEREDRECT: "chamfrect", + PAD_STACK_SHAPE.PSS_CUSTOM: "custom", + }.get(psl.shape, "") if shape == "": - self.logger.info("Unsupported pad shape %s, skipping.", pad_shape) + self.logger.info("Unsupported pad shape %s, skipping.", psl.shape) return None pad_dict = { "pos": pos, @@ -534,21 +512,24 @@ def parse_pad_layer(self, pad, layer): "shape": shape } if shape == "custom": - polygon_set = pcbnew.SHAPE_POLY_SET() - try: - pad.MergePrimitivesAsPolygon(layer, polygon_set) - except TypeError: - pad.MergePrimitivesAsPolygon(polygon_set) - if polygon_set.HasHoles(): + polygon = self.board.get_pad_shapes_as_polygons(pad, layer) + if polygon is None: + self.logger.info( + "Failed to get polygon for custom pad %s", pad.number) + return None + # Polygon comes in absolute board coordinates, convert it to + # pad-relative coordinates that the renderer expects. + points = self.parse_poly_line(polygon.outline) + if polygon.holes: self.logger.warn('Detected holes in custom pad polygons') - pad_dict["polygons"] = self.parse_poly_set(polygon_set) + points = [self.rotate(p, pos, -angle) for p in points] + points = [[p[0] - pos[0], p[1] - pos[1]] for p in points] + pad_dict["polygons"] = [points] if shape == "trapezoid": # treat trapezoid as custom shape pad_dict["shape"] = "custom" - try: - delta = self.normalize(pad.GetDelta(layer)) - except TypeError: - delta = self.normalize(pad.GetDelta()) + delta = self.normalize(psl.trapezoid_delta) + size = pad_dict["size"] pad_dict["polygons"] = [[ [size[0] / 2 + delta[1] / 2, size[1] / 2 - delta[0] / 2], [-size[0] / 2 - delta[1] / 2, size[1] / 2 + delta[0] / 2], @@ -557,88 +538,109 @@ def parse_pad_layer(self, pad, layer): ]] if shape in ["roundrect", "chamfrect"]: - try: - pad_dict["radius"] = pad.GetRoundRectCornerRadius(layer) * 1e-6 - except TypeError: - pad_dict["radius"] = pad.GetRoundRectCornerRadius() * 1e-6 + pad_dict["radius"] = (psl.corner_rounding_ratio * + min(psl.size.x, psl.size.y) * 1e-6) if shape == "chamfrect": - try: - pad_dict["chamfpos"] = pad.GetChamferPositions(layer) - pad_dict["chamfratio"] = pad.GetChamferRectRatio(layer) - except TypeError: - pad_dict["chamfpos"] = pad.GetChamferPositions() - pad_dict["chamfratio"] = pad.GetChamferRectRatio() + corners = psl.chamfered_corners + chamfpos = 0 + if corners.top_left: + chamfpos |= CHAMFER_TOP_LEFT + if corners.top_right: + chamfpos |= CHAMFER_TOP_RIGHT + if corners.bottom_left: + chamfpos |= CHAMFER_BOTTOM_LEFT + if corners.bottom_right: + chamfpos |= CHAMFER_BOTTOM_RIGHT + pad_dict["chamfpos"] = chamfpos + pad_dict["chamfratio"] = psl.chamfer_ratio if self._pad_is_through_hole(pad): + drill = padstack.drill pad_dict["type"] = "th" pad_dict["drillshape"] = { - pcbnew.PAD_DRILL_SHAPE_CIRCLE: "circle", - pcbnew.PAD_DRILL_SHAPE_OBLONG: "oblong" - }.get(pad.GetDrillShape()) - pad_dict["drillsize"] = self.normalize(pad.GetDrillSize()) + DRILL_SHAPE.DS_CIRCLE: "circle", + DRILL_SHAPE.DS_OBLONG: "oblong", + }.get(drill.shape, "circle") + pad_dict["drillsize"] = self.normalize(drill.diameter) else: pad_dict["type"] = "smd" - if hasattr(pad, "GetOffset"): - try: - pad_dict["offset"] = self.normalize(pad.GetOffset(layer)) - except TypeError: - pad_dict["offset"] = self.normalize(pad.GetOffset()) + pad_dict["offset"] = self.normalize(psl.offset) if self.config.include_nets: - pad_dict["net"] = pad.GetNetname() + pad_dict["net"] = pad.net.name return pad_dict + def _footprint_bbox(self, f): + """Computes the footprint bounding box at orientation 0, relative + to the footprint position, like the SWIG parser did by copying the + footprint and resetting its orientation. + """ + pos = self.normalize(f.position) + angle = f.orientation.degrees + points = [] + + for d in f.definition.shapes: + if d.layer == BL.BL_Edge_Cuts: + continue + points.extend(self._shape_points_with_extents(d)) + + for pad in f.definition.pads: + pad_pos = self.normalize(pad.position) + psl_list = pad.padstack.copper_layers + half_w = half_h = 0 + for psl in psl_list: + half_w = max(half_w, psl.size.x * 1e-6 / 2) + half_h = max(half_h, psl.size.y * 1e-6 / 2) + drill = pad.padstack.drill + half_w = max(half_w, drill.diameter.x * 1e-6 / 2) + half_h = max(half_h, drill.diameter.y * 1e-6 / 2) + pad_angle = pad.padstack.angle.degrees + for sx in (-1, 1): + for sy in (-1, 1): + corner = [pad_pos[0] + sx * half_w, + pad_pos[1] + sy * half_h] + points.append(self.rotate(corner, pad_pos, pad_angle)) + + if not points: + points = [pos] + + # Transform from board coordinates to footprint-local unrotated + # coordinates. + local = [self.rotate(p, pos, -angle) for p in points] + minx = min(p[0] for p in local) + miny = min(p[1] for p in local) + maxx = max(p[0] for p in local) + maxy = max(p[1] for p in local) + + return { + "pos": pos, + "relpos": [minx - pos[0], miny - pos[1]], + "size": [maxx - minx, maxy - miny], + "angle": angle, + } + def parse_footprints(self): - # type: () -> list footprints = [] for f in self.footprints: - ref = f.GetReference() + ref = f.reference_field.text.value - # bounding box - if hasattr(pcbnew, 'MODULE'): - f_copy = pcbnew.MODULE(f) - else: - f_copy = pcbnew.FOOTPRINT(f) - try: - f_copy.SetOrientation(0) - except TypeError: - f_copy.SetOrientation( - pcbnew.EDA_ANGLE(0, pcbnew.TENTHS_OF_A_DEGREE_T)) - pos = f_copy.GetPosition() - pos.x = pos.y = 0 - f_copy.SetPosition(pos) - if hasattr(f_copy, 'GetFootprintRect'): - footprint_rect = f_copy.GetFootprintRect() - else: - try: - footprint_rect = f_copy.GetBoundingBox(False, False) - except TypeError: - footprint_rect = f_copy.GetBoundingBox(False) - bbox = { - "pos": self.normalize(f.GetPosition()), - "relpos": self.normalize(footprint_rect.GetPosition()), - "size": self.normalize(footprint_rect.GetSize()), - "angle": self.normalize_angle(f.GetOrientation()), - } - - # graphical drawings + # graphical drawings on copper layers drawings = [] - for d in f.GraphicalItems(): - # we only care about copper ones, silkscreen is taken care of - if d.GetLayer() not in [pcbnew.F_Cu, pcbnew.B_Cu]: + for d in f.definition.shapes: + if d.layer not in [BL.BL_F_Cu, BL.BL_B_Cu]: continue for drawing in self.parse_drawing(d): drawings.append({ - "layer": "F" if d.GetLayer() == pcbnew.F_Cu else "B", + "layer": "F" if d.layer == BL.BL_F_Cu else "B", "drawing": drawing, }) # footprint pads pads = [] - for p in f.Pads(): + for p in f.definition.pads: for pad_dict in self.parse_pad(p): - pads.append((p.GetPadName(), pad_dict)) + pads.append((p.number, pad_dict)) if pads: # Try to guess first pin name. @@ -657,144 +659,162 @@ def parse_footprints(self): pads = [p[1] for p in pads] - # add footprint footprints.append({ "ref": ref, - "bbox": bbox, + "bbox": self._footprint_bbox(f), "pads": pads, "drawings": drawings, - "layer": { - pcbnew.F_Cu: "F", - pcbnew.B_Cu: "B" - }.get(f.GetLayer()) + "layer": self.layer_letter(f.layer), }) return footprints - def parse_tracks(self, tracks): - tent_vias = True - if hasattr(self.board, "GetTentVias"): - tent_vias = self.board.GetTentVias() - result = {pcbnew.F_Cu: [], pcbnew.B_Cu: []} - for track in tracks: - if track.GetClass() in ["VIA", "PCB_VIA"]: + def parse_tracks(self, tracks_and_vias): + result = {BL.BL_F_Cu: [], BL.BL_B_Cu: []} + for track in tracks_and_vias: + if isinstance(track, Via): track_dict = { - "start": self.normalize(track.GetStart()), - "end": self.normalize(track.GetEnd()), - "width": track.GetWidth() * 1e-6, - "net": track.GetNetname(), + "start": self.normalize(track.position), + "end": self.normalize(track.position), + "width": track.diameter * 1e-6, + "net": track.net.name, } - if not tent_vias: - track_dict["drillsize"] = track.GetDrillValue() * 1e-6 - for layer in [pcbnew.F_Cu, pcbnew.B_Cu]: - if track.IsOnLayer(layer): + for layer, _ in OUTER_COPPER_LAYERS: + if layer in track.padstack.layers: result[layer].append(track_dict) else: - if track.GetLayer() in [pcbnew.F_Cu, pcbnew.B_Cu]: - if track.GetClass() in ["ARC", "PCB_ARC"]: - a1, a2 = self.get_arc_angles(track) + if track.layer in [BL.BL_F_Cu, BL.BL_B_Cu]: + if isinstance(track, ArcTrack): + a1, a2 = self.arc_angles_degrees(track) + center = track.center() + if center is None: + continue track_dict = { - "center": self.normalize(track.GetCenter()), + "center": self.normalize(center), "startangle": a1, "endangle": a2, - "radius": track.GetRadius() * 1e-6, - "width": track.GetWidth() * 1e-6, + "radius": track.radius() * 1e-6, + "width": track.width * 1e-6, } else: track_dict = { - "start": self.normalize(track.GetStart()), - "end": self.normalize(track.GetEnd()), - "width": track.GetWidth() * 1e-6, + "start": self.normalize(track.start), + "end": self.normalize(track.end), + "width": track.width * 1e-6, } if self.config.include_nets: - track_dict["net"] = track.GetNetname() - result[track.GetLayer()].append(track_dict) + track_dict["net"] = track.net.name + result[track.layer].append(track_dict) return { - 'F': result.get(pcbnew.F_Cu), - 'B': result.get(pcbnew.B_Cu) + 'F': result[BL.BL_F_Cu], + 'B': result[BL.BL_B_Cu], } def parse_zones(self, zones): - # type: (list[pcbnew.ZONE]) -> dict - result = {pcbnew.F_Cu: [], pcbnew.B_Cu: []} + result = {BL.BL_F_Cu: [], BL.BL_B_Cu: []} for zone in zones: - if (not zone.IsFilled() or - hasattr(zone, 'GetIsKeepout') and zone.GetIsKeepout() or - hasattr(zone, 'GetIsRuleArea') and zone.GetIsRuleArea()): + if not zone.filled or zone.is_rule_area(): continue - layers = [layer for layer in list(zone.GetLayerSet().Seq()) - if layer in [pcbnew.F_Cu, pcbnew.B_Cu]] - for layer in layers: - try: - # kicad 5.1 and earlier - poly_set = zone.GetFilledPolysList() - except TypeError: - poly_set = zone.GetFilledPolysList(layer) - width = zone.GetMinThickness() * 1e-6 - if (hasattr(zone, 'GetFilledPolysUseThickness') and - not zone.GetFilledPolysUseThickness()): - width = 0 - if KICAD_VERSION[0] >= 7: - width = 0 + filled = zone.filled_polygons + for layer, polys in filled.items(): + if layer not in result: + continue zone_dict = { - "polygons": self.parse_poly_set(poly_set), - "width": width, + "polygons": self.parse_polygons(polys), + "width": 0, } if self.config.include_nets: - zone_dict["net"] = zone.GetNetname() + net = getattr(zone, 'net', None) + zone_dict["net"] = net.name if net is not None else "" result[layer].append(zone_dict) return { - 'F': result.get(pcbnew.F_Cu), - 'B': result.get(pcbnew.B_Cu) + 'F': result[BL.BL_F_Cu], + 'B': result[BL.BL_B_Cu], } - @staticmethod - def parse_netlist(net_info): - # type: (pcbnew.NETINFO_LIST) -> list - nets = net_info.NetsByName().asdict().keys() - nets = sorted([str(s) for s in nets]) - return nets + def parse_netlist(self): + nets = [n.name for n in self.board.get_nets()] + return sorted(nets) + + def get_footprint_fields(self, f): + props = {} + for t in f.texts_and_fields: + if isinstance(t, Field): + value = t.text.value + props[t.name] = value + # Reference/value/etc are Fields too, keep parity with + # GetFieldsShownText which includes them. + expanded = self.board.expand_text_variables(list(props.values())) + props = dict(zip(props.keys(), expanded)) + if "dnp" in props and props["dnp"] == "": + del props["dnp"] + props["kicad_dnp"] = "DNP" + if f.attributes.do_not_populate: + props["kicad_dnp"] = "DNP" + return props + + def parse_extra_data_from_pcb(self): + field_set = set() + by_ref = {} + by_index = {} + + for (i, f) in enumerate(self.footprints): + props = self.get_footprint_fields(f) + by_index[i] = props + ref = f.reference_field.text.value + ref_fields = by_ref.setdefault(ref, {}) + + for k, v in props.items(): + field_set.add(k) + ref_fields[k] = v + + return ExtraFieldData(list(field_set), by_ref, by_index) + + def get_extra_field_data(self, file_name): + if os.path.abspath(file_name) == os.path.abspath(self.file_name): + return self.parse_extra_data_from_pcb() + if os.path.splitext(file_name)[1] == '.kicad_pcb': + return None + + data = parse_schematic_data(file_name) + + return ExtraFieldData(data[0], data[1]) + + def latest_extra_data(self, extra_dirs=None): + base_name = os.path.splitext(os.path.basename(self.file_name))[0] + file_dir_name = os.path.dirname(self.file_name) + directories = [file_dir_name] + for dir in (extra_dirs or []): + if not os.path.isabs(dir): + dir = os.path.join(file_dir_name, dir) + if os.path.exists(dir): + directories.append(dir) + return find_latest_schematic_data(base_name, directories) + + def extra_data_file_filter(self): + return ("Netlist, xml and pcb files (*.net; *.xml; *.kicad_pcb)|" + "*.net;*.xml;*.kicad_pcb") def footprint_to_component(self, footprint, extra_fields): - # type: (pcbnew.FOOTPRINT, list) -> Component - try: - footprint_name = str(footprint.GetFPID().GetFootprintName()) - except AttributeError: - footprint_name = str(footprint.GetFPID().GetLibItemName()) - - value = footprint.GetValue() - if hasattr(footprint, 'GetFieldValueForVariant'): - value = footprint.GetFieldValueForVariant( - self.config.kicad_variant, 'Value') + footprint_name = str(footprint.definition.id.name) + attr = 'Normal' - if hasattr(pcbnew, 'FP_EXCLUDE_FROM_BOM'): - if hasattr(footprint, 'GetExcludedFromBOMForVariant'): - if footprint.GetExcludedFromBOMForVariant( - self.config.kicad_variant): - attr = 'Virtual' - elif footprint.GetAttributes() & pcbnew.FP_EXCLUDE_FROM_BOM: - attr = 'Virtual' - elif hasattr(pcbnew, 'MOD_VIRTUAL'): - if footprint.GetAttributes() == pcbnew.MOD_VIRTUAL: - attr = 'Virtual' - layer = { - pcbnew.F_Cu: 'F', - pcbnew.B_Cu: 'B', - }.get(footprint.GetLayer()) - - return Component(footprint.GetReference(), - value, + if footprint.attributes.exclude_from_bill_of_materials: + attr = 'Virtual' + + return Component(footprint.reference_field.text.value, + footprint.value_field.text.value, footprint_name, - layer, + self.layer_letter(footprint.layer), attr, extra_fields) def parse(self): from ..errors import ParsingException - # Get extra field data from netlist + # Get extra field data field_set = set(self.config.show_fields) field_set.discard("Value") field_set.discard("Footprint") @@ -818,18 +838,14 @@ def parse(self): raise ParsingException( 'Failed parsing %s' % self.config.extra_data_file) - title_block = self.board.GetTitleBlock() - title = title_block.GetTitle() - revision = title_block.GetRevision() - company = title_block.GetCompany() - file_date = title_block.GetDate() - if (hasattr(self.board, "GetProject") and - hasattr(pcbnew, "ExpandTextVars")): - project = self.board.GetProject() - title = pcbnew.ExpandTextVars(title, project) - revision = pcbnew.ExpandTextVars(revision, project) - company = pcbnew.ExpandTextVars(company, project) - file_date = pcbnew.ExpandTextVars(file_date, project) + title_block = self.board.get_title_block_info() + texts = self.board.expand_text_variables([ + title_block.title, + title_block.revision, + title_block.company, + title_block.date, + ]) + title, revision, company, file_date = texts if not file_date: file_mtime = os.path.getmtime(self.file_name) @@ -839,17 +855,18 @@ def parse(self): if not title: # remove .kicad_pcb extension title = os.path.splitext(pcb_file_name)[0] - edges, bbox = self.parse_edges(self.board) + + edges, bbox = self.parse_edges() if bbox is None: self.logger.error('Please draw pcb outline on the edges ' 'layer on sheet or any footprint before ' 'generating BOM.') return None, None bbox = { - "minx": bbox.GetPosition().x * 1e-6, - "miny": bbox.GetPosition().y * 1e-6, - "maxx": bbox.GetRight() * 1e-6, - "maxy": bbox.GetBottom() * 1e-6, + "minx": bbox[0], + "miny": bbox[1], + "maxx": bbox[2], + "maxy": bbox[3], } drawings = self.get_all_drawings() @@ -859,9 +876,9 @@ def parse(self): "edges": edges, "drawings": { "silkscreen": self.parse_drawings_on_layers( - drawings, pcbnew.F_SilkS, pcbnew.B_SilkS), + drawings, BL.BL_F_SilkS, BL.BL_B_SilkS), "fabrication": self.parse_drawings_on_layers( - drawings, pcbnew.F_Fab, pcbnew.B_Fab), + drawings, BL.BL_F_Fab, BL.BL_B_Fab), }, "footprints": self.parse_footprints(), "metadata": { @@ -869,20 +886,18 @@ def parse(self): "revision": revision, "company": company, "date": file_date, - "variant": self.config.kicad_variant, + "variant": getattr(self.config, 'kicad_variant', ''), }, "bom": {}, - "font_data": self.font_parser.get_parsed_font() + "font_data": {} } if self.config.include_tracks: - pcbdata["tracks"] = self.parse_tracks(self.board.GetTracks()) - if hasattr(self.board, "Zones"): - pcbdata["zones"] = self.parse_zones(self.board.Zones()) - else: - self.logger.info("Zones not supported for KiCad 4, skipping") - pcbdata["zones"] = {'F': [], 'B': []} - if self.config.include_nets and hasattr(self.board, "GetNetInfo"): - pcbdata["nets"] = self.parse_netlist(self.board.GetNetInfo()) + tracks_and_vias = (list(self.board.get_tracks()) + + list(self.board.get_vias())) + pcbdata["tracks"] = self.parse_tracks(tracks_and_vias) + pcbdata["zones"] = self.parse_zones(self.board.get_zones()) + if self.config.include_nets: + pcbdata["nets"] = self.parse_netlist() if extra_field_data and need_extra_fields: extra_fields = extra_field_data.fields_by_index @@ -895,13 +910,15 @@ def parse(self): warning_shown = False for f in self.footprints: - extra_fields.append(field_map.get(f.GetReference(), {})) - if f.GetReference() not in field_map: - # Some components are on pcb but not in schematic data. - # Show a warning about outdated extra data file. + ref = f.reference_field.text.value + extra_fields.append(field_map.get(ref, {})) + if ref not in field_map: + # Some components are on pcb but not in schematic + # data. Show a warning about outdated extra data + # file. self.logger.warn( 'Component %s is missing from schematic data.' - % f.GetReference()) + % ref) warning_shown = True if warning_shown: @@ -913,43 +930,3 @@ def parse(self): for (f, e) in zip(self.footprints, extra_fields)] return pcbdata, components - - -class InteractiveHtmlBomPlugin(pcbnew.ActionPlugin, object): - - def __init__(self): - super(InteractiveHtmlBomPlugin, self).__init__() - self.name = "Generate Interactive HTML BOM" - self.category = "Read PCB" - self.pcbnew_icon_support = hasattr(self, "show_toolbar_button") - self.show_toolbar_button = True - icon_dir = os.path.dirname(os.path.dirname(__file__)) - self.icon_file_name = os.path.join(icon_dir, 'icon.png') - self.description = "Generate interactive HTML page with BOM " \ - "table and pcb drawing." - - def defaults(self): - pass - - def Run(self): - from ..version import version - from ..errors import ParsingException - - logger = ibom.Logger() - board = pcbnew.GetBoard() # type: pcbnew.BOARD - pcb_file_name = board.GetFileName() - - if not pcb_file_name: - logger.error('Please save the board file before generating BOM.') - return - - config = Config(version, os.path.dirname(pcb_file_name)) - if hasattr(board, 'GetCurrentVariant'): - config.kicad_variant = board.GetCurrentVariant() - - parser = PcbnewParser(pcb_file_name, config, logger, board) - - try: - ibom.run_with_dialog(parser, config, logger) - except ParsingException as e: - logger.error(str(e)) diff --git a/InteractiveHtmlBom/ecad/kicad_ipc.py b/InteractiveHtmlBom/ecad/kicad_ipc.py deleted file mode 100644 index 4b5cc38e..00000000 --- a/InteractiveHtmlBom/ecad/kicad_ipc.py +++ /dev/null @@ -1,932 +0,0 @@ -"""KiCad IPC API parser. - -Talks to a running KiCad instance through the IPC API using the kipy -(kicad-python) bindings instead of the legacy SWIG pcbnew module. -Produces the same pcbdata structure as the legacy PcbnewParser so the -rest of iBOM (and the html renderer) is unaffected. - -Requires KiCad 9.0.3+ with the API server enabled and kicad-python 0.7+. -""" - -import math -import os -from datetime import datetime - -from kipy import KiCad -from kipy.board_types import ( - ArcTrack, - BoardArc, - BoardBezier, - BoardCircle, - BoardPolygon, - BoardRectangle, - BoardSegment, - BoardText, - BoardTextBox, - Field, - Track, - Via, -) -from kipy.proto.board import board_types_pb2 - -from .common import EcadParser, Component, ExtraFieldData -from .kicad_extra import find_latest_schematic_data, parse_schematic_data -from .svgpath import create_path - -BL = board_types_pb2.BoardLayer -PAD_TYPE = board_types_pb2.PadType -PAD_STACK_TYPE = board_types_pb2.PadStackType -PAD_STACK_SHAPE = board_types_pb2.PadStackShape -DRILL_SHAPE = board_types_pb2.DrillShape -ULR = board_types_pb2.UnconnectedLayerRemoval -ZONE_TYPE = board_types_pb2.ZoneType - -OUTER_COPPER_LAYERS = [(BL.BL_F_Cu, "F"), (BL.BL_B_Cu, "B")] - -# Bitmask values matching pcbnew's RECT_CHAMFER_POSITIONS, which is what -# the html renderer expects in the "chamfpos" field. -CHAMFER_TOP_LEFT = 1 -CHAMFER_TOP_RIGHT = 2 -CHAMFER_BOTTOM_LEFT = 4 -CHAMFER_BOTTOM_RIGHT = 8 - - -class IpcApiParser(EcadParser): - - def __init__(self, file_name, config, logger, kicad=None, board=None): - self.kicad = kicad if kicad is not None else KiCad() - self.board = board if board is not None else self.kicad.get_board() - if not file_name: - project = self.board.get_project() - file_name = os.path.join(project.path, self.board.name) - super(IpcApiParser, self).__init__(file_name, config, logger) - self.footprints = list(self.board.get_footprints()) - self._text_render_cache = {} - - @staticmethod - def normalize(point): - return [point.x * 1e-6, point.y * 1e-6] - - @staticmethod - def rotate(point, center, angle_degrees): - # Rotation in kicad's coordinate system (y axis down, - # positive angle is counterclockwise). - a = -math.radians(angle_degrees) - dx = point[0] - center[0] - dy = point[1] - center[1] - return [center[0] + dx * math.cos(a) - dy * math.sin(a), - center[1] + dx * math.sin(a) + dy * math.cos(a)] - - @staticmethod - def layer_letter(layer): - return {BL.BL_F_Cu: "F", BL.BL_B_Cu: "B"}.get(layer) - - def parse_poly_line(self, poly_line): - # type: (...) -> list - # PolyLine nodes are either points or arcs. Arcs are approximated - # with their start/mid/end points; filled polygons coming from - # KiCad are already flattened so this is rarely hit. - result = [] - for node in poly_line.nodes: - if node.has_point: - result.append(self.normalize(node.point)) - elif node.has_arc: - arc = node.arc - result.append(self.normalize(arc.start)) - result.append(self.normalize(arc.mid)) - result.append(self.normalize(arc.end)) - return result - - def parse_polygons(self, polygons_with_holes): - # Holes are not supported by the legacy data format either: - # SWIG parser warned about them and used outlines only. - result = [] - for poly in polygons_with_holes: - if poly.holes: - self.logger.warn('Detected holes in polygon, ignoring them') - result.append(self.parse_poly_line(poly.outline)) - return result - - @staticmethod - def arc_angles_degrees(shape): - """Computes (startangle, endangle) the way the legacy parser did. - - Angles are in kicad's screen coordinate system (y axis down, - angles grow clockwise on screen). kipy's start_angle()/end_angle() - use the math convention (y up) so we compute from the arc's - start/mid/end points directly. - """ - center = shape.center() - if center is None: - return 0, 0 - - def angle_to(p): - return math.degrees(math.atan2(p.y - center.y, p.x - center.x)) - - a1 = angle_to(shape.start) - am = angle_to(shape.mid) - a2 = angle_to(shape.end) - if a1 >= 180 - 1e-3: - a1 -= 360 # match pcbnew's (-180, 180] -> [-180, 180) edge - # Determine sweep direction: the mid point must lie on the arc. - ccw_mid = (am - a1) % 360 - ccw_end = (a2 - a1) % 360 - if ccw_mid <= ccw_end: - sweep = ccw_end - else: - sweep = ccw_end - 360 - a2 = a1 + sweep - if a2 < a1: - a1, a2 = a2, a1 - return round(a1, 2), round(a2, 2) - - def parse_shape(self, d): - if isinstance(d, BoardSegment): - return { - "type": "segment", - "start": self.normalize(d.start), - "end": self.normalize(d.end), - "width": d.attributes.stroke.width * 1e-6, - } - if isinstance(d, BoardRectangle): - start = self.normalize(d.top_left) - end = self.normalize(d.bottom_right) - return { - "type": "polygon", - "pos": [0, 0], - "angle": 0, - "polygons": [[ - start, - [end[0], start[1]], - end, - [start[0], end[1]], - ]], - "width": d.attributes.stroke.width * 1e-6, - "filled": 1 if d.attributes.fill.filled else 0, - } - if isinstance(d, BoardCircle): - radius = (d.radius_point - d.center).length() - shape_dict = { - "type": "circle", - "start": self.normalize(d.center), - "radius": radius * 1e-6, - "width": d.attributes.stroke.width * 1e-6, - } - if d.attributes.fill.filled: - shape_dict["filled"] = 1 - return shape_dict - if isinstance(d, BoardArc): - a1, a2 = self.arc_angles_degrees(d) - center = d.center() - if center is None: - # Degenerate arc, draw as segment. - return { - "type": "segment", - "start": self.normalize(d.start), - "end": self.normalize(d.end), - "width": d.attributes.stroke.width * 1e-6, - } - return { - "type": "arc", - "start": self.normalize(center), - "radius": d.radius() * 1e-6, - "startangle": a1, - "endangle": a2, - "width": d.attributes.stroke.width * 1e-6, - } - if isinstance(d, BoardPolygon): - shape_dict = { - "type": "polygon", - "pos": [0.0, 0.0], - "angle": 0, - "polygons": self.parse_polygons(d.polygons), - } - if not d.attributes.fill.filled: - shape_dict["filled"] = 0 - shape_dict["width"] = d.attributes.stroke.width * 1e-6 - return shape_dict - if isinstance(d, BoardBezier): - return { - "type": "curve", - "start": self.normalize(d.start), - "cpa": self.normalize(d.control1), - "cpb": self.normalize(d.control2), - "end": self.normalize(d.end), - "width": d.attributes.stroke.width * 1e-6, - } - self.logger.info("Unsupported shape %s, skipping", type(d).__name__) - return None - - def _resolve_text_value(self, value, footprint): - """Expands text variables. Footprint-context variables are - substituted locally, the rest is expanded by KiCad.""" - if '${' not in value: - return value - if footprint is not None: - subs = { - 'REFERENCE': footprint.reference_field.text.value, - 'VALUE': footprint.value_field.text.value, - 'FOOTPRINT_NAME': footprint.definition.id.name, - 'FOOTPRINT_LIBRARY': footprint.definition.id.library, - } - for key, sub in subs.items(): - value = value.replace('${%s}' % key, sub) - if '${' in value: - value = self.board.expand_text_variables(value) - return value - - def _as_common_text(self, d, footprint=None): - from kipy.common_types import Text - - if isinstance(d, Field): - text = d.text.as_text() - elif isinstance(d, BoardText): - text = d.as_text() - elif isinstance(d, BoardTextBox): - return d.as_textbox() - else: - text = d # already a common Text (e.g. dimension text) - - modified = False - - # GetTextAsShapes renders the stored string as-is, with text - # variables like ${REFERENCE} unexpanded. Resolve them first. - value = self._resolve_text_value(text.value, footprint) - if value != text.value: - text = Text(text.proto) # copy, do not mutate the board - text.value = value - modified = True - - # GetTextAsShapes renders the stored angle as-is, but pcbnew - # draws keep-upright text rotated back into readable orientation - # (see EDA_TEXT::GetDrawRotation). Replicate that here, otherwise - # left/right justified text on rotated footprints renders on the - # wrong side of its anchor. - attrs = text.proto.attributes - if attrs.keep_upright: - angle = attrs.angle.value_degrees % 360 - if 90 < angle <= 270: - if not modified: - text = Text(text.proto) - text.proto.attributes.angle.value_degrees = angle - 180 - return text - - def prerender_text_items(self, items): - """Renders all text items in one IPC round trip. - - KiCad renders text to polygonal shapes server side, which replaces - the SWIG GetEffectiveTextShape() call. Results are cached keyed by - the text item's python object id. - - :param items: list of (text_item, parent_footprint_or_None) - """ - items = [(t, f) for (t, f) in items - if id(t) not in self._text_render_cache] - if not items: - return - shapes = self.kicad.get_text_as_shapes( - [self._as_common_text(t, f) for (t, f) in items]) - for (item, _), compound in zip(items, shapes): - self._text_render_cache[id(item)] = compound - - def parse_text(self, d, footprint=None): - common_text = self._as_common_text(d, footprint) - thickness = common_text.attributes.stroke_width * 1e-6 - compound = self._text_render_cache.get(id(d)) - if compound is None: - compound = self.kicad.get_text_as_shapes(common_text)[0] - segments = [] - polygons = [] - from kipy.common_types import Segment, Polygon - for s in compound.shapes: - if isinstance(s, Segment): - segments.append( - [self.normalize(s.start), self.normalize(s.end)]) - elif isinstance(s, Polygon): - polygons.extend(self.parse_polygons(s.polygons)) - else: - self.logger.warn( - "Unsupported subshape in text: %s" % type(s).__name__) - if segments: - return { - "thickness": thickness, - "svgpath": create_path(segments) - } - else: - return { - "polygons": polygons - } - - def parse_drawing(self, d, footprint=None): - if isinstance(d, (BoardSegment, BoardRectangle, BoardCircle, - BoardArc, BoardPolygon, BoardBezier)): - s = self.parse_shape(d) - return [s] if s else [] - if isinstance(d, (BoardText, BoardTextBox, Field)): - s = self.parse_text(d, footprint) - return [s] if s else [] - self.logger.info("Unsupported drawing type %s, skipping", - type(d).__name__) - return [] - - def _field_visible(self, field): - # Note: BoardText.attributes.visible is deprecated and always - # True over the IPC API; Field.visible is the reliable flag. - return field.visible - - def get_all_drawings(self): - """Returns a list of (kind, item, parent_footprint) tuples for all - silk/fab drawings. - - kind is "ref"/"val" for reference and value fields so they can be - tagged in the output, mirroring the SWIG parser. - """ - drawings = [] - for d in self.board.get_shapes(): - drawings.append(("shape", d, None)) - for t in self.board.get_text(): - if isinstance(t, BoardText) and not t.attributes.visible: - continue - drawings.append(("text", t, None)) - for f in self.footprints: - if self._field_visible(f.reference_field): - drawings.append(("ref", f.reference_field, f)) - if self._field_visible(f.value_field): - drawings.append(("val", f.value_field, f)) - for d in f.definition.shapes: - drawings.append(("shape", d, f)) - ref_val_ids = (f.reference_field.field_id, - f.value_field.field_id) - for t in f.texts_and_fields: - if isinstance(t, Field): - if t.field_id in ref_val_ids: - continue - if not self._field_visible(t): - continue - elif isinstance(t, BoardText): - if not t.attributes.visible: - continue - drawings.append(("text", t, f)) - return drawings - - def parse_drawings_on_layers(self, drawings, f_layer, b_layer): - front = [] - back = [] - - text_items = [(d, f) for (kind, d, f) in drawings - if d.layer in [f_layer, b_layer] and - not isinstance(d, (BoardSegment, BoardRectangle, - BoardCircle, BoardArc, - BoardPolygon, BoardBezier))] - self.prerender_text_items(text_items) - - for kind, d, f in drawings: - if d.layer not in [f_layer, b_layer]: - continue - for drawing in self.parse_drawing(d, f): - if kind in ["ref", "val"]: - drawing[kind] = 1 - if d.layer == f_layer: - front.append(drawing) - else: - back.append(drawing) - - return { - "F": front, - "B": back, - } - - def _shape_points_with_extents(self, d): - """Returns points describing the extents of a shape, used for - bounding box computation on the client side.""" - width = d.attributes.stroke.width - points = [] - if isinstance(d, BoardSegment): - points = [d.start, d.end] - elif isinstance(d, BoardRectangle): - points = [d.top_left, d.bottom_right] - elif isinstance(d, (BoardCircle, BoardArc, BoardPolygon, - BoardBezier)): - try: - box = d.bounding_box() - except Exception: - return [] - points = [box.pos, - type(box.pos).from_xy(box.pos.x + box.size.x, - box.pos.y + box.size.y)] - result = [] - for p in points: - x, y = p.x, p.y - result.append([(x - width / 2) * 1e-6, (y - width / 2) * 1e-6]) - result.append([(x + width / 2) * 1e-6, (y + width / 2) * 1e-6]) - return result - - def parse_edges(self): - edges = [] - bbox = None # [minx, miny, maxx, maxy] in mm - - items = list(self.board.get_shapes()) - for f in self.footprints: - items.extend(f.definition.shapes) - - for d in items: - if d.layer != BL.BL_Edge_Cuts: - continue - for parsed_drawing in self.parse_drawing(d): - edges.append(parsed_drawing) - for p in self._shape_points_with_extents(d): - if bbox is None: - bbox = [p[0], p[1], p[0], p[1]] - else: - bbox = [min(bbox[0], p[0]), min(bbox[1], p[1]), - max(bbox[2], p[0]), max(bbox[3], p[1])] - return edges, bbox - - def _pad_is_through_hole(self, pad): - return pad.pad_type in [PAD_TYPE.PT_PTH, PAD_TYPE.PT_NPTH] - - def parse_pad(self, pad): - padstack = pad.padstack - layers_set = list(padstack.layers) - layers = [] - for layer, letter in OUTER_COPPER_LAYERS: - if layer in layers_set: - layers.append(letter) - if not layers and not self._pad_is_through_hole(pad): - return [] - - custom_padstack = ( - padstack.type != PAD_STACK_TYPE.PST_NORMAL or - padstack.unconnected_layer_removal != ULR.ULR_KEEP - ) - - if custom_padstack: - presence = self.board.check_padstack_presence_on_layers( - pad, [layer for layer, _ in OUTER_COPPER_LAYERS]) - presence = presence.get(pad, {}) - pads = [] - for layer, letter in OUTER_COPPER_LAYERS: - if layer in layers_set and presence.get(layer, True): - pad_dict = self.parse_pad_layer(pad, layer) - if pad_dict is not None: - pad_dict["layers"] = [letter] - pads.append(pad_dict) - return pads - else: - pad_layer = layers_set[0] if layers_set else BL.BL_F_Cu - pad_dict = self.parse_pad_layer(pad, pad_layer) - if pad_dict is None: - return [] - pad_dict["layers"] = layers - return [pad_dict] - - def parse_pad_layer(self, pad, layer): - padstack = pad.padstack - psl = padstack.copper_layer(layer) - if psl is None: - cls = padstack.copper_layers - psl = cls[0] if cls else None - if psl is None: - self.logger.info("Pad %s has no copper layers, skipping.", - pad.number) - return None - pos = self.normalize(pad.position) - size = self.normalize(psl.size) - angle = padstack.angle.degrees - shape = { - PAD_STACK_SHAPE.PSS_CIRCLE: "circle", - PAD_STACK_SHAPE.PSS_RECTANGLE: "rect", - PAD_STACK_SHAPE.PSS_OVAL: "oval", - PAD_STACK_SHAPE.PSS_TRAPEZOID: "trapezoid", - PAD_STACK_SHAPE.PSS_ROUNDRECT: "roundrect", - PAD_STACK_SHAPE.PSS_CHAMFEREDRECT: "chamfrect", - PAD_STACK_SHAPE.PSS_CUSTOM: "custom", - }.get(psl.shape, "") - if shape == "": - self.logger.info("Unsupported pad shape %s, skipping.", psl.shape) - return None - pad_dict = { - "pos": pos, - "size": size, - "angle": angle, - "shape": shape - } - if shape == "custom": - polygon = self.board.get_pad_shapes_as_polygons(pad, layer) - if polygon is None: - self.logger.info( - "Failed to get polygon for custom pad %s", pad.number) - return None - # Polygon comes in absolute board coordinates, convert it to - # pad-relative coordinates that the renderer expects. - points = self.parse_poly_line(polygon.outline) - if polygon.holes: - self.logger.warn('Detected holes in custom pad polygons') - points = [self.rotate(p, pos, -angle) for p in points] - points = [[p[0] - pos[0], p[1] - pos[1]] for p in points] - pad_dict["polygons"] = [points] - if shape == "trapezoid": - # treat trapezoid as custom shape - pad_dict["shape"] = "custom" - delta = self.normalize(psl.trapezoid_delta) - size = pad_dict["size"] - pad_dict["polygons"] = [[ - [size[0] / 2 + delta[1] / 2, size[1] / 2 - delta[0] / 2], - [-size[0] / 2 - delta[1] / 2, size[1] / 2 + delta[0] / 2], - [-size[0] / 2 + delta[1] / 2, -size[1] / 2 - delta[0] / 2], - [size[0] / 2 - delta[1] / 2, -size[1] / 2 + delta[0] / 2], - ]] - - if shape in ["roundrect", "chamfrect"]: - pad_dict["radius"] = (psl.corner_rounding_ratio * - min(psl.size.x, psl.size.y) * 1e-6) - if shape == "chamfrect": - corners = psl.chamfered_corners - chamfpos = 0 - if corners.top_left: - chamfpos |= CHAMFER_TOP_LEFT - if corners.top_right: - chamfpos |= CHAMFER_TOP_RIGHT - if corners.bottom_left: - chamfpos |= CHAMFER_BOTTOM_LEFT - if corners.bottom_right: - chamfpos |= CHAMFER_BOTTOM_RIGHT - pad_dict["chamfpos"] = chamfpos - pad_dict["chamfratio"] = psl.chamfer_ratio - - if self._pad_is_through_hole(pad): - drill = padstack.drill - pad_dict["type"] = "th" - pad_dict["drillshape"] = { - DRILL_SHAPE.DS_CIRCLE: "circle", - DRILL_SHAPE.DS_OBLONG: "oblong", - }.get(drill.shape, "circle") - pad_dict["drillsize"] = self.normalize(drill.diameter) - else: - pad_dict["type"] = "smd" - - pad_dict["offset"] = self.normalize(psl.offset) - if self.config.include_nets: - pad_dict["net"] = pad.net.name - - return pad_dict - - def _footprint_bbox(self, f): - """Computes the footprint bounding box at orientation 0, relative - to the footprint position, like the SWIG parser did by copying the - footprint and resetting its orientation. - """ - pos = self.normalize(f.position) - angle = f.orientation.degrees - points = [] - - for d in f.definition.shapes: - if d.layer == BL.BL_Edge_Cuts: - continue - points.extend(self._shape_points_with_extents(d)) - - for pad in f.definition.pads: - pad_pos = self.normalize(pad.position) - psl_list = pad.padstack.copper_layers - half_w = half_h = 0 - for psl in psl_list: - half_w = max(half_w, psl.size.x * 1e-6 / 2) - half_h = max(half_h, psl.size.y * 1e-6 / 2) - drill = pad.padstack.drill - half_w = max(half_w, drill.diameter.x * 1e-6 / 2) - half_h = max(half_h, drill.diameter.y * 1e-6 / 2) - pad_angle = pad.padstack.angle.degrees - for sx in (-1, 1): - for sy in (-1, 1): - corner = [pad_pos[0] + sx * half_w, - pad_pos[1] + sy * half_h] - points.append(self.rotate(corner, pad_pos, pad_angle)) - - if not points: - points = [pos] - - # Transform from board coordinates to footprint-local unrotated - # coordinates. - local = [self.rotate(p, pos, -angle) for p in points] - minx = min(p[0] for p in local) - miny = min(p[1] for p in local) - maxx = max(p[0] for p in local) - maxy = max(p[1] for p in local) - - return { - "pos": pos, - "relpos": [minx - pos[0], miny - pos[1]], - "size": [maxx - minx, maxy - miny], - "angle": angle, - } - - def parse_footprints(self): - footprints = [] - for f in self.footprints: - ref = f.reference_field.text.value - - # graphical drawings on copper layers - drawings = [] - for d in f.definition.shapes: - if d.layer not in [BL.BL_F_Cu, BL.BL_B_Cu]: - continue - for drawing in self.parse_drawing(d): - drawings.append({ - "layer": "F" if d.layer == BL.BL_F_Cu else "B", - "drawing": drawing, - }) - - # footprint pads - pads = [] - for p in f.definition.pads: - for pad_dict in self.parse_pad(p): - pads.append((p.number, pad_dict)) - - if pads: - # Try to guess first pin name. - pads = sorted(pads, key=lambda el: el[0]) - pin1_pads = [p for p in pads if p[0] in - ['1', 'A', 'A1', 'P1', 'PAD1']] - if pin1_pads: - pin1_pad_name = pin1_pads[0][0] - else: - # No pads have common first pin name, - # pick lexicographically smallest. - pin1_pad_name = pads[0][0] - for pad_name, pad_dict in pads: - if pad_name == pin1_pad_name: - pad_dict['pin1'] = 1 - - pads = [p[1] for p in pads] - - footprints.append({ - "ref": ref, - "bbox": self._footprint_bbox(f), - "pads": pads, - "drawings": drawings, - "layer": self.layer_letter(f.layer), - }) - - return footprints - - def parse_tracks(self, tracks_and_vias): - result = {BL.BL_F_Cu: [], BL.BL_B_Cu: []} - for track in tracks_and_vias: - if isinstance(track, Via): - track_dict = { - "start": self.normalize(track.position), - "end": self.normalize(track.position), - "width": track.diameter * 1e-6, - "net": track.net.name, - } - for layer, _ in OUTER_COPPER_LAYERS: - if layer in track.padstack.layers: - result[layer].append(track_dict) - else: - if track.layer in [BL.BL_F_Cu, BL.BL_B_Cu]: - if isinstance(track, ArcTrack): - a1, a2 = self.arc_angles_degrees(track) - center = track.center() - if center is None: - continue - track_dict = { - "center": self.normalize(center), - "startangle": a1, - "endangle": a2, - "radius": track.radius() * 1e-6, - "width": track.width * 1e-6, - } - else: - track_dict = { - "start": self.normalize(track.start), - "end": self.normalize(track.end), - "width": track.width * 1e-6, - } - if self.config.include_nets: - track_dict["net"] = track.net.name - result[track.layer].append(track_dict) - - return { - 'F': result[BL.BL_F_Cu], - 'B': result[BL.BL_B_Cu], - } - - def parse_zones(self, zones): - result = {BL.BL_F_Cu: [], BL.BL_B_Cu: []} - for zone in zones: - if not zone.filled or zone.is_rule_area(): - continue - filled = zone.filled_polygons - for layer, polys in filled.items(): - if layer not in result: - continue - zone_dict = { - "polygons": self.parse_polygons(polys), - "width": 0, - } - if self.config.include_nets: - net = getattr(zone, 'net', None) - zone_dict["net"] = net.name if net is not None else "" - result[layer].append(zone_dict) - - return { - 'F': result[BL.BL_F_Cu], - 'B': result[BL.BL_B_Cu], - } - - def parse_netlist(self): - nets = [n.name for n in self.board.get_nets()] - return sorted(nets) - - def get_footprint_fields(self, f): - props = {} - for t in f.texts_and_fields: - if isinstance(t, Field): - value = t.text.value - props[t.name] = value - # Reference/value/etc are Fields too, keep parity with - # GetFieldsShownText which includes them. - expanded = self.board.expand_text_variables(list(props.values())) - props = dict(zip(props.keys(), expanded)) - if "dnp" in props and props["dnp"] == "": - del props["dnp"] - props["kicad_dnp"] = "DNP" - if f.attributes.do_not_populate: - props["kicad_dnp"] = "DNP" - return props - - def parse_extra_data_from_pcb(self): - field_set = set() - by_ref = {} - by_index = {} - - for (i, f) in enumerate(self.footprints): - props = self.get_footprint_fields(f) - by_index[i] = props - ref = f.reference_field.text.value - ref_fields = by_ref.setdefault(ref, {}) - - for k, v in props.items(): - field_set.add(k) - ref_fields[k] = v - - return ExtraFieldData(list(field_set), by_ref, by_index) - - def get_extra_field_data(self, file_name): - if os.path.abspath(file_name) == os.path.abspath(self.file_name): - return self.parse_extra_data_from_pcb() - if os.path.splitext(file_name)[1] == '.kicad_pcb': - return None - - data = parse_schematic_data(file_name) - - return ExtraFieldData(data[0], data[1]) - - def latest_extra_data(self, extra_dirs=None): - base_name = os.path.splitext(os.path.basename(self.file_name))[0] - file_dir_name = os.path.dirname(self.file_name) - directories = [file_dir_name] - for dir in (extra_dirs or []): - if not os.path.isabs(dir): - dir = os.path.join(file_dir_name, dir) - if os.path.exists(dir): - directories.append(dir) - return find_latest_schematic_data(base_name, directories) - - def extra_data_file_filter(self): - return ("Netlist, xml and pcb files (*.net; *.xml; *.kicad_pcb)|" - "*.net;*.xml;*.kicad_pcb") - - def footprint_to_component(self, footprint, extra_fields): - footprint_name = str(footprint.definition.id.name) - - attr = 'Normal' - if footprint.attributes.exclude_from_bill_of_materials: - attr = 'Virtual' - - return Component(footprint.reference_field.text.value, - footprint.value_field.text.value, - footprint_name, - self.layer_letter(footprint.layer), - attr, - extra_fields) - - def parse(self): - from ..errors import ParsingException - - # Get extra field data - field_set = set(self.config.show_fields) - field_set.discard("Value") - field_set.discard("Footprint") - need_extra_fields = (field_set or - self.config.board_variant_whitelist or - self.config.board_variant_blacklist or - self.config.dnp_field) - - if not self.config.extra_data_file and need_extra_fields: - self.config.extra_data_file = self.file_name - self.logger.warn('Assuming extra data file to be the pcb file ' - 'since --extra-data-file was not specified.') - - extra_field_data = None - if (self.config.extra_data_file and - os.path.isfile(self.config.extra_data_file)): - extra_field_data = self.parse_extra_data( - self.config.extra_data_file, self.config.normalize_field_case) - - if extra_field_data is None and need_extra_fields: - raise ParsingException( - 'Failed parsing %s' % self.config.extra_data_file) - - title_block = self.board.get_title_block_info() - texts = self.board.expand_text_variables([ - title_block.title, - title_block.revision, - title_block.company, - title_block.date, - ]) - title, revision, company, file_date = texts - - if not file_date: - file_mtime = os.path.getmtime(self.file_name) - file_date = datetime.fromtimestamp(file_mtime).strftime( - '%Y-%m-%d %H:%M:%S') - pcb_file_name = os.path.basename(self.file_name) - if not title: - # remove .kicad_pcb extension - title = os.path.splitext(pcb_file_name)[0] - - edges, bbox = self.parse_edges() - if bbox is None: - self.logger.error('Please draw pcb outline on the edges ' - 'layer on sheet or any footprint before ' - 'generating BOM.') - return None, None - bbox = { - "minx": bbox[0], - "miny": bbox[1], - "maxx": bbox[2], - "maxy": bbox[3], - } - - drawings = self.get_all_drawings() - - pcbdata = { - "edges_bbox": bbox, - "edges": edges, - "drawings": { - "silkscreen": self.parse_drawings_on_layers( - drawings, BL.BL_F_SilkS, BL.BL_B_SilkS), - "fabrication": self.parse_drawings_on_layers( - drawings, BL.BL_F_Fab, BL.BL_B_Fab), - }, - "footprints": self.parse_footprints(), - "metadata": { - "title": title, - "revision": revision, - "company": company, - "date": file_date, - "variant": getattr(self.config, 'kicad_variant', ''), - }, - "bom": {}, - "font_data": {} - } - if self.config.include_tracks: - tracks_and_vias = (list(self.board.get_tracks()) + - list(self.board.get_vias())) - pcbdata["tracks"] = self.parse_tracks(tracks_and_vias) - pcbdata["zones"] = self.parse_zones(self.board.get_zones()) - if self.config.include_nets: - pcbdata["nets"] = self.parse_netlist() - - if extra_field_data and need_extra_fields: - extra_fields = extra_field_data.fields_by_index - if extra_fields: - extra_fields = extra_fields.values() - - if extra_fields is None: - extra_fields = [] - field_map = extra_field_data.fields_by_ref - warning_shown = False - - for f in self.footprints: - ref = f.reference_field.text.value - extra_fields.append(field_map.get(ref, {})) - if ref not in field_map: - # Some components are on pcb but not in schematic - # data. Show a warning about outdated extra data - # file. - self.logger.warn( - 'Component %s is missing from schematic data.' - % ref) - warning_shown = True - - if warning_shown: - self.logger.warn('Netlist/xml file is likely out of date.') - else: - extra_fields = [{}] * len(self.footprints) - - components = [self.footprint_to_component(f, e) - for (f, e) in zip(self.footprints, extra_fields)] - - return pcbdata, components diff --git a/InteractiveHtmlBom/ecad/kicad_swig.py b/InteractiveHtmlBom/ecad/kicad_swig.py new file mode 100644 index 00000000..bef0b30b --- /dev/null +++ b/InteractiveHtmlBom/ecad/kicad_swig.py @@ -0,0 +1,955 @@ +import os +from datetime import datetime + +import pcbnew + +from .common import EcadParser, Component, ExtraFieldData +from .kicad_extra import find_latest_schematic_data, parse_schematic_data +from .svgpath import create_path +from ..core import ibom +from ..core.config import Config +from ..core.fontparser import FontParser + + +KICAD_VERSION = [5, 1, 0] + +if hasattr(pcbnew, 'Version'): + version = pcbnew.Version().split('.') + try: + for i in range(len(version)): + version[i] = int(version[i].split('-')[0]) + except ValueError: + pass + KICAD_VERSION = version + + +class PcbnewParser(EcadParser): + + def __init__(self, file_name, config, logger, board=None): + super(PcbnewParser, self).__init__(file_name, config, logger) + self.board = board + if self.board is None: + self.board = pcbnew.LoadBoard(self.file_name) # type: pcbnew.BOARD + if not self.board: + raise Exception('Failed to load board file') + if hasattr(self.board, "SetCurrentVariant"): + self.board.SetCurrentVariant(config.kicad_variant) + if hasattr(self.board, 'GetModules'): + # type: list[pcbnew.MODULE] + self.footprints = list(self.board.GetModules()) + else: + # type: list[pcbnew.FOOTPRINT] + self.footprints = list(self.board.GetFootprints()) + self.font_parser = FontParser() + + def get_extra_field_data(self, file_name): + if os.path.abspath(file_name) == os.path.abspath(self.file_name): + return self.parse_extra_data_from_pcb() + if os.path.splitext(file_name)[1] == '.kicad_pcb': + return None + + data = parse_schematic_data(file_name) + + return ExtraFieldData(data[0], data[1]) + + def get_footprint_fields(self, f): + # type: (pcbnew.FOOTPRINT) -> dict + props = {} + if hasattr(f, "GetProperties"): + props = f.GetProperties() + if hasattr(f, "GetFields"): + props = f.GetFieldsShownText() + if "dnp" in props and props["dnp"] == "": + del props["dnp"] + props["kicad_dnp"] = "DNP" + if hasattr(f, "IsDNP"): + if f.IsDNP(): + props["kicad_dnp"] = "DNP" + if hasattr(f, 'GetVariant'): + variant = f.GetVariant(self.config.kicad_variant) + if variant: + var_fields = variant.GetFields() + for k in var_fields.keys(): + props[str(k)] = str(f.GetFieldShownText(str(k))) + props["kicad_dnp"] = "DNP" if variant.GetDNP() else "" + + return props + + def parse_extra_data_from_pcb(self): + field_set = set() + by_ref = {} + by_index = {} + + for (i, f) in enumerate(self.footprints): + props = self.get_footprint_fields(f) + by_index[i] = props + ref = f.GetReference() + ref_fields = by_ref.setdefault(ref, {}) + + for k, v in props.items(): + field_set.add(k) + ref_fields[k] = v + + return ExtraFieldData(list(field_set), by_ref, by_index) + + def latest_extra_data(self, extra_dirs=None): + base_name = os.path.splitext(os.path.basename(self.file_name))[0] + extra_dirs.append(self.board.GetPlotOptions().GetOutputDirectory()) + file_dir_name = os.path.dirname(self.file_name) + directories = [file_dir_name] + for dir in extra_dirs: + if not os.path.isabs(dir): + dir = os.path.join(file_dir_name, dir) + if os.path.exists(dir): + directories.append(dir) + return find_latest_schematic_data(base_name, directories) + + def extra_data_file_filter(self): + if hasattr(self.board, 'GetModules'): + return "Netlist and xml files (*.net; *.xml)|*.net;*.xml" + else: + return ("Netlist, xml and pcb files (*.net; *.xml; *.kicad_pcb)|" + "*.net;*.xml;*.kicad_pcb") + + @staticmethod + def normalize(point): + return [point.x * 1e-6, point.y * 1e-6] + + @staticmethod + def normalize_angle(angle): + if isinstance(angle, int) or isinstance(angle, float): + return angle * 0.1 + else: + return angle.AsDegrees() + + def get_arc_angles(self, d): + # type: (pcbnew.PCB_SHAPE) -> tuple + a1 = self.normalize_angle(d.GetArcAngleStart()) + if hasattr(d, "GetAngle"): + a2 = a1 + self.normalize_angle(d.GetAngle()) + else: + a2 = a1 + self.normalize_angle(d.GetArcAngle()) + if a2 < a1: + a1, a2 = a2, a1 + return round(a1, 2), round(a2, 2) + + def parse_shape(self, d): + # type: (pcbnew.PCB_SHAPE) -> dict | None + shape = { + pcbnew.S_SEGMENT: "segment", + pcbnew.S_CIRCLE: "circle", + pcbnew.S_ARC: "arc", + pcbnew.S_POLYGON: "polygon", + pcbnew.S_CURVE: "curve", + pcbnew.S_RECT: "rect", + }.get(d.GetShape(), "") + if shape == "": + self.logger.info("Unsupported shape %s, skipping", d.GetShape()) + return None + start = self.normalize(d.GetStart()) + end = self.normalize(d.GetEnd()) + if shape == "segment": + return { + "type": shape, + "start": start, + "end": end, + "width": d.GetWidth() * 1e-6 + } + + if shape == "rect": + if hasattr(d, "GetRectCorners"): + points = list(map(self.normalize, d.GetRectCorners())) + else: + points = [ + start, + [end[0], start[1]], + end, + [start[0], end[1]] + ] + shape_dict = { + "type": "polygon", + "pos": [0, 0], + "angle": 0, + "polygons": [points], + "width": d.GetWidth() * 1e-6, + "filled": 0 + } + if hasattr(d, "IsFilled") and d.IsFilled(): + shape_dict["filled"] = 1 + return shape_dict + + if shape == "circle": + shape_dict = { + "type": shape, + "start": start, + "radius": d.GetRadius() * 1e-6, + "width": d.GetWidth() * 1e-6 + } + if hasattr(d, "IsFilled") and d.IsFilled(): + shape_dict["filled"] = 1 + return shape_dict + + if shape == "arc": + a1, a2 = self.get_arc_angles(d) + if hasattr(d, "GetCenter"): + start = self.normalize(d.GetCenter()) + return { + "type": shape, + "start": start, + "radius": d.GetRadius() * 1e-6, + "startangle": a1, + "endangle": a2, + "width": d.GetWidth() * 1e-6 + } + + if shape == "polygon": + if hasattr(d, "GetPolyShape"): + polygons = self.parse_poly_set(d.GetPolyShape()) + else: + self.logger.info( + "Polygons not supported for KiCad 4, skipping") + return None + angle = 0 + if hasattr(d, 'GetParentModule'): + parent_footprint = d.GetParentModule() + else: + parent_footprint = d.GetParentFootprint() + if parent_footprint is not None and KICAD_VERSION[0] < 8: + angle = self.normalize_angle(parent_footprint.GetOrientation()) + shape_dict = { + "type": shape, + "pos": start, + "angle": angle, + "polygons": polygons + } + if ((hasattr(d, "IsFilled") and not d.IsFilled()) or + (hasattr(d, "IsSolidFill") and not d.IsSolidFill())): + shape_dict["filled"] = 0 + shape_dict["width"] = d.GetWidth() * 1e-6 + return shape_dict + if shape == "curve": + if hasattr(d, "GetBezierC1"): + c1 = self.normalize(d.GetBezierC1()) + c2 = self.normalize(d.GetBezierC2()) + else: + c1 = self.normalize(d.GetBezControl1()) + c2 = self.normalize(d.GetBezControl2()) + return { + "type": shape, + "start": start, + "cpa": c1, + "cpb": c2, + "end": end, + "width": d.GetWidth() * 1e-6 + } + + def parse_line_chain(self, shape): + # type: (pcbnew.SHAPE_LINE_CHAIN) -> list + result = [] + if not hasattr(shape, "PointCount"): + self.logger.warn("No PointCount method on outline object. " + "Unpatched kicad version?") + return result + + for point_index in range(shape.PointCount()): + result.append( + self.normalize(shape.CPoint(point_index))) + + return result + + def parse_poly_set(self, poly): + # type: (pcbnew.SHAPE_POLY_SET) -> list + result = [] + + for i in range(poly.OutlineCount()): + result.append(self.parse_line_chain(poly.Outline(i))) + + return result + + def parse_text(self, d): + # type: (pcbnew.PCB_TEXT) -> dict + if not d.IsVisible() and d.GetClass() not in ["PTEXT", "PCB_TEXT"]: + return None + pos = self.normalize(d.GetPosition()) + if hasattr(d, "GetTextThickness"): + thickness = d.GetTextThickness() * 1e-6 + else: + thickness = d.GetThickness() * 1e-6 + if hasattr(d, 'TransformToSegmentList'): + segments = [self.normalize(p) for p in d.TransformToSegmentList()] + lines = [] + for i in range(0, len(segments), 2): + if i == 0 or segments[i - 1] != segments[i]: + lines.append([segments[i]]) + lines[-1].append(segments[i + 1]) + return { + "thickness": thickness, + "svgpath": create_path(lines) + } + elif hasattr(d, 'GetEffectiveTextShape'): + # type: pcbnew.SHAPE_COMPOUND + shape = d.GetEffectiveTextShape(False) + segments = [] + polygons = [] + for s in shape.GetSubshapes(): + if s.Type() == pcbnew.SH_LINE_CHAIN: + polygons.append(self.parse_line_chain(s)) + elif s.Type() == pcbnew.SH_SEGMENT: + seg = s.GetSeg() + segments.append( + [self.normalize(seg.A), self.normalize(seg.B)]) + else: + self.logger.warn( + "Unsupported subshape in text: %s" % s.Type()) + if segments: + return { + "thickness": thickness, + "svgpath": create_path(segments) + } + else: + return { + "polygons": polygons + } + + if d.GetClass() == "MTEXT": + angle = self.normalize_angle(d.GetDrawRotation()) + else: + if hasattr(d, "GetTextAngle"): + angle = self.normalize_angle(d.GetTextAngle()) + else: + angle = self.normalize_angle(d.GetOrientation()) + if hasattr(d, "GetTextHeight"): + height = d.GetTextHeight() * 1e-6 + width = d.GetTextWidth() * 1e-6 + else: + height = d.GetHeight() * 1e-6 + width = d.GetWidth() * 1e-6 + if hasattr(d, "GetShownText"): + text = d.GetShownText() + else: + text = d.GetText() + self.font_parser.parse_font_for_string(text) + attributes = [] + if d.IsMirrored(): + attributes.append("mirrored") + if d.IsItalic(): + attributes.append("italic") + if d.IsBold(): + attributes.append("bold") + + return { + "pos": pos, + "text": text, + "height": height, + "width": width, + "justify": [d.GetHorizJustify(), d.GetVertJustify()], + "thickness": thickness, + "attr": attributes, + "angle": angle + } + + def parse_dimension(self, d): + # type: (pcbnew.PCB_DIMENSION_BASE) -> dict + segments = [] + circles = [] + for s in d.GetShapes(): + s = s.Cast() + if s.Type() == pcbnew.SH_SEGMENT: + seg = s.GetSeg() + segments.append( + [self.normalize(seg.A), self.normalize(seg.B)]) + elif s.Type() == pcbnew.SH_CIRCLE: + circles.append( + [self.normalize(s.GetCenter()), s.GetRadius() * 1e-6]) + else: + self.logger.info( + "Unsupported shape type in dimension object: %s", s.Type()) + + svgpath = create_path(segments, circles=circles) + + return { + "thickness": d.GetLineThickness() * 1e-6, + "svgpath": svgpath + } + + def parse_drawing(self, d): + # type: (pcbnew.BOARD_ITEM) -> list + result = [] + s = None + if d.GetClass() in ["DRAWSEGMENT", "MGRAPHIC", "PCB_SHAPE"]: + s = self.parse_shape(d) + elif d.GetClass() in ["PTEXT", "MTEXT", "FP_TEXT", "PCB_TEXT", "PCB_FIELD"]: + s = self.parse_text(d) + elif (d.GetClass().startswith("PCB_DIM") + and hasattr(pcbnew, "VECTOR_SHAPEPTR")): + result.append(self.parse_dimension(d)) + if hasattr(d, "Text"): + s = self.parse_text(d.Text()) + else: + s = self.parse_text(d) + else: + self.logger.info("Unsupported drawing class %s, skipping", + d.GetClass()) + if s: + result.append(s) + return result + + def parse_edges(self, pcb): + edges = [] + drawings = list(pcb.GetDrawings()) + bbox = None + for f in self.footprints: + for g in f.GraphicalItems(): + drawings.append(g) + for d in drawings: + if d.GetLayer() == pcbnew.Edge_Cuts: + for parsed_drawing in self.parse_drawing(d): + edges.append(parsed_drawing) + if bbox is None: + bbox = d.GetBoundingBox() + else: + bbox.Merge(d.GetBoundingBox()) + if bbox: + bbox.Normalize() + return edges, bbox + + def parse_drawings_on_layers(self, drawings, f_layer, b_layer): + front = [] + back = [] + + for d in drawings: + if d[1].GetLayer() not in [f_layer, b_layer]: + continue + for drawing in self.parse_drawing(d[1]): + if d[0] in ["ref", "val"]: + drawing[d[0]] = 1 + if d[1].GetLayer() == f_layer: + front.append(drawing) + else: + back.append(drawing) + + return { + "F": front, + "B": back + } + + def get_all_drawings(self): + drawings = [(d.GetClass(), d) for d in list(self.board.GetDrawings())] + for f in self.footprints: + drawings.append(("ref", f.Reference())) + drawings.append(("val", f.Value())) + for d in f.GraphicalItems(): + drawings.append((d.GetClass(), d)) + if hasattr(f, "GetFields"): + fields = f.GetFields() # type: list[pcbnew.PCB_FIELD] + for field in fields: + if field.IsReference() or field.IsValue(): + continue + drawings.append((field.GetClass(), field)) + + return drawings + + @staticmethod + def _pad_is_through_hole(pad): + # type: (pcbnew.PAD) -> bool + if hasattr(pcbnew, 'PAD_ATTRIB_PTH'): + through_hole_attributes = [pcbnew.PAD_ATTRIB_PTH, + pcbnew.PAD_ATTRIB_NPTH] + else: + through_hole_attributes = [pcbnew.PAD_ATTRIB_STANDARD, + pcbnew.PAD_ATTRIB_HOLE_NOT_PLATED] + return pad.GetAttribute() in through_hole_attributes + + def parse_pad(self, pad): + # type: (pcbnew.PAD) -> list[dict] + custom_padstack = False + outer_layers = [(pcbnew.F_Cu, "F"), (pcbnew.B_Cu, "B")] + if hasattr(pad, 'Padstack'): + padstack = pad.Padstack() # type: pcbnew.PADSTACK + layers_set = list(padstack.LayerSet().Seq()) + if hasattr(pcbnew, "UNCONNECTED_LAYER_MODE_REMOVE_ALL"): + ULMRA = pcbnew.UNCONNECTED_LAYER_MODE_REMOVE_ALL + else: + ULMRA = padstack.UNCONNECTED_LAYER_MODE_REMOVE_ALL + custom_padstack = ( + padstack.Mode() != padstack.MODE_NORMAL or + padstack.UnconnectedLayerMode() == ULMRA + ) + else: + layers_set = list(pad.GetLayerSet().Seq()) + layers = [] + for layer, letter in outer_layers: + if layer in layers_set: + layers.append(letter) + if not layers and not self._pad_is_through_hole(pad): + return [] + + if custom_padstack: + pads = [] + for layer, letter in outer_layers: + if layer in layers_set and pad.FlashLayer(layer): + pad_dict = self.parse_pad_layer(pad, layer) + pad_dict["layers"] = [letter] + pads.append(pad_dict) + return pads + else: + pad_layer = layers_set[0] if layers_set else pcbnew.F_Cu + pad_dict = self.parse_pad_layer(pad, pad_layer) + pad_dict["layers"] = layers + return [pad_dict] + + def parse_pad_layer(self, pad, layer): + # type: (pcbnew.PAD, int) -> dict | None + pos = self.normalize(pad.GetPosition()) + try: + size = self.normalize(pad.GetSize(layer)) + except TypeError: + size = self.normalize(pad.GetSize()) + angle = self.normalize_angle(pad.GetOrientation()) + shape_lookup = { + pcbnew.PAD_SHAPE_RECT: "rect", + pcbnew.PAD_SHAPE_OVAL: "oval", + pcbnew.PAD_SHAPE_CIRCLE: "circle", + } + if hasattr(pcbnew, "PAD_SHAPE_TRAPEZOID"): + shape_lookup[pcbnew.PAD_SHAPE_TRAPEZOID] = "trapezoid" + if hasattr(pcbnew, "PAD_SHAPE_ROUNDRECT"): + shape_lookup[pcbnew.PAD_SHAPE_ROUNDRECT] = "roundrect" + if hasattr(pcbnew, "PAD_SHAPE_CUSTOM"): + shape_lookup[pcbnew.PAD_SHAPE_CUSTOM] = "custom" + if hasattr(pcbnew, "PAD_SHAPE_CHAMFERED_RECT"): + shape_lookup[pcbnew.PAD_SHAPE_CHAMFERED_RECT] = "chamfrect" + try: + pad_shape = pad.GetShape(layer) + except TypeError: + pad_shape = pad.GetShape() + shape = shape_lookup.get(pad_shape, "") + if shape == "": + self.logger.info("Unsupported pad shape %s, skipping.", pad_shape) + return None + pad_dict = { + "pos": pos, + "size": size, + "angle": angle, + "shape": shape + } + if shape == "custom": + polygon_set = pcbnew.SHAPE_POLY_SET() + try: + pad.MergePrimitivesAsPolygon(layer, polygon_set) + except TypeError: + pad.MergePrimitivesAsPolygon(polygon_set) + if polygon_set.HasHoles(): + self.logger.warn('Detected holes in custom pad polygons') + pad_dict["polygons"] = self.parse_poly_set(polygon_set) + if shape == "trapezoid": + # treat trapezoid as custom shape + pad_dict["shape"] = "custom" + try: + delta = self.normalize(pad.GetDelta(layer)) + except TypeError: + delta = self.normalize(pad.GetDelta()) + pad_dict["polygons"] = [[ + [size[0] / 2 + delta[1] / 2, size[1] / 2 - delta[0] / 2], + [-size[0] / 2 - delta[1] / 2, size[1] / 2 + delta[0] / 2], + [-size[0] / 2 + delta[1] / 2, -size[1] / 2 - delta[0] / 2], + [size[0] / 2 - delta[1] / 2, -size[1] / 2 + delta[0] / 2], + ]] + + if shape in ["roundrect", "chamfrect"]: + try: + pad_dict["radius"] = pad.GetRoundRectCornerRadius(layer) * 1e-6 + except TypeError: + pad_dict["radius"] = pad.GetRoundRectCornerRadius() * 1e-6 + if shape == "chamfrect": + try: + pad_dict["chamfpos"] = pad.GetChamferPositions(layer) + pad_dict["chamfratio"] = pad.GetChamferRectRatio(layer) + except TypeError: + pad_dict["chamfpos"] = pad.GetChamferPositions() + pad_dict["chamfratio"] = pad.GetChamferRectRatio() + + if self._pad_is_through_hole(pad): + pad_dict["type"] = "th" + pad_dict["drillshape"] = { + pcbnew.PAD_DRILL_SHAPE_CIRCLE: "circle", + pcbnew.PAD_DRILL_SHAPE_OBLONG: "oblong" + }.get(pad.GetDrillShape()) + pad_dict["drillsize"] = self.normalize(pad.GetDrillSize()) + else: + pad_dict["type"] = "smd" + + if hasattr(pad, "GetOffset"): + try: + pad_dict["offset"] = self.normalize(pad.GetOffset(layer)) + except TypeError: + pad_dict["offset"] = self.normalize(pad.GetOffset()) + if self.config.include_nets: + pad_dict["net"] = pad.GetNetname() + + return pad_dict + + def parse_footprints(self): + # type: () -> list + footprints = [] + for f in self.footprints: + ref = f.GetReference() + + # bounding box + if hasattr(pcbnew, 'MODULE'): + f_copy = pcbnew.MODULE(f) + else: + f_copy = pcbnew.FOOTPRINT(f) + try: + f_copy.SetOrientation(0) + except TypeError: + f_copy.SetOrientation( + pcbnew.EDA_ANGLE(0, pcbnew.TENTHS_OF_A_DEGREE_T)) + pos = f_copy.GetPosition() + pos.x = pos.y = 0 + f_copy.SetPosition(pos) + if hasattr(f_copy, 'GetFootprintRect'): + footprint_rect = f_copy.GetFootprintRect() + else: + try: + footprint_rect = f_copy.GetBoundingBox(False, False) + except TypeError: + footprint_rect = f_copy.GetBoundingBox(False) + bbox = { + "pos": self.normalize(f.GetPosition()), + "relpos": self.normalize(footprint_rect.GetPosition()), + "size": self.normalize(footprint_rect.GetSize()), + "angle": self.normalize_angle(f.GetOrientation()), + } + + # graphical drawings + drawings = [] + for d in f.GraphicalItems(): + # we only care about copper ones, silkscreen is taken care of + if d.GetLayer() not in [pcbnew.F_Cu, pcbnew.B_Cu]: + continue + for drawing in self.parse_drawing(d): + drawings.append({ + "layer": "F" if d.GetLayer() == pcbnew.F_Cu else "B", + "drawing": drawing, + }) + + # footprint pads + pads = [] + for p in f.Pads(): + for pad_dict in self.parse_pad(p): + pads.append((p.GetPadName(), pad_dict)) + + if pads: + # Try to guess first pin name. + pads = sorted(pads, key=lambda el: el[0]) + pin1_pads = [p for p in pads if p[0] in + ['1', 'A', 'A1', 'P1', 'PAD1']] + if pin1_pads: + pin1_pad_name = pin1_pads[0][0] + else: + # No pads have common first pin name, + # pick lexicographically smallest. + pin1_pad_name = pads[0][0] + for pad_name, pad_dict in pads: + if pad_name == pin1_pad_name: + pad_dict['pin1'] = 1 + + pads = [p[1] for p in pads] + + # add footprint + footprints.append({ + "ref": ref, + "bbox": bbox, + "pads": pads, + "drawings": drawings, + "layer": { + pcbnew.F_Cu: "F", + pcbnew.B_Cu: "B" + }.get(f.GetLayer()) + }) + + return footprints + + def parse_tracks(self, tracks): + tent_vias = True + if hasattr(self.board, "GetTentVias"): + tent_vias = self.board.GetTentVias() + result = {pcbnew.F_Cu: [], pcbnew.B_Cu: []} + for track in tracks: + if track.GetClass() in ["VIA", "PCB_VIA"]: + track_dict = { + "start": self.normalize(track.GetStart()), + "end": self.normalize(track.GetEnd()), + "width": track.GetWidth() * 1e-6, + "net": track.GetNetname(), + } + if not tent_vias: + track_dict["drillsize"] = track.GetDrillValue() * 1e-6 + for layer in [pcbnew.F_Cu, pcbnew.B_Cu]: + if track.IsOnLayer(layer): + result[layer].append(track_dict) + else: + if track.GetLayer() in [pcbnew.F_Cu, pcbnew.B_Cu]: + if track.GetClass() in ["ARC", "PCB_ARC"]: + a1, a2 = self.get_arc_angles(track) + track_dict = { + "center": self.normalize(track.GetCenter()), + "startangle": a1, + "endangle": a2, + "radius": track.GetRadius() * 1e-6, + "width": track.GetWidth() * 1e-6, + } + else: + track_dict = { + "start": self.normalize(track.GetStart()), + "end": self.normalize(track.GetEnd()), + "width": track.GetWidth() * 1e-6, + } + if self.config.include_nets: + track_dict["net"] = track.GetNetname() + result[track.GetLayer()].append(track_dict) + + return { + 'F': result.get(pcbnew.F_Cu), + 'B': result.get(pcbnew.B_Cu) + } + + def parse_zones(self, zones): + # type: (list[pcbnew.ZONE]) -> dict + result = {pcbnew.F_Cu: [], pcbnew.B_Cu: []} + for zone in zones: + if (not zone.IsFilled() or + hasattr(zone, 'GetIsKeepout') and zone.GetIsKeepout() or + hasattr(zone, 'GetIsRuleArea') and zone.GetIsRuleArea()): + continue + layers = [layer for layer in list(zone.GetLayerSet().Seq()) + if layer in [pcbnew.F_Cu, pcbnew.B_Cu]] + for layer in layers: + try: + # kicad 5.1 and earlier + poly_set = zone.GetFilledPolysList() + except TypeError: + poly_set = zone.GetFilledPolysList(layer) + width = zone.GetMinThickness() * 1e-6 + if (hasattr(zone, 'GetFilledPolysUseThickness') and + not zone.GetFilledPolysUseThickness()): + width = 0 + if KICAD_VERSION[0] >= 7: + width = 0 + zone_dict = { + "polygons": self.parse_poly_set(poly_set), + "width": width, + } + if self.config.include_nets: + zone_dict["net"] = zone.GetNetname() + result[layer].append(zone_dict) + + return { + 'F': result.get(pcbnew.F_Cu), + 'B': result.get(pcbnew.B_Cu) + } + + @staticmethod + def parse_netlist(net_info): + # type: (pcbnew.NETINFO_LIST) -> list + nets = net_info.NetsByName().asdict().keys() + nets = sorted([str(s) for s in nets]) + return nets + + def footprint_to_component(self, footprint, extra_fields): + # type: (pcbnew.FOOTPRINT, list) -> Component + try: + footprint_name = str(footprint.GetFPID().GetFootprintName()) + except AttributeError: + footprint_name = str(footprint.GetFPID().GetLibItemName()) + + value = footprint.GetValue() + if hasattr(footprint, 'GetFieldValueForVariant'): + value = footprint.GetFieldValueForVariant( + self.config.kicad_variant, 'Value') + attr = 'Normal' + if hasattr(pcbnew, 'FP_EXCLUDE_FROM_BOM'): + if hasattr(footprint, 'GetExcludedFromBOMForVariant'): + if footprint.GetExcludedFromBOMForVariant( + self.config.kicad_variant): + attr = 'Virtual' + elif footprint.GetAttributes() & pcbnew.FP_EXCLUDE_FROM_BOM: + attr = 'Virtual' + elif hasattr(pcbnew, 'MOD_VIRTUAL'): + if footprint.GetAttributes() == pcbnew.MOD_VIRTUAL: + attr = 'Virtual' + layer = { + pcbnew.F_Cu: 'F', + pcbnew.B_Cu: 'B', + }.get(footprint.GetLayer()) + + return Component(footprint.GetReference(), + value, + footprint_name, + layer, + attr, + extra_fields) + + def parse(self): + from ..errors import ParsingException + + # Get extra field data from netlist + field_set = set(self.config.show_fields) + field_set.discard("Value") + field_set.discard("Footprint") + need_extra_fields = (field_set or + self.config.board_variant_whitelist or + self.config.board_variant_blacklist or + self.config.dnp_field) + + if not self.config.extra_data_file and need_extra_fields: + self.config.extra_data_file = self.file_name + self.logger.warn('Assuming extra data file to be the pcb file ' + 'since --extra-data-file was not specified.') + + extra_field_data = None + if (self.config.extra_data_file and + os.path.isfile(self.config.extra_data_file)): + extra_field_data = self.parse_extra_data( + self.config.extra_data_file, self.config.normalize_field_case) + + if extra_field_data is None and need_extra_fields: + raise ParsingException( + 'Failed parsing %s' % self.config.extra_data_file) + + title_block = self.board.GetTitleBlock() + title = title_block.GetTitle() + revision = title_block.GetRevision() + company = title_block.GetCompany() + file_date = title_block.GetDate() + if (hasattr(self.board, "GetProject") and + hasattr(pcbnew, "ExpandTextVars")): + project = self.board.GetProject() + title = pcbnew.ExpandTextVars(title, project) + revision = pcbnew.ExpandTextVars(revision, project) + company = pcbnew.ExpandTextVars(company, project) + file_date = pcbnew.ExpandTextVars(file_date, project) + + if not file_date: + file_mtime = os.path.getmtime(self.file_name) + file_date = datetime.fromtimestamp(file_mtime).strftime( + '%Y-%m-%d %H:%M:%S') + pcb_file_name = os.path.basename(self.file_name) + if not title: + # remove .kicad_pcb extension + title = os.path.splitext(pcb_file_name)[0] + edges, bbox = self.parse_edges(self.board) + if bbox is None: + self.logger.error('Please draw pcb outline on the edges ' + 'layer on sheet or any footprint before ' + 'generating BOM.') + return None, None + bbox = { + "minx": bbox.GetPosition().x * 1e-6, + "miny": bbox.GetPosition().y * 1e-6, + "maxx": bbox.GetRight() * 1e-6, + "maxy": bbox.GetBottom() * 1e-6, + } + + drawings = self.get_all_drawings() + + pcbdata = { + "edges_bbox": bbox, + "edges": edges, + "drawings": { + "silkscreen": self.parse_drawings_on_layers( + drawings, pcbnew.F_SilkS, pcbnew.B_SilkS), + "fabrication": self.parse_drawings_on_layers( + drawings, pcbnew.F_Fab, pcbnew.B_Fab), + }, + "footprints": self.parse_footprints(), + "metadata": { + "title": title, + "revision": revision, + "company": company, + "date": file_date, + "variant": self.config.kicad_variant, + }, + "bom": {}, + "font_data": self.font_parser.get_parsed_font() + } + if self.config.include_tracks: + pcbdata["tracks"] = self.parse_tracks(self.board.GetTracks()) + if hasattr(self.board, "Zones"): + pcbdata["zones"] = self.parse_zones(self.board.Zones()) + else: + self.logger.info("Zones not supported for KiCad 4, skipping") + pcbdata["zones"] = {'F': [], 'B': []} + if self.config.include_nets and hasattr(self.board, "GetNetInfo"): + pcbdata["nets"] = self.parse_netlist(self.board.GetNetInfo()) + + if extra_field_data and need_extra_fields: + extra_fields = extra_field_data.fields_by_index + if extra_fields: + extra_fields = extra_fields.values() + + if extra_fields is None: + extra_fields = [] + field_map = extra_field_data.fields_by_ref + warning_shown = False + + for f in self.footprints: + extra_fields.append(field_map.get(f.GetReference(), {})) + if f.GetReference() not in field_map: + # Some components are on pcb but not in schematic data. + # Show a warning about outdated extra data file. + self.logger.warn( + 'Component %s is missing from schematic data.' + % f.GetReference()) + warning_shown = True + + if warning_shown: + self.logger.warn('Netlist/xml file is likely out of date.') + else: + extra_fields = [{}] * len(self.footprints) + + components = [self.footprint_to_component(f, e) + for (f, e) in zip(self.footprints, extra_fields)] + + return pcbdata, components + + +class InteractiveHtmlBomPlugin(pcbnew.ActionPlugin, object): + + def __init__(self): + super(InteractiveHtmlBomPlugin, self).__init__() + self.name = "Generate Interactive HTML BOM" + self.category = "Read PCB" + self.pcbnew_icon_support = hasattr(self, "show_toolbar_button") + self.show_toolbar_button = True + icon_dir = os.path.dirname(os.path.dirname(__file__)) + self.icon_file_name = os.path.join(icon_dir, 'icon.png') + self.description = "Generate interactive HTML page with BOM " \ + "table and pcb drawing." + + def defaults(self): + pass + + def Run(self): + from ..version import version + from ..errors import ParsingException + + logger = ibom.Logger() + board = pcbnew.GetBoard() # type: pcbnew.BOARD + pcb_file_name = board.GetFileName() + + if not pcb_file_name: + logger.error('Please save the board file before generating BOM.') + return + + config = Config(version, os.path.dirname(pcb_file_name)) + if hasattr(board, 'GetCurrentVariant'): + config.kicad_variant = board.GetCurrentVariant() + + parser = PcbnewParser(pcb_file_name, config, logger, board) + + try: + ibom.run_with_dialog(parser, config, logger) + except ParsingException as e: + logger.error(str(e))