/* global React */
/* ===========================================================
VOLTLAB · MONTAGEM DE QUADROS ELÉTRICOS (v2)
Editor de posição livre na placa de montagem + geração do
DESENHO TÉCNICO (Frontal · Interna · Lateral) exportável em PNG.
Depende de window.QM (painel/qm-core.js) e window.PE.
=========================================================== */
const { useState, useRef, useEffect, useMemo, useCallback } = React;
const qmPersist = (k, init) => (window.useLocalStorage ? window.useLocalStorage(k, init) : React.useState(init));
const QM_LIB = () => window.QM?.COMP_LIB || {};
const QM_GROUPS = () => window.QM?.GROUPS || [];
const QM_PRESETS = () => window.QM?.PRESETS || [];
/* ---- ícones ---- */
const QmIcon = ({ name, size = 16 }) => {
const p = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };
const paths = {
image: <>>,
download: <>>,
trash: <>>,
grid: <>>,
ruler: <>>,
edit: <>>,
list: <>>,
close: <>>,
};
return ;
};
/* ===========================================================
TAG automática
=========================================================== */
function nextTag(type, items) {
const d = QM_LIB()[type]; if (!d) return "X1";
let n = 0;
items.forEach((c) => { const m = (c.tag || "").match(new RegExp(`^${d.tag}(\\d+)$`)); if (m) n = Math.max(n, +m[1]); });
return `${d.tag}${n + 1}`;
}
/* ===========================================================
RENDER DE UM COMPONENTE NO EDITOR (coordenadas em mm)
=========================================================== */
function EditorItem({ item, def, size, selected, onPointerDown }) {
const { w, h } = size;
const c = def.color;
const term = (cx, cy, r) => ;
let body = null;
if (def.family === "mcb") {
const pitch = w / def.poles;
body = (
{Array.from({ length: def.poles }).map((_, i) => (
{i > 0 && }
{term(pitch * (i + 0.5), h * 0.1, Math.min(pitch * 0.18, h * 0.06))}
{term(pitch * (i + 0.5), h * 0.9, Math.min(pitch * 0.18, h * 0.06))}
))}
);
} else if (def.family === "mccb") {
body = (
{[0, 1, 2].map((i) => ({term(w * (0.25 + 0.25 * i), h * 0.07, w * 0.05)}{term(w * (0.25 + 0.25 * i), h * 0.93, w * 0.05)}))}
);
} else if (def.family === "surge") {
body = (
{Array.from({ length: def.bars || 4 }).map((_, i) => {
const bx = w * (0.16 + 0.68 * i / ((def.bars || 4) - 1));
return ;
})}
{[0, 1, 2].map((i) => {term(w * (0.25 + 0.25 * i), h * 0.9, w * 0.06)})}
);
} else if (def.family === "rail") {
const slots = Math.max(2, Math.round(w / 30));
body = (
{Array.from({ length: slots }).map((_, i) => (
))}
);
} else if (def.family === "busbar") {
body = (
CANALETA
);
} else if (def.family === "termH" || def.family === "termV") {
const vertical = def.family === "termV";
const n = window.QM.termCount(item);
const cells = Array.from({ length: n });
body = (
{cells.map((_, i) => {
const cx = vertical ? w / 2 : (w / n) * (i + 0.5);
const cy = vertical ? (h / n) * (i + 0.5) : h / 2;
return (
{i > 0 && (vertical
?
: )}
{term(cx, cy, Math.min(vertical ? w * 0.2 : (w / n) * 0.22, h * 0.3))}
);
})}
);
} else { // block
body = (
{Array.from({ length: def.poles || 3 }).map((_, i) => {
const cx = w * ((i + 0.5) / (def.poles || 3));
return {term(cx, h * 0.06, Math.min(w / (def.poles || 3) * 0.16, h * 0.05))}{term(cx, h * 0.94, Math.min(w / (def.poles || 3) * 0.16, h * 0.05))};
})}
{def.label2 && {def.label2}}
);
}
return (
{body}
{def.family !== "busbar" && def.family !== "rail" && item.tag && (
{item.tag}
)}
{selected && }
);
}
/* ===========================================================
CANVAS DO EDITOR — placa de montagem, posição livre
=========================================================== */
function PlateEditor({ preset, items, selectedId, armedType, snap50, ovl, onOvlMove, onSelect, onMove, onPlace, onCommit }) {
const svgRef = useRef(null);
const dragRef = useRef(null);
const plate = window.QM.plateSize(preset);
const step = snap50 ? 50 : 1; // 1mm = arraste suave, contínuo
const toMM = (e) => {
const svg = svgRef.current; if (!svg) return { x: 0, y: 0 };
const pt = svg.createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY;
const ctm = svg.getScreenCTM(); if (!ctm) return { x: 0, y: 0 };
const p = pt.matrixTransform(ctm.inverse());
return { x: p.x, y: p.y };
};
const snap = (v) => Math.round(v / step) * step;
const snapRail = (x, y, s, family, skipId) => {
if (family === "rail" || family === "busbar") return y;
const clipY = y + s.h * 0.5; let best = null, bestD = 46;
items.forEach((r) => {
if (r.id === skipId) return;
const rd = QM_LIB()[r.type]; if (!rd || rd.family !== "rail") return;
const rs = window.QM.itemSize(r); const rCy = r.y + rs.h / 2;
if (x + s.w < r.x || x > r.x + rs.w) return;
const dd = Math.abs(clipY - rCy); if (dd < bestD) { bestD = dd; best = rCy; }
});
if (best == null) return y;
return Math.max(0, Math.min(plate.h - s.h, Math.round(best - s.h * 0.5)));
};
const onItemDown = (item) => (e) => {
e.stopPropagation();
if (armedType) return;
onSelect(item.id);
const m = toMM(e);
dragRef.current = { id: item.id, offx: m.x - item.x, offy: m.y - item.y, moved: false };
svgRef.current.setPointerCapture?.(e.pointerId);
};
const onOvlDown = (e) => {
if (armedType || !ovl || ovl.locked) return;
e.stopPropagation();
const m = toMM(e);
dragRef.current = { id: "__ovl", offx: m.x - ovl.x, offy: m.y - ovl.y, moved: false };
svgRef.current.setPointerCapture?.(e.pointerId);
};
const onSvgMove = (e) => {
const d = dragRef.current; if (!d) return;
const m = toMM(e);
if (d.id === "__ovl") { d.moved = true; onOvlMove(Math.round(m.x - d.offx), Math.round(m.y - d.offy)); return; }
const it = items.find((i) => i.id === d.id); if (!it) return;
const s = window.QM.itemSize(it);
const fam = QM_LIB()[it.type]?.family;
let x = snap(m.x - d.offx), y = snap(m.y - d.offy);
x = Math.max(0, Math.min(plate.w - s.w, x));
y = Math.max(0, Math.min(plate.h - s.h, y));
y = snapRail(x, y, s, fam, it.id);
d.moved = true;
onMove(d.id, x, y);
};
const endDrag = () => { if (dragRef.current?.moved) onCommit?.(); dragRef.current = null; };
const onSvgDown = (e) => {
if (!armedType) { onSelect(null); return; }
const m = toMM(e);
const d = QM_LIB()[armedType];
const s = window.QM.itemSize({ type: armedType });
let x = snap(m.x - s.w / 2), y = snap(m.y - s.h / 2);
x = Math.max(0, Math.min(plate.w - s.w, x));
y = Math.max(0, Math.min(plate.h - s.h, y));
y = snapRail(x, y, s, d.family, null);
onPlace(armedType, x, y);
};
const GRID = 50;
const vLines = []; for (let gx = GRID; gx < plate.w; gx += GRID) vLines.push(gx);
const hLines = []; for (let gy = GRID; gy < plate.h; gy += GRID) hLines.push(gy);
return (
);
}
/* ===========================================================
ITEM DA PALETA
=========================================================== */
const QmPaletteItem = ({ type, def, active, onClick }) => (
);
/* ===========================================================
DESENHO TÉCNICO — 3 vistas + exportação
=========================================================== */
function TecnicoView({ config }) {
const svgRef = useRef(null);
const [showCotas, setCotas] = useState(true);
const [showGrid, setGrid] = useState(true);
const [showTags, setTags] = useState(true);
const [lw, setLw] = useState(1);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!svgRef.current || !window.QM) return;
window.PE.CONFIG.lwScale = lw;
window.QM.buildPanelFromConfig(svgRef.current, config, { showCotas, showGrid, showTags });
}, [config, showCotas, showGrid, showTags, lw]);
const opts = { showCotas, showGrid, showTags };
const doPNG = async () => { setBusy(true); window.PE.CONFIG.lwScale = lw; try { await window.QM.exportPNG(config, opts, "quadro-eletrico.png"); } finally { setBusy(false); } };
const doSVG = () => { window.PE.CONFIG.lwScale = lw; window.QM.exportSVG(config, opts, "quadro-eletrico.svg"); };
const Toggle = ({ on, set, children }) => (
);
return (
As três vistas são geradas automaticamente da sua montagem. O PNG sai com fundo branco (papel), em alta resolução, pronto para o projeto.
);
}
/* ===========================================================
MÓDULO PRINCIPAL
=========================================================== */
const QuadroMontagem = () => {
const ready = !!window.QM;
const [presetKey, setPresetKey] = qmPersist("vl_qm2_preset", "p800b");
const [items, setItems] = qmPersist("vl_qm2_items", null);
const [view, setView] = useState("editor"); // editor | tecnico
const [armed, setArmed] = useState(null);
const [selId, setSelId] = useState(null);
const [snap50, setSnap50] = useState(false);
const [seeded, setSeeded] = useState(false);
const [custom, setCustom] = qmPersist("vl_qm2_custom", { W: 800, H: 1200, D: 250 });
const [ovl, setOvl] = useState(null);
const [showMat, setShowMat] = useState(false);
// semeia o exemplo inicial (uma vez)
useEffect(() => {
if (!ready || seeded) return;
if (items == null) { const ex = window.QM.defaultExample(); setPresetKey(ex.presetKey); setItems(ex.items); }
setSeeded(true);
}, [ready, seeded]); // eslint-disable-line
const list = items || [];
const dims = presetKey === "custom"
? { W: +custom.W || 600, H: +custom.H || 800, D: +custom.D || 250 }
: (ready ? window.QM.presetByKey(presetKey) : { W: 800, H: 1200, D: 250 });
const dimLabel = `${dims.W} × ${dims.H} × ${dims.D}`;
const LIB = QM_LIB();
const config = useMemo(() => ({ presetKey, dims, items: list }), [presetKey, dims.W, dims.H, dims.D, list]);
const selected = list.find((i) => i.id === selId) || null;
const placeComp = (type, x, y) => {
setItems((prev) => { const arr = prev || []; const it = { id: "c" + Date.now().toString(36), type, x, y, tag: nextTag(type, arr) }; const d = LIB[type]; if (d?.lengthAdjustable) it.len = d.vertical ? d.h : d.w; return [...arr, it]; });
setArmed(null);
};
const moveComp = (id, x, y) => setItems((prev) => (prev || []).map((i) => (i.id === id ? { ...i, x, y } : i)));
const updateComp = (patch) => setItems((prev) => (prev || []).map((i) => (i.id === selId ? { ...i, ...patch } : i)));
const removeComp = () => { setItems((prev) => (prev || []).filter((i) => i.id !== selId)); setSelId(null); };
const clearAll = () => { if (window.confirm("Limpar toda a montagem?")) { setItems([]); setSelId(null); } };
const loadExample = () => { if (window.confirm("Carregar o exemplo de referência? Isto substitui a montagem atual.")) { const ex = window.QM.defaultExample(); setPresetKey(ex.presetKey); setItems(ex.items); setSelId(null); } };
const onOvlUpload = (e) => { const f = e.target.files?.[0]; if (!f) return; const r = new FileReader(); r.onload = () => setOvl({ src: r.result, x: 0, y: 0, scale: 1, opacity: 0.55, visible: true, locked: true }); r.readAsDataURL(f); e.target.value = ""; };
// teclado: Delete remove selecionado
useEffect(() => {
const h = (e) => { if ((e.key === "Delete" || e.key === "Backspace") && selId && view === "editor" && document.activeElement?.tagName !== "INPUT") { e.preventDefault(); removeComp(); } };
window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h);
}); // eslint-disable-line
// BOM agregada
const bom = useMemo(() => {
const map = {};
list.forEach((c) => { const d = LIB[c.type]; if (!d) return; (map[c.type] = map[c.type] || { def: d, type: c.type, tags: [] }).tags.push(c.tag); });
return Object.values(map).sort((a, b) => a.def.group.localeCompare(b.def.group));
}, [list]); // eslint-disable-line
const exportBOM = () => {
const rows = list.map((c) => ({ tag: c.tag, tipo: LIB[c.type]?.label, grupo: LIB[c.type]?.group, dim: `${window.QM.itemSize(c).w}×${window.QM.itemSize(c).h}mm`, x: c.x, y: c.y }));
const m = window.QM.materials(list);
const resumo = { equipamentos: m.equip, disjuntores: m.disj.qty, disjuntores_modulos: m.disj.mod, contatoras: m.cont, reles_timer: m.rele, dps: m.dps, mccb: m.mccb, bornes_vias: m.bornes, trilho_din_m: m.rail_m, canaleta_m: m.duct_m };
const blob = new Blob([JSON.stringify({ gabinete: dimLabel, total: rows.length, resumo, bom: rows }, null, 2)], { type: "application/json" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "bom-quadro.json"; a.click();
};
if (!ready) return Carregando motor de desenho…
;
const usedArea = list.reduce((s, c) => { const z = window.QM.itemSize(c); return s + z.w * z.h; }, 0);
const plate = window.QM.plateSize(dims);
const occ = Math.min(100, Math.round((usedArea / (plate.w * plate.h)) * 100));
return (
{/* ── Barra superior ───────────────────────────── */}
Gabinete
{presetKey === "custom" ? (
{[["W", "L"], ["H", "A"], ["D", "P"]].map(([k, lbl], i) => (
{i > 0 && ×}
setCustom((c) => ({ ...c, [k]: +e.target.value }))}
style={{ width: 66, padding: "6px 8px", background: "var(--bg-1)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--fg)", fontFamily: "var(--ff-mono)", fontSize: 12 }} title={lbl} />
))}
mm
) :
L×A×P}
{/* alternador de visão */}
{[["editor", "Montagem", "edit"], ["tecnico", "Desenho Técnico", "ruler"]].map(([v, lbl, ic]) => (
))}
{view === "editor" && (
)}
{view === "tecnico" ? (
) : (
{/* COLUNA 1 — Biblioteca */}
BIBLIOTECA
{armed && (
✓ {LIB[armed]?.label} — clique na placa
)}
{QM_GROUPS().map((group) => (
{group}
{Object.entries(LIB).filter(([, d]) => d.group === group).map(([type, def]) => (
{ setArmed(armed === type ? null : type); setSelId(null); }} />
))}
))}
{/* COLUNA 2 — Placa */}
PLACA DE MONTAGEM
Posição livre — arraste para mover
{dimLabel} mm
{[["COMPONENTES", list.length], ["GABINETE", `${dims.W}×${dims.H}`], ["OCUPAÇÃO", occ + "%"], ["PLACA", `${plate.w}×${plate.h}`]].map(([l, v]) => (
{l}{v}
))}
{/* imagem de referência para sobrepor / decalcar */}
Imagem de referência
{ovl && (<>
OPAC setOvl((o) => ({ ...o, opacity: +e.target.value }))} style={{ width: 78, accentColor: "var(--accent)" }} />
ESC setOvl((o) => ({ ...o, scale: +e.target.value }))} style={{ width: 78, accentColor: "var(--accent)" }} />
>)}
setOvl((o) => ({ ...o, x, y }))}
onSelect={setSelId} onMove={moveComp} onPlace={placeComp} />
① Selecione um componente na biblioteca② Clique na placa para posicionar③ Arraste para ajustar · Delete remove
{/* COLUNA 3 — Propriedades / BOM */}
{selected ? (
setSelId(null)} />
) : (
)}
)}
{/* ── Referência técnica ─────────────────────────── */}
REFERÊNCIA · NBR 5410 / IEC 60715
Módulos DIN e trilho 35 mm
MÓDULOLARGURAEXEMPLOTRILHONORMA
{[["1 mód.", "18 mm", "Disjuntor 1P, sinaleiro", "35 mm", "IEC 60715"], ["2 mód.", "36 mm", "Disj. 2P, temporizador", "35 mm", "IEC 60715"], ["3 mód.", "54 mm", "Disj. 3P, relé térmico", "35 mm", "IEC 60715"], ["Bloco", "45–80 mm", "Contatoras P/M/G", "35 mm", "IEC 60947"], ["Moldada", "105 mm", "Disjuntor geral (MCCB)", "fixa", "IEC 60947"]].map((r) => (
{r[0]}{r[1]}{r[2]}{r[3]}{r[4]}
))}
{showMat &&
p.key === presetKey)?.label || dimLabel)}
total={list.length} occ={occ} onClose={() => setShowMat(false)} />}
);
};
/* ===========================================================
PROPRIEDADES DO COMPONENTE
=========================================================== */
const QmProps = ({ item, def, onUpdate, onRemove, onDeselect }) => {
if (!def) return null;
const s = window.QM.itemSize(item);
return (
COMPONENTE
{def.label}
{def.group} · {s.w}×{s.h} mm
onUpdate({ tag: e.target.value })}
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-0)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--fg)", fontFamily: "var(--ff-mono)", fontSize: 14, fontWeight: 600, outline: "none", marginBottom: 14 }} />
{def.lengthAdjustable && (
onUpdate({ len: +e.target.value })} style={{ width: "100%", accentColor: "var(--accent)" }} />
)}
);
};
/* ===========================================================
LISTA DE MATERIAIS
=========================================================== */
const QmBom = ({ bom, total, occ, mat }) => (
LISTA DE MATERIAIS
{mat && (
RESUMO DE MATERIAIS
Trilho DIN{mat.rail_m.toFixed(2)}m
Canaleta{mat.duct_m.toFixed(2)}m
Disjuntores{mat.disj.qty}un · {mat.disj.mod} mód. DIN
{[["Contatoras", mat.cont], ["Relés/Timer", mat.rele], ["DPS", mat.dps], ["MCCB", mat.mccb], ["Bornes (vias)", mat.bornes]].filter(([, v]) => v > 0).map(([l, v]) => (
{l}: {v}
))}
)}
{bom.length === 0 ? (
Adicione componentes à placa para montar a lista de materiais.
) : (
COMPONENTEQTTAGS
{bom.map((b) => (
{b.def.label}
{b.def.group}
{b.tags.length}
{b.tags.join(", ")}
))}
)}
);
/* ===========================================================
MODAL · GERAR LISTA DE MATERIAIS
=========================================================== */
const QmMaterialModal = ({ mat, dimLabel, dims, presetLabel, total, occ, onClose }) => {
const order = ["Estrutura", "Proteção", "Manobra", "Proteção Motriz", "Controle", "Distribuição"];
const rows = Object.values(mat.byType).sort((a, b) => {
const ga = order.indexOf(a.group), gb = order.indexOf(b.group);
return (ga < 0 ? 99 : ga) - (gb < 0 ? 99 : gb) || a.label.localeCompare(b.label);
});
const grouped = order.map((g) => ({ g, items: rows.filter((r) => r.group === g) })).filter((x) => x.items.length);
const asText = () => {
const L = [];
L.push(`LISTA DE MATERIAIS`);
L.push(`Quadro / Gabinete: ${presetLabel} (${dims.W} × ${dims.H} × ${dims.D} mm · L×A×P)`);
L.push(`Trilho DIN: ${mat.rail_qty} un · ${mat.rail_m.toFixed(2)} m`);
L.push(`Canaleta: ${mat.duct_qty} un · ${mat.duct_m.toFixed(2)} m`);
L.push(`Disjuntores: ${mat.disj.qty} un · ${mat.disj.mod} módulos DIN`);
L.push(`Total de componentes: ${total}`);
L.push("");
grouped.forEach(({ g, items }) => {
L.push(`[${g}]`);
items.forEach((r) => L.push(` ${String(r.qty).padStart(2)}× ${r.label}${r.len_mm ? ` (${(r.len_mm / 1000).toFixed(2)} m)` : ""}`));
});
return L.join("\n");
};
const [copied, setCopied] = useState(false);
const doCopy = () => { navigator.clipboard?.writeText(asText()).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1600); }); };
const doCSV = () => {
const head = "Qtd,Componente,Grupo,Comprimento_m\n";
const body = rows.map((r) => `${r.qty},"${r.label}","${r.group}",${r.len_mm ? (r.len_mm / 1000).toFixed(2) : ""}`).join("\n");
const blob = new Blob(["\uFEFF" + head + body], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "lista-materiais.csv"; a.click();
};
const Hi = ({ label, big, sub, accent }) => (
{label}
{big}
{sub &&
{sub}
}
);
return (
e.stopPropagation()} className="panel" style={{ width: "min(760px, 100%)", maxHeight: "88vh", display: "flex", flexDirection: "column", padding: 0, overflow: "hidden" }}>
{/* head */}
LISTA DE MATERIAIS
Quadro {presetLabel}
{dims.W} × {dims.H} × {dims.D} mm · L×A×P · ocupação {occ}%
{/* corpo rolável */}
{(mat.cont || mat.rele || mat.dps || mat.mccb || mat.borne_reguas) > 0 && (
{[["Disj. Geral (MCCB)", mat.mccb], ["DPS / Surto", mat.dps], ["Contatoras", mat.cont], ["Relés / Timer", mat.rele], ["Réguas de bornes", mat.borne_reguas], ["Vias de borne", mat.bornes]]
.filter(([, v]) => v > 0).map(([l, v]) => (
{l}: {v}
))}
)}
{/* tabela detalhada por grupo */}
{rows.length === 0 ? (
Nenhum componente na placa. Adicione itens para gerar a lista.
) : grouped.map(({ g, items }) => (
{g}
{items.map((r) => (
{r.qty}×
{r.label}
{r.len_mm ? `${(r.len_mm / 1000).toFixed(2)} m` : ""}
))}
))}
{/* rodapé */}
);
};
window.VL_QuadroMontagem = QuadroMontagem;