feat: standard parts library, fuse box configurator, and theming

Standard parts (partsLibrary.js)
- ACDC TXL palette: 15 solids plus the 16 stocked stripe combinations
- 29 wire functions fix colour, stripe and gauge per signal, so a wire
  drawn from a given pin comes out identical on every build
- 16 pre-labelled parts: Skudak VCU (GRAY/BLACK), openinverter LDU
  23-pin, Dilong OBC/DCDC, BMW pedal, Honeywell CSSV1500, Bender
  ISO175, Bosch iBooster, Prius EPS, Volvo PS pump, cluster, DNR
  switch, 32-way enclosure bulkhead, contactors, VW MEB BMS
- OEM colours override the in-house standard where we splice into a
  factory loom (VW MEB LV connector, recovered from the VW bms diagram)
- Brown is ground, matching the MEB loom and European practice; orange
  is reserved for AC mains and OEM high-voltage runs

Fuse box configurator (pdmLibrary.js)
- GEP FRH-A12/A24 and the 48-way PDM as 280-footprint cavity grids
- Place, drag and rotate fuses, relays, diodes, breakers and bus bars
- Bus bars occupy their own collision layer and feed the blades they
  cross, suppressing those inputs so one wire supplies the run
- Cavity capacity is tracked; Apply is blocked while a placement
  overlaps or overhangs
- Naming a circuit renames its pins: "VCU" gives "VCU in" / "VCU out"

Theming (theme.js)
- Light and dark modes; canvas background, grid dots, part fill and
  part outline are configurable and persisted
- CSS converted to custom properties with property-aware light-mode
  pairs, since a colour used as text and as a surface must flip
  differently; no rule falls below 3:1 contrast in either theme
- Canvas devices, pins, labels, cable rows and the harness view are
  themed too, since Konva bakes colours in at draw time and cannot
  read CSS variables

Also
- Pass-through bulkheads: one device covering both connector faces,
  with a single centred label per circuit
- Pin labels scale with the device font instead of a fixed 8px
- Keyboard handling is modal-scoped, so Delete no longer removes the
  canvas device while the fuse box editor is open

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:54:49 -04:00
parent 43c0ea46c6
commit cca03cd2f3
8 changed files with 2410 additions and 200 deletions
+410
View File
@@ -0,0 +1,410 @@
// ─────────────────────────────────────────────────────────────────────────────
// GEP power distribution modules — fuse box grid model
//
// Loaded AFTER partsLibrary.js. Registers a `pdm` device type whose pins are
// derived from what is physically placed in the box, so arranging the grid in
// the fuse-box tool immediately changes what you can wire to on the canvas.
//
// GEP FRH / PDM modules are open-plan 280-footprint grids: the "way" count is
// the number of Metri-Pack 280 cavities and you place your own mix of
// components anywhere in them. GEP quote the FRH-A24 as taking up to 4 five-
// prong relays, or 6 four-prong relays, or 12 mini fuses — all three come to
// exactly 24 cavities on a 4x6 grid with the footprints below, which is where
// the geometry comes from.
// ─────────────────────────────────────────────────────────────────────────────
const PDM_MODULES = {
"gep-frh-a24": {
id: "gep-frh-a24",
name: "GEP FRH-A24",
manufacturer: "GEP Power Products",
partNumber: "FRH-A24",
cols: 4, rows: 6,
description: "Sealed 24-cavity 280-footprint fuse / relay holder, IP66/IP67. ~60 x 50 x 60 mm.",
},
"gep-frh-a12": {
id: "gep-frh-a12",
name: "GEP FRH-A12",
manufacturer: "GEP Power Products",
partNumber: "FRH-A12",
cols: 4, rows: 3,
description: "Sealed 12-cavity 280-footprint fuse / relay holder.",
},
"gep-pdm-48": {
id: "gep-pdm-48",
name: "GEP PDM 48-way",
manufacturer: "GEP Power Products",
partNumber: "PDM-R4A01",
cols: 4, rows: 12,
description: "Sealed stackable 48-cavity 280-footprint power distribution module.",
},
};
// ── Component footprints ─────────────────────────────────────────────────────
// w/h are in cavities. Terminals sit at (c,r) inside the unrotated footprint;
// a 5-prong relay is a 2x3 block with one cavity unused, which is why four of
// them exactly fill a 24-way.
const PDM_COMPONENTS = {
fuse: {
key: "fuse",
label: "Mini fuse",
short: "FUSE",
w: 1, h: 2,
colour: "#4a7dd6",
ratings: ["2A", "3A", "5A", "7.5A", "10A", "15A", "20A", "25A", "30A"],
defaultRating: "10A",
terminals: [
{ t: "IN", c: 0, r: 0, fn: "KL30" },
{ t: "OUT", c: 0, r: 1, fn: "LOAD_12V" },
],
},
relay_spdt: {
key: "relay_spdt",
label: "Micro relay, 5-prong SPDT",
short: "RLY5",
w: 2, h: 3,
colour: "#8a5fd6",
ratings: ["20A/10A", "30A/20A", "40A/30A"],
defaultRating: "30A/20A",
terminals: [
{ t: "86", c: 0, r: 0, fn: "COIL" },
{ t: "85", c: 1, r: 0, fn: "COIL" },
{ t: "30", c: 0, r: 1, fn: "KL30" },
{ t: "87a", c: 1, r: 1, fn: "LOAD_12V" },
{ t: "87", c: 0, r: 2, fn: "LOAD_12V" },
],
},
relay_spst: {
key: "relay_spst",
label: "Micro relay, 4-prong SPST",
short: "RLY4",
w: 2, h: 2,
colour: "#6f4fc0",
ratings: ["20A", "30A", "40A"],
defaultRating: "30A",
terminals: [
{ t: "86", c: 0, r: 0, fn: "COIL" },
{ t: "85", c: 1, r: 0, fn: "COIL" },
{ t: "30", c: 0, r: 1, fn: "KL30" },
{ t: "87", c: 1, r: 1, fn: "LOAD_12V" },
],
},
diode: {
key: "diode",
label: "Mini diode",
short: "DIODE",
w: 1, h: 2,
colour: "#3f8f6a",
ratings: ["3A", "6A"],
defaultRating: "6A",
terminals: [
{ t: "A", c: 0, r: 0, fn: "LOAD_12V" },
{ t: "K", c: 0, r: 1, fn: "LOAD_12V" },
],
},
// Bus bars link a run of cavities into one node, so a single feed wire
// supplies every component blade sitting on the bar. They therefore SHARE
// cavities with those blades by design — `isBus` puts them on their own
// layer, colliding only with each other, and any input terminal landing on a
// bar stops needing its own wire.
bus2: {
key: "bus2", label: "Bus bar, 2-way", short: "BUS2", isBus: true,
w: 1, h: 2, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
bus3: {
key: "bus3", label: "Bus bar, 3-way", short: "BUS3", isBus: true,
w: 1, h: 3, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
bus4: {
key: "bus4", label: "Bus bar, 4-way", short: "BUS4", isBus: true,
w: 1, h: 4, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
bus6: {
key: "bus6", label: "Bus bar, 6-way", short: "BUS6", isBus: true,
w: 1, h: 6, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
breaker: {
key: "breaker",
label: "Mini circuit breaker",
short: "CB",
w: 1, h: 2,
colour: "#b5813a",
ratings: ["5A", "10A", "15A", "20A", "25A", "30A"],
defaultRating: "20A",
terminals: [
{ t: "IN", c: 0, r: 0, fn: "KL30" },
{ t: "OUT", c: 0, r: 1, fn: "LOAD_12V" },
],
},
};
// ── Rotation ─────────────────────────────────────────────────────────────────
// Clockwise. Returns the footprint size at that rotation.
function pdmSize(comp, rot) {
return (rot === 90 || rot === 270)
? { w: comp.h, h: comp.w }
: { w: comp.w, h: comp.h };
}
// Maps a terminal's cell inside the unrotated footprint to its cell inside the
// rotated one.
function pdmRotateCell(c, r, w, h, rot) {
switch (rot) {
case 90: return { c: h - 1 - r, r: c };
case 180: return { c: w - 1 - c, r: h - 1 - r };
case 270: return { c: r, r: w - 1 - c };
default: return { c, r };
}
}
let _pdmSeq = 0;
function pdmNewCircuit(typeKey) {
const comp = PDM_COMPONENTS[typeKey] || PDM_COMPONENTS.fuse;
return {
id: `c${Date.now().toString(36)}${(_pdmSeq++).toString(36)}`,
type: comp.key,
rating: comp.defaultRating,
name: "",
col: 0, row: 0, rot: 0,
};
}
// Every cavity a placement covers, in absolute grid coordinates.
function pdmCells(circuit) {
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return [];
const { w, h } = pdmSize(comp, circuit.rot || 0);
const cells = [];
for (let r = 0; r < h; r++) {
for (let c = 0; c < w; c++) cells.push({ c: circuit.col + c, r: circuit.row + r });
}
return cells;
}
// Absolute cavity of each terminal, plus its 1-based cavity number.
function pdmTerminals(circuit, mod) {
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return [];
const rot = circuit.rot || 0;
return comp.terminals.map((t) => {
const m = pdmRotateCell(t.c, t.r, comp.w, comp.h, rot);
const c = circuit.col + m.c;
const r = circuit.row + m.r;
return { ...t, c, r, cavity: r * mod.cols + c + 1 };
});
}
// Validation: overlaps and out-of-bounds. Both are physical impossibilities,
// so the tool refuses to save them rather than warning and letting them through.
function pdmIsBus(circuit) {
return !!PDM_COMPONENTS[circuit?.type]?.isBus;
}
// Which bus bar, if any, covers this cavity.
function pdmBusAt(props, c, r) {
for (const ci of props.circuits || []) {
if (!pdmIsBus(ci)) continue;
if (pdmCells(ci).some((x) => x.c === c && x.r === r)) return ci;
}
return null;
}
function pdmValidate(props) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const circuits = props.circuits || [];
// Two layers: components collide with components, bus bars with bus bars.
// A bar crossing a fuse blade is the whole point of a bar, not a clash.
const layers = { part: new Map(), bus: new Map() };
const errors = [];
const badIds = new Set();
const nameOf = (id) => {
const x = circuits.find((y) => y.id === id);
return x ? (x.name || PDM_COMPONENTS[x.type]?.short || "component") : "component";
};
circuits.forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
const { w, h } = pdmSize(comp, ci.rot || 0);
if (ci.col < 0 || ci.row < 0 || ci.col + w > mod.cols || ci.row + h > mod.rows) {
errors.push(`${ci.name || comp.short} hangs outside the box`);
badIds.add(ci.id);
return;
}
const occupied = layers[comp.isBus ? "bus" : "part"];
pdmCells(ci).forEach(({ c, r }) => {
const key = `${c},${r}`;
if (occupied.has(key)) {
errors.push(
`${ci.name || comp.short} overlaps ${nameOf(occupied.get(key))} at cavity ${r * mod.cols + c + 1}`);
badIds.add(ci.id);
badIds.add(occupied.get(key));
} else {
occupied.set(key, ci.id);
}
});
});
return {
module: mod,
used: layers.part.size,
total: mod.cols * mod.rows,
busUsed: layers.bus.size,
errors: [...new Set(errors)],
badIds,
ok: errors.length === 0,
};
}
// Can this component sit here without overlapping anything or leaving the box?
function pdmFits(props, circuit, col, row, rot) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return false;
const { w, h } = pdmSize(comp, rot);
if (col < 0 || row < 0 || col + w > mod.cols || row + h > mod.rows) return false;
// Only same-layer components block each other — a bus bar is meant to lie
// across the blades it feeds.
const bus = !!comp.isBus;
const taken = new Set();
(props.circuits || []).forEach((o) => {
if (o.id === circuit.id || pdmIsBus(o) !== bus) return;
pdmCells(o).forEach(({ c, r }) => taken.add(`${c},${r}`));
});
const probe = { ...circuit, col, row, rot };
return pdmCells(probe).every(({ c, r }) => !taken.has(`${c},${r}`));
}
// Clamp a placement so the whole footprint sits inside the grid. Overlap is
// deliberately allowed: an overlapping component is a state you must be able to
// drag your way out of, so the editor permits it and validation flags it in red
// rather than refusing the move and trapping you.
function pdmClamp(props, circuit, col, row, rot) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return { col: 0, row: 0 };
const { w, h } = pdmSize(comp, rot ?? circuit.rot ?? 0);
return {
col: Math.max(0, Math.min(col, mod.cols - w)),
row: Math.max(0, Math.min(row, mod.rows - h)),
};
}
// Pull every component back inside the grid — used after a module change, so
// switching to a smaller or differently-shaped box never strands a tile off
// the edge where it cannot be clicked.
function pdmClampAll(props) {
(props.circuits || []).forEach((ci) => {
const p = pdmClamp(props, ci, ci.col, ci.row, ci.rot || 0);
ci.col = p.col;
ci.row = p.row;
});
}
// First free slot, scanning row-major. Returns null if the box is full.
function pdmAutoPlace(props, circuit) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
for (let r = 0; r < mod.rows; r++) {
for (let c = 0; c < mod.cols; c++) {
for (const rot of [0, 90]) {
if (pdmFits(props, circuit, c, r, rot)) return { col: c, row: r, rot };
}
}
}
return null;
}
// ── Canvas pins ──────────────────────────────────────────────────────────────
// One pin per terminal, named by cavity, carrying the wire standard. Coils go
// left, switched outputs right, so power reads across the device.
// Friendly suffixes so a named circuit reads as plain English on the pin.
// Relay terminals keep their standard numbers — "Main contactor 87" is what is
// printed on the relay, so renaming it would help nobody.
const PDM_TERM_LABEL = { IN: "in", OUT: "out", FEED: "feed" };
// The pin name a terminal ends up with. Naming a fuse "VCU" turns its pins into
// "VCU in" and "VCU out"; unnamed components fall back to the cavity number so
// they are still identifiable on the board.
function pdmPinName(circuit, comp, t) {
const suffix = PDM_TERM_LABEL[t.t] || t.t;
const name = (circuit.name || "").trim();
return name ? `${name} ${suffix}` : `${t.cavity}·${t.t}`;
}
function pdmPins(props, w, h) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const left = [{ id: "FEED", name: "FEED", fn: "KL30", note: "Module bus input" }];
const right = [];
(props.circuits || []).forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
const tag = ci.name || `${comp.short}${comp.isBus ? "" : ` ${ci.rating}`}`;
pdmTerminals(ci, mod).forEach((t) => {
// A component input sitting on a bus bar is fed by the bar, so it needs
// no wire of its own — the bar's single FEED pin covers the whole run.
if (!comp.isBus && t.fn === "KL30") {
const bar = pdmBusAt(props, t.c, t.r);
if (bar) return;
}
const pin = {
id: `${ci.id}_${t.t}`,
name: pdmPinName(ci, comp, t),
fn: t.fn,
// The cavity stays in the note, so renaming a circuit never loses where
// it physically sits in the box.
note: comp.isBus
? `${tag} — cavity ${t.cavity}, feeds ${pdmCells(ci).map((x) => x.r * mod.cols + x.c + 1).join(", ")}`
: `${tag} (${ci.rating}) — cavity ${t.cavity}`,
};
(t.fn === "COIL" ? left : right).push(pin);
});
});
const place = (arr, side) =>
arr.map((p, i) => ({
id: p.id,
name: p.name,
side,
x_offset: side === "left" ? 0 : w,
y_offset: ((i + 1) / (arr.length + 1)) * h,
wire_fn: p.fn,
wire_oem: null,
note: p.note,
}));
return [...place(left, "left"), ...place(right, "right")];
}
function pdmDefaultProps() {
const m = PDM_MODULES["gep-frh-a24"];
return {
moduleId: m.id,
circuits: [],
partNumber: m.partNumber,
manufacturer: m.manufacturer,
};
}
if (typeof DEVICE_TYPES !== "undefined") {
DEVICE_TYPES.pdm = {
label: "Fuse / Relay Box",
description: "GEP power distribution module — arrange the cavity grid in the fuse box tool",
icon: "▦",
defaultProps: pdmDefaultProps(),
defaultSize: (p) => {
const n = Math.max((p.circuits || []).length, 3);
return { w: 190, h: Math.max(90, n * 24 + 40) };
},
getPins: (p, w, h) => pdmPins(p, w, h),
};
}