diff --git a/frontend/src/components/ComponentPickerModal.tsx b/frontend/src/components/ComponentPickerModal.tsx index 514e6434..a4f60091 100644 --- a/frontend/src/components/ComponentPickerModal.tsx +++ b/frontend/src/components/ComponentPickerModal.tsx @@ -75,6 +75,9 @@ const BOARD_DESCRIPTIONS: Record = { '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 = ({ 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 = ({ 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 = ({ 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' ? ( Raspberry Pi 3 ) : kind === 'raspberry-pi-4' ? ( diff --git a/frontend/src/components/simulator/BoardOnCanvas.tsx b/frontend/src/components/simulator/BoardOnCanvas.tsx index 3364e664..a7db42eb 100644 --- a/frontend/src/components/simulator/BoardOnCanvas.tsx +++ b/frontend/src/components/simulator/BoardOnCanvas.tsx @@ -43,6 +43,11 @@ export const BOARD_SIZE: Record = { // 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 ; + // 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 ; case 'raspberry-pi-4': diff --git a/frontend/src/components/simulator/PartInspectorDialog.tsx b/frontend/src/components/simulator/PartInspectorDialog.tsx index ac2871ca..c0cfa510 100644 --- a/frontend/src/components/simulator/PartInspectorDialog.tsx +++ b/frontend/src/components/simulator/PartInspectorDialog.tsx @@ -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 = ({ componentId, @@ -242,41 +248,62 @@ export const PartInspectorDialog: React.FC = ({ 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 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 = ({ 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 diff --git a/frontend/src/utils/pinInspectorLayout.ts b/frontend/src/utils/pinInspectorLayout.ts index a60d9ffd..02f731d9 100644 --- a/frontend/src/utils/pinInspectorLayout.ts +++ b/frontend/src/utils/pinInspectorLayout.ts @@ -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) => {