diff --git a/web/src/lib/components/home/electronics/FloppyComputer.stories.svelte b/web/src/lib/components/home/electronics/FloppyComputer.stories.svelte
new file mode 100644
index 000000000..cc332632f
--- /dev/null
+++ b/web/src/lib/components/home/electronics/FloppyComputer.stories.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/home/electronics/FloppyComputer.svelte b/web/src/lib/components/home/electronics/FloppyComputer.svelte
new file mode 100644
index 000000000..79a9704e8
--- /dev/null
+++ b/web/src/lib/components/home/electronics/FloppyComputer.svelte
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#each vents as d, i (i)}
+
+ {/each}
+
+ {#each drives as drive, i (i)}
+
+
+
+
+ {/each}
+
+
+
+ {#each panelVents as d, i (i)}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ {#each keys as shape, i (i)}
+
+ {/each}
+
+
+
+ {#each disk.guides as [from, to], i (i)}
+
+ {/each}
+
+
+
+
+
+ {#each disk.holes as d, i (i)}
+
+ {/each}
+
+
+
+
diff --git a/web/src/lib/components/home/electronics/PiCartridge.stories.svelte b/web/src/lib/components/home/electronics/PiCartridge.stories.svelte
new file mode 100644
index 000000000..088a9aa09
--- /dev/null
+++ b/web/src/lib/components/home/electronics/PiCartridge.stories.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/web/src/lib/components/home/electronics/PiCartridge.svelte b/web/src/lib/components/home/electronics/PiCartridge.svelte
new file mode 100644
index 000000000..73015990a
--- /dev/null
+++ b/web/src/lib/components/home/electronics/PiCartridge.svelte
@@ -0,0 +1,183 @@
+
+
+{#snippet rod(part: Part)}
+ {@const [[ax, ay], [bx, by]] = axisOf(part, part.z0, part.z1)}
+
+
+{/snippet}
+
+{#snippet guide(part: Part)}
+ {@const [from, to] = axisOf(part, part.z1, part.z0 + 1)}
+ {#if from[1] < to[1] - 10}
+
+ {/if}
+{/snippet}
+
+{#snippet component(part: Part)}
+
+
+ {#each part.marks ?? [] as m, i (i)}
+
+ {/each}
+
+
+{/snippet}
+
+{#snippet shell(part: Part)}
+
+
+ {#each part.marks ?? [] as m, i (i)}
+
+ {/each}
+ {#each ridgesOf(part) as d (d)}
+
+ {/each}
+
+
+
+{/snippet}
+
+
+
+
+
+
+ {#each holes as d, i (i)}
+
+ {/each}
+
+
+ {#each ordered as part (part.id)}
+ {#if part.kind === "rod"}
+ {@render rod(part)}
+ {:else if part.kind === "guide"}
+ {@render guide(part)}
+ {:else if part.id === "cartridge"}
+ {@render shell(part)}
+ {:else}
+ {@render component(part)}
+ {/if}
+ {/each}
+
diff --git a/web/src/lib/components/home/electronics/SnesCartridge.stories.svelte b/web/src/lib/components/home/electronics/SnesCartridge.stories.svelte
new file mode 100644
index 000000000..3941bfdad
--- /dev/null
+++ b/web/src/lib/components/home/electronics/SnesCartridge.stories.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/home/electronics/SnesCartridge.svelte b/web/src/lib/components/home/electronics/SnesCartridge.svelte
new file mode 100644
index 000000000..3bb9ce45e
--- /dev/null
+++ b/web/src/lib/components/home/electronics/SnesCartridge.svelte
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+ {#each grooves as d, i (i)}
+
+ {/each}
+
+ {#each ports as d, i (i)}
+
+ {/each}
+ {#each pins as d, i (i)}
+
+ {/each}
+
+
+
+
+
+ {#each cart.guides as [from, to], i (i)}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ {#each switches as shape, i (i)}
+
+ {/each}
+
+
diff --git a/web/src/lib/components/home/electronics/pi.test.ts b/web/src/lib/components/home/electronics/pi.test.ts
new file mode 100644
index 000000000..5a2def8a4
--- /dev/null
+++ b/web/src/lib/components/home/electronics/pi.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { paintOrder } from "$lib/components/home/electronics/iso";
+import { board, cartridge } from "$lib/components/home/electronics/pi";
+
+describe("board", () => {
+ it("paints every part once", () => {
+ const parts = [...board(), ...cartridge(20)];
+ expect(new Set(paintOrder(parts).map((p) => p.id)).size).toBe(parts.length);
+ });
+});
+
+describe("cartridge", () => {
+ it("seats its socket on the header's base, clear of the pins' tops", () => {
+ const header = board().find((p) => p.id === "header")!;
+ const socket = cartridge(0).find((p) => p.id === "socket")!;
+ expect(socket.z0).toBe(header.z1);
+ expect(socket.x0).toBeLessThanOrEqual(header.x0);
+ expect(socket.y1).toBeGreaterThanOrEqual(header.y1);
+ });
+
+ it("only shows its guides while it is off the board", () => {
+ expect(cartridge(0).some((p) => p.kind === "guide")).toBe(false);
+ expect(cartridge(30).filter((p) => p.kind === "guide")).toHaveLength(2);
+ });
+});
diff --git a/web/src/lib/components/home/electronics/pi.ts b/web/src/lib/components/home/electronics/pi.ts
new file mode 100644
index 000000000..14f6668ea
--- /dev/null
+++ b/web/src/lib/components/home/electronics/pi.ts
@@ -0,0 +1,273 @@
+import {
+ arched,
+ circle,
+ cuboid,
+ rect,
+ span,
+ stadium,
+ type Box,
+ type Face,
+ type Pt,
+ type Pt3
+} from "$lib/components/home/electronics/iso";
+
+export interface Mark {
+ face: Face;
+ points: Pt[];
+ recess?: boolean;
+}
+
+export interface Part extends Box {
+ id: string;
+ fine?: boolean;
+ kind?: "box" | "rod" | "guide";
+ marks?: Mark[];
+}
+
+const LENGTH = 85;
+const T = 1.6;
+
+export const BOARD = { ...cuboid([0, 0, 0], [56, LENGTH, T]), r: 3 };
+
+const turnPoint = ([u, v]: Pt): Pt => [v, LENGTH - u];
+
+const turn = (p: Part): Part => ({
+ ...p,
+ x0: p.y0,
+ x1: p.y1,
+ y0: LENGTH - p.x1,
+ y1: LENGTH - p.x0,
+ marks: p.marks?.flatMap((mark): Mark[] => {
+ if (mark.face === "top") return [{ ...mark, points: mark.points.map(turnPoint) }];
+ if (mark.face === "front")
+ return [
+ { ...mark, face: "side", points: mark.points.map(([u, v]) => [LENGTH - u, v]) }
+ ];
+ return [];
+ })
+});
+
+export const HOLES: Pt[] = (
+ [
+ [3.5, 3.5],
+ [61.5, 3.5],
+ [3.5, 52.5],
+ [61.5, 52.5]
+ ] as Pt[]
+).map(turnPoint);
+
+const box = (id: string, min: Pt3, max: Pt3, extra: Partial = {}): Part => ({
+ id,
+ ...cuboid(min, max),
+ ...extra
+});
+
+const pin = (id: string, x: number, y: number, z0: number, z1: number) =>
+ box(id, [x - 0.32, y - 0.32, z0], [x + 0.32, y + 0.32, z1], { fine: true, kind: "rod" });
+
+const HEADER = { cx: 32.5, cy: 3.5, pitch: 2.54, base: 2.5, pin: 6 };
+const HEADER_TOP = T + HEADER.base;
+const PINS_TOP = HEADER_TOP + HEADER.pin;
+
+const header = (): Part[] => {
+ const { cx, cy, pitch } = HEADER;
+ return [
+ box("header", [cx - 10 * pitch, cy - pitch, T], [cx + 10 * pitch, cy + pitch, HEADER_TOP]),
+ ...[0, 1].flatMap((row) =>
+ Array.from({ length: 20 }, (_, i) =>
+ pin(
+ `pin-${row}-${i}`,
+ cx + (i - 9.5) * pitch,
+ cy + (row - 0.5) * pitch,
+ HEADER_TOP,
+ PINS_TOP
+ )
+ )
+ )
+ ];
+};
+
+const usb = (id: string, y0: number): Part => {
+ const y1 = y0 + 13.1;
+ const tab = (c: number): Mark => ({
+ face: "top",
+ points: [
+ [86, c - 2.2],
+ [81.4, c],
+ [86, c + 2.2],
+ [86, c + 1.2],
+ [83.4, c],
+ [86, c - 1.2]
+ ]
+ });
+ return box(id, [67, y0, T], [87.1, y1, T + 16], { marks: [tab(y0 + 3.6), tab(y1 - 3.6)] });
+};
+
+const hdmi = (id: string, x0: number): Part => {
+ const x1 = x0 + 7.5;
+ return box(id, [x0, 49, T], [x1, 57.3, T + 3.5], {
+ marks: [
+ {
+ face: "front",
+ points: [
+ [x0 + 0.6, 2.9],
+ [x1 - 0.6, 2.9],
+ [x1 - 0.6, 1.6],
+ [x1 - 1.5, 0.6],
+ [x0 + 1.5, 0.6],
+ [x0 + 0.6, 1.6]
+ ]
+ }
+ ]
+ });
+};
+
+const mipi = (id: string, x0: number): Part =>
+ box(id, [x0, 39, T], [x0 + 3, 52, T + 1.2], {
+ marks: [{ face: "top", points: rect(span(x0 + 1.9, 39.4, x0 + 2.7, 51.6)), recess: true }]
+ });
+
+const PASSIVES: [number, number, boolean][] = [
+ [23, 22, false],
+ [23, 25, false],
+ [23, 28, false],
+ [23, 31, false],
+ [23, 34, false],
+ [43.4, 22, false],
+ [43.4, 25, false],
+ [43.4, 34, false],
+ [28.5, 38.6, true],
+ [32.5, 38.6, true],
+ [36.5, 38.6, true],
+ [28.5, 18.4, true],
+ [32.5, 18.4, true],
+ [36.5, 18.4, true],
+ [55.6, 24.5, false],
+ [55.6, 27.5, false],
+ [12.5, 38.4, true],
+ [15.5, 38.4, true],
+ [18.5, 38.4, true],
+ [13.5, 47.8, true],
+ [48.5, 33.5, true],
+ [51.5, 33.5, true]
+];
+
+const passive = ([x, y, across]: [number, number, boolean], k: number) => {
+ const [du, dv] = across ? [0.25, 0.5] : [0.5, 0.25];
+ return box(`passive-${k}`, [x - du, y - dv, T], [x + du, y + dv, T + 0.4], { fine: true });
+};
+
+const onTop = (points: Pt[], recess = false): Mark[] => [{ face: "top", points, recess }];
+
+const flat = (): Part[] => [
+ ...header(),
+ box("fan", [59, 7, T], [63.2, 10.2, T + 2.9], {
+ marks: onTop(rect(span(59.6, 7.9, 62.6, 9.4)))
+ }),
+ box("wireless", [5, 9, T], [16.5, 20, T + 1.6], { marks: onTop(circle([7, 11], 0.5, 10)) }),
+ box("ram", [8.5, 24, T], [20.5, 36, T + 1], { marks: onTop(circle([9.6, 25.1], 0.45, 10)) }),
+ box("soc", [25, 20, T], [42, 37, T + 0.9]),
+ box("soc-lid", [27, 22, T + 0.9], [40, 35, T + 2.3]),
+ box("rp1", [46, 22.5, T], [54, 30.5, T + 1], { marks: onTop(circle([47, 23.5], 0.4, 10)) }),
+ box("pmic", [5, 41, T], [11, 46.5, T + 0.9]),
+ box("inductor", [12.5, 41.5, T], [15, 44, T + 1.2]),
+ ...PASSIVES.map(passive),
+ box("pcie", [0.6, 20, T], [4.4, 36, T + 1.3], {
+ marks: onTop(rect(span(0.9, 20.4, 1.8, 35.6)), true)
+ }),
+ box("button", [-0.4, 40, T], [2.8, 43.6, T + 2.6], {
+ marks: onTop(circle([1.2, 41.8], 1, 16))
+ }),
+ box("led-power", [0.9, 45, T], [2, 46, T + 0.6], { fine: true }),
+ box("led-activity", [0.9, 47.2, T], [2, 48.2, T + 0.6], { fine: true }),
+ box("rtc", [17, 44.5, T], [20.5, 47.5, T + 2], {
+ marks: onTop(rect(span(17.6, 45.3, 19.9, 46.6)))
+ }),
+ box("usb-c", [7, 49.5, T], [16, 57.3, T + 3.2], {
+ marks: [
+ { face: "front", points: stadium(span(7.8, 0.5, 15.2, 2.7)) },
+ { face: "front", points: rect(span(9.6, 1.4, 13.4, 1.8)) }
+ ]
+ }),
+ hdmi("hdmi-0", 22),
+ box("uart", [31.2, 51, T], [34.4, 54.2, T + 2.8], {
+ marks: onTop(rect(span(31.8, 51.8, 33.8, 53.4)))
+ }),
+ hdmi("hdmi-1", 36),
+ mipi("mipi-0", 45),
+ mipi("mipi-1", 50.5),
+ box("poe", [57.4, 45.2, T], [62.5, 50.3, T + 2.5]),
+ ...[0, 1].flatMap((row) =>
+ [0, 1].map((col) =>
+ pin(`poe-pin-${row}-${col}`, 58.68 + col * 2.54, 46.48 + row * 2.54, T + 2.5, T + 7)
+ )
+ ),
+ usb("usb-2", 2.5),
+ usb("usb-3", 20.5),
+ box("ethernet", [65.5, 38, T], [87.1, 54, T + 13.5], {
+ marks: onTop(rect(span(82.5, 39, 86.3, 53)))
+ })
+];
+
+export const board = (): Part[] => flat().map(turn);
+
+const SEAT = HEADER_TOP;
+const SOCKET = 8.5;
+const SHELL = { thick: 8, length: 57, tall: 52 };
+
+const RECESS = { inset: 4.5, bottom: 5, top: 39 };
+export const LOGO = { size: 20, v: (RECESS.bottom + RECESS.top) / 2 };
+
+export const RIDGES = { at: [42.5, 44.6, 46.7, 48.8], depth: 0.9, reach: 9.5 };
+
+export const cartridge = (lift: number): Part[] => {
+ const { cx, cy, pitch } = HEADER;
+ const z = SEAT + lift;
+ const base = z + SOCKET;
+ const x = cy;
+ const y = LENGTH - cx;
+ const along = 10 * pitch;
+ const guides =
+ lift > 1
+ ? [-1, 1].map((side) =>
+ box(
+ `guide-${side}`,
+ [x - 0.1, y + side * along - 0.1, HEADER_TOP],
+ [x + 0.1, y + side * along + 0.1, z],
+ { kind: "guide" }
+ )
+ )
+ : [];
+ const y0 = y - SHELL.length / 2;
+ const y1 = y + SHELL.length / 2;
+ return [
+ ...guides,
+ box(
+ "socket",
+ [x - pitch - 0.3, y - along - 0.2, z],
+ [x + pitch + 0.3, y + along + 0.2, base]
+ ),
+ box(
+ "cartridge",
+ [x - SHELL.thick / 2, y0, base],
+ [x + SHELL.thick / 2, y1, base + SHELL.tall],
+ {
+ marks: [
+ {
+ face: "side",
+ points: arched(
+ span(y0 + RECESS.inset, RECESS.bottom, y1 - RECESS.inset, RECESS.top),
+ 3
+ ),
+ recess: true
+ },
+ {
+ face: "side",
+ points: stadium(span(y0 + 14, 43.5, y1 - 14, 49.5)),
+ recess: true
+ }
+ ]
+ }
+ )
+ ];
+};