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:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user