From d1bbcdfeba1664d8c6defcff98b8725006c6967d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mr=C3=A1zek?= Date: Sun, 17 Mar 2024 17:41:23 +0100 Subject: [PATCH] Introduce error annotations --- kikit/actionPlugins/panelize.py | 6 ++- kikit/common.py | 12 +++++ kikit/panelize.py | 92 +++++++++++++++++++++++++-------- kikit/panelize_ui.py | 18 +++++-- kikit/substrate.py | 31 ++++++----- 5 files changed, 118 insertions(+), 41 deletions(-) diff --git a/kikit/actionPlugins/panelize.py b/kikit/actionPlugins/panelize.py index 9df3fac2..c3711422 100644 --- a/kikit/actionPlugins/panelize.py +++ b/kikit/actionPlugins/panelize.py @@ -4,7 +4,7 @@ from pcbnewTransition import pcbnew, isV8 from kikit.panelize_ui_impl import loadPresetChain, obtainPreset, mergePresets from kikit import panelize_ui -from kikit.panelize import appendItem +from kikit.panelize import NonFatalErrors, appendItem from kikit.common import PKG_BASE from .common import initDialog, destroyDialog import kikit.panelize_ui_sections @@ -420,7 +420,7 @@ def OnPanelize(self, event): thread.join(timeout=1) if not thread.is_alive(): break - if thread.exception: + if thread.exception and not isinstance(thread.exception, NonFatalErrors): raise thread.exception # KiCAD 6 does something strange here, so we will load # an empty file if we read it directly, but we can always make @@ -436,6 +436,8 @@ def OnPanelize(self, event): panel = pcbnew.LoadBoard(copyPanelName) transplateBoard(panel, self.board) self.dirty = True + if thread.exception: + raise thread.exception except Exception as e: dlg = wx.MessageDialog( None, f"Cannot perform:\n\n{e}", "Error", wx.OK) diff --git a/kikit/common.py b/kikit/common.py index 0c564c82..1d22b064 100644 --- a/kikit/common.py +++ b/kikit/common.py @@ -260,6 +260,18 @@ def isLinestringCyclic(line): c = line.coords return c[0] == c[-1] or isinstance(line, LinearRing) +def constructArrow(origin, direction, distance: float, tipSize: float) -> shapely.LineString: + origin = np.array(origin) + direction = np.array(direction) + + endpoint = origin + direction * distance + + tipEndpoint1 = endpoint + tipSize / 2 * (-direction - np.array([-direction[1], direction[0]])) + tipEndpoint2 = endpoint + tipSize / 2 * (-direction + np.array([-direction[1], direction[0]])) + + arrow = shapely.LineString([origin, endpoint, tipEndpoint1, tipEndpoint2, endpoint]) + return arrow + def fromOpt(object, default): """ Given an object, return it if not None. Otherwise return default diff --git a/kikit/panelize.py b/kikit/panelize.py index 4860a9f6..c3807bef 100644 --- a/kikit/panelize.py +++ b/kikit/panelize.py @@ -1,5 +1,6 @@ from copy import deepcopy import itertools +import textwrap from pcbnewTransition import pcbnew, isV6 from kikit import sexpr from kikit.common import normalize @@ -41,6 +42,17 @@ class PanelError(RuntimeError): class TooLargeError(PanelError): pass +class NonFatalErrors(PanelError): + def __init__(self, errors: List[Tuple[KiPoint, str]]) -> None: + multiple = len(errors) > 1 + + message = f"There {'are' if multiple else 'is'} {len(errors)} error{'s' if multiple else ''} in the panel. The panel with error markers was saved for inspection.\n\n" + message += "The following errors occurred:\n" + for pos, err in errors: + message += f"- Location [{toMm(pos[0])}, {toMm(pos[1])}]\n" + message += textwrap.indent(err, " ") + super().__init__(message) + def identity(x): return x @@ -366,7 +378,7 @@ def polygonToZone(polygon, board): zone.Outline().AddHole(linestringToKicad(boundary)) return zone -def buildTabs(substrate: Substrate, +def buildTabs(panel: "Panel", substrate: Substrate, partitionLines: Union[GeometryCollection, LineString], tabAnnotations: Iterable[TabAnnotation], fillet: KiLength = 0) -> \ Tuple[List[Polygon], List[LineString]]: @@ -379,11 +391,17 @@ def buildTabs(substrate: Substrate, """ tabs, cuts = [], [] for annotation in tabAnnotations: - t, c = substrate.tab(annotation.origin, annotation.direction, - annotation.width, partitionLines, annotation.maxLength, fillet) - if t is not None: - tabs.append(t) - cuts.append(c) + try: + t, c = substrate.tab(annotation.origin, annotation.direction, + annotation.width, partitionLines, annotation.maxLength, fillet) + if t is not None: + tabs.append(t) + cuts.append(c) + except TabError as e: + panel._renderLines( + [constructArrow(annotation.origin, annotation.direction, fromMm(3), fromMm(1))], + Layer.Margin) + panel.reportError(toKiCADPoint(e.origin), str(e)) return tabs, cuts def normalizePartitionLineOrientation(line): @@ -453,6 +471,8 @@ def __init__(self, panelFilename): when boards are always associated with a project, you have to pass a name of the resulting file. """ + self.errors: List[Tuple[KiPoint, str]] = [] + self.filename = panelFilename self.board = pcbnew.NewBoard(panelFilename) self.sourcePaths = set() # A set of all board files that were appended to the panel @@ -487,6 +507,28 @@ def __init__(self, panelFilename): self.chamferWidth: Optional[KiLength] = None self.chamferHeight: Optional[KiLength] = None + def reportError(self, position: KiPoint, message: str) -> None: + """ + Reports a non-fatal error. The error is marked and rendered to the panel + """ + footprint = pcbnew.FootprintLoad(KIKIT_LIB, "Error") + footprint.SetPosition(position) + for x in footprint.GraphicalItems(): + if not isinstance(x, pcbnew.PCB_TEXTBOX): + continue + text = x.GetText() + if text == "MESSAGE": + x.SetText(message) + self.board.Add(footprint) + + self.errors.append((position, message)) + + def hasErrors(self) -> bool: + """ + Report if panel has any non-fatal errors presents + """ + return len(self.errors) > 0 + def save(self, reconstructArcs: bool=False, refillAllZones: bool=False): """ Saves the panel to a file and makes the requested changes to the prl and @@ -1306,9 +1348,9 @@ def makeFrame(self, width: KiLength, hspace: KiLength, vspace: KiLength, minHeight - if the panel doesn't meet this height, it is extended - maxWidth - if the panel doesn't meet this width, TooLargeError is raised + maxWidth - if the panel doesn't meet this width, error is set and marked - maxHeight - if the panel doesn't meet this height, TooLargeHeight is raised + maxHeight - if the panel doesn't meet this height, error is set and marked """ frameInnerRect = expandRect(shpBoxToRect(self.boardsBBox()), hspace, vspace) frameOuterRect = expandRect(frameInnerRect, width) @@ -1319,7 +1361,8 @@ def makeFrame(self, width: KiLength, hspace: KiLength, vspace: KiLength, if maxHeight is not None and frameOuterRect.GetHeight() > maxHeight: sizeErrors.append(f"Panel height {frameOuterRect.GetHeight() / units.mm} mm exceeds the limit {maxHeight / units.mm} mm") if len(sizeErrors) > 0: - raise TooLargeError(f"Panel doesn't meet size constraints:\n" + "\n".join(f"- {x}" for x in sizeErrors)) + self.reportError(toKiCADPoint(frameOuterRect.GetEnd()), + "Panel doesn't meet size constraints:\n" + "\n".join(f"- {x}" for x in sizeErrors)) if frameOuterRect.GetWidth() < minWidth: diff = minWidth - frameOuterRect.GetWidth() @@ -1363,9 +1406,9 @@ def makeTightFrame(self, width: KiLength, slotwidth: KiLength, minHeight - if the panel doesn't meet this height, it is extended - maxWidth - if the panel doesn't meet this width, TooLargeError is raised + maxWidth - if the panel doesn't meet this width, error is set - maxHeight - if the panel doesn't meet this height, TooLargeHeight is raised + maxHeight - if the panel doesn't meet this height, error is set """ self.makeFrame(width, hspace, vspace, minWidth, minHeight, maxWidth, maxHeight) boardSlot = GeometryCollection() @@ -1380,12 +1423,12 @@ def makeRailsTb(self, thickness: KiLength, minHeight: KiLength = 0, """ Adds a rail to top and bottom. You can specify minimal height the panel has to feature. You can also specify maximal height of the panel. If the - height would be exceeded, TooLargeError is raised. + height would be exceeded, error is set. """ minx, miny, maxx, maxy = self.panelBBox() height = maxy - miny + 2 * thickness if maxHeight is not None and height > maxHeight: - raise TooLargeError(f"Panel height {height / units.mm} mm exceeds the limit {maxHeight / units.mm} mm") + self.reportError(toKiCADPoint((maxx, maxy)), f"Panel height {height / units.mm} mm exceeds the limit {maxHeight / units.mm} mm") if height < minHeight: thickness = (minHeight - maxy + miny) // 2 topRail = box(minx, maxy, maxx, maxy + thickness) @@ -1402,7 +1445,7 @@ def makeRailsLr(self, thickness: KiLength, minWidth: KiLength = 0, minx, miny, maxx, maxy = self.panelBBox() width = maxx - minx + 2 * thickness if maxWidth is not None and width > maxWidth: - raise TooLargeError(f"Panel width {width / units.mm} mm exceeds the limit {maxWidth / units.mm} mm") + self.reportError(toKiCADPoint((maxx, maxy)), f"Panel width {width / units.mm} mm exceeds the limit {maxWidth / units.mm} mm") if width < minWidth: thickness = (minWidth - maxx + minx) // 2 leftRail = box(minx - thickness, miny, minx, maxy) @@ -1442,7 +1485,7 @@ def makeVCuts(self, cuts, boundCurves=False, offset=fromMm(0)): """ Take a list of lines to cut and performs V-CUTS. When boundCurves is set, approximate curved cuts by a line from the first and last point. - Otherwise, raise an exception. + Otherwise, make an approximate cut and report error. """ for cut in cuts: if len(cut.simplify(SHP_EPSILON).coords) > 2 and not boundCurves: @@ -1451,7 +1494,9 @@ def makeVCuts(self, cuts, boundCurves=False, offset=fromMm(0)): message += "- your tabs hit a curved boundary of your PCB,\n" message += "- your vertical or horizontal PCB edges are not precisely vertical or horizontal.\n" message += "Modify the design or accept curve approximation via V-cuts." - raise RuntimeError(message) + self._renderLines([cut], Layer.Margin) + self.reportError(toKiCADPoint(cut[0]), message) + continue cut = cut.simplify(1).parallel_offset(offset, "left") start = roundPoint(cut.coords[0]) end = roundPoint(cut.coords[-1]) @@ -1466,7 +1511,9 @@ def makeVCuts(self, cuts, boundCurves=False, offset=fromMm(0)): message += "- check that intended edges are truly horizonal or vertical\n" message += "- check your tab placement if it as expected\n" message += "You can use layer style of cuts to see them and validate them." - raise RuntimeError(message) + self._renderLines([cut], Layer.Margin) + self.reportError(toKiCADPoint(cut[0]), message) + continue def makeMouseBites(self, cuts, diameter, spacing, offset=fromMm(0.25), prolongation=fromMm(0.5)): @@ -1655,7 +1702,7 @@ def buildTabsFromAnnotations(self, fillet: KiLength) -> List[LineString]: """ tabs, cuts = [], [] for s in self.substrates: - t, c = buildTabs(s, s.partitionLine, s.annotations, fillet) + t, c = buildTabs(self, s, s.partitionLine, s.annotations, fillet) tabs.extend(t) cuts.extend(c) self.boardSubstrate.union(tabs) @@ -1868,10 +1915,11 @@ def copperFillNonBoardAreas(self, clearance: KiLength=fromMm(1), By default, fills top and bottom layer, but you can specify any other copper layer that is enabled. """ + _, _, maxx, maxy = self.panelBBox() if not self.boardSubstrate.isSinglePiece(): - raise RuntimeError("The substrate has to be a single piece to fill unused areas") - if not len(layers)>0: - raise RuntimeError("No layers to add copper to") + self.reportError(toKiCADPoint((maxx, maxy)), "The substrate has to be a single piece to fill unused areas") + if len(layers) == 0: + self.reportError(toKiCADPoint((maxx, maxy)), "No layers to add copper to") increaseZonePriorities(self.board) zoneArea = self.boardSubstrate.exterior() @@ -2213,6 +2261,8 @@ def translate(self, vec): c += vec[1] self.setAuxiliaryOrigin(self.getAuxiliaryOrigin() + vec) self.setGridOrigin(self.getGridOrigin() + vec) + for error in self.errors: + error = (error[0] + vec, error[1]) for drcE in self.drcExclusions: drcE.position += vec diff --git a/kikit/panelize_ui.py b/kikit/panelize_ui.py index 2e642f3a..c6cff2bd 100644 --- a/kikit/panelize_ui.py +++ b/kikit/panelize_ui.py @@ -221,8 +221,12 @@ def panelize(input, output, preset, plugin, layout, source, tabs, cuts, framing, f.write(ki.dumpPreset(preset)) except Exception as e: import sys - sys.stderr.write("An error occurred: " + str(e) + "\n") - sys.stderr.write("No output files produced\n") + from kikit.panelize import NonFatalErrors + if isinstance(e, NonFatalErrors): + sys.stderr.write(str(e) + "\n") + else: + sys.stderr.write("An error occurred: " + str(e) + "\n") + sys.stderr.write("No output files produced\n") if isinstance(preset, dict) and preset["debug"]["trace"]: traceback.print_exc(file=sys.stderr) sys.exit(1) @@ -233,7 +237,7 @@ def doPanelization(input, output, preset, plugins=[]): handle errors based on the context; e.g., CLI vs GUI """ from kikit import panelize_ui_impl as ki - from kikit.panelize import Panel + from kikit.panelize import Panel, NonFatalErrors from pcbnewTransition.transition import pcbnew from pcbnewTransition.pcbnew import LoadBoard from itertools import chain @@ -301,6 +305,9 @@ def doPanelization(input, output, preset, plugins=[]): panel.save(reconstructArcs=preset["post"]["reconstructarcs"], refillAllZones=preset["post"]["refillzones"]) + if panel.hasErrors(): + raise NonFatalErrors(panel.errors) + @click.command() @click.argument("input", type=click.Path(dir_okay=False)) @@ -326,7 +333,7 @@ def separate(input, output, source, page, debug, keepannotations, preservearcs): """ try: from kikit import panelize_ui_impl as ki - from kikit.panelize import Panel + from kikit.panelize import Panel, NonFatalErrors from kikit.units import mm from pcbnewTransition import pcbnew from pcbnewTransition.pcbnew import LoadBoard, VECTOR2I @@ -355,6 +362,9 @@ def separate(input, output, source, page, debug, keepannotations, preservearcs): ki.positionPanel(preset["page"], panel) panel.save(reconstructArcs=preservearcs) + + if panel.hasErrors(): + raise NonFatalErrors(panel.errors) except Exception as e: import sys sys.stderr.write("An error occurred: " + str(e) + "\n") diff --git a/kikit/substrate.py b/kikit/substrate.py index 5eb5ac7c..ca831a00 100644 --- a/kikit/substrate.py +++ b/kikit/substrate.py @@ -30,6 +30,15 @@ def __init__(self, message, point): super().__init__(message) self.point = point +class TabError(RuntimeError): + def __init__(self, origin, direction, hints): + self.origin = origin + self.direction = direction + message = "Cannot create tab; possible causes:\n" + for hint in hints: + message += f"- {hint}\n" + super().__init__(message) + class TabFilletError(RuntimeError): pass @@ -823,9 +832,9 @@ def tab(self, origin, direction, width, partitionLine=None, self.orient() origin = np.array(origin) + direction = np.around(normalize(direction), 4) for geom in listGeometries(self.substrates): try: - direction = np.around(normalize(direction), 4) sideOriginA = origin + makePerpendicular(direction) * width / 2 sideOriginB = origin - makePerpendicular(direction) * width / 2 boundary = geom.exterior @@ -874,19 +883,13 @@ def tab(self, origin, direction, width, partitionLine=None, except NoIntersectionError as e: continue except TabFilletError as e: - message = f"Cannot create fillet for tab: {e}\n" - message += f" Annotation position {self._strPosition(origin)}\n" - message += "This is a bug. Please open an issue and provide the board on which the fillet failed." - raise RuntimeError(message) from None - - message = "Cannot create tab:\n" - message += f" Annotation position {self._strPosition(origin)}\n" - message += f" Tab ray origin that failed: {self._strPosition(origin)}\n" - message += "Possible causes:\n" - message += "- too wide tab so it does not hit the board,\n" - message += "- annotation is placed inside the board,\n" - message += "- ray length is not sufficient,\n" - raise RuntimeError(message) from None + raise TabError(origin, direction, ["This is a bug. Please open an issue and provide the board on which the fillet failed."]) + + raise TabError(origin, direction, [ + "too wide tab so it does not hit the board", + "annotation is placed inside the board", + "ray length is not sufficient" + ]) def _makeTabFillet(self, tab: Polygon, tabFace: LineString, fillet: KiLength) \ -> Tuple[Polygon, LineString]: