From 8dccb3a61dce3eccf6cb8fbd5d2a3844b4c920b9 Mon Sep 17 00:00:00 2001 From: Kostya Tytelmaier Date: Sat, 3 Oct 2026 06:08:09 +0300 Subject: [PATCH 1/5] Render castellated pad holes using board clipping --- InteractiveHtmlBom/ecad/kicad.py | 58 +++++ .../ecad/schema/genericjsonpcbdata_v1.schema | 15 ++ InteractiveHtmlBom/web/render.js | 30 ++- tests/test_board_clipping.py | 202 ++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/test_board_clipping.py diff --git a/InteractiveHtmlBom/ecad/kicad.py b/InteractiveHtmlBom/ecad/kicad.py index d32a7d9f..1ac518cf 100644 --- a/InteractiveHtmlBom/ecad/kicad.py +++ b/InteractiveHtmlBom/ecad/kicad.py @@ -450,6 +450,63 @@ 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 parse_board_clip(self): + """Export a board interior and edge drill openings for canvas clipping.""" + if not hasattr(self.board, "GetBoardPolygonOutlines"): + return {} + try: + board = pcbnew.SHAPE_POLY_SET() + 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 {} + + # Work in native integer coordinates. Tessellate drills to 1 um, + # independently of the board's (usually coarser) zone-fill setting. + error = pcbnew.FromMM(0.001) + 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 + 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. + result = {} + if edge_drills.OutlineCount(): + self._polygon_operation(board.BooleanSubtract, edge_drills) + result["edge_drills"] = { + "polygons": self.parse_poly_set(edge_drills)} + result["board_clip"] = {"polygons": self.parse_poly_set(board)} + return result + 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 +985,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..48511133 100644 --- a/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema +++ b/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema @@ -67,6 +67,12 @@ "edges": { "$ref": "#/definitions/DrawingArray" }, + "board_clip": { + "$ref": "#/definitions/ClippingRegion" + }, + "edge_drills": { + "$ref": "#/definitions/ClippingRegion" + }, "drawings": { "$ref": "#/definitions/LayerDrawings" }, @@ -599,6 +605,15 @@ ], "title": "Zone" }, + "ClippingRegion": { + "description": "Board-coordinate contours, filled with the even-odd rule.", + "type": "object", + "additionalProperties": false, + "properties": { + "polygons": { "$ref": "#/definitions/Polygons" } + }, + "required": ["polygons"] + }, "Polygons": { "type": "array", "items": { diff --git a/InteractiveHtmlBom/web/render.js b/InteractiveHtmlBom/web/render.js index 723df214..3f386830 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,27 @@ 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.edge_drills) { + // Exclude drill openings without clipping away the outside half of the + // edge stroke. The bounds include the minimum one-pixel stroke at any zoom. + var path = new Path2D(getPolygonsPath(pcbdata.edge_drills)); + var bbox = pcbdata.edges_bbox; + var margin = 1 + 1 / scalefactor; + path.rect(bbox.minx - margin, bbox.miny - margin, + bbox.maxx - bbox.minx + 2 * margin, bbox.maxy - bbox.miny + 2 * margin); + ctx.clip(path, "evenodd"); + } for (var edge of pcbdata.edges) { drawDrawing(ctx, scalefactor, edge, edgecolor); } + ctx.restore(); } function drawFootprints(canvas, layer, scalefactor, highlight) { @@ -512,6 +533,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 +548,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 +566,7 @@ function drawNets(canvas, layer, highlight) { } } } + ctx.restore(); } function drawHighlightsOnLayer(canvasdict, clear = true) { @@ -765,6 +789,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..eedfb7bb --- /dev/null +++ b/tests/test_board_clipping.py @@ -0,0 +1,202 @@ +"""Native geometry regressions; runnable with unittest or pytest inside KiCad.""" +import json +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): + edge = pcbnew.PCB_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)) + 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 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.assertTrue(contains(data['edge_drills'], -0.5, 5)) + self.assertFalse(contains(data['edge_drills'], 5, 5)) + + def test_external_internal_and_tangent_drills_are_not_castellations(self): + for x, y in [(-3, 3), (5, 5), (1, 8), (-1, 8)]: + add_drill(self.board, x, y) + self.assertNotIn('edge_drills', parse_clip(self.board)) + + 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.assertTrue(contains(data['edge_drills'], 0.2, 5.25)) + 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.assertTrue(contains(data['edge_drills'], 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) + data['board_clip']['polygons'] = 'invalid' + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate(data, schema) + + 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_unavailable_drill_binding_falls_back(self): + parser = object.__new__(kicad.PcbnewParser) + parser.board = self.board + parser.footprints = [SimpleNamespace(Pads=lambda: [object()])] + parser.logger = SimpleNamespace(warn=lambda message: None) + self.assertEqual(parser.parse_board_clip(), {}) + + def test_older_outline_signature(self): + native = self.board + calls = [] + # Capture the installed API before mocking the version. + current_version = kicad.KICAD_VERSION[0] + def get_outline(polygons): + calls.append(True) + if current_version >= 10: + return native.GetBoardPolygonOutlines(polygons, False) + return native.GetBoardPolygonOutlines(polygons) + parser = object.__new__(kicad.PcbnewParser) + parser.board = SimpleNamespace(GetBoardPolygonOutlines=get_outline) + parser.footprints = [] + parser.logger = SimpleNamespace(warn=lambda message: None) + with patch.object(kicad, 'KICAD_VERSION', [9, 0, 0]): + self.assertIn('board_clip', parser.parse_board_clip()) + self.assertEqual(calls, [True]) + + def test_older_boolean_mode_argument(self): + calls = [] + api = SimpleNamespace(SHAPE_POLY_SET=SimpleNamespace(PM_STRICTLY_SIMPLE=17)) + with patch.object(kicad, 'pcbnew', api): + kicad.PcbnewParser._polygon_operation( + lambda *args: calls.append(args), 'a', 'b') + self.assertEqual(calls, [('a', 'b', 17)]) + + +if __name__ == '__main__': + unittest.main() From 68adfce7302dd37fcf8bbf205b2e6527c7bfb1b0 Mon Sep 17 00:00:00 2001 From: Kostya Tytelmaier Date: Sat, 3 Oct 2026 23:59:16 +0300 Subject: [PATCH 2/5] Draw continuous outlines around castellated holes --- InteractiveHtmlBom/web/render.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/InteractiveHtmlBom/web/render.js b/InteractiveHtmlBom/web/render.js index 3f386830..c9a99ec8 100644 --- a/InteractiveHtmlBom/web/render.js +++ b/InteractiveHtmlBom/web/render.js @@ -380,6 +380,20 @@ function drawEdgeCuts(canvas, scalefactor) { var ctx = canvas.getContext("2d"); var edgecolor = getComputedStyle(topmostdiv).getPropertyValue('--pcb-edge-color'); ctx.save(); + if (pcbdata.board_clip && pcbdata.edge_drills) { + // The clipped perimeter includes the new edges along castellated drills. + // Use one consistent width, preserving the widest geometric Edge.Cuts + // stroke and the minimum one-pixel width at any zoom. + ctx.lineWidth = pcbdata.edges.reduce((width, edge) => + ["segment", "arc", "circle", "curve", "rect", "polygon"].includes(edge.type) + ? Math.max(width, edge.width || 0) : width, 1 / scalefactor); + ctx.strokeStyle = edgecolor; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.stroke(getPolygonsPath(pcbdata.board_clip)); + ctx.restore(); + return; + } if (pcbdata.edge_drills) { // Exclude drill openings without clipping away the outside half of the // edge stroke. The bounds include the minimum one-pixel stroke at any zoom. From 062d5336e96f06f46d814f1a6c5328af2653ba4b Mon Sep 17 00:00:00 2001 From: Kostya Tytelmaier Date: Fri, 9 Oct 2026 02:15:06 +0300 Subject: [PATCH 3/5] Refine board clipping and cover KiCad compatibility --- .github/workflows/ci.yml | 8 +- InteractiveHtmlBom/ecad/kicad.py | 36 +++- .../ecad/schema/genericjsonpcbdata_v1.schema | 3 - InteractiveHtmlBom/web/render.js | 17 +- tests/test_board_clipping.py | 185 ++++++++++++++---- 5 files changed, 185 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e05cc3e..4eeae671 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: @@ -32,8 +34,10 @@ jobs: - name: Set up build environment shell: bash run: | - apt update && apt install -y python3-pip - python3 -m pip install --break-system-packages --upgrade hatch + apt update && apt install -y python3-venv + python3 -m venv /opt/hatch + /opt/hatch/bin/pip install --upgrade hatch + echo "/opt/hatch/bin" >> "$GITHUB_PATH" - name: Test run: hatch -v run pytest -vv tests diff --git a/InteractiveHtmlBom/ecad/kicad.py b/InteractiveHtmlBom/ecad/kicad.py index 1ac518cf..749d0973 100644 --- a/InteractiveHtmlBom/ecad/kicad.py +++ b/InteractiveHtmlBom/ecad/kicad.py @@ -458,12 +458,29 @@ def _polygon_operation(operation, *polygons): 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 a board interior and edge drill openings for canvas clipping.""" - if not hasattr(self.board, "GetBoardPolygonOutlines"): + """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: @@ -472,14 +489,17 @@ def parse_board_clip(self): self.logger.warn("Invalid board outline; copper clipping disabled") return {} - # Work in native integer coordinates. Tessellate drills to 1 um, - # independently of the board's (usually coarser) zone-fill setting. - error = pcbnew.FromMM(0.001) + # 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 @@ -495,13 +515,9 @@ def parse_board_clip(self): self._polygon_operation(edge_drills.BooleanAdd, drill) # Union first: overlapping holes must not cancel under even-odd fill. - result = {} if edge_drills.OutlineCount(): self._polygon_operation(board.BooleanSubtract, edge_drills) - result["edge_drills"] = { - "polygons": self.parse_poly_set(edge_drills)} - result["board_clip"] = {"polygons": self.parse_poly_set(board)} - return result + 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) diff --git a/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema b/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema index 48511133..aa59dbff 100644 --- a/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema +++ b/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema @@ -70,9 +70,6 @@ "board_clip": { "$ref": "#/definitions/ClippingRegion" }, - "edge_drills": { - "$ref": "#/definitions/ClippingRegion" - }, "drawings": { "$ref": "#/definitions/LayerDrawings" }, diff --git a/InteractiveHtmlBom/web/render.js b/InteractiveHtmlBom/web/render.js index c9a99ec8..3aeb1ddc 100644 --- a/InteractiveHtmlBom/web/render.js +++ b/InteractiveHtmlBom/web/render.js @@ -380,7 +380,7 @@ function drawEdgeCuts(canvas, scalefactor) { var ctx = canvas.getContext("2d"); var edgecolor = getComputedStyle(topmostdiv).getPropertyValue('--pcb-edge-color'); ctx.save(); - if (pcbdata.board_clip && pcbdata.edge_drills) { + if (pcbdata.board_clip) { // The clipped perimeter includes the new edges along castellated drills. // Use one consistent width, preserving the widest geometric Edge.Cuts // stroke and the minimum one-pixel width at any zoom. @@ -394,16 +394,6 @@ function drawEdgeCuts(canvas, scalefactor) { ctx.restore(); return; } - if (pcbdata.edge_drills) { - // Exclude drill openings without clipping away the outside half of the - // edge stroke. The bounds include the minimum one-pixel stroke at any zoom. - var path = new Path2D(getPolygonsPath(pcbdata.edge_drills)); - var bbox = pcbdata.edges_bbox; - var margin = 1 + 1 / scalefactor; - path.rect(bbox.minx - margin, bbox.miny - margin, - bbox.maxx - bbox.minx + 2 * margin, bbox.maxy - bbox.miny + 2 * margin); - ctx.clip(path, "evenodd"); - } for (var edge of pcbdata.edges) { drawDrawing(ctx, scalefactor, edge, edgecolor); } @@ -446,6 +436,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); @@ -455,6 +449,7 @@ function drawBgLayer(layername, canvas, layer, scalefactor, edgeColor, polygonCo drawText(ctx, d, textColor); } } + ctx.restore(); } function drawTracks(canvas, layer, defaultColor, highlight) { diff --git a/tests/test_board_clipping.py b/tests/test_board_clipping.py index eedfb7bb..a4816820 100644 --- a/tests/test_board_clipping.py +++ b/tests/test_board_clipping.py @@ -1,5 +1,10 @@ """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 @@ -17,7 +22,10 @@ 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): - edge = pcbnew.PCB_SHAPE(board) + # 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])) @@ -34,6 +42,9 @@ def add_drill(board, x, y, width=2, height=2, angle=0, npth=False): 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) @@ -42,6 +53,34 @@ def add_drill(board, x, y, width=2, height=2, angle=0, npth=False): 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 @@ -77,19 +116,20 @@ def test_round_castellation_and_regular_hole(self): 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.assertTrue(contains(data['edge_drills'], -0.5, 5)) - self.assertFalse(contains(data['edge_drills'], 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) - self.assertNotIn('edge_drills', parse_clip(self.board)) + # 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.assertTrue(contains(data['edge_drills'], 0.2, 5.25)) self.assertFalse(contains(data['board_clip'], 0.2, 5.25)) def test_rotated_slot_uses_drill_not_copper_offset(self): @@ -98,7 +138,7 @@ def test_rotated_slot_uses_drill_not_copper_offset(self): data = parse_clip(self.board) self.assertFalse(contains(data['board_clip'], 19.8, 5)) self.assertTrue(contains(data['board_clip'], 17, 5)) - self.assertTrue(contains(data['edge_drills'], 20.2, 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) @@ -159,43 +199,112 @@ def test_optional_clipping_schema(self): with self.assertRaises(jsonschema.ValidationError): jsonschema.validate(data, 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_unavailable_drill_binding_falls_back(self): - parser = object.__new__(kicad.PcbnewParser) - parser.board = self.board - parser.footprints = [SimpleNamespace(Pads=lambda: [object()])] - parser.logger = SimpleNamespace(warn=lambda message: None) - self.assertEqual(parser.parse_board_clip(), {}) - - def test_older_outline_signature(self): - native = self.board - calls = [] - # Capture the installed API before mocking the version. - current_version = kicad.KICAD_VERSION[0] - def get_outline(polygons): - calls.append(True) - if current_version >= 10: - return native.GetBoardPolygonOutlines(polygons, False) - return native.GetBoardPolygonOutlines(polygons) - parser = object.__new__(kicad.PcbnewParser) - parser.board = SimpleNamespace(GetBoardPolygonOutlines=get_outline) - parser.footprints = [] - parser.logger = SimpleNamespace(warn=lambda message: None) - with patch.object(kicad, 'KICAD_VERSION', [9, 0, 0]): - self.assertIn('board_clip', parser.parse_board_clip()) - self.assertEqual(calls, [True]) - - def test_older_boolean_mode_argument(self): - calls = [] - api = SimpleNamespace(SHAPE_POLY_SET=SimpleNamespace(PM_STRICTLY_SIMPLE=17)) - with patch.object(kicad, 'pcbnew', api): - kicad.PcbnewParser._polygon_operation( - lambda *args: calls.append(args), 'a', 'b') - self.assertEqual(calls, [('a', 'b', 17)]) + 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__': From 77d04755e88171a054e49b51e86ccfc5c0986658 Mon Sep 17 00:00:00 2001 From: Kostya Tytelmaier Date: Fri, 9 Oct 2026 02:40:01 +0300 Subject: [PATCH 4/5] Keep existing CI setup with expanded KiCad matrix --- .github/workflows/ci.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eeae671..6f561609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,10 +34,8 @@ jobs: - name: Set up build environment shell: bash run: | - apt update && apt install -y python3-venv - python3 -m venv /opt/hatch - /opt/hatch/bin/pip install --upgrade hatch - echo "/opt/hatch/bin" >> "$GITHUB_PATH" + apt update && apt install -y python3-pip + python3 -m pip install --break-system-packages --upgrade hatch - name: Test run: hatch -v run pytest -vv tests From bb343cbdafcf391ccd27e3f740a6716a5f31a655 Mon Sep 17 00:00:00 2001 From: Kostya Tytelmaier Date: Fri, 9 Oct 2026 17:26:15 +0300 Subject: [PATCH 5/5] Support SVG board clipping and refine outline widths --- DATAFORMAT.md | 18 ++++++++++++++++++ .../ecad/schema/genericjsonpcbdata_v1.schema | 6 +++++- InteractiveHtmlBom/web/render.js | 9 +++++---- tests/test_board_clipping.py | 14 +++++++++++--- 4 files changed, 39 insertions(+), 8 deletions(-) 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/schema/genericjsonpcbdata_v1.schema b/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema index aa59dbff..796174ab 100644 --- a/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema +++ b/InteractiveHtmlBom/ecad/schema/genericjsonpcbdata_v1.schema @@ -607,9 +607,13 @@ "type": "object", "additionalProperties": false, "properties": { + "svgpath": { "type": "string" }, "polygons": { "$ref": "#/definitions/Polygons" } }, - "required": ["polygons"] + "anyOf": [ + { "required": [ "svgpath" ] }, + { "required": [ "polygons" ] } + ] }, "Polygons": { "type": "array", diff --git a/InteractiveHtmlBom/web/render.js b/InteractiveHtmlBom/web/render.js index 3aeb1ddc..584e45f6 100644 --- a/InteractiveHtmlBom/web/render.js +++ b/InteractiveHtmlBom/web/render.js @@ -382,11 +382,12 @@ function drawEdgeCuts(canvas, scalefactor) { ctx.save(); if (pcbdata.board_clip) { // The clipped perimeter includes the new edges along castellated drills. - // Use one consistent width, preserving the widest geometric Edge.Cuts - // stroke and the minimum one-pixel width at any zoom. - ctx.lineWidth = pcbdata.edges.reduce((width, edge) => + // 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.max(width, edge.width || 0) : width, 1 / scalefactor); + ? 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"; diff --git a/tests/test_board_clipping.py b/tests/test_board_clipping.py index a4816820..3608bd67 100644 --- a/tests/test_board_clipping.py +++ b/tests/test_board_clipping.py @@ -195,9 +195,17 @@ def test_optional_clipping_schema(self): add_drill(self.board, 0, 5) data.update(parse_clip(self.board)) jsonschema.validate(data, schema) - data['board_clip']['polygons'] = 'invalid' - with self.assertRaises(jsonschema.ValidationError): - 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())