diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 162cefe..681f9e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -393,14 +393,25 @@ 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: 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 @@ -422,7 +433,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 +445,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..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 ) @@ -1604,6 +1604,15 @@ procedure( addPad( pin ) "\"type\": " type ) + ; 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\": \"" pcreReplace( pattern pinNumber "\\\\\"" 0 ) "\"" + ) + ) + when( svgpath pad = strcat( pad ",\n" svgpath diff --git a/mcp/src/pads.js b/mcp/src/pads.js index 825e000..c09275a 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,15 +37,29 @@ 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.'), }); -// 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; @@ -53,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/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/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 87f2a27..3be0c12 100644 --- a/tests/browser/pad-names.spec.js +++ b/tests/browser/pad-names.spec.js @@ -82,6 +82,45 @@ 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']]); +}); + +// 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 563f8de..ba399fb 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,37 @@ 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 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 && isPair(rows[r].pad, pad)) { + if (pad.type === "th") rows[r].pad = pad; + delete at[key]; + 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; 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) {