From 7dd9b90cf99d15e61d2946c2839779a43ef8cf84 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 4 Aug 2026 03:01:21 +0200 Subject: [PATCH] fix(ui): a column is a RUN of pads, not two opposite ends of the board The Arduino Uno came out wrong: 18 pins on the top header, 13 on the bottom, and SIX x values shared between the two. The column detector accepted any two pads sharing an x, so those pairs were read as columns and most of the board was thrown into the side gutters with leader lines fanning across the art. A column now needs three pads AND small gaps between them (no more than 40% of the body), which is what a real header looks like; rows get the same guard on x. The Uno lands as two headers, top and bottom, with no side gutters at all - covered by a test using its real 31-pin table. --- .../__tests__/pin-inspector-layout.test.ts | 61 +++++++++++++++++++ frontend/src/utils/pinInspectorLayout.ts | 26 ++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/pin-inspector-layout.test.ts b/frontend/src/__tests__/pin-inspector-layout.test.ts index f9d86985..2b7a11da 100644 --- a/frontend/src/__tests__/pin-inspector-layout.test.ts +++ b/frontend/src/__tests__/pin-inspector-layout.test.ts @@ -206,6 +206,67 @@ describe('layoutInspectorPins — inset columns (the ReSpeaker Lite case)', () = }); }); + +describe('layoutInspectorPins — the Arduino Uno (two opposite headers)', () => { + // node_modules/@wokwi/elements arduino-uno-element pinInfo, board 274x208. + // 18 pins along the top header (y=9) and 13 along the bottom (y=192), with + // SIX x values shared between the two. Reading those pairs as columns threw + // most of the board into the side gutters with leaders fanning across the + // art; a column has to be a run of pads, not two opposite ends. + const UNO: InspectorPinInput[] = [ + { name: 'A5.2', x: 87, y: 9 }, + { name: 'A4.2', x: 97, y: 9 }, + { name: 'AREF', x: 106, y: 9 }, + { name: 'GND.1', x: 115.5, y: 9 }, + { name: '13', x: 125, y: 9 }, + { name: '12', x: 134.5, y: 9 }, + { name: '11', x: 144, y: 9 }, + { name: '10', x: 153.5, y: 9 }, + { name: '9', x: 163, y: 9 }, + { name: '8', x: 173, y: 9 }, + { name: '7', x: 189, y: 9 }, + { name: '6', x: 198.5, y: 9 }, + { name: '5', x: 208, y: 9 }, + { name: '4', x: 217.5, y: 9 }, + { name: '3', x: 227, y: 9 }, + { name: '2', x: 236.5, y: 9 }, + { name: '1', x: 246, y: 9 }, + { name: '0', x: 255.5, y: 9 }, + { name: 'IOREF', x: 131, y: 191.5 }, + { name: 'RESET', x: 140.5, y: 191.5 }, + { name: '3.3V', x: 150, y: 191.5 }, + { name: '5V', x: 160, y: 191.5 }, + { name: 'GND.2', x: 169.5, y: 191.5 }, + { name: 'GND.3', x: 179, y: 191.5 }, + { name: 'VIN', x: 188.5, y: 191.5 }, + { name: 'A0', x: 208, y: 191.5 }, + { name: 'A1', x: 217.5, y: 191.5 }, + { name: 'A2', x: 227, y: 191.5 }, + { name: 'A3', x: 236.5, y: 191.5 }, + { name: 'A4', x: 246, y: 191.5 }, + { name: 'A5', x: 255.5, y: 191.5 } + ]; + + const r = layoutInspectorPins(UNO, { width: 274, height: 208 }); + + it('puts every pin on the header it belongs to', () => { + expect(r.pins).toHaveLength(31); + expect(r.pins.filter((p) => p.edge === 'top')).toHaveLength(18); + expect(r.pins.filter((p) => p.edge === 'bottom')).toHaveLength(13); + }); + + it('uses no side gutters at all', () => { + expect(r.pins.some((p) => p.edge === 'left' || p.edge === 'right')).toBe(false); + }); + + it('keeps the shared-x pairs apart — one per header', () => { + for (const name of ['5', '4', '3', '2', '1', '0']) { + const p = r.pins.find((q) => q.name === name); + if (p) expect(p.edge).toBe('top'); + } + }); +}); + describe('signalKindOf', () => { it('maps the wokwi signal shapes', () => { expect(signalKindOf({ name: 'SDA', x: 0, y: 0, signals: [{ type: 'i2c', signal: 'SDA' }] })).toBe('i2c'); diff --git a/frontend/src/utils/pinInspectorLayout.ts b/frontend/src/utils/pinInspectorLayout.ts index 5ae3603c..a60d9ffd 100644 --- a/frontend/src/utils/pinInspectorLayout.ts +++ b/frontend/src/utils/pinInspectorLayout.ts @@ -187,12 +187,25 @@ export function layoutInspectorPins( const ys = pins.map((p) => p.y * scale); const side = new Array(pins.length).fill(null); + /** Largest gap between consecutive values — a real header has small ones. */ + const maxGap = (vals: number[]): number => { + const s = [...vals].sort((a, b) => a - b); + let m = 0; + for (let i = 1; i < s.length; i++) m = Math.max(m, s[i] - s[i - 1]); + return m; + }; + // Vertical columns win first: they are the dominant shape on breakout - // boards, shields and every DIP part. + // boards, shields and every DIP part. A column must be a RUN of pads, which + // is why it needs three of them and small gaps: an Arduino Uno has two + // opposite headers with six x values shared between them, and reading those + // pairs as columns threw most of the board's pins into the side gutters + // with a fan of leader lines across the art. for (const g of cluster(xs)) { - if (g.length < 2) continue; - const spanY = Math.max(...g.map((i) => ys[i])) - Math.min(...g.map((i) => ys[i])); - if (spanY < TOL) continue; // that is a row, not a column + if (g.length < 3) continue; + const gys = g.map((i) => ys[i]); + if (Math.max(...gys) - Math.min(...gys) < TOL) continue; // a row, not a column + if (maxGap(gys) > 0.4 * H) continue; // opposite ends of the body, not a run const cx = g.reduce((s, i) => s + xs[i], 0) / g.length; const e: PinEdge = cx <= W / 2 ? 'left' : 'right'; for (const i of g) side[i] = e; @@ -201,8 +214,9 @@ export function layoutInspectorPins( for (const g of cluster(ys)) { const free = g.filter((i) => side[i] === null); if (free.length < 2) continue; - const spanX = Math.max(...free.map((i) => xs[i])) - Math.min(...free.map((i) => xs[i])); - if (spanX < TOL) continue; + const gxs = free.map((i) => xs[i]); + if (Math.max(...gxs) - Math.min(...gxs) < TOL) continue; + if (maxGap(gxs) > 0.4 * W) continue; const cy = free.reduce((s, i) => s + ys[i], 0) / free.length; const e: PinEdge = cy <= H / 2 ? 'top' : 'bottom'; for (const i of free) side[i] = e;