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:
parent
32b11d83ec
commit
cd83813a9b
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue