diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e05cc3e..6f561609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,10 @@ jobs: os: - Ubuntu kicad-version: + - "7.0" - "8.0" - "9.0" + - "10.0" runs-on: ${{ matrix.os }}-latest container: diff --git a/DATAFORMAT.md b/DATAFORMAT.md index a8d0959e..fc8c1958 100644 --- a/DATAFORMAT.md +++ b/DATAFORMAT.md @@ -23,6 +23,24 @@ pcbdata = { // Describes all edge cut drawings including ones in footprints. // See drawing structure description below. "edges": [drawing1, drawing2, ...], + // Optional physical board region in board coordinates, using the same + // units as edges. Includes cutouts and boundary-crossing drill notches. + // Used for copper/silkscreen clipping, net hit testing and perimeter + // drawing, independently of pad visibility. Fabrication is not clipped. + // Omit this field when valid geometry is unavailable to preserve the + // original rendering. At least one of svgpath or polygons is required. + "board_clip": { + // SVG path 'd' string. Supports native arcs and curves; each contour + // should be closed with Z. Takes precedence over polygons if both exist. + "svgpath": svgpath, + // Closed contours; the last point connects back to the first. + // Even-odd filling applies to both representations: nested contours + // form cutouts, and separate outer contours form board islands. + "polygons": [ + [[point1x, point1y], [point2x, point2y], ...], + ... + ], + }, "drawings": { // Contains all drawings + reference + value texts on silkscreen // layer grouped by front and back. diff --git a/InteractiveHtmlBom/ecad/kicad.py b/InteractiveHtmlBom/ecad/kicad.py index d32a7d9f..749d0973 100644 --- a/InteractiveHtmlBom/ecad/kicad.py +++ b/InteractiveHtmlBom/ecad/kicad.py @@ -450,6 +450,79 @@ def parse_edges(self, pcb): bbox.Normalize() return edges, bbox + @staticmethod + def _polygon_operation(operation, *polygons): + # KiCad 8 and earlier require the polygon mode argument. + if hasattr(pcbnew.SHAPE_POLY_SET, "PM_STRICTLY_SIMPLE"): + operation(*polygons, pcbnew.SHAPE_POLY_SET.PM_STRICTLY_SIMPLE) + else: + operation(*polygons) + + def _clipping_methods_available(self, obj, *names): + missing = [name for name in names + if not callable(getattr(obj, name, None))] + if missing: + self.logger.warn("Board clipping unavailable: missing API %s" % + ", ".join(missing)) + return not missing + + def parse_board_clip(self): + """Export the physical board perimeter for clipping and edge drawing.""" + if not self._clipping_methods_available( + self.board, "GetBoardPolygonOutlines"): + return {} + if not self._clipping_methods_available( + pcbnew, "SHAPE_POLY_SET", "FromMM"): + return {} + try: + board = pcbnew.SHAPE_POLY_SET() + if not self._clipping_methods_available( + board, "OutlineCount", "Outline", "HoleCount", "Hole", + "Area", "BooleanIntersection", "BooleanSubtract", + "BooleanAdd"): + return {} + if KICAD_VERSION[0] >= 10: + valid = self.board.GetBoardPolygonOutlines(board, False) + else: + valid = self.board.GetBoardPolygonOutlines(board) + if not valid or board.OutlineCount() == 0: + self.logger.warn("Invalid board outline; copper clipping disabled") + return {} + + # 0.01 mm keeps 0.3 mm drills smooth without the excessive vertex + # count of 0.001 mm. Work in KiCad's native integer coordinates. + error = pcbnew.FromMM(0.01) + edge_drills = pcbnew.SHAPE_POLY_SET() + for footprint in self.footprints: + for pad in footprint.Pads(): + if not self._pad_is_through_hole(pad): + continue + if not self._clipping_methods_available( + pad, "TransformHoleToPolygon"): + return {} + drill = pcbnew.SHAPE_POLY_SET() + if not pad.TransformHoleToPolygon(drill, 0, error): + continue + inside = pcbnew.SHAPE_POLY_SET() + self._polygon_operation( + inside.BooleanIntersection, drill, board) + if inside.Area() <= 0: + continue + outside = pcbnew.SHAPE_POLY_SET() + self._polygon_operation( + outside.BooleanSubtract, drill, board) + if outside.Area() > 0: + self._polygon_operation(edge_drills.BooleanAdd, drill) + + # Union first: overlapping holes must not cancel under even-odd fill. + if edge_drills.OutlineCount(): + self._polygon_operation(board.BooleanSubtract, edge_drills) + return {"board_clip": {"polygons": self.parse_poly_set(board)}} + except (AttributeError, TypeError, RuntimeError) as e: + # Optional geometry must not prevent generation on older bindings. + self.logger.warn("Board clipping unavailable: %s" % e) + return {} + def parse_drawings_on_layers(self, drawings, f_layer, b_layer): front = [] back = [] @@ -928,6 +1001,7 @@ def parse(self): "bom": {}, "font_data": self.font_parser.get_parsed_font() } + pcbdata.update(self.parse_board_clip()) if self.config.include_tracks: pcbdata["tracks"] = self.parse_tracks(self.board.GetTracks()) if hasattr(self.board, "Zones"): diff --git a/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema b/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema index 53a87a0c..796174ab 100644 --- a/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema +++ b/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema @@ -67,6 +67,9 @@ "edges": { "$ref": "#/definitions/DrawingArray" }, + "board_clip": { + "$ref": "#/definitions/ClippingRegion" + }, "drawings": { "$ref": "#/definitions/LayerDrawings" }, @@ -599,6 +602,19 @@ ], "title": "Zone" }, + "ClippingRegion": { + "description": "Board-coordinate contours, filled with the even-odd rule.", + "type": "object", + "additionalProperties": false, + "properties": { + "svgpath": { "type": "string" }, + "polygons": { "$ref": "#/definitions/Polygons" } + }, + "anyOf": [ + { "required": [ "svgpath" ] }, + { "required": [ "polygons" ] } + ] + }, "Polygons": { "type": "array", "items": { diff --git a/InteractiveHtmlBom/web/render.js b/InteractiveHtmlBom/web/render.js index 723df214..584e45f6 100644 --- a/InteractiveHtmlBom/web/render.js +++ b/InteractiveHtmlBom/web/render.js @@ -323,6 +323,12 @@ function drawPadHole(ctx, pad, padHoleColor) { ctx.restore(); } +function clipBoard(ctx) { + if (pcbdata.board_clip) { + ctx.clip(getPolygonsPath(pcbdata.board_clip), "evenodd"); + } +} + function drawFootprint(ctx, layer, scalefactor, footprint, colors, highlight, outline) { if (highlight) { // draw bounding box @@ -341,6 +347,8 @@ function drawFootprint(ctx, layer, scalefactor, footprint, colors, highlight, ou ctx.restore(); } } + ctx.save(); + clipBoard(ctx); // draw drawings for (var drawing of footprint.drawings) { if (drawing.layer == layer) { @@ -365,14 +373,32 @@ function drawFootprint(ctx, layer, scalefactor, footprint, colors, highlight, ou drawPadHole(ctx, pad, colors.padHole); } } + ctx.restore(); } function drawEdgeCuts(canvas, scalefactor) { var ctx = canvas.getContext("2d"); var edgecolor = getComputedStyle(topmostdiv).getPropertyValue('--pcb-edge-color'); + ctx.save(); + if (pcbdata.board_clip) { + // The clipped perimeter includes the new edges along castellated drills. + // Use the thinnest geometric Edge.Cuts stroke: older KiCad boards may + // use thick edges for zone clearance. Keep at least one pixel at any zoom. + var width = pcbdata.edges.reduce((width, edge) => + ["segment", "arc", "circle", "curve", "rect", "polygon"].includes(edge.type) + ? Math.min(width, edge.width || 0) : width, Infinity); + ctx.lineWidth = Math.max(Number.isFinite(width) ? width : 0, 1 / scalefactor); + ctx.strokeStyle = edgecolor; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.stroke(getPolygonsPath(pcbdata.board_clip)); + ctx.restore(); + return; + } for (var edge of pcbdata.edges) { drawDrawing(ctx, scalefactor, edge, edgecolor); } + ctx.restore(); } function drawFootprints(canvas, layer, scalefactor, highlight) { @@ -411,6 +437,10 @@ function drawFootprints(canvas, layer, scalefactor, highlight) { function drawBgLayer(layername, canvas, layer, scalefactor, edgeColor, polygonColor, textColor) { var ctx = canvas.getContext("2d"); + ctx.save(); + if (layername === "silkscreen") { + clipBoard(ctx); + } for (var d of pcbdata.drawings[layername][layer]) { if (["segment", "arc", "circle", "curve", "rect"].includes(d.type)) { drawedge(ctx, scalefactor, d, edgeColor); @@ -420,6 +450,7 @@ function drawBgLayer(layername, canvas, layer, scalefactor, edgeColor, polygonCo drawText(ctx, d, textColor); } } + ctx.restore(); } function drawTracks(canvas, layer, defaultColor, highlight) { @@ -512,6 +543,9 @@ function clearCanvas(canvas, color = null) { } function drawNets(canvas, layer, highlight) { + var ctx = canvas.getContext("2d"); + ctx.save(); + clipBoard(ctx); var style = getComputedStyle(topmostdiv); if (settings.renderZones) { var zoneColor = style.getPropertyValue(highlight ? '--zone-color-highlight' : '--zone-color'); @@ -524,7 +558,6 @@ function drawNets(canvas, layer, highlight) { if (highlight && settings.renderPads) { var padColor = style.getPropertyValue('--pad-color-highlight'); var padHoleColor = style.getPropertyValue('--pad-hole-color'); - var ctx = canvas.getContext("2d"); for (var footprint of pcbdata.footprints) { // draw pads var padDrawn = false; @@ -543,6 +576,7 @@ function drawNets(canvas, layer, highlight) { } } } + ctx.restore(); } function drawHighlightsOnLayer(canvasdict, clear = true) { @@ -765,6 +799,10 @@ function pointWithinPad(x, y, pad) { } function netHitScan(layer, x, y) { + if (pcbdata.board_clip && !emptyContext2d.isPointInPath( + getPolygonsPath(pcbdata.board_clip), x, y, "evenodd")) { + return null; + } // Check track segments if (settings.renderTracks && pcbdata.tracks) { for (var track of pcbdata.tracks[layer]) { diff --git a/tests/test_board_clipping.py b/tests/test_board_clipping.py new file mode 100644 index 00000000..3608bd67 --- /dev/null +++ b/tests/test_board_clipping.py @@ -0,0 +1,319 @@ +"""Native geometry regressions; runnable with unittest or pytest inside KiCad.""" +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import pcbnew +from InteractiveHtmlBom.ecad import kicad + + +def point(x, y): + return pcbnew.VECTOR2I(pcbnew.FromMM(x), pcbnew.FromMM(y)) + + +def rectangle(board, x, y, width, height): + points = [(x, y), (x + width, y), + (x + width, y + height), (x, y + height)] + for i in range(4): + # KiCad 7 distinguishes footprint graphics from board graphics. + shape = (pcbnew.FP_SHAPE if isinstance(board, pcbnew.FOOTPRINT) + and hasattr(pcbnew, "FP_SHAPE") else pcbnew.PCB_SHAPE) + edge = shape(board) + edge.SetShape(pcbnew.S_SEGMENT) + edge.SetLayer(pcbnew.Edge_Cuts) + edge.SetStart(point(*points[i])) + edge.SetEnd(point(*points[(i + 1) % 4])) + edge.SetWidth(pcbnew.FromMM(0.05)) + board.Add(edge) + + +def add_drill(board, x, y, width=2, height=2, angle=0, npth=False): + footprint = pcbnew.FOOTPRINT(board) + board.Add(footprint) + pad = pcbnew.PAD(footprint) + pad.SetAttribute(pcbnew.PAD_ATTRIB_NPTH if npth else pcbnew.PAD_ATTRIB_PTH) + pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE) + pad.SetSize(point(width + 2, height + 2)) + pad.SetPosition(point(x, y)) + if hasattr(pad, "SetPos0"): + # Older versions serialize this separate footprint-local position. + pad.SetPos0(point(x, y)) + pad.SetDrillSize(point(width, height)) + pad.SetDrillShape(pcbnew.PAD_DRILL_SHAPE_CIRCLE if width == height + else pcbnew.PAD_DRILL_SHAPE_OBLONG) + pad.SetOrientationDegrees(angle) + footprint.Add(pad) + return pad + + +def bezier(board, start, c1, c2, end): + edge = pcbnew.PCB_SHAPE(board) + edge.SetShape(pcbnew.S_CURVE) + edge.SetLayer(pcbnew.Edge_Cuts) + edge.SetStart(point(*start)) + edge.SetEnd(point(*end)) + edge.SetBezierC1(point(*c1)) + edge.SetBezierC2(point(*c2)) + edge.SetWidth(pcbnew.FromMM(0.05)) + board.Add(edge) + return edge + + +def curved_board(): + board = pcbnew.BOARD() + rectangle(board, 0, 0, 20, 10) + top = next(edge for edge in board.GetDrawings() + if edge.GetStart().y == 0 and edge.GetEnd().y == 0) + board.Remove(top) + # At t=0.5 this curve passes through (10, -3). + bezier(board, (0, 0), (0, -4), (20, -4), (20, 0)) + # A closed curved cutout, spanning x=7..13 and y=3..6. + bezier(board, (7, 4.5), (7, 2.5), (13, 2.5), (13, 4.5)) + bezier(board, (13, 4.5), (13, 6.5), (7, 6.5), (7, 4.5)) + add_drill(board, 10, -3, 0.3, 0.3) + return board + + +def parse_clip(board): + parser = object.__new__(kicad.PcbnewParser) + parser.board = board + parser.footprints = list(board.GetFootprints()) + parser.logger = SimpleNamespace(warn=lambda message: None) + return parser.parse_board_clip() + + +def contains(region, x, y): + """Independent even-odd point test on the exported contours.""" + result = False + for polygon in region['polygons']: + previous = polygon[-1] + for current in polygon: + x1, y1 = previous + x2, y2 = current + if (y1 > y) != (y2 > y): + if x < (x2 - x1) * (y - y1) / (y2 - y1) + x1: + result = not result + previous = current + return result + + +class BoardClippingTests(unittest.TestCase): + def setUp(self): + self.board = pcbnew.BOARD() + rectangle(self.board, 0, 0, 20, 10) + + def test_round_castellation_and_regular_hole(self): + add_drill(self.board, 0, 5) + add_drill(self.board, 5, 5) + data = parse_clip(self.board) + self.assertFalse(contains(data['board_clip'], 0.5, 5)) + self.assertTrue(contains(data['board_clip'], 1.5, 5)) + self.assertTrue(contains(data['board_clip'], 5, 5)) + self.assertEqual(set(data), {'board_clip'}) + self.assertFalse(contains(data['board_clip'], -0.5, 5)) + + def test_external_internal_and_tangent_drills_are_not_castellations(self): + original = parse_clip(self.board) + for x, y in [(-3, 3), (5, 5), (1, 8), (-1, 8)]: + add_drill(self.board, x, y) + # Non-crossing drills must not change the physical perimeter. + self.assertEqual(parse_clip(self.board), original) + + def test_overlapping_and_duplicate_edge_drills_are_unioned(self): + for y in [5, 5, 5.5]: + add_drill(self.board, 0, y) + data = parse_clip(self.board) + self.assertFalse(contains(data['board_clip'], 0.2, 5.25)) + + def test_rotated_slot_uses_drill_not_copper_offset(self): + pad = add_drill(self.board, 20, 5, 2, 4, 30) + pad.SetOffset(point(3, 0)) + data = parse_clip(self.board) + self.assertFalse(contains(data['board_clip'], 19.8, 5)) + self.assertTrue(contains(data['board_clip'], 17, 5)) + self.assertFalse(contains(data['board_clip'], 20.2, 5)) + + def test_cutout_edge_and_separate_island(self): + rectangle(self.board, 6, 3, 4, 4) + rectangle(self.board, 25, 0, 5, 5) + add_drill(self.board, 6, 5) + data = parse_clip(self.board) + self.assertFalse(contains(data['board_clip'], 8, 5)) + self.assertFalse(contains(data['board_clip'], 5.5, 5)) + self.assertTrue(contains(data['board_clip'], 4.5, 5)) + self.assertTrue(contains(data['board_clip'], 27, 2)) + self.assertFalse(contains(data['board_clip'], 22, 2)) + + def test_npth_edge_openings(self): + add_drill(self.board, 0, 5, npth=True) + self.assertFalse(contains(parse_clip(self.board)['board_clip'], 0.5, 5)) + + def test_circle_outline(self): + board = pcbnew.BOARD() + edge = pcbnew.PCB_SHAPE(board) + edge.SetShape(pcbnew.S_CIRCLE) + edge.SetLayer(pcbnew.Edge_Cuts) + edge.SetStart(point(10, 10)) + edge.SetEnd(point(20, 10)) + board.Add(edge) + add_drill(board, 20, 10) + clip = parse_clip(board)['board_clip'] + self.assertTrue(contains(clip, 10, 10)) + self.assertFalse(contains(clip, 19.5, 10)) + self.assertFalse(contains(clip, 1, 1)) + + def test_outline_in_footprint(self): + board = pcbnew.BOARD() + footprint = pcbnew.FOOTPRINT(board) + board.Add(footprint) + rectangle(footprint, 0, 0, 20, 10) + add_drill(board, 0, 5) + clip = parse_clip(board)['board_clip'] + self.assertTrue(contains(clip, 10, 5)) + self.assertFalse(contains(clip, 0.5, 5)) + + def test_optional_clipping_schema(self): + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema is not installed in this KiCad runtime") + schema_path = (Path(__file__).resolve().parents[1] / + 'InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema') + schema = json.loads(schema_path.read_text()) + schema['$ref'] = '#/definitions/Pcbdata' + # Validate the optional contract independently of the metadata schema. + schema['definitions']['Pcbdata']['required'] = [] + data = {} + jsonschema.validate(data, schema) + add_drill(self.board, 0, 5) + data.update(parse_clip(self.board)) + jsonschema.validate(data, schema) + polygons = data['board_clip']['polygons'] + svgpath = 'M0 0H20V10H0Z M5 5A1 1 0 1 0 7 5A1 1 0 1 0 5 5Z' + for region in [{'svgpath': svgpath}, + {'svgpath': svgpath, 'polygons': polygons}]: + with self.subTest(region=region): + jsonschema.validate({'board_clip': region}, schema) + for region in [{}, {'polygons': 'invalid'}, {'svgpath': 42}, + {'svgpath': svgpath, 'unexpected': True}]: + with self.subTest(invalid=region): + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate({'board_clip': region}, schema) + + def test_bezier_outline_cutout_and_crossing_drill(self): + clip = parse_clip(curved_board()) + self.assertEqual(set(clip), {'board_clip'}) + self.assertTrue(contains(clip['board_clip'], 10, 0)) + self.assertFalse(contains(clip['board_clip'], 10, -3.05)) + self.assertFalse(contains(clip['board_clip'], 10, -2.95)) + self.assertTrue(contains(clip['board_clip'], 10, -2.7)) + self.assertFalse(contains(clip['board_clip'], 10, 4.5)) + self.assertTrue(contains(clip['board_clip'], 10, 7)) + + def test_open_bezier_outline_falls_back(self): + board = curved_board() + edge = next(edge for edge in board.GetDrawings() + if edge.GetShape() == pcbnew.S_CURVE and + edge.GetStart().y == 0) + board.Remove(edge) + self.assertEqual(parse_clip(board), {}) + + def test_cli_generation_with_beziers_and_fallback(self): + generator = (Path(__file__).resolve().parents[1] / + 'InteractiveHtmlBom/generate_interactive_bom.py') + board = curved_board() + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + board_file = directory / 'curved.kicad_pcb' + pcbnew.SaveBoard(str(board_file), board) + original = board_file.read_bytes() + env = dict(os.environ, INTERACTIVE_HTML_BOM_CLI_MODE='1', + INTERACTIVE_HTML_BOM_NO_DISPLAY='1', + PYTHONDONTWRITEBYTECODE='1') + for case in ['bezier', 'missing_api', 'open_outline']: + with self.subTest(case=case): + if case == 'open_outline': + edge = next(edge for edge in board.GetDrawings() + if edge.GetShape() == pcbnew.S_CURVE and + edge.GetStart().y == 0) + board.Remove(edge) + pcbnew.SaveBoard(str(board_file), board) + original = board_file.read_bytes() + args = [str(generator), str(board_file), '--no-browser', + '--no-compression', '--dest-dir', str(directory), + '--name-format', case] + command = [sys.executable, '-B'] + if case == 'missing_api': + command += ['-c', + 'import pcbnew,runpy,sys; ' + 'pcbnew.BOARD.GetBoardPolygonOutlines=None; ' + 'sys.argv=sys.argv[1:]; ' + 'runpy.run_path(sys.argv[0],run_name="__main__")'] + result = subprocess.run(command + args, env=env, + capture_output=True, text=True, + timeout=90) + self.assertEqual(result.returncode, 0, + result.stdout + result.stderr) + html = (directory / (case + '.html')).read_text(encoding='utf-8') + match = re.search(r'^var pcbdata = (.+)$', html, re.M) + self.assertIsNotNone(match) + data = json.loads(match.group(1)) + self.assertNotIn('edge_drills', data) + self.assertTrue(data['edges']) + self.assertTrue(data['footprints']) + self.assertEqual(board_file.read_bytes(), original) + if case == 'bezier': + self.assertTrue(any(edge['type'] == 'curve' + for edge in data['edges'])) + self.assertIn('board_clip', data) + self.assertFalse(contains(data['board_clip'], 10, 4.5)) + self.assertFalse(contains(data['board_clip'], 10, -2.95)) + self.assertTrue(contains(data['board_clip'], 10, 0)) + else: + self.assertNotIn('board_clip', data) + artifacts = os.environ.get('IBOM_TEST_OUTPUT_DIR') + if artifacts: + output = Path(artifacts) + output.mkdir(parents=True, exist_ok=True) + (output / (case + '.html')).write_text(html, encoding='utf-8') + (output / (case + '.json')).write_text(json.dumps(data)) + (output / (case + '.log')).write_text( + result.stdout + result.stderr) + + def test_missing_optional_geometry_apis(self): + add_drill(self.board, 0, 5) + required = [ + (pcbnew, ['SHAPE_POLY_SET', 'FromMM']), + (pcbnew.BOARD, ['GetBoardPolygonOutlines']), + (pcbnew.SHAPE_POLY_SET, ['OutlineCount', 'Outline', 'HoleCount', + 'Hole', 'Area', 'BooleanIntersection', + 'BooleanSubtract', 'BooleanAdd']), + (pcbnew.PAD, ['TransformHoleToPolygon']), + ] + for owner, names in required: + for name in names: + with self.subTest(api=name), patch.object(owner, name, None): + self.assertEqual(parse_clip(self.board), {}) + + def test_missing_and_open_outlines_fall_back(self): + self.assertEqual(parse_clip(pcbnew.BOARD()), {}) + self.board.Remove(list(self.board.GetDrawings())[0]) + self.assertEqual(parse_clip(self.board), {}) + + def test_incompatible_geometry_api_falls_back(self): + for error in [AttributeError, TypeError, RuntimeError]: + with self.subTest(error=error), patch.object( + pcbnew.BOARD, 'GetBoardPolygonOutlines', + side_effect=error('unsupported geometry operation')): + self.assertEqual(parse_clip(self.board), {}) + + +if __name__ == '__main__': + unittest.main()