Spaces:
Runtime error
Runtime error
| import streamlit as st | |
| import json | |
| import math | |
| import matplotlib.pyplot as plt | |
| import matplotlib.patches as patches | |
| import io | |
| import csv | |
| import os | |
| from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image as RLImage, Table, TableStyle, PageBreak | |
| from reportlab.lib.styles import getSampleStyleSheet | |
| from reportlab.lib import colors | |
| from reportlab.lib.pagesizes import letter | |
| # ========================================== | |
| # CONFIGURAÇÃO GERAL | |
| # ========================================== | |
| st.set_page_config(page_title="SGP - Gestão de Embalagem v21.0", layout="wide", page_icon="🏭") | |
| FITAS = [ | |
| {"nome": "Fita 1.20", "tamanho": 1.20}, {"nome": "Fita 1.40", "tamanho": 1.40}, | |
| {"nome": "Fita 1.50", "tamanho": 1.50}, {"nome": "Fita 1.60", "tamanho": 1.60}, | |
| {"nome": "Fita 1.70", "tamanho": 1.70}, {"nome": "Fita 1.80", "tamanho": 1.80}, | |
| {"nome": "Fita 1.85", "tamanho": 1.85}, {"nome": "Fita 1.90", "tamanho": 1.90}, | |
| {"nome": "Fita 2.50", "tamanho": 2.50}, {"nome": "Fita 3.00", "tamanho": 3.00}, | |
| {"nome": "Fita 3.50", "tamanho": 3.50}, {"nome": "Fita 4.00", "tamanho": 4.00} | |
| ] | |
| MADEIRAS_PADRAO = [370, 400, 500] | |
| DB_FILE = 'catalogo.json' | |
| DENSIDADE_ACO = 7.85e-6 | |
| def load_db(): | |
| if not os.path.exists(DB_FILE): return {"IT13": {}, "IT14": {}} | |
| try: | |
| with open(DB_FILE, 'r') as f: return json.load(f) | |
| except: return {"IT13": {}, "IT14": {}} | |
| def save_db(new_db): | |
| with open(DB_FILE, 'w') as f: json.dump(new_db, f, indent=4) | |
| st.cache_data.clear() | |
| DB = load_db() | |
| # ========================================== | |
| # LÓGICA DE CÁLCULO | |
| # ========================================== | |
| class PackageCalculator: | |
| def _parse_layout(self, layout_str): | |
| try: return [int(x) for x in layout_str.split('-') if x.strip().isdigit()] | |
| except: return [] | |
| def _escolher_fita(self, perimetro_m): | |
| p_margem = perimetro_m * 1.10 | |
| for f in FITAS: | |
| if f["tamanho"] >= p_margem: return f["nome"], f["tamanho"] | |
| return "Sob Medida", round(p_margem, 2) | |
| def _escolher_madeira(self, w): | |
| for m in MADEIRAS_PADRAO: | |
| if m >= w: return m | |
| return MADEIRAS_PADRAO[-1] | |
| def _buscar_no_banco(self, tipo, l, a, e): | |
| target_db = DB["IT13"] if tipo == "Redondo" else DB["IT14"] | |
| melhor_vizinho = None | |
| menor_distancia = float('inf') | |
| for key, item in target_db.items(): | |
| try: | |
| parts = [float(x) for x in key.split('_')] | |
| if tipo == "Redondo": | |
| db_l, db_e = parts[0], parts[1] | |
| if math.isclose(l, db_l, abs_tol=0.1) and math.isclose(e, db_e, abs_tol=0.05): return item, None | |
| if math.isclose(l, db_l, abs_tol=0.1): | |
| dist = abs(e - db_e) | |
| if dist < menor_distancia: menor_distancia, melhor_vizinho = dist, item | |
| else: | |
| db_l, db_a, db_e = parts[0], parts[1], parts[2] | |
| match_n = math.isclose(l, db_l, abs_tol=0.1) and math.isclose(a, db_a, abs_tol=0.1) | |
| match_i = math.isclose(l, db_a, abs_tol=0.1) and math.isclose(a, db_l, abs_tol=0.1) | |
| if (match_n or match_i) and math.isclose(e, db_e, abs_tol=0.05): return item, None | |
| if (match_n or match_i): | |
| dist = abs(e - db_e) | |
| if dist < menor_distancia: menor_distancia, melhor_vizinho = dist, item | |
| except: continue | |
| return None, melhor_vizinho | |
| def simulate_wood_scenarios(self, l, a, e, c, tipo, peso_unit): | |
| scenarios = {} | |
| for madeira_size in MADEIRAS_PADRAO: | |
| if tipo == "Redondo": base = int(madeira_size / l) | |
| else: base = int(madeira_size / l) | |
| if base < 1: | |
| scenarios[madeira_size] = None | |
| continue | |
| qtd_alvo = int(1000 / peso_unit) if peso_unit > 0 else 1 | |
| if tipo == "Redondo": | |
| layout = [] | |
| curr = 0 | |
| while curr < qtd_alvo: | |
| take = base if len(layout)%2==0 else base-1 | |
| if (qtd_alvo - curr) < take: take = qtd_alvo - curr | |
| layout.append(take); curr += take | |
| pkg = self._build_pkg(l, a, e, c, tipo, 0, curr, curr*peso_unit, f"Simulação {madeira_size}", True, layout, 0, 0, False, True, 6.60) | |
| pkg['madeira_real'] = madeira_size | |
| else: | |
| ga = math.ceil(qtd_alvo / base) | |
| num = base * ga | |
| pkg = self._build_pkg(l, a, e, c, tipo, 0, num, num*peso_unit, f"Simulação {madeira_size}", False, None, base, ga, False, True, 6.60) | |
| pkg['madeira_real'] = madeira_size | |
| scenarios[madeira_size] = pkg | |
| return scenarios | |
| def calculate(self, modo, meta, l, a, e, c, tipo, acabamento, aba=0): | |
| l, a, e = float(l), float(a), float(e) | |
| is_galv = (acabamento == "Galvanizado (GI/GA)") | |
| wood_h = 6.60 if is_galv else 0.0 | |
| item_exato, item_vizinho = self._buscar_no_banco(tipo, l, a, e) | |
| if tipo == "Redondo": area = math.pi * ((l/2)**2 - (l/2-e)**2) | |
| elif tipo == "Enrijecido": area = (l + 2*a + 2*aba - 4*e) * e | |
| else: area = (l + 2*a - 2*e) * e | |
| peso_unit = area * c * DENSIDADE_ACO | |
| # --- CENÁRIO 1: NORMA --- | |
| pkg_norma = None | |
| item_ref = item_exato or item_vizinho | |
| if item_ref: | |
| origem_ref = "Catálogo (Exato)" if item_exato else "Catálogo (Vizinho)" | |
| num_ref = item_ref['qtde'] | |
| if item_ref.get('sextavado'): | |
| layout_n = self._parse_layout(item_ref['padrao']) | |
| pkg_norma = self._build_pkg(l, a, e, c, tipo, aba, num_ref, num_ref*peso_unit, origem_ref, True, layout_n, 0, 0, False, is_galv, wood_h) | |
| elif tipo == "Redondo": | |
| gl, ga = item_ref.get('ml', 0), item_ref.get('ma', 0) | |
| pkg_norma = self._build_pkg(l, a, e, c, tipo, aba, num_ref, num_ref*peso_unit, origem_ref, False, None, gl, ga, False, is_galv, wood_h) | |
| else: | |
| gl, ga = item_ref.get('ml', 0), item_ref.get('ma', 0) | |
| pkg_norma = self._build_pkg(l, a, e, c, tipo, aba, num_ref, num_ref*peso_unit, origem_ref, False, None, gl, ga, False, is_galv, wood_h) | |
| # --- CENÁRIO 2: IDEAL --- | |
| base_ideal = 0 | |
| override_base = False | |
| target_pecas = pkg_norma['num'] if pkg_norma else int(1000/peso_unit) | |
| if target_pecas == 0: target_pecas = 1 | |
| if is_galv and tipo != "Redondo": | |
| if e <= 1.25: base_ideal, override_base = 10, True | |
| elif 1.55 <= e <= 1.95: base_ideal, override_base = 8, True | |
| if is_galv and not override_base: | |
| base_ideal = int(500 / l) | |
| elif not override_base: | |
| if pkg_norma and pkg_norma['gl'] > 0: base_ideal = pkg_norma['gl'] | |
| else: base_ideal = int(math.sqrt(target_pecas)) | |
| if base_ideal < 1: base_ideal = 1 | |
| if tipo == "Redondo": | |
| layout_ideal, curr = [], 0 | |
| while curr < target_pecas: | |
| take = base_ideal if len(layout_ideal)%2==0 else base_ideal-1 | |
| if (target_pecas - curr) < take: take = target_pecas - curr | |
| layout_ideal.append(take); curr += take | |
| pkg_ideal = self._build_pkg(l, a, e, c, tipo, aba, curr, curr*peso_unit, "Ideal (Físico)", True, layout_ideal, 0, 0, False, is_galv, wood_h) | |
| else: | |
| gl_ideal = base_ideal | |
| ga_ideal = math.ceil(target_pecas / gl_ideal) | |
| num_ideal = gl_ideal * ga_ideal | |
| pkg_ideal = self._build_pkg(l, a, e, c, tipo, aba, num_ideal, num_ideal*peso_unit, "Ideal (Físico)", False, None, gl_ideal, ga_ideal, False, is_galv, wood_h) | |
| # --- SIMULAÇÕES EXTRAS (Se Galv) --- | |
| simulations = {} | |
| if is_galv: | |
| simulations = self.simulate_wood_scenarios(l, a, e, c, tipo, peso_unit) | |
| # Totais | |
| main_ref = pkg_ideal | |
| if modo == "Toneladas": total_pcs = int((meta*1000)/peso_unit) if peso_unit>0 else 0 | |
| else: total_pcs = int(meta) | |
| qtd_pcts = total_pcs // main_ref['num'] if main_ref['num'] > 0 else 0 | |
| sobra = total_pcs % main_ref['num'] if main_ref['num'] > 0 else 0 | |
| sobra_pkg = None | |
| if sobra > 0: | |
| if pkg_ideal['sext']: | |
| rem, s_lay = sobra, [] | |
| b = pkg_ideal['layout'][0] if pkg_ideal['layout'] else 5 | |
| while rem > 0: | |
| tk = min(rem, b if len(s_lay)%2==0 else b-1) | |
| s_lay.append(tk); rem -= tk | |
| sobra_pkg = self._build_pkg(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", True, s_lay, 0, 0, True, is_galv, wood_h) | |
| else: | |
| s_ga = math.ceil(sobra/pkg_ideal['gl']) | |
| sobra_pkg = self._build_pkg(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", False, None, pkg_ideal['gl'], s_ga, True, is_galv, wood_h) | |
| return { | |
| "norma": pkg_norma, "ideal": pkg_ideal, "sobra": sobra_pkg, | |
| "simulations": simulations, | |
| "total_pcts": qtd_pcts, "peso_total": (qtd_pcts * main_ref['peso']) + (sobra_pkg['peso'] if sobra_pkg else 0) | |
| } | |
| def _build_pkg(self, l, a, e, c, tipo, aba, num, peso, orig, sext, layout, gl, ga, is_rem, is_galv, wood_h): | |
| info = {"l":l, "a":a, "e":e, "c":c, "tipo":tipo, "aba":aba, "num":num, "peso":peso, "origem":orig, "sext":sext, "layout":layout, "gl":gl, "ga":ga, "is_rem":is_rem, "is_galv":is_galv, "wood_h":wood_h, "wood_w": 48.20} | |
| if sext: | |
| r = layout or [] | |
| mc = max(r) if r else 1 | |
| info["larg_total"] = mc * l | |
| info["alt_total"] = l + (len(r)-1)*l*math.sin(math.radians(60)) | |
| if is_galv: | |
| cm = max(0, len(r)-1) | |
| info["alt_total"] += cm * wood_h | |
| info["qtd_madeiras"] = cm * 5 | |
| else: info["qtd_madeiras"] = 0 | |
| info["layout_str"] = "-".join(map(str, r)) if r else "" | |
| else: | |
| info["larg_total"] = gl*l + (aba if tipo=="Enrijecido" else 0) | |
| h_base = (ga * l) if tipo=="Redondo" else (ga * a) | |
| if is_galv: | |
| cm = max(0, ga-1) | |
| info["alt_total"] = h_base + cm*wood_h | |
| info["qtd_madeiras"] = cm * 5 | |
| else: | |
| info["alt_total"] = h_base | |
| info["qtd_madeiras"] = 0 | |
| info["layout_str"] = f"{gl} x {ga}" | |
| info["madeira_real"] = self._escolher_madeira(info["larg_total"]) if is_galv else 0 | |
| pt = (info["larg_total"] + info["alt_total"])*2/1000 | |
| info["fita_nome"], info["fita_tam"] = self._escolher_fita(pt) | |
| info["fita_meio_nome"], info["fita_meio_tam"] = "N/A", 0 | |
| if not is_galv: | |
| pm = (info["larg_total"] + info["alt_total"]/2)*2/1000 | |
| info["fita_meio_nome"], info["fita_meio_tam"] = self._escolher_fita(pm) | |
| info["desc_fitas"] = "4 Fitas + 1 Meio" | |
| else: info["desc_fitas"] = "5 Fitas Totais" | |
| return info | |
| # ========================================== | |
| # VISUALIZAÇÃO | |
| # ========================================== | |
| def plot_cross_section(info): | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| l, a, e = info['l'], info['a'], info['e'] | |
| wood_h = info['wood_h'] | |
| is_galv = info['is_galv'] | |
| wood_len = info.get("madeira_real", 0) | |
| total_w = info['larg_total'] | |
| fc = 'lightblue' if not info['is_rem'] else 'lightcoral' | |
| ec = 'black' | |
| if info['sext']: | |
| rows = info['layout'] | |
| rad = l/2; dy = l*0.866 | |
| for i, c in enumerate(rows): | |
| x0 = (total_w - c*l)/2 | |
| y = rad + i*dy + i*wood_h | |
| if is_galv and i>0: | |
| wx = (total_w - wood_len)/2 | |
| ax.add_patch(patches.Rectangle((wx, y-rad-wood_h), wood_len, wood_h, fc='peru', ec='brown')) | |
| for k in range(c): | |
| cx = x0 + k*l + rad | |
| ax.add_patch(patches.Circle((cx, y), rad, fc=fc, ec=ec)) | |
| ax.add_patch(patches.Circle((cx, y), rad-e, fc='white', ec=ec, lw=0.5)) | |
| else: | |
| gl, ga = info['gl'], info['ga'] | |
| drawn = 0 | |
| rad = l/2; dy = l*0.866 | |
| for i in range(ga): | |
| if info['tipo']=="Redondo": y = i*(l+wood_h); cy = y + l/2 | |
| else: y = i*(a+wood_h); tube_y = y | |
| if is_galv and i>0: | |
| wy = y-wood_h | |
| wx = (total_w - wood_len)/2 | |
| ax.add_patch(patches.Rectangle((wx, wy), wood_len, wood_h, fc='peru', ec='brown')) | |
| for j in range(gl): | |
| if drawn >= info['num']: break | |
| x = j*l | |
| if info['tipo']=="Redondo": | |
| cx = x + rad | |
| ax.add_patch(patches.Circle((cx, cy), rad, fc=fc, ec=ec)) | |
| ax.add_patch(patches.Circle((cx, cy), rad-e, fc='white', ec=ec, lw=0.5)) | |
| else: | |
| ax.add_patch(patches.Rectangle((x, tube_y), l, a, fc=fc, ec=ec)) | |
| ax.add_patch(patches.Rectangle((x+e, tube_y+e), l-2*e, a-2*e, fc='white', ec=ec, lw=0.5)) | |
| drawn+=1 | |
| margin = max(total_w, info['alt_total']) * 0.15 | |
| ax.add_patch(patches.Rectangle((-2, -2), total_w+4, info['alt_total']+4, fill=False, ec='red', ls='--', lw=1)) | |
| if not info['sext']: | |
| ax.text(total_w/2, info['alt_total']+margin*0.2, f"BASE: {info['gl']}", ha='center', color='blue', weight='bold', fontsize=10) | |
| ax.text(-margin*0.2, info['alt_total']/2, f"ALT: {info['ga']}", ha='right', va='center', color='blue', weight='bold', rotation=90, fontsize=10) | |
| if is_galv and wood_len < total_w: | |
| ax.text(total_w/2, info['alt_total']/2, "MADEIRA CURTA!", ha='center', color='red', weight='bold', fontsize=14, rotation=45) | |
| ax.text(total_w/2, -margin/2, f"L: {total_w:.1f}mm", ha='center') | |
| ax.text(-margin/2, info['alt_total']/2, f"A: {info['alt_total']:.1f}mm", ha='center', rotation=90) | |
| if is_galv: ax.text(total_w/2, -margin, f"Madeira: {wood_len}mm", ha='center', color='brown', weight='bold') | |
| ax.set_xlim(-margin, total_w+margin); ax.set_ylim(-margin, info['alt_total']+margin) | |
| ax.set_aspect('equal'); ax.axis('off') | |
| plt.title(f"{info['origem']} | {info['num']} Pçs", fontsize=10) | |
| return fig | |
| def plot_side_view(info): | |
| fig, ax = plt.subplots(figsize=(8, 2)) | |
| c, h = info['c'], info['alt_total'] | |
| ax.add_patch(patches.Rectangle((0,0), c, h, fc='lightgray', ec='black')) | |
| if info['is_galv']: | |
| lays = info['ga'] if not info['sext'] else len(info['layout']) | |
| for i in range(1, lays): | |
| if info['sext']: y = i*(info['l']*0.866 + info['wood_h']) - info['wood_h'] | |
| else: | |
| y = i*(info['l']+info['wood_h']) - info['wood_h'] if info['tipo']=="Redondo" else i*(info['a']+info['wood_h']) - info['wood_h'] | |
| for p in [0.1, 0.3, 0.5, 0.7, 0.9]: | |
| ax.add_patch(patches.Rectangle((c*p-24, y), 48, info['wood_h'], fc='peru')) | |
| for idx, p in enumerate([0.1, 0.3, 0.5, 0.7, 0.9]): | |
| col = 'orange' if (not info['is_galv'] and idx==2) else 'purple' | |
| ls = '--' if col=='orange' else '-' | |
| to_h = h/2 if col=='orange' else h | |
| ax.plot([c*p, c*p], [0, to_h], color=col, lw=2, ls=ls) | |
| ax.set_xlim(-c*0.05, c*1.05); ax.set_ylim(0, h*1.2); ax.axis('off') | |
| plt.title("Vista Lateral", fontsize=8) | |
| return fig | |
| def fig_to_img(fig, w=400, h=300): | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format='png', dpi=100, bbox_inches='tight') | |
| buf.seek(0); plt.close(fig) | |
| return RLImage(buf, width=w, height=h, kind='proportional') | |
| def generate_pdf(res): | |
| buffer = io.BytesIO() | |
| doc = SimpleDocTemplate(buffer, pagesize=letter) | |
| styles = getSampleStyleSheet() | |
| story = [] | |
| m = res['ideal'] | |
| story.append(Paragraph(f"Ficha Técnica: {m['tipo']} {m['l']}x{m['a']} #{m['e']}", styles['Title'])) | |
| story.append(Spacer(1, 12)) | |
| acab = "Galvanizado (Com Madeira)" if m['is_galv'] else "Preto (Normal)" | |
| fita = f"4x {m['fita_nome']} ({m['fita_tam']}m)" | |
| if m['fita_meio_tam'] > 0: fita += f" + 1x {m['fita_meio_nome']} ({m['fita_meio_tam']}m)" | |
| else: fita = f"5x {m['fita_nome']} ({m['fita_tam']}m)" | |
| wood = f"{m['qtd_madeiras']} pçs (Ref: {m.get('madeira_real', 'N/A')}mm)" if m['is_galv'] else "N/A" | |
| data = [ | |
| ["Item", f"{m['l']}x{m['a']} #{m['e']}mm"], | |
| ["Acabamento", acab], | |
| ["Pacotes", f"{res['total_pcts']} (Padrão) + {1 if res['sobra'] else 0} (Sobra)"], | |
| ["Peso Padrão", f"{m['peso']:.2f} kg"], | |
| ["Cinta", fita], | |
| ["Madeiras", wood] | |
| ] | |
| t = Table(data, colWidths=[150, 300]) | |
| t.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.black), ('BACKGROUND', (0,0), (0,-1), colors.lightgrey)])) | |
| story.append(t); story.append(Spacer(1, 20)) | |
| story.append(Paragraph("Visualização Pacote Ideal (Produção)", styles['Heading2'])) | |
| story.append(fig_to_img(plot_cross_section(m))) | |
| story.append(fig_to_img(plot_side_view(m), h=150)) | |
| # ADICIONADO: SIMULAÇÃO DE MADEIRAS NO PDF | |
| if m['is_galv'] and res.get('simulations'): | |
| story.append(PageBreak()) | |
| story.append(Paragraph("Simulação de Cenários de Madeira", styles['Heading2'])) | |
| sim_data = [] | |
| headers = [] | |
| imgs = [] | |
| for mad_size, scen in res['simulations'].items(): | |
| headers.append(f"Madeira {mad_size}mm") | |
| if scen: | |
| img = fig_to_img(plot_cross_section(scen), w=180, h=150) | |
| imgs.append(img) | |
| else: | |
| imgs.append(Paragraph("Não Compatível", styles['Normal'])) | |
| sim_data = [headers, imgs] | |
| t_sim = Table(sim_data, colWidths=[190, 190, 190]) | |
| t_sim.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.black), ('ALIGN', (0,0), (-1,-1), 'CENTER')])) | |
| story.append(t_sim) | |
| if res['norma']: | |
| story.append(PageBreak()) | |
| story.append(Paragraph("Comparativo: O que diz a Norma (Tabela)", styles['Heading2'])) | |
| story.append(fig_to_img(plot_cross_section(res['norma']))) | |
| if res['sobra']: | |
| story.append(PageBreak()) | |
| story.append(Paragraph(f"Pacote de Sobra ({res['sobra']['num']} pçs)", styles['Heading2'])) | |
| story.append(fig_to_img(plot_cross_section(res['sobra']))) | |
| doc.build(story) | |
| buffer.seek(0) | |
| return buffer | |
| def generate_csv(res): | |
| output = io.StringIO() | |
| writer = csv.writer(output) | |
| m = res['ideal'] | |
| writer.writerow(["Tipo", "L", "A", "E", "Acab", "Qtd Pcts", "Pecas/Pct", "Peso", "Fita", "Madeira"]) | |
| acab = "GI/GA" if m['is_galv'] else "Normal" | |
| writer.writerow([m['tipo'], m['l'], m['a'], m['e'], acab, res['total_pcts'], m['num'], m['peso'], m['fita_nome'], m.get('madeira_real', 0)]) | |
| return output.getvalue() | |
| # ========================================== | |
| # INTERFACE | |
| # ========================================== | |
| st.title("🏭 SGP - Gestão de Embalagem v21.0") | |
| with st.sidebar: | |
| st.header("Parâmetros") | |
| tipo = st.selectbox("Perfil", ["Quadrado/Retangular", "Redondo", "U/Enrijecido"]) | |
| acabamento = st.radio("Acabamento", ["Preto (Normal)", "Galvanizado (GI/GA)"]) | |
| c1, c2 = st.columns(2) | |
| l = c1.number_input("L/Diam", value=50.0) | |
| a = c2.number_input("Altura", value=50.0, disabled=(tipo=="Redondo")) | |
| c3, c4 = st.columns(2) | |
| e = c3.number_input("Espessura", value=2.00) | |
| c = c4.number_input("Comp", value=6000) | |
| aba = st.number_input("Aba (U)", value=0.0, disabled=(tipo!="U/Enrijecido")) | |
| meta = st.number_input("Meta", value=1000) | |
| modo = st.selectbox("Unidade", ["Peças", "Toneladas"]) | |
| btn = st.button("CALCULAR", type="primary") | |
| if btn: | |
| calc = PackageCalculator() | |
| t_map = "Enrijecido" if tipo == "U/Enrijecido" else tipo | |
| if tipo == "Quadrado/Retangular": t_map = "Quadrado/Retangular" | |
| res = calc.calculate(modo, meta, l, a, e, c, t_map, acabamento, aba) | |
| if res['norma']: | |
| st.info("ℹ️ Encontrado na Norma. A seguir, o cálculo Ideal (Físico).") | |
| # 2. Painel Principal (Recomendado) | |
| st.subheader("✅ Configuração Recomendada") | |
| m = res['ideal'] | |
| k1, k2, k3, k4 = st.columns(4) | |
| k1.metric("Pacotes", res['total_pcts']) | |
| k2.metric("Pçs/Pct", m['num']) | |
| k3.metric("Peso", f"{m['peso']:.0f}kg") | |
| k4.metric("Madeira", f"{m.get('madeira_real', 0)}mm" if m['is_galv'] else "N/A") | |
| c_main, c_side = st.columns([1,1]) | |
| with c_main: st.pyplot(plot_cross_section(m)) | |
| with c_side: | |
| st.pyplot(plot_side_view(m)) | |
| if res['sobra']: st.warning(f"Sobra: {res['sobra']['num']} pçs"); st.pyplot(plot_cross_section(res['sobra'])) | |
| # 3. SIMULADOR DE MADEIRAS (Mostra no site e agora no PDF) | |
| if m['is_galv']: | |
| st.markdown("---") | |
| st.subheader("🪵 Simulador por Tamanho de Madeira") | |
| cols = st.columns(3) | |
| sims = res['simulations'] | |
| for i, mad_size in enumerate(MADEIRAS_PADRAO): | |
| with cols[i]: | |
| st.markdown(f"### Madeira {mad_size}mm") | |
| scenario = sims.get(mad_size) | |
| if scenario: | |
| st.caption(f"{scenario['num']} Pçs | {scenario['peso']:.0f}kg | {scenario['gl']}x{scenario['ga']}") | |
| st.pyplot(plot_cross_section(scenario)) | |
| else: | |
| st.error("❌ Não cabe") | |
| # 4. Downloads | |
| st.markdown("---") | |
| c1, c2 = st.columns(2) | |
| c1.download_button("📄 PDF Relatório Completo", generate_pdf(res), "relatorio.pdf", "application/pdf") | |
| c2.download_button("📊 CSV Dados", generate_csv(res), "dados.csv", "text/csv") |