#!/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()