Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<short>.
# PRs from this repo publish :pr-<N>, 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-<N> 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

Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion allegro-skills/exportJson.il
Original file line number Diff line number Diff line change
Expand Up @@ -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 )

Expand Down Expand Up @@ -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
Expand Down
32 changes: 24 additions & 8 deletions mcp/src/pads.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand All @@ -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'] };
Expand Down
4 changes: 2 additions & 2 deletions mcp/src/tools/nearest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>, or with "testpoint" / "test point" in its value or footprint name. Prefer a ' +
Expand Down
2 changes: 1 addition & 1 deletion mcp/src/tools/net_places.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' +
Expand Down
23 changes: 23 additions & 0 deletions mcp/test/net_places.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
39 changes: 39 additions & 0 deletions tests/browser/pad-names.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
36 changes: 34 additions & 2 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion web/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading