Skip to content

Render castellated pad holes with board clipping and continuous outlines - #576

Merged
qu1ck merged 5 commits into
openscopeproject:masterfrom
tytelmaier:fix/325-castellated-holes
Oct 14, 2026
Merged

qu1ck merged 5 commits into
openscopeproject:masterfrom
tytelmaier:fix/325-castellated-holes

Conversation

@tytelmaier

@tytelmaier tytelmaier commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Addresses #325 for KiCad boards.

When a pad drill intersects the PCB boundary, the current viewer draws the complete pad and gray drill, including the portion outside the board. This change clips copper to the physical board region and renders the opening as a notch. The board outline follows the curved notch edge and remains continuous when pad visibility is turned off.

Implementation

This follows the clipping approach suggested in the issue, using KiCad's existing geometry API instead of reconstructing a closed outline from exported Edge.Cuts segments.

  • Obtain the board region with GetBoardPolygonOutlines(), including internal cutouts and separate board islands.
  • Convert through-hole pad drills to polygons with TransformHoleToPolygon(). A crossing drill must have positive area both inside and outside the original board region. This uses the physical drill geometry, so rotated slots and copper offsets are handled independently.
  • Union crossing drills before subtraction so overlapping or duplicate holes do not cancel under even-odd rendering. Export two optional regions: board_clip for the remaining board interior and edge_drills for the crossing-drill union.
  • Reuse cached Path2D objects and apply even-odd clipping to normal and highlighted copper, including pads, tracks, zones, and copper footprint drawings. Net hit testing rejects points in removed regions.
  • For boards with crossing drills, stroke the clipped perimeter to draw continuous notch boundaries. Use the existing edge color, the widest geometric Edge.Cuts stroke as a consistent width, and a minimum width of one pixel. Boards without crossing drills retain their original edge drawing path.

Geometry construction happens during HTML generation. Browser redraws reuse the cached paths. No additional geometry library is introduced.

Clipping is scoped with canvas save/restore, including the shared-canvas PNG export path. Openings remain transparent rather than being painted with an opaque background color. Ordinary internal drill holes keep their gray appearance. Silkscreen, fabrication graphics, and footprint selection boxes retain their existing behavior.

Before / after

These examples use the same saved STK8321 test board for both renderers. “Before” uses upstream 5114701; “After” uses this implementation. They do not use the intermediate clipping-only implementation as the baseline.

With pads visible, complete pads and gray drills extending beyond the edge become clipped pads with open notches:

Before After
Upstream front view with pads visible STK8321_before_F Updated front view with pads visible STK8321_after_F

With pads hidden, the original edge does not show the drill cutouts. The new perimeter explicitly outlines the notches:

Before After
Upstream front view with pads hidden PR_before_F_pads_hidden Updated front view with pads hidden PR_after_F_pads_hidden

KiCad compatibility

Version Handling and validation
KiCad 10 Uses the newer outline API signature with aInferOutlineIfNecessary=False, so it does not infer a guessed outline. Tested with KiCad 10.0.6.
KiCad 8 / 9 Uses the older outline signature. Boolean operations supply PM_STRICTLY_SIMPLE when that older API constant is available. Automated tests passed in the KiCad 8.0 and 9.0 CI containers. KiCad 10.0.6 was validated locally, including browser rendering and PNG export. Interactive rendering was not separately tested under KiCad 8/9.
Earlier KiCad versions Clipping is optional. If the outline API is absent, or required geometry operations are unavailable or incompatible, clipping data is omitted and the previous rendering is retained. This is a fallback path, not a claim that castellated-hole rendering has been validated on every older release.

Malformed outline: no clipping is performed. If KiCad rejects the outline, generation logs Invalid board outline; copper clipping disabled and falls back to the original rendering. It does not apply a partial or guessed clipping region. Correct the Edge.Cuts geometry, save the PCB, and regenerate the HTML to restore clipping. This fallback and recovery were observed on the test project.

Other ECAD formats

The rendering contract is format-independent, but this PR adds automatic clipping-geometry extraction only to the KiCad parser.

  • Eagle/Fusion and EasyEDA parsers are unchanged and do not generate the new regions. Their existing rendering is retained; this PR does not claim automatic castellated-hole support for those formats.
  • The generic JSON schema accepts optional board_clip and edge_drills polygon regions. An external exporter can supply them to use the same renderer behavior.
  • Existing inputs without these optional fields continue to use the original rendering path. A future adapter can add geometry extraction for another ECAD without replacing the viewer's clipping implementation.

Validation

  • Twelve native geometry/compatibility tests passed under KiCad 10.0.6, covering round holes, rotated slots, copper offsets, overlapping drills, cutouts, separate islands, tangent/internal/external holes, NPTH edge openings, circular and footprint-owned outlines, fallback behavior, and older call signatures.
  • The existing module-import smoke test passed. One schema test was skipped locally because jsonschema is unavailable in the installed KiCad Python environment.
  • Local Edge/Chromium checks passed for normal and highlighted copper, hit testing, both sides, light/dark themes, rotation, zoom, legacy inputs, and drawing-state restoration.
  • An additional 1,584 notch-outline pixel assertions passed with pads visible and hidden. Actual transparent and opaque PNG exports were checked, including transparent notch interiors and visible notch outlines.
  • JavaScript syntax and Git whitespace checks passed.

Native regression tests are included in tests/test_board_clipping.py. Browser checks and screenshots were run locally and are supporting evidence, not part of the committed repository test suite. Full local pytest collection encountered the existing CLI-mode package-initializer issue; native tests were run with unittest and the import smoke test was invoked directly.

Scope and tradeoffs

  • Through-hole pad openings include plated and non-plated holes. Boundary-crossing via drills are not included in the drill-subtraction scan.
  • Drill polygons use a 0.001 mm approximation setting; board contours use KiCad's tessellation. Extreme zoom can expose polygon faceting.
  • On boards with crossing drills, the physical perimeter replaces raw Edge.Cuts rendering. Different original edge widths become one consistent width, and non-perimeter graphics on Edge.Cuts are not reproduced by that perimeter stroke.
  • A selected footprint's translucent bounding box can tint an opening; removed pad copper is still clipped.
  • Real KiCad 8/9 runtimes, non-Chromium browsers, and mixed Bezier outlines need further validation.

Additional local examples:

STK8321_after.html
STK8321_before.html

@qu1ck qu1ck left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great contribution but needs some changes:

  1. As far as I can tell the split between board geometry and drill hole geometry is done so that when pads are hidden the notches that the castellated pads create in the outline disappear. I don't think they should disappear at all, hiding pads should not affect visual board outline. So merge the clipping geometry of holes with the board edge geometry on the backend, export only one set of polygons.
  2. Always clip the silkscreen but not the fab layer.
  3. 0.001 error in polygon approxymation is way too fine and will generate unnecessarily large amount of vertexes. Use something from 0.01-0.05 range, pick largest one that doesnt make 0.3mm hole look jagged.
  4. Bezier curve outlines need to be tested
  5. KiCad 7.0+ API compatibility need to be tested. There are kicad docker images for all versions, use them to generate a simple board BOM in cli mode. It's fine if old kicad does not have new functionality, it just has to not crash and preserve old behavior.

Comment thread InteractiveHtmlBom/ecad/kicad.py
Comment thread InteractiveHtmlBom/ecad/kicad.py Outdated

# 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tolerance is too fine.

Comment thread InteractiveHtmlBom/web/render.js Outdated
Comment on lines +401 to +403
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The margin is too small. Large pads or silkscreen can extend far beyond the edge and bounding box. Apply a liberally huge margin (say 10x bbox length/width).
  2. Calculate clipping path with margin once, cache and reuse it.

@tytelmaier

tytelmaier commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review. The follow-up addresses the five points and the inline API/margin comments as follows.

1. Merge board and drill geometry; keep notches when pads are hidden

The backend now exports only board_clip, containing the physical board region after subtracting the union of boundary-crossing drills. The separate edge_drills export and schema field have been removed. Copper clipping, net hit testing and perimeter drawing share this geometry.

The perimeter is drawn independently of pad visibility, including on valid boards without crossing drills. The comparison below shows the same outline with pads visible and hidden; it is a visibility comparison of the current implementation, rather than a claim that the previous PR revision lost the notches.

Figure 1. Current board perimeter with pads visible and hidden; the curved mounting-hole notch, cutout and lower castellations remain outlined.
01-outline-pads-visible-hidden

2. Clip silkscreen, but preserve fabrication

Silkscreen shapes and text are clipped to the board region. Fabrication remains unclipped. Canvas save/restore scopes prevent the silk clip from leaking into fabrication or later drawing, including PNG export where layers share a canvas.

In Figure 2, the top STK8321 label and the part of the lower silkscreen rectangle outside the board disappear after clipping. The fabrication panel shows that references and the J1 footprint outline can still extend outside the board.

Before is the local snapshot immediately before the layer-clipping change, already using the 0.01 mm drill tolerance. All panels use the same saved STK8321 board, front view and scale, with pads hidden.

Figure 2. Before and after silkscreen clipping, alongside the current unclipped fabrication layer.
02-silkscreen-before-after-fabrication

3. Reduce drill polygon detail

Changed the approximation tolerance from 0.001 mm to 0.01 mm. I compared 0.001, 0.01, 0.02, 0.03 and 0.05 mm for a 0.3 mm drill at displayed diameters of 30, 90 and 160 pixels. Among these candidates, 0.01 mm is the largest setting that avoids the clearly flattened appearance of the coarser alternatives. Board-outline tessellation still uses KiCad's native API.

In KiCad 10.0.6, this reduces a 0.3 mm drill from 34 to 18 vertices, and a 2.5 mm drill from 82 to 34 vertices. Figure 3 compares native drill polygons against an ideal circular outline.

Figure 3. Drill tolerance comparison for a 0.3 mm hole; the selected 0.01 mm setting is highlighted.
03-drill-tolerance-comparison

For the same STK8321 board revision used in Figures 1–2 (before the via was added), compact uncompressed clipping-geometry JSON changed as follows:

Implementation Geometry payload
Original two fields, 0.001 mm 18,554 bytes
Two fields, 0.01 mm 9,120 bytes
Final single field, 0.01 mm 5,417 bytes

The final payload is about 71% smaller than the original. These measurements concern geometry only, not the entire compressed HTML file.

4. Test Bezier outlines

Added a repeatable native fixture with a cubic Bezier outer edge, a closed two-Bezier cutout and a 0.3 mm drill crossing the outer curve. Tests verify retained board material, removed drill/cutout regions, and fallback when the outline is opened.

Also tested the updated STK8321 board shown in Figure 1: the top-right mounting hole crosses a Bezier edge, the internal cutout has a curved edge, and the lower edge intersects two connector drills. The existing native polygon extraction handles these features without a separate Bezier clipping algorithm. The source PCB was not modified.

5. KiCad 7+ compatibility and defensive API checks

The existence check runs before either version-dependent GetBoardPolygonOutlines() call:

if not self._clipping_methods_available(
        self.board, "GetBoardPolygonOutlines"):
    return {}

The helper uses callable(getattr(obj, name, None)), so absent and non-callable APIs both disable clipping safely. The required polygon APIs and TransformHoleToPolygon() are checked too. AttributeError, TypeError and RuntimeError are caught to handle incompatible signatures or geometry failures. This addresses the inline concern about calling unavailable methods on older versions.

Malformed outline: no clipping is performed. HTML generation continues with the original rendering behavior when valid clipping geometry cannot be obtained.

Real CLI tests save a temporary fixture using each installed KiCad version and run the generator in a subprocess. They cover valid Bezier geometry, an intentionally disabled outline API, and an open outline. All three must generate HTML successfully; the valid case must contain clipping, and fallback cases must omit it. Separate tests cover unavailable methods and incompatible API failures.

Official Docker image Actual KiCad version Full pytest result CLI cases
kicad/kicad:7.0 7.0.11 16 tests + 18 subtests passed; no skips All 3 passed
kicad/kicad:8.0 8.0.9 16 tests + 18 subtests passed; no skips All 3 passed
kicad/kicad:9.0 9.0.9 16 tests + 18 subtests passed; no skips All 3 passed
kicad/kicad:10.0 10.0.6 16 tests + 18 subtests passed; no skips All 3 passed

The CLI cases are included in the suite totals. Native Windows KiCad 10.0.6 was also checked. The fixtures account for KiCad 7's footprint-graphics class and local pad coordinates when saving boards.

CI now includes KiCad 7–10; the existing setup commands remain unchanged. The successful local Docker tests above used an isolated Hatch virtual environment. The current GitHub run fails during KiCad 10 environment setup, before tests execute; the other matrix jobs were cancelled. I have left the setup unchanged pending your feedback on using an isolated Hatch environment. Versions before KiCad 7 were not executed. Other ECAD parsers are unchanged and retain their previous behavior when clipping data is absent.

Inline comment: clipping margin and cached path

Removed the inverse drill-clipping branch and its bounding rectangle entirely. With the merged physical board region, there is no outer margin to size. This addresses the margin problem without introducing a larger arbitrary rectangle.

The existing cached Path2D for board_clip is reused for clipping and perimeter strokes across redraws, zoom and rotation. Browser tests include oversized pads and silkscreen thousands of millimeters outside the board, and verify that the same cached path is reused.

Final validation and scope

The earlier board revision and synthetic fixtures passed 21,952 Bezier/perimeter pixel checks and 1,490 layer/export assertions in Edge/Chromium. These cover both sides, themes, pad visibility, rotation, scale, silk shapes/text, fabrication, legacy inputs and actual transparent/opaque PNG exports. No browser page errors were recorded. Browser checks are local supporting evidence; native geometry, schema and CLI tests are in the repository CI.

The final cleanup reduced the full diff from 502 to 441 added lines, retaining checks for the optional geometry APIs and strengthening CLI assertions. Two mocked signature tests were replaced by real-version coverage.

The physical perimeter uses a consistent geometric stroke width and does not reproduce decorative Edge.Cuts text/graphics. Fabrication and footprint selection boxes can extend outside the board. Non-Chromium browsers have not been validated.

Edge openings are generated from footprint-pad drills; edge-crossing routing vias are not currently included.

Current behavior with an edge-crossing via

I added an edge-crossing routing via to the STK8321 test board and regenerated the BOM with tracks included. This via has a 1.2 mm copper diameter and a 0.8 mm drill, and is now untented on both faces. The examples and focused checks were regenerated after enlarging this untented via to make the gray drill fill easier to see. Its center is approximately 0.10 mm inside the extracted boundary, so the 0.40 mm drill radius crosses it.

The observed behavior is:

  • Via copper and the gray drill fill are clipped at the physical board boundary.
  • The via's drill is not subtracted from board_clip, so the outline continues across the via rather than forming a drill-shaped notch.
  • The exported via now includes drillsize: 0.8 on both faces. Its visible drill remains an opaque gray fill, rather than a transparent edge opening. Changing tenting therefore exposes the drill in the drawing but does not add its geometry to the board cutout.
  • Vias follow track visibility: hiding pads does not hide the via, while hiding tracks removes it. The outline stays unchanged in both cases.

Figure 4 compares the upstream renderer with the current implementation using the same updated board. The third panel shows the current implementation with tracks hidden. The board-edge stroke retains its normal width, centered on the boundary; it is not a representation of a via cutout.

Figure 4. Updated-board close-up: upstream untented-via rendering, current copper/drill clipping, and current rendering with tracks hidden. No via-drill notch is generated.
04-edge-via-current-behavior

Targeted pixel checks passed on both front and back with pads visible/hidden and tracks visible/hidden. They verify clipping of copper and drill fill, the actual opaque drill color, and the unchanged edge. A native check also confirmed that removing this via from the in-memory board leaves the exported board_clip identical. The source PCB was not saved or modified.

I'm not sure whether dedicated via-drill subtraction and its regression coverage belong in this PR. I would normally use footprint pads for castellated connections and consider an accidental board-edge intersection with a routing via a layout error. If intentional edge-crossing vias are a use case you want supported, please let me know.

@qu1ck qu1ck left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, just 2 more minor comments inline.

Also please update DATAFORMAT.md with board_clip field description and rebase the branch on top of master.

"type": "object",
"additionalProperties": false,
"properties": {
"polygons": { "$ref": "#/definitions/Polygons" }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add option to pass geometry as svgpath. For some CAD packages they might support native arcs in the outline and this is preferable format.

Make any of [svgpath, polygons] required similar to other structs containing svgpath.

Comment thread InteractiveHtmlBom/web/render.js Outdated
// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduce edge widths using min(), pick max of that and 1 / scalefactor to ensure it's always visible.
In legacy kicad versions edge thickness was used as margin for zone pullbacks. Picking max thickness can lead to extremely thick outlines in some cases. Nowadays kicad treats all edge lines as 0 thickness for board geometry so min edge thickness is a safe pick.

@tytelmaier
tytelmaier force-pushed the fix/325-castellated-holes branch from 432a037 to bb343cb Compare October 9, 2026 14:26
@tytelmaier

Copy link
Copy Markdown
Contributor Author

Thanks for the follow-up review. I’ve addressed the remaining points:

  • board_clip now accepts either svgpath or polygons, with at least one required. The existing renderer already supports SVG paths, so KiCad can keep exporting polygons while other exporters can preserve arcs.
  • The outline now uses the smallest geometric edge width, with a one-pixel minimum to keep it visible at any zoom.
  • Added the board_clip description to DATAFORMAT.md and rebased onto the latest master.

The full suite passed locally on KiCad 7–10, with 16 tests and 24 subtests per version. Browser checks for SVG clipping, outline widths and PNG export also passed. The intermittent outline rejection was traced to three zero-length curves in my test board; after removing them, all repeated Windows and Linux outline checks passed.

CI setup is unchanged; the isolated Hatch environment was used only for local testing.

@tytelmaier
tytelmaier requested a review from qu1ck October 10, 2026 08:31
@qu1ck
qu1ck merged commit fa409db into openscopeproject:master Oct 14, 2026
0 of 4 checks passed
@qu1ck

qu1ck commented Oct 14, 2026

Copy link
Copy Markdown
Member

Thanks!

@tytelmaier

tytelmaier commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

@qu1ck I was wondering if you could publish another release. There have been quite a few new features and bug fixes lately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants