projet crepp_git/crepp-projects/tracker-solaire/hardware/v1_tht · branche main
export_board.py Voir sur GitLab
#!/usr/bin/env python3
"""
Génère, à la racine de CE repo (1 repo = 1 carte), le dossier _doc/ attendu
par generate_page.py :

  _doc/
    view-top.png               ← rendu du PCB, vue de dessus, avec composants (kicad-cli pcb render)
    view-bottom.png            ← rendu du PCB, vue de dessous, avec composants
    bom.csv                    ← liste du matériel complète (kicad-cli sch export bom), commune à toutes les variantes
    doc.html                  ← placeholder créé s'il est absent (document ICD à écrire à la main)
    gerbers/                  ← fichiers de fabrication (gerbers + perçage), communs à toutes les variantes
    gerbers.zip                ← archive de gerbers/ pour téléchargement en un clic
    variants/
      default/                ← toujours générée
        schematic.pdf
        assembly.html         ← vue recto/verso (SVG des couches F.Fab/B.Fab)
        ibom.html              ← BOM interactive (InteractiveHtmlBom)
        pcba.step              ← modèle 3D, avec composants (téléchargement)
        pcba.glb                ← modèle 3D, avec composants (vue interactive dans le navigateur)
      <autre-variante>/        ← si variants.json en déclare (voir variants.example.json)

Cherche automatiquement le *.kicad_pro / *.kicad_sch / *.kicad_pcb à la racine
du repo. Aucun paramètre requis dans le cas standard.

Usage :
  python3 scripts/export_board.py [--source .]
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

ASSEMBLY_LAYERS = {
    "top": "F.Fab,F.SilkS,Edge.Cuts",
    "bottom": "B.Fab,B.SilkS,Edge.Cuts",
}
GERBER_LAYERS = "F.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS,Edge.Cuts"

PLACEHOLDER_DOC = """<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">
<title>Documentation — {name}</title></head>
<body style="font-family:sans-serif;background:#0a1f17;color:#edeae0;padding:40px;">
<h1>{name}</h1>
<p>Documentation à rédiger. Remplace ce fichier par le vrai document ICD : _doc/doc.html</p>
</body></html>"""


def detect_model_vars(pcb: Path) -> list:
    """Repère les variables de chemin utilisées par les modèles 3D du PCB.

    Les empreintes référencent souvent leurs modèles via une variable de chemin
    KiCad personnalisée, ex. ${CREPP_3DMODEL_DIR}/CREPP_LEDs/WS2812C.STEP.
    Cette variable est définie dans les préférences KiCad de chaque poste
    (Préférences → Configurer les chemins), donc elle N'EXISTE PAS dans un
    conteneur Docker ni sur un runner CI : sans elle, kicad-cli ne trouve aucun
    modèle et exporte une carte nue (sans composants).
    """
    text = pcb.read_text(encoding="utf-8", errors="ignore")
    names = set()
    for m in re.finditer(r'\(model\s+"\$\{([^}]+)\}', text):
        names.add(m.group(1))
    return sorted(names)


def build_define_vars(pcb: Path) -> list:
    """Construit les arguments --define-var à partir de l'environnement.

    Pour chaque variable détectée dans le PCB (ex. CREPP_3DMODEL_DIR), on
    cherche une variable d'environnement du même nom. Si elle existe, on la
    passe à kicad-cli ; sinon on avertit clairement, car c'est la cause n°1 des
    composants absents en 3D.
    """
    args = []
    for name in detect_model_vars(pcb):
        value = os.environ.get(name)
        if value:
            args += ["--define-var", f"{name}={value}"]
            print(f"  [3D] {name} = {value}")
        else:
            print(f"  [3D] ⚠ variable {name} NON définie : les modèles 3D qui l'utilisent "
                  f"seront introuvables (composants absents des exports 3D).")
            print(f"       Définis-la avant de lancer, ex. : export {name}=/chemin/vers/les/modeles")
    return args


def run(cmd) -> bool:
    print("→", " ".join(str(c) for c in cmd))
    try:
        result = subprocess.run(cmd, capture_output=True, text=True)
    except (FileNotFoundError, OSError) as e:
        print(f"[avertissement] commande introuvable ({e}), on continue", file=sys.stderr)
        return False
    if result.returncode != 0:
        print(result.stdout)
        print(result.stderr, file=sys.stderr)
        print("[avertissement] échec de la commande ci-dessus, on continue")
        return False
    return True


def find_one(folder: Path, pattern: str):
    matches = sorted(folder.glob(pattern))
    return matches[0] if matches else None


def load_variants(repo: Path):
    cfg_file = repo / "variants.json"
    variants = ["default"]
    field = "Variant"
    if cfg_file.exists():
        try:
            cfg = json.loads(cfg_file.read_text(encoding="utf-8"))
            for v in cfg.get("variants", []):
                if v not in variants:
                    variants.append(v)
            field = cfg.get("field", field)
        except json.JSONDecodeError:
            print(f"[avertissement] {cfg_file} invalide (JSON), ignoré")
    return variants, field


def make_assembly_html(svg_top: str, svg_bottom: str, out_file: Path, board_name: str):
    out_file.write_text(f"""<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">
<title>Plan d'assemblage — {board_name}</title>
<style>
body{{margin:0;background:#0a1f17;color:#edeae0;font-family:sans-serif}}
.tabs{{display:flex;gap:8px;padding:12px;background:#123328}}
button{{background:none;border:1px solid #c87f4a;color:#edeae0;padding:6px 14px;cursor:pointer;font-size:12px}}
button.active{{background:#c87f4a;color:#0a1f17}}
.view{{padding:20px;text-align:center}}
.view svg{{max-width:100%;height:auto;background:#fff;border-radius:4px}}
</style></head><body>
<div class="tabs">
  <button class="active" onclick="show(this,'top')">Recto</button>
  <button onclick="show(this,'bottom')">Verso</button>
</div>
<div class="view" id="viewTop">{svg_top}</div>
<div class="view" id="viewBottom" style="display:none">{svg_bottom}</div>
<script>
function show(btn, side){{
  document.getElementById('viewTop').style.display = side==='top' ? 'block' : 'none';
  document.getElementById('viewBottom').style.display = side==='bottom' ? 'block' : 'none';
  document.querySelectorAll('.tabs button').forEach(b=>b.classList.remove('active'));
  btn.classList.add('active');
}}
</script></body></html>""", encoding="utf-8")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", default=".", help="racine du repo (défaut : .)")
    args = ap.parse_args()

    repo = Path(args.source).resolve()
    sch = find_one(repo, "*.kicad_sch")
    pcb = find_one(repo, "*.kicad_pcb")
    if not (sch and pcb):
        print("[erreur] .kicad_sch ou .kicad_pcb introuvable à la racine du repo", file=sys.stderr)
        sys.exit(1)

    pro = find_one(repo, "*.kicad_pro")
    board_name = pro.stem if pro else repo.name

    doc_dir = repo / "_doc"
    doc_dir.mkdir(exist_ok=True)

    if not (doc_dir / "doc.html").exists():
        (doc_dir / "doc.html").write_text(PLACEHOLDER_DOC.format(name=board_name), encoding="utf-8")

    # Variables de chemin des modèles 3D (ex. CREPP_3DMODEL_DIR) : sans elles,
    # kicad-cli ne trouve pas les modèles et exporte une carte SANS composants.
    print("Modèles 3D — variables de chemin détectées dans le PCB :")
    dv = build_define_vars(pcb)

    run(["kicad-cli", "pcb", "render", "--output", str(doc_dir / "view-top.png"),
         "--side", "top", "--quality", "high", "--width", "900", "--height", "700"]
        + dv + [str(pcb)])
    run(["kicad-cli", "pcb", "render", "--output", str(doc_dir / "view-bottom.png"),
         "--side", "bottom", "--quality", "high", "--width", "900", "--height", "700"]
        + dv + [str(pcb)])

    # BOM complète (tous les composants montés, hors DNP) : générée une fois au
    # niveau de la carte, comme les gerbers — kicad-cli ne permet pas de filtrer
    # cet export par variante (contrairement à InteractiveHtmlBom pour ibom.html).
    run(["kicad-cli", "sch", "export", "bom", "--output", str(doc_dir / "bom.csv"),
         "--fields", "Reference,Value,Footprint,Datasheet,MPN",
         "--group-by", "Value,Footprint", "--sort-field", "Reference",
         "--exclude-dnp", str(sch)])

    tmp_top = doc_dir / "_top.svg"
    tmp_bottom = doc_dir / "_bottom.svg"
    ok_top = run(["kicad-cli", "pcb", "export", "svg", "--output", str(tmp_top),
                  "--layers", ASSEMBLY_LAYERS["top"], str(pcb)])
    ok_bottom = run(["kicad-cli", "pcb", "export", "svg", "--output", str(tmp_bottom),
                     "--layers", ASSEMBLY_LAYERS["bottom"], str(pcb)])
    svg_top = tmp_top.read_text(encoding="utf-8") if ok_top and tmp_top.exists() else "<p>Vue non générée</p>"
    svg_bottom = tmp_bottom.read_text(encoding="utf-8") if ok_bottom and tmp_bottom.exists() else "<p>Vue non générée</p>"

    (doc_dir / "svg").mkdir(exist_ok=True)
    run(["kicad-cli", "sch", "export", "svg", "--output", str(doc_dir) + "/svg/", str(sch)])

    # Fichiers de fabrication (gerbers + perçage) : identiques pour toutes les
    # variantes (le routage ne change pas), générés une seule fois.
    gerbers_dir = doc_dir / "gerbers"
    gerbers_dir.mkdir(exist_ok=True)
    run(["kicad-cli", "pcb", "export", "gerbers", "--output", str(gerbers_dir) + "/",
         "--layers", GERBER_LAYERS, str(pcb)])
    run(["kicad-cli", "pcb", "export", "drill", "--output", str(gerbers_dir) + "/",
         "--format", "excellon", "--drill-origin", "absolute", "--excellon-units", "mm",
         "--generate-map", "--map-format", "pdf", str(pcb)])
    if any(gerbers_dir.iterdir()):
        (doc_dir / "gerbers.zip").unlink(missing_ok=True)
        shutil.make_archive(str(doc_dir / "gerbers"), "zip", root_dir=str(gerbers_dir))
    else:
        print("[avertissement] aucun gerber généré, vérifie les logs kicad-cli ci-dessus")

    variants, variant_field = load_variants(repo)

    for variant in variants:
        vdir = doc_dir / "variants" / variant
        vdir.mkdir(parents=True, exist_ok=True)

        run(["kicad-cli", "sch", "export", "pdf", "--output", str(vdir / "schematic.pdf"), str(sch)])
        run(["kicad-cli", "pcb", "export", "step", "--output", str(vdir / "pcba.step"),
             "--subst-models"] + dv + [str(pcb)])
        # GLB : même modèle 3D, dans un format lisible directement dans le
        # navigateur (via <model-viewer>) pour une vue interactive.
        run(["kicad-cli", "pcb", "export", "glb", "--output", str(vdir / "pcba.glb"),
             "--subst-models"] + dv + [str(pcb)])

        make_assembly_html(svg_top, svg_bottom, vdir / "assembly.html", board_name)

        ibom_cmd = ["generate_interactive_bom", "--no-browser",
                    "--dest-dir", str(vdir), "--name-format", "ibom"]
        if variant != "default":
            ibom_cmd += ["--variant-field", variant_field, "--variants-whitelist", variant]
        ibom_cmd.append(str(pcb))
        # pcbnew (utilisé par InteractiveHtmlBom) peut exiger un display même en
        # mode --no-browser sur Linux headless : on passe par xvfb-run si dispo.
        if shutil.which("xvfb-run"):
            ibom_cmd = ["xvfb-run", "--auto-servernum"] + ibom_cmd
        run(ibom_cmd)

    tmp_top.unlink(missing_ok=True)
    tmp_bottom.unlink(missing_ok=True)
    print(f"[ok] {board_name} : {len(variants)} variante(s) exportée(s)")


if __name__ == "__main__":
    main()