/* 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 {paths[name]}; }; /* =========================================================== 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 (
{/* placa de montagem (cyan) */} {ovl && ovl.visible && ovl.src && ( )} {ovl && !ovl.locked && ovl.visible && ( )} {vLines.map((gx) => )} {hLines.map((gy) => )} PLACA DE MONTAGEM · {plate.w} × {plate.h} mm {[...items].sort((a, b) => ((QM_LIB()[a.type]?.family === "rail" ? 0 : 1) - (QM_LIB()[b.type]?.family === "rail" ? 0 : 1))).map((it) => { const def = QM_LIB()[it.type]; if (!def) return null; 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 (
DESENHO TÉCNICO · 3 VISTAS

Frontal · Interna · Lateral

COTAS GRADE TAGS TRAÇO setLw(+e.target.value)} style={{ width: 90, accentColor: "var(--accent)" }} />
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)" }} />
)}
Posição X
{item.x}mm
Posição Y
{item.y}mm
); }; /* =========================================================== LISTA DE MATERIAIS =========================================================== */ const QmBom = ({ bom, total, occ, mat }) => (
LISTA DE MATERIAIS
Componentes
{total}
Ocupação
{occ}%
{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;