fix(picker+inspector): dedupe Pi cards, wire Zero/1/2 as real boards, readable many-pin inspectors

Component picker: registry entries that ARE boards (the injected Raspberry
Pi family) no longer render as component cards — clicking one dropped a
dead 40-pin canvas prop that never boots (that is how "add a Pi 4" looked
like it placed a Pi 3). The boards row is the only path now, and it gains
Raspberry Pi Zero / 1 / 2 cards (Pi-3 illustration, PRO pill, same gate):
their QEMU profiles already existed in qemu_manager; the canvas renders
them through the Pi-3 element and BoardOnCanvas/store route them like any
Pi kind.

Part inspector: a board whose canvas node is NOT a registered custom
element (the Pi family's plain <img> wrapper) now clones the live node for
the preview instead of giving up — the Pi 3/4/5 pin map was completely
missing before. Wide boards with 40+ pins (Mega, Pi family) render
ROTATED 90° so their long pin rows become side columns with vertical room
(new PINS_ART_ROTATED_H box), and pinInspectorLayout splits a dual-line
header pair to OPPOSITE gutters the way printed pinout posters do — 40
interleaved labels never fit one gutter. Existing 18 layout tests pass.
This commit is contained in:
David Montero Crespo 2026-08-07 23:03:05 +02:00
parent 32b11d83ec
commit cd83813a9b
4 changed files with 147 additions and 43 deletions

View File

@ -75,6 +75,9 @@ const BOARD_DESCRIPTIONS: Record<BoardKind, string> = {
'arduino-mega': '8-bit AVR, 256KB flash, 54 digital I/O',
'raspberry-pi-pico': 'RP2040 dual-core Cortex-M0+',
'pi-pico-w': 'RP2040 + WiFi/BT, same emulator as Pico',
'raspberry-pi-zero': 'ARM Cortex-A7, 1 core / 512 MB, Linux/Python (QEMU)',
'raspberry-pi-1': 'Pi 1 B+, ARM Cortex-A7 profile, Linux/Python (QEMU)',
'raspberry-pi-2': 'Pi 2B, ARM Cortex-A7 quad-core, Linux/Python (QEMU)',
'raspberry-pi-3': 'ARM64 Cortex-A53 quad-core, Linux/Python (QEMU)',
'raspberry-pi-4': 'ARM64 Cortex-A72 quad-core, Linux/Python (QEMU)',
'raspberry-pi-5': 'ARM64 Cortex-A76 quad-core + RP1 I/O, Linux/Python (QEMU)',
@ -105,6 +108,9 @@ const ALL_BOARDS: BoardKind[] = [
'arduino-mega',
'raspberry-pi-pico',
'pi-pico-w',
'raspberry-pi-zero',
'raspberry-pi-1',
'raspberry-pi-2',
'raspberry-pi-3',
'raspberry-pi-4',
'raspberry-pi-5',
@ -224,9 +230,16 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
components = components.filter((c) => c.category === selectedCategory);
}
// Registry entries that ARE boards (the injected Raspberry Pi family)
// must never render as component cards: the boards row above already
// shows every BoardKind with the real art, and adding one through the
// component path drops a dead canvas part instead of a running board
// (that is how "add a Pi 4" placed a 40-pin prop that never boots).
components = components.filter((c) => !(c.id in BOARD_KIND_LABELS));
return components;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedCategory, registry, isLoading, registryVersion]);
}, [searchQuery, selectedCategory, registry, isLoading, registryVersion, proBoardsVersion]);
// Boards list: static OSS kinds + overlay-registered boards (proBoardRegistry).
@ -726,6 +739,9 @@ const BoardCard: React.FC<BoardCardProps> = ({ kind, onSelect, hoverApi }) => {
// natural size + CSS scale keeps its unscaled layout box, so the
// 100px thumbnail clips it to a narrow sliver).
if (
kind === 'raspberry-pi-zero' ||
kind === 'raspberry-pi-1' ||
kind === 'raspberry-pi-2' ||
kind === 'raspberry-pi-3' ||
kind === 'raspberry-pi-4' ||
kind === 'raspberry-pi-5' ||
@ -751,10 +767,15 @@ const BoardCard: React.FC<BoardCardProps> = ({ kind, onSelect, hoverApi }) => {
}, [kind]);
const reactThumbnail =
kind === 'raspberry-pi-3' ? (
// Zero/1/2 render on canvas through the Pi-3 element (same 40-pin art),
// so their cards reuse the same illustration.
kind === 'raspberry-pi-3' ||
kind === 'raspberry-pi-zero' ||
kind === 'raspberry-pi-1' ||
kind === 'raspberry-pi-2' ? (
<img
src={raspberryPi3Svg}
alt="Raspberry Pi 3"
alt={BOARD_KIND_LABELS[kind]}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : kind === 'raspberry-pi-4' ? (

View File

@ -43,6 +43,11 @@ export const BOARD_SIZE: Record<string, { w: number; h: number }> = {
// referenced GP10/GP18/etc. landed at (0,0). The render now matches
// the boardKind name.
'raspberry-pi-pico': { w: 105, h: 264 },
// Zero/1/2 render through the Pi-3 element (same 40-pin header art); the
// backend picks their QEMU CPU/memory profile from the boardKind.
'raspberry-pi-zero': { w: 250, h: 160 },
'raspberry-pi-1': { w: 250, h: 160 },
'raspberry-pi-2': { w: 250, h: 160 },
'raspberry-pi-3': { w: 250, h: 160 }, // RaspberryPi3Element: PI_WIDTH=250 PI_HEIGHT=160
'raspberry-pi-4': { w: 330, h: 215 }, // RaspberryPi4Element — real board photo (925×602 @ scale)
'raspberry-pi-5': { w: 330, h: 220 }, // RaspberryPi5Element — real board photo (1024×681 @ scale)
@ -162,6 +167,12 @@ export const BoardOnCanvas = ({
case 'raspberry-pi-pico':
case 'pi-pico-w':
return <PiPicoW id={id} x={x} y={y} />;
// Zero/1/2 share the Pi-3's 40-pin board art (their canvas identity is
// the header, and the ComponentRegistry metadata reuses the same tag);
// only the backend QEMU profile differs per kind.
case 'raspberry-pi-zero':
case 'raspberry-pi-1':
case 'raspberry-pi-2':
case 'raspberry-pi-3':
return <RaspberryPi3 id={id} x={x} y={y} />;
case 'raspberry-pi-4':

View File

@ -134,6 +134,12 @@ interface PartInspectorDialogProps {
* the dialog's left column ends up a little wider than this.
*/
const PINS_ART = 190;
/**
* Taller art box used when a wide many-pin board is shown ROTATED (Mega, the
* Pi Linux family): its long pin rows become side columns, and the labels
* need vertical room — the dialog column has it to spare.
*/
const PINS_ART_ROTATED_H = 420;
export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
componentId,
@ -242,41 +248,62 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
setLayout(null);
host.textContent = '';
type PreviewEl = HTMLElement & {
pinInfo?: Array<{ name: string; x: number; y: number; signals?: any[] }>;
};
const tag = previewTagName || metaRef.current.tagName;
if (!tag || !customElements.get(tag)) {
// Unregistered tag (or none): the header thumbnail already shows the
let el: PreviewEl | null = null;
if (tag && customElements.get(tag)) {
el = document.createElement(tag) as PreviewEl;
// Apply the instance's properties (an LED's color/flip changes both the
// art and, for flip, the pinInfo itself), then the metadata defaults for
// whatever the instance does not carry.
for (const [k, v] of Object.entries({
...metaRef.current.defaultValues,
...componentProperties,
})) {
try {
(el as any)[k] = v;
} catch {
/* read-only prop on some element - ignore */
}
}
// Boards select their art through an ATTRIBUTE (board-kind), not a
// property: velxio-esp32 renders nothing until it has one.
for (const [k, v] of Object.entries(previewAttributes ?? {})) el!.setAttribute(k, v);
} else {
// Not a registered custom element. The Pi Linux family draws on the
// canvas as a plain <img> React wrapper carrying pinInfo as a JS
// property — so the old "unregistered tag → give up" path showed the
// Pi 3/4/5 inspector with NO pins at all. Clone the live canvas node
// instead: same art, same natural size, pins from the pinInfo prop.
const live = document.getElementById(componentId);
if (live) {
el = live.cloneNode(true) as HTMLElement;
el.removeAttribute('id');
// Neutralize the canvas placement so the clone lays out at its
// natural size inside the preview box.
el.style.position = 'static';
el.style.left = '';
el.style.top = '';
}
}
if (!el) {
// No element and no live node: the header thumbnail already shows the
// art, so the preview just reports there is nothing live to mount.
setPreviewFailed(true);
return;
}
const el = document.createElement(tag) as HTMLElement & {
pinInfo?: Array<{ name: string; x: number; y: number; signals?: any[] }>;
};
// Apply the instance's properties (an LED's color/flip changes both the
// art and, for flip, the pinInfo itself), then the metadata defaults for
// whatever the instance does not carry.
for (const [k, v] of Object.entries({
...metaRef.current.defaultValues,
...componentProperties,
})) {
try {
(el as any)[k] = v;
} catch {
/* read-only prop on some element - ignore */
}
}
// Boards select their art through an ATTRIBUTE (board-kind), not a
// property: velxio-esp32 renders nothing until it has one.
for (const [k, v] of Object.entries(previewAttributes ?? {})) el.setAttribute(k, v);
el.classList.add('pid-preview-element');
host.appendChild(el);
const pel: PreviewEl = el;
pel.classList.add('pid-preview-element');
host.appendChild(pel);
let cancelled = false;
let tries = 0;
const measure = () => {
if (cancelled) return;
const w = el.offsetWidth;
const h = el.offsetHeight;
const w = pel.offsetWidth;
const h = pel.offsetHeight;
if ((w < 2 || h < 2) && tries < 12) {
// Elements render in connectedCallback and settle a tick later —
// same retry pattern as the canvas PinOverlay (issues #230/#232).
@ -288,24 +315,38 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
setPreviewFailed(true);
return;
}
const pins: InspectorPinInput[] = (el.pinInfo && el.pinInfo.length ? el.pinInfo : pinInfo) ?? [];
const art = PINS_ART;
const result = layoutInspectorPins(pins, { width: w, height: h }, {
maxArtWidth: art,
maxArtHeight: art,
const pins: InspectorPinInput[] =
(pel.pinInfo && pel.pinInfo.length ? pel.pinInfo : pinInfo) ?? [];
// Wide many-pin boards (Arduino Mega, the Pi Linux family) show
// ROTATED 90°: their pins live in long horizontal rows whose labels
// crowd past the art width; as side columns the labels stack
// vertically, where the taller art box has room.
const rotate = w > h * 1.25 && pins.length >= 40;
const layoutPins = rotate
? pins.map((p) => ({ ...p, x: h - p.y, y: p.x }))
: pins;
const natural = rotate ? { width: h, height: w } : { width: w, height: h };
const result = layoutInspectorPins(layoutPins, natural, {
maxArtWidth: PINS_ART,
maxArtHeight: rotate ? PINS_ART_ROTATED_H : PINS_ART,
});
el.style.transform = `scale(${result.scale})`;
el.style.transformOrigin = 'top left';
// translate+rotate about the top-left maps (x,y) → (h−y, x) scaled —
// exactly the transform applied to layoutPins above, so dots land on
// the rotated pads.
pel.style.transform = rotate
? `translate(${h * result.scale}px, 0px) rotate(90deg) scale(${result.scale})`
: `scale(${result.scale})`;
pel.style.transformOrigin = 'top left';
setLayout(result);
};
requestAnimationFrame(measure);
// Some parts move their pins at runtime (LED flip, custom chip JSON).
const onPinsChange = () => requestAnimationFrame(measure);
el.addEventListener('pininfo-change', onPinsChange);
pel.addEventListener('pininfo-change', onPinsChange);
return () => {
cancelled = true;
el.removeEventListener('pininfo-change', onPinsChange);
pel.removeEventListener('pininfo-change', onPinsChange);
host.textContent = '';
};
// propsKey/pinsKey stand in for componentProperties/pinInfo: a real change

View File

@ -201,25 +201,56 @@ export function layoutInspectorPins(
// 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.
const colGroups: Array<{ idx: number[]; c: number }> = [];
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;
colGroups.push({ idx: g, c: g.reduce((s, i) => s + xs[i], 0) / g.length });
}
// A dual-row pin header (the Pi's 40-pin GPIO, rotated into two adjacent
// columns) must NOT dump both lines into one gutter — 40 interleaved
// labels never fit one side. Printed pinout posters split such a pair:
// first line's labels left, second line's right, leaders crossing the art.
const isHeaderPair = (a: { idx: number[]; c: number }, b: { idx: number[]; c: number }) =>
Math.abs(a.c - b.c) <= 5 * TOL && a.idx.length + b.idx.length >= 16;
if (colGroups.length === 2 && isHeaderPair(colGroups[0], colGroups[1])) {
const [a, b] =
colGroups[0].c <= colGroups[1].c
? [colGroups[0], colGroups[1]]
: [colGroups[1], colGroups[0]];
for (const i of a.idx) side[i] = 'left';
for (const i of b.idx) side[i] = 'right';
} else {
for (const g of colGroups) {
const e: PinEdge = g.c <= W / 2 ? 'left' : 'right';
for (const i of g.idx) side[i] = e;
}
}
// Then horizontal rows, for whatever is still unassigned (headers).
const rowGroups: Array<{ idx: number[]; c: number }> = [];
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;
rowGroups.push({ idx: free, c: free.reduce((s, i) => s + ys[i], 0) / free.length });
}
// Same poster-style split for an unrotated dual-row header (top/bottom).
if (rowGroups.length === 2 && isHeaderPair(rowGroups[0], rowGroups[1])) {
const [a, b] =
rowGroups[0].c <= rowGroups[1].c
? [rowGroups[0], rowGroups[1]]
: [rowGroups[1], rowGroups[0]];
for (const i of a.idx) side[i] = 'top';
for (const i of b.idx) side[i] = 'bottom';
} else {
for (const g of rowGroups) {
const e: PinEdge = g.c <= H / 2 ? 'top' : 'bottom';
for (const i of g.idx) side[i] = e;
}
}
const classified = pins.map((pin, i) => {