From d5166d2831f630285f4094641de97acee347be3f Mon Sep 17 00:00:00 2001 From: gherkin Date: Thu, 29 Oct 2026 17:32:05 +0100 Subject: [PATCH 1/3] The Allegro export writes the pin number, and the pad table shows a through-hole pin once (#158) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- .github/workflows/ci.yml | 13 ++++++++++--- allegro-skills/exportJson.il | 7 +++++++ tests/browser/pad-names.spec.js | 20 ++++++++++++++++++++ web/app.js | 26 ++++++++++++++++++++++++-- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 162cefe..52eb363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -393,9 +393,14 @@ jobs: - CIAA-ACC: ${{ steps.preview.outputs.preview-url }}ciaa-acc/ # main pushes publish :latest, v* tags publish the version. Both get :sha-. + # PRs from this repo publish :pr-, so a branch can be tried before merge. + # PRs from forks are skipped: their token can't push. publish: name: Publish image - if: github.event_name == 'push' + if: >- + github.event_name == 'push' || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) needs: [checks, smoke, build, browser, mcp] runs-on: ubuntu-latest permissions: @@ -422,7 +427,8 @@ jobs: tags: | type=semver,pattern={{version}} type=raw,value=latest,enable={{is_default_branch}} - type=sha + type=sha,enable=${{ github.event_name == 'push' }} + type=ref,event=pr # The MCP server, same tags. - id: meta-mcp @@ -433,7 +439,8 @@ jobs: tags: | type=semver,pattern={{version}} type=raw,value=latest,enable={{is_default_branch}} - type=sha + type=sha,enable=${{ github.event_name == 'push' }} + type=ref,event=pr # Build both images for all platforms before pushing either, so a # failed build (most likely arm64, which no other job builds) leaves diff --git a/allegro-skills/exportJson.il b/allegro-skills/exportJson.il index 85b2d62..f4bb908 100644 --- a/allegro-skills/exportJson.il +++ b/allegro-skills/exportJson.il @@ -1604,6 +1604,13 @@ procedure( addPad( pin ) "\"type\": " type ) + ; pin number as named in the netlist, on both pads of a through-hole pair + when( pinNumber && ( pinNumber != "" ) + pad = strcat( pad ",\n" + "\"name\": \"" pinNumber "\"" + ) + ) + when( svgpath pad = strcat( pad ",\n" svgpath diff --git a/tests/browser/pad-names.spec.js b/tests/browser/pad-names.spec.js index 87f2a27..8b346c7 100644 --- a/tests/browser/pad-names.spec.js +++ b/tests/browser/pad-names.spec.js @@ -82,6 +82,26 @@ test('unnamed board: hovering a pad still shows pad N', async ({ page }) => { expect(await page.locator('#detail-pane .pad-table th').allInnerTexts()).toEqual(['Pin', 'Net', 'Type']); }); +// Allegro writes a through-hole pin as two pads at one pos: F "th" and B +// "smd", both with the pin name. The pad table shows each pin once. +test('pad table: an Allegro through-hole pin pair is one row', async ({ page }) => { + await load(page, 'named', 'U24'); + const rows = await page.evaluate(() => { + const idx = pcbdata.footprints.findIndex((f) => f.ref === 'U24'); + const fp = pcbdata.footprints[idx]; + const [a, b] = fp.pads; + const pair = (pad, name, net) => [ + { ...pad, layers: ['F'], type: 'th', name, net }, + { ...pad, layers: ['B'], type: 'smd', name, net }, + ]; + fp.pads = [...pair(a, '1', 'N1'), ...pair(b, '2', 'N2')]; + renderDetailPane(idx); + return [...document.querySelectorAll('#detail-pane .pad-table tr')].slice(1) + .map((tr) => [...tr.children].map((td) => td.innerText)); + }); + expect(rows).toEqual([['1', 'N1', 'th'], ['2', 'N2', 'th']]); +}); + test('the tooltip shows a pad name as text, not markup', async ({ page }) => { await load(page, 'named', 'U24'); const fp = footprint('named', 'U24'); diff --git a/web/app.js b/web/app.js index 563f8de..ba65f49 100644 --- a/web/app.js +++ b/web/app.js @@ -1185,12 +1185,13 @@ function renderDetailPane(fpIdx, showPads) { }); padTable.appendChild(thead); - fp.pads.forEach(function(pad, i) { + tablePads(fp.pads).forEach(function(row) { + var pad = row.pad; var tr = document.createElement("tr"); var tdNum = document.createElement("td"); // The pad name when the export has one, else the place in the list. - tdNum.textContent = padName(pad) || (i + 1); + tdNum.textContent = padName(pad) || (row.i + 1); tr.appendChild(tdNum); var tdNet = document.createElement("td"); @@ -1225,6 +1226,27 @@ function renderDetailPane(fpIdx, showPads) { } } +// The pads of a footprint as rows of the pad table, each { pad, i } with i +// the pad's place in the list. Allegro writes a through-hole pin as two pads +// at one pos on one net, one of them "th"; that pair is one row, the "th" +// pad, at the place of the first. Same rule as boardPads in the MCP tools. +function tablePads(pads) { + var rows = []; + var at = {}; + pads.forEach(function(pad, i) { + var key = pad.pos.join(","); + var r = at[key]; + if (r !== undefined && rows[r].pad.net === pad.net && + (rows[r].pad.type === "th" || pad.type === "th")) { + if (pad.type === "th") rows[r].pad = pad; + return; + } + at[key] = rows.length; + rows.push({ pad: pad, i: i }); + }); + return rows; +} + function copyRef() { if (selectedFootprintIdx === null) return; var ref = pcbdata.footprints[selectedFootprintIdx].ref; From 469cbf836b924c0c8664906bac121255154f4488 Mon Sep 17 00:00:00 2001 From: gherkin Date: Fri, 30 Oct 2026 11:39:19 +0100 Subject: [PATCH 2/3] The pin name in the Allegro export is escaped, and the docs say Allegro boards have pad names Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- allegro-skills/exportJson.il | 6 ++++-- mcp/src/pads.js | 8 +++++--- mcp/src/tools/nearest.js | 4 ++-- mcp/src/tools/net_places.js | 2 +- web/render.js | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/allegro-skills/exportJson.il b/allegro-skills/exportJson.il index f4bb908..5bec3fb 100644 --- a/allegro-skills/exportJson.il +++ b/allegro-skills/exportJson.il @@ -1303,7 +1303,7 @@ procedure( addPad( pin ) let( ( ( padLayers 'unbound ) ( drill 'unbound ) ( pads 'unbound ) - xy xPos yPos pos net angle pinOnes pinNumber + xy xPos yPos pos net angle pinOnes pinNumber pattern type padstack startEnd layers pad drillShape drillSize bBox xSize ySize size shape svgpath innerDiameter outerDiameter startInner startOuter ) @@ -1606,8 +1606,10 @@ procedure( addPad( pin ) ; pin number as named in the netlist, on both pads of a through-hole pair when( pinNumber && ( pinNumber != "" ) + ; escape " correctly + pattern = pcreCompile( "[\\\"]" ) pad = strcat( pad ",\n" - "\"name\": \"" pinNumber "\"" + "\"name\": \"" pcreReplace( pattern pinNumber "\\\\\"" 0 ) "\"" ) ) diff --git a/mcp/src/pads.js b/mcp/src/pads.js index 825e000..a3dd7e3 100644 --- a/mcp/src/pads.js +++ b/mcp/src/pads.js @@ -13,8 +13,10 @@ // net net name as written in the CAD tool; "" when unconnected // pin1 1 on the pad the export marks as pin 1, absent otherwise // name the pad name as a string ("5", "A1", "EP"), the PIN in a netlist's -// REFDES:PIN. Written by tools/make_fixture.py for KiCad boards; -// stock ibom and Allegro exports have none, so it is often absent. +// REFDES:PIN. Written by tools/make_fixture.py for KiCad boards +// and by exportJson.il for Allegro boards (on both pads of a +// through-hole pair); stock ibom and older Allegro exports have +// none, so it is often absent. // // Vias are tracks in the payload, not pads, so they never come out of here. @@ -35,7 +37,7 @@ export const padSchema = z.object({ .optional() .describe( 'Pad name, the PIN in a netlist REFDES:PIN (e.g. "5", "A1", "EP"). Absent when the export has ' + - 'no pad names (Allegro boards, and KiCad boards exported with stock ibom).' + 'no pad names (KiCad boards exported with stock ibom, and Allegro boards exported before pad names).' ), pin1: z.literal(true).optional().describe('Present on the pad the export marks as pin 1.'), }); diff --git a/mcp/src/tools/nearest.js b/mcp/src/tools/nearest.js index 3019acb..10ff703 100644 --- a/mcp/src/tools/nearest.js +++ b/mcp/src/tools/nearest.js @@ -31,8 +31,8 @@ export default { 'Find the pads on a net nearest to a part or a point, to choose where to probe or solder a ' + 'wire. Use it for "where can I measure or patch +3.3V close to U69?".\n' + 'Each pad has pin, the pad name that matches the PIN in a netlist REFDES:PIN, when the ' + - 'board has pad names (KiCad boards exported with tools/make_fixture.py do; Allegro boards and ' + - 'KiCad boards from stock ibom do not).\n' + + 'board has pad names (KiCad boards exported with tools/make_fixture.py and current Allegro ' + + 'exports do; KiCad boards from stock ibom and older Allegro exports do not).\n' + 'Order: by distance only, nearest first. Each pad says what it is: type "th" (through-hole) ' + 'or "smd", and testpoint: true on a test point. A test point is a part with refdes ' + 'TP, or with "testpoint" / "test point" in its value or footprint name. Prefer a ' + diff --git a/mcp/src/tools/net_places.js b/mcp/src/tools/net_places.js index cbabb8e..45b4017 100644 --- a/mcp/src/tools/net_places.js +++ b/mcp/src/tools/net_places.js @@ -11,7 +11,7 @@ export default { 'is as written in the CAD tool, e.g. "+3.3V" or "/ADC1/CS". For each pad: the refdes of ' + 'its part, the side it is on, its center, whether it is SMD or through-hole, and its pin: ' + 'the pad name, which matches the PIN in a netlist REFDES:PIN (e.g. U24 pin "4" is U24:4). ' + - 'Boards exported without pad names (Allegro, or KiCad with stock ibom) have no pin. The pad the export marks as ' + + 'Boards exported without pad names (KiCad with stock ibom, older Allegro exports) have no pin. The pad the export marks as ' + 'pin 1 has pin1: true.\n' + 'Vias are not listed.\n' + 'Coordinates are board units, the same ones the viewer uses in its links: mm for KiCad ' + diff --git a/web/render.js b/web/render.js index 6b15caf..7f70b87 100644 --- a/web/render.js +++ b/web/render.js @@ -1135,7 +1135,7 @@ function padName(pad) { // What the viewer calls pad j of a footprint. A pad name from the CAD export // ("11", "A1", "EP") is the pin as the netlist names it (REFDES:PIN), so it -// reads "pin 11". Exports without names (stock ibom, Allegro) only have the +// reads "pin 11". Exports without names (stock ibom, older Allegro) only have the // pad's place in the footprint's list, which is not the pin number, so that // reads "pad N". function padLabel(pad, j) { From 4bbdd569a379923a306792112ee1814cfb680208 Mon Sep 17 00:00:00 2001 From: gherkin Date: Fri, 30 Oct 2026 12:14:15 +0100 Subject: [PATCH 3/3] Only an F pad and a B pad make a through-hole pair, and a PR publish cancels the older one Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- .github/workflows/ci.yml | 6 ++++++ mcp/src/pads.js | 24 +++++++++++++++++++----- mcp/test/net_places.test.js | 23 +++++++++++++++++++++++ tests/browser/pad-names.spec.js | 19 +++++++++++++++++++ web/app.js | 18 ++++++++++++++---- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52eb363..681f9e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -406,6 +406,12 @@ jobs: permissions: contents: read packages: write + # A newer push to a PR cancels the older publish, so :pr- is never + # left on an older commit. A running main or tag publish is not cancelled; + # GitHub keeps only the newest waiting run in the group. + concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} steps: - uses: actions/checkout@v7 diff --git a/mcp/src/pads.js b/mcp/src/pads.js index a3dd7e3..c09275a 100644 --- a/mcp/src/pads.js +++ b/mcp/src/pads.js @@ -42,10 +42,24 @@ export const padSchema = z.object({ pin1: z.literal(true).optional().describe('Present on the pad the export marks as pin 1.'), }); -// Every pad on the board, with the footprint it belongs to. Two pads of one -// footprint at the same pos and on the same net, where one is "th", come out -// as one "th" pad on both sides (the Allegro pair above). pin1 and name are -// kept if either pad has them. Pads on different nets are never merged. +// Whether pads a and b of one footprint are the two sides of one pin: same +// pos and net, one on ["F"] and the other on ["B"], one of them "th", and +// the same name if both have one. A KiCad SMD pad and a through-hole pad at +// one point (an EP with a thermal via in its center) are not a pair. +function isPadPair(a, b) { + const side = (p) => (Array.isArray(p.layers) && p.layers.length === 1 ? p.layers[0] : ''); + const sides = side(a) + side(b); + return ( + a.net === b.net && + (sides === 'FB' || sides === 'BF') && + (a.type === 'th' || b.type === 'th') && + (!a.name || !b.name || a.name === b.name) + ); +} + +// Every pad on the board, with the footprint it belongs to. A pad pair (see +// isPadPair, the Allegro pair above) comes out as one "th" pad on both sides. +// pin1 and name are kept if either pad has them. export function* boardPads(data) { for (const fp of data.footprints) { if (!fp || typeof fp.ref !== 'string' || !Array.isArray(fp.pads)) continue; @@ -55,7 +69,7 @@ export function* boardPads(data) { if (!pad || !Array.isArray(pad.pos)) continue; const key = pad.pos.join(','); const i = at.get(key); - if (i !== undefined && pads[i].net === pad.net && (pads[i].type === 'th' || pad.type === 'th')) { + if (i !== undefined && isPadPair(pads[i], pad)) { const other = pads[i]; const th = other.type === 'th' ? other : pad; pads[i] = { ...th, type: 'th', layers: ['F', 'B'] }; diff --git a/mcp/test/net_places.test.js b/mcp/test/net_places.test.js index ef40901..9dfe295 100644 --- a/mcp/test/net_places.test.js +++ b/mcp/test/net_places.test.js @@ -204,6 +204,29 @@ test('a merged pad pair keeps the pad name', () => { assert.deepEqual(places, [['J1', '3'], ['J2', '7'], ['J3', '8']]); }); +// Only an F pad and a B pad make a pair: a KiCad EP with a through-hole +// thermal pad in its center, both on GND, are two pads. Two names that +// differ are two pins. +test('an SMD pad and a through-hole pad at one point are not merged', () => { + const pad = (layers, type, name) => ({ pos: [0, 0], layers, type, net: 'GND', name }); + const board = { + footprints: [ + { ref: 'U1', layer: 'F', pads: [pad(['F'], 'smd', 'EP'), pad(['F', 'B'], 'th', 'EP')] }, + { ref: 'U2', layer: 'F', pads: [pad(['F'], 'th', '1'), pad(['B'], 'smd', '2')] }, + { ref: 'U3', layer: 'F', pads: [pad(['F'], 'smd', 'A'), pad(['F'], 'th', 'A')] }, + ], + }; + const places = [...boardPads(board)].map(({ fp, pad }) => [fp.ref, padPlace(fp, pad).side, pad.type]); + assert.deepEqual(places, [ + ['U1', 'F', 'smd'], + ['U1', 'both', 'th'], + ['U2', 'F', 'th'], + ['U2', 'B', 'smd'], + ['U3', 'F', 'smd'], + ['U3', 'F', 'th'], + ]); +}); + test('pads at one point on different nets are not merged', () => { const board = { footprints: [ diff --git a/tests/browser/pad-names.spec.js b/tests/browser/pad-names.spec.js index 8b346c7..3be0c12 100644 --- a/tests/browser/pad-names.spec.js +++ b/tests/browser/pad-names.spec.js @@ -102,6 +102,25 @@ test('pad table: an Allegro through-hole pin pair is one row', async ({ page }) expect(rows).toEqual([['1', 'N1', 'th'], ['2', 'N2', 'th']]); }); +// A KiCad EP with a through-hole thermal pad in its center, both on GND, +// is two rows: only an F pad and a B pad make a pair. +test('pad table: an SMD pad and a through-hole pad at one point are two rows', async ({ page }) => { + await load(page, 'named', 'U24'); + const rows = await page.evaluate(() => { + const idx = pcbdata.footprints.findIndex((f) => f.ref === 'U24'); + const fp = pcbdata.footprints[idx]; + const [a] = fp.pads; + fp.pads = [ + { ...a, layers: ['F'], type: 'smd', name: 'EP', net: 'GND' }, + { ...a, layers: ['F', 'B'], type: 'th', name: 'EP', net: 'GND' }, + ]; + renderDetailPane(idx); + return [...document.querySelectorAll('#detail-pane .pad-table tr')].slice(1) + .map((tr) => [...tr.children].map((td) => td.innerText)); + }); + expect(rows).toEqual([['EP', 'GND', 'smd'], ['EP', 'GND', 'th']]); +}); + test('the tooltip shows a pad name as text, not markup', async ({ page }) => { await load(page, 'named', 'U24'); const fp = footprint('named', 'U24'); diff --git a/web/app.js b/web/app.js index ba65f49..ba399fb 100644 --- a/web/app.js +++ b/web/app.js @@ -1228,17 +1228,27 @@ function renderDetailPane(fpIdx, showPads) { // The pads of a footprint as rows of the pad table, each { pad, i } with i // the pad's place in the list. Allegro writes a through-hole pin as two pads -// at one pos on one net, one of them "th"; that pair is one row, the "th" -// pad, at the place of the first. Same rule as boardPads in the MCP tools. +// at one pos on one net, one on ["F"] and one on ["B"], one of them "th"; +// that pair is one row, the "th" pad, at the place of the first. Same rule +// as isPadPair in the MCP tools. function tablePads(pads) { var rows = []; var at = {}; + function side(pad) { + return pad.layers && pad.layers.length === 1 ? pad.layers[0] : ""; + } + function isPair(a, b) { + var sides = side(a) + side(b); + return a.net === b.net && (sides === "FB" || sides === "BF") && + (a.type === "th" || b.type === "th") && + (!padName(a) || !padName(b) || padName(a) === padName(b)); + } pads.forEach(function(pad, i) { var key = pad.pos.join(","); var r = at[key]; - if (r !== undefined && rows[r].pad.net === pad.net && - (rows[r].pad.type === "th" || pad.type === "th")) { + if (r !== undefined && isPair(rows[r].pad, pad)) { if (pad.type === "th") rows[r].pad = pad; + delete at[key]; return; } at[key] = rows.length;