338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
/**
|
|
* pinInspectorLayout — pure geometry for the Part Inspector's spatial pin view.
|
|
*
|
|
* Input: a part's pinInfo (element-local CSS px at scale 1 — the same numbers
|
|
* the canvas snaps wires to) and its measured natural size. Output: where to
|
|
* draw each pin dot and its label around a scaled preview of the part, so the
|
|
* dialog can show "pins where they really are" for ANY part or board without
|
|
* per-component data. That genericity is the point: pinInfo + size is the
|
|
* whole input, so future parts work unmodified.
|
|
*
|
|
* No DOM, no React, deterministic — unit-tested in
|
|
* __tests__/pin-inspector-layout.test.ts with real pin tables.
|
|
*/
|
|
|
|
export type PinEdge = 'left' | 'right' | 'top' | 'bottom' | 'interior';
|
|
|
|
export interface InspectorPinInput {
|
|
name: string;
|
|
x: number;
|
|
y: number;
|
|
signals?: Array<{ type?: string; signal?: string }>;
|
|
}
|
|
|
|
export type PinSignalKind =
|
|
| 'i2c'
|
|
| 'spi'
|
|
| 'usart'
|
|
| 'power-gnd'
|
|
| 'power-vcc'
|
|
| 'pwm'
|
|
| 'analog'
|
|
| 'other';
|
|
|
|
export interface LaidOutPin {
|
|
name: string;
|
|
edge: PinEdge;
|
|
/** Scaled px, relative to the ART box top-left. */
|
|
dotX: number;
|
|
dotY: number;
|
|
/** Scaled px, label anchor point, same origin. */
|
|
labelX: number;
|
|
labelY: number;
|
|
/** True when the label was nudged away from its dot row/column. */
|
|
needsLeader: boolean;
|
|
signalKind: PinSignalKind;
|
|
}
|
|
|
|
export interface PinLayoutResult {
|
|
/** Scale applied to the natural size. */
|
|
scale: number;
|
|
artWidth: number;
|
|
artHeight: number;
|
|
pins: LaidOutPin[];
|
|
/** Gutter reserved on each side for labels; 0 when that side has no pins. */
|
|
padLeft: number;
|
|
padRight: number;
|
|
padTop: number;
|
|
padBottom: number;
|
|
}
|
|
|
|
export interface PinLayoutOptions {
|
|
maxArtWidth?: number;
|
|
maxArtHeight?: number;
|
|
/** Minimum label spacing along an edge, px (left/right stacks). */
|
|
minSpacing?: number;
|
|
/** A pin farther than this fraction of the dimension from EVERY edge is interior. */
|
|
interiorFrac?: number;
|
|
}
|
|
|
|
/** Horizontal gutter for left/right label columns (room for "GND2" etc.). */
|
|
const SIDE_GUTTER = 72;
|
|
/**
|
|
* Vertical gutter for top/bottom labels. Those are drawn as VERTICAL text —
|
|
* the way printed pinout diagrams do it — because horizontal ones do not fit:
|
|
* an SSD1306 has 8 pins along a 240px edge, and at the ~36px a horizontal
|
|
* "DATA" needs they demand 288px and spill out of the dialog. Vertical text
|
|
* needs only the same spacing as a side column, and the gutter grows instead.
|
|
*/
|
|
const CAP_GUTTER = 52;
|
|
/** Small parts (a resistor is 107x11) are upscaled, but only this far. */
|
|
const MAX_UPSCALE = 2;
|
|
|
|
export function signalKindOf(pin: InspectorPinInput): PinSignalKind {
|
|
const s = pin.signals?.[0];
|
|
if (!s || typeof s !== 'object') return 'other';
|
|
switch (s.type) {
|
|
case 'i2c':
|
|
return 'i2c';
|
|
case 'spi':
|
|
return 'spi';
|
|
case 'usart':
|
|
return 'usart';
|
|
case 'pwm':
|
|
return 'pwm';
|
|
case 'analog':
|
|
return 'analog';
|
|
case 'power':
|
|
return s.signal === 'GND' ? 'power-gnd' : 'power-vcc';
|
|
default:
|
|
return 'other';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stack labels along one edge: process in position order and push each label
|
|
* forward so no two sit closer than `spacing`. If the stack overruns the art,
|
|
* shift it back as one block (clamped at 0) — this keeps a 15-pin column
|
|
* centred on its side instead of trailing off the bottom.
|
|
*/
|
|
function stackLabels(
|
|
positions: number[],
|
|
spacing: number,
|
|
extent: number,
|
|
): { out: number[]; nudged: boolean[] } {
|
|
const order = positions.map((p, i) => [p, i] as const).sort((a, b) => a[0] - b[0]);
|
|
const placed: number[] = [];
|
|
for (let k = 0; k < order.length; k++) {
|
|
const want = order[k][0];
|
|
placed.push(k === 0 ? want : Math.max(want, placed[k - 1] + spacing));
|
|
}
|
|
// Shift the whole stack up if it overran the art extent and there is room.
|
|
const overrun = placed.length ? placed[placed.length - 1] - extent : 0;
|
|
if (overrun > 0) {
|
|
const room = placed[0]; // how far the first label can move toward 0
|
|
const shift = Math.min(overrun, Math.max(0, room));
|
|
for (let k = 0; k < placed.length; k++) placed[k] -= shift;
|
|
}
|
|
const out = new Array<number>(positions.length);
|
|
const nudged = new Array<boolean>(positions.length);
|
|
for (let k = 0; k < order.length; k++) {
|
|
const i = order[k][1];
|
|
out[i] = placed[k];
|
|
nudged[i] = Math.abs(placed[k] - positions[i]) > 1;
|
|
}
|
|
return { out, nudged };
|
|
}
|
|
|
|
export function layoutInspectorPins(
|
|
pins: InspectorPinInput[],
|
|
natural: { width: number; height: number },
|
|
opts: PinLayoutOptions = {},
|
|
): PinLayoutResult {
|
|
const maxW = opts.maxArtWidth ?? 260;
|
|
const maxH = opts.maxArtHeight ?? 260;
|
|
const minSpacing = opts.minSpacing ?? 16;
|
|
const interiorFrac = opts.interiorFrac ?? 0.18;
|
|
|
|
const w = Math.max(1, natural.width);
|
|
const h = Math.max(1, natural.height);
|
|
const scale = Math.min(maxW / w, maxH / h, MAX_UPSCALE);
|
|
const W = w * scale;
|
|
const H = h * scale;
|
|
|
|
// Classify each pin.
|
|
//
|
|
// Nearest-edge alone is wrong for real parts, and got caught by the
|
|
// ReSpeaker Lite: its XIAO socket is two INSET columns (x=50.5 and 126.5 on
|
|
// a 178-wide body) plus two rows of breakout pads. Every one of those sat
|
|
// far enough from all four edges to be called "interior", which printed no
|
|
// label — the pins were on screen as dots and looked missing. And a plain
|
|
// nearest-edge rule scatters a column's end pins to the top/bottom gutter.
|
|
//
|
|
// So: find the LINES first. Pins sharing an x form a vertical column, pins
|
|
// sharing a y form a horizontal row; a column belongs to the side of the
|
|
// body it sits on, however far inset it is, and a leader line joins its
|
|
// labels to the dots. Only a pin that belongs to no line falls back to its
|
|
// nearest edge. Every pin gets a printed label — that is the contract.
|
|
const TOL = 6 * scale; // pads within this are the same column/row
|
|
const cluster = (vals: number[]): number[][] => {
|
|
const idx = vals.map((v, i) => [v, i] as const).sort((a, b) => a[0] - b[0]);
|
|
const groups: number[][] = [];
|
|
let cur: number[] = [];
|
|
let last = NaN;
|
|
for (const [v, i] of idx) {
|
|
if (cur.length && Math.abs(v - last) > TOL) {
|
|
groups.push(cur);
|
|
cur = [];
|
|
}
|
|
cur.push(i);
|
|
last = v;
|
|
}
|
|
if (cur.length) groups.push(cur);
|
|
return groups;
|
|
};
|
|
|
|
const xs = pins.map((p) => p.x * scale);
|
|
const ys = pins.map((p) => p.y * scale);
|
|
const side = new Array<PinEdge | null>(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. 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 < 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;
|
|
}
|
|
// Then horizontal rows, for whatever is still unassigned (headers).
|
|
for (const g of cluster(ys)) {
|
|
const free = g.filter((i) => side[i] === null);
|
|
if (free.length < 2) 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;
|
|
}
|
|
|
|
const classified = pins.map((pin, i) => {
|
|
const x = xs[i];
|
|
const y = ys[i];
|
|
let edge = side[i];
|
|
if (!edge) {
|
|
// A lone pad: nearest edge.
|
|
const d = [
|
|
['left', x],
|
|
['right', W - x],
|
|
['top', y],
|
|
['bottom', H - y],
|
|
] as Array<[PinEdge, number]>;
|
|
d.sort((a, b) => a[1] - b[1]);
|
|
edge = d[0][0];
|
|
}
|
|
return { pin, x, y, edge };
|
|
});
|
|
// interiorFrac is retained in the options for callers that want the old
|
|
// behaviour; the line-first classifier makes it unnecessary here.
|
|
void interiorFrac;
|
|
|
|
// Lay labels per edge. Left/right stack along y; top/bottom along x
|
|
// (with wider spacing — their labels are horizontal text).
|
|
const laid: LaidOutPin[] = new Array(pins.length);
|
|
const byEdge = (e: PinEdge) =>
|
|
classified.map((c, i) => [c, i] as const).filter(([c]) => c.edge === e);
|
|
|
|
for (const edge of ['left', 'right'] as const) {
|
|
const group = byEdge(edge);
|
|
const { out, nudged } = stackLabels(
|
|
group.map(([c]) => c.y),
|
|
minSpacing,
|
|
H,
|
|
);
|
|
group.forEach(([c, i], k) => {
|
|
laid[i] = {
|
|
name: c.pin.name,
|
|
edge,
|
|
dotX: c.x,
|
|
dotY: c.y,
|
|
labelX: edge === 'left' ? -8 : W + 8,
|
|
labelY: out[k],
|
|
needsLeader: nudged[k],
|
|
signalKind: signalKindOf(c.pin),
|
|
};
|
|
});
|
|
}
|
|
for (const edge of ['top', 'bottom'] as const) {
|
|
const group = byEdge(edge);
|
|
const { out, nudged } = stackLabels(
|
|
group.map(([c]) => c.x),
|
|
minSpacing,
|
|
W,
|
|
);
|
|
group.forEach(([c, i], k) => {
|
|
laid[i] = {
|
|
name: c.pin.name,
|
|
edge,
|
|
dotX: c.x,
|
|
dotY: c.y,
|
|
labelX: out[k],
|
|
labelY: edge === 'top' ? -8 : H + 8,
|
|
needsLeader: nudged[k],
|
|
signalKind: signalKindOf(c.pin),
|
|
};
|
|
});
|
|
}
|
|
// A leader is also needed whenever the DOT is inset from its gutter, even
|
|
// if the label sits on the dot's own row: without it an inset column's
|
|
// labels look unattached to anything.
|
|
for (const p of laid) {
|
|
if (!p) continue;
|
|
if (p.edge === 'left' && p.dotX > 6) p.needsLeader = true;
|
|
else if (p.edge === 'right' && p.dotX < W - 6) p.needsLeader = true;
|
|
else if (p.edge === 'top' && p.dotY > 6) p.needsLeader = true;
|
|
else if (p.edge === 'bottom' && p.dotY < H - 6) p.needsLeader = true;
|
|
}
|
|
|
|
const has = (e: PinEdge) => classified.some((c) => c.edge === e);
|
|
let padLeft = has('left') ? SIDE_GUTTER : 0;
|
|
let padRight = has('right') ? SIDE_GUTTER : 0;
|
|
let padTop = has('top') ? CAP_GUTTER : 0;
|
|
let padBottom = has('bottom') ? CAP_GUTTER : 0;
|
|
|
|
// A stack taller than the art must not be clipped. A ReSpeaker's left side
|
|
// carries 13 labels: at the minimum spacing that is 208 px against a 190 px
|
|
// body, so the column necessarily runs past both ends and the first and
|
|
// last labels were cut off. Grow the gutters to whatever the labels
|
|
// actually occupy — the box follows the content, not the other way round.
|
|
const HALF_LINE = 7; // half a label's line box, plus a hair of margin
|
|
for (const p of laid) {
|
|
if (!p) continue;
|
|
if (p.edge === 'left' || p.edge === 'right') {
|
|
padTop = Math.max(padTop, HALF_LINE - p.labelY);
|
|
padBottom = Math.max(padBottom, p.labelY + HALF_LINE - H);
|
|
} else {
|
|
// Vertical text on the caps: its extent along x is the line box.
|
|
padLeft = Math.max(padLeft, HALF_LINE - p.labelX);
|
|
padRight = Math.max(padRight, p.labelX + HALF_LINE - W);
|
|
}
|
|
}
|
|
|
|
return {
|
|
scale,
|
|
artWidth: W,
|
|
artHeight: H,
|
|
pins: laid,
|
|
padLeft: Math.max(0, Math.round(padLeft)),
|
|
padRight: Math.max(0, Math.round(padRight)),
|
|
padTop: Math.max(0, Math.round(padTop)),
|
|
padBottom: Math.max(0, Math.round(padBottom)),
|
|
};
|
|
}
|