#!/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
sys.path.insert(0, str(Path(__file__).parent))
from kicad_cli_utils import resolve_kicad_cli, check_kicad_cli
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"
# Couches exportées individuellement en SVG pour le visualiseur de couches de
# la vitrine (_doc/svg/layers/<slug>.svg) : contrairement aux gerbers bruts,
# un SVG s'affiche directement dans le navigateur sans logiciel dédié.
# Edge.Cuts est ajouté à chaque couche pour garder le contour de la carte
# comme repère visuel.
SHOWCASE_LAYERS = [
("cuivre-dessus", "Cuivre — dessus", "F.Cu,Edge.Cuts"),
("cuivre-dessous", "Cuivre — dessous", "B.Cu,Edge.Cuts"),
("serigraphie-dessus", "Sérigraphie — dessus", "F.SilkS,Edge.Cuts"),
("serigraphie-dessous", "Sérigraphie — dessous", "B.SilkS,Edge.Cuts"),
("masque-dessus", "Masque de soudure — dessus", "F.Mask,Edge.Cuts"),
("masque-dessous", "Masque de soudure — dessous","B.Mask,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 check_ibom() -> tuple:
"""Vérifie que generate_interactive_bom (paquet pip InteractiveHtmlBom)
est utilisable : présent dans le PATH ET capable d'importer pcbnew (le
module Python interne de KiCad, indispensable pour lire le .kicad_pcb).
Ces deux conditions sont INDÉPENDANTES : le paquet peut être installé
(pip install InteractiveHtmlBom) sans que pcbnew soit importable depuis
ce même interpréteur — cause la plus fréquente d'échec silencieux, et
différente pour chaque type d'installation KiCad :
- Install native (paquet système) : pcbnew est dans le python3 système
SI InteractiveHtmlBom a été installé avec ce même python3 (pas un
venv isolé, sauf à y exposer aussi pcbnew).
- Flatpak : pcbnew tourne DANS le sandbox Flatpak, invisible à un pip
installé sur l'hôte — il faudrait exécuter generate_interactive_bom
à l'intérieur du sandbox, ce qui sort du cadre de ce script.
- Image Docker CI (kicad/kicad:X, voir .gitlab-ci.yml) : ça marche
car cette image fournit déjà le bon python3 avec pcbnew inclus.
"""
exe = shutil.which("generate_interactive_bom")
if not exe:
return False, (
"generate_interactive_bom introuvable dans le PATH — installe "
"le paquet : pip install InteractiveHtmlBom "
"(pip install --break-system-packages ... en CI)."
)
try:
result = subprocess.run([exe, "--help"], capture_output=True, text=True, timeout=15)
except Exception as e:
return False, f"Erreur lors du test de generate_interactive_bom : {e}"
combined = (result.stdout or "") + (result.stderr or "")
if result.returncode != 0 or "No module named 'pcbnew'" in combined or "ModuleNotFoundError" in combined:
return False, (
"generate_interactive_bom est installé mais ne peut pas importer 'pcbnew'.\n"
"Cause la plus fréquente : InteractiveHtmlBom a été installé (pip) dans un "
"interpréteur Python différent de celui utilisé par KiCad — pcbnew n'est "
"visible que depuis le Python interne de l'installation KiCad (voir "
"check_ibom() ci-dessus pour le détail par type d'install).\n"
f"Détail : {combined.strip()[:300]}"
)
return True, f"generate_interactive_bom OK ({exe})"
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 export_layer_svgs(pcb: Path, out_dir: Path, kicad_cli: list) -> list[dict]:
"""Exporte chaque couche de SHOWCASE_LAYERS en SVG individuel (contour
Edge.Cuts inclus), pour le visualiseur de couches de la vitrine — pas
besoin d'un logiciel de gerbers pour se faire une idée du PCB en ligne."""
out_dir.mkdir(parents=True, exist_ok=True)
exported = []
for slug, label, layer_arg in SHOWCASE_LAYERS:
svg_path = out_dir / f"{slug}.svg"
ok = run(kicad_cli + ["pcb", "export", "svg", "--output", str(svg_path),
"--layers", layer_arg, str(pcb)])
if ok and svg_path.exists():
exported.append({"slug": slug, "label": label})
else:
print(f"[avertissement] export SVG de la couche '{label}' échoué, ignorée")
return exported
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()
# Résolution cross-plateforme de kicad-cli (natif, Flatpak, PATH restreint
# d'un processus GUI-lancé...) — voir kicad_cli_utils.py. On échoue vite
# et clairement ici plutôt que de laisser chaque appel kicad-cli échouer
# un par un plus loin avec juste un avertissement.
ok, msg = check_kicad_cli()
print(msg)
if not ok:
print("[erreur] kicad-cli introuvable, impossible de continuer.", file=sys.stderr)
sys.exit(1)
KICAD_CLI = resolve_kicad_cli()
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)])
# Fichier de placement (pick-and-place) : données d'assemblage lisibles
# directement en CSV, sans ouvrir KiCad. Généré AVANT le zip ci-dessous
# pour être inclus dedans (comme le font la plupart des fabricants).
run(KICAD_CLI + ["pcb", "export", "pos", "--output", str(gerbers_dir / "positions.csv"),
"--side", "both", "--format", "csv", "--units", "mm", 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")
# Vérifications qualité (DRC PCB + ERC schéma) : rapports JSON, seule
# info qu'on ne peut vraiment pas obtenir sans ouvrir KiCad et relancer
# les checks soi-même. On n'utilise PAS --exit-code-violations : on veut
# le rapport même s'il y a des violations, pas faire échouer l'export.
run(KICAD_CLI + ["pcb", "drc", "--output", str(doc_dir / "drc-report.json"),
"--format", "json", "--severity-all", str(pcb)])
run(KICAD_CLI + ["sch", "erc", "--output", str(doc_dir / "erc-report.json"),
"--format", "json", "--severity-all", str(sch)])
# DXF mécanique (contour + niveau fab) : pour un logiciel de CAO
# mécanique (boîtier, découpe), sans passer par KiCad.
run(KICAD_CLI + ["pcb", "export", "dxf", "--output", str(doc_dir / "board-mechanical.dxf"),
"--layers", "Edge.Cuts,F.Fab", str(pcb)])
# Couches individuelles en SVG (visualiseur de couches de la vitrine) —
# dans un sous-dossier de _doc/svg/ pour ne pas être confondues avec les
# SVG de schémas exportés juste au-dessus (find_svg_for_sheet, côté
# generate_icd.py, ne regarde que les fichiers directement dans _doc/svg/).
layer_svgs = export_layer_svgs(pcb, doc_dir / "svg" / "layers", KICAD_CLI)
print(f"[ok] {len(layer_svgs)}/{len(SHOWCASE_LAYERS)} couche(s) exportée(s) en SVG")
variants, variant_field = load_variants(repo)
ibom_ok, ibom_msg = check_ibom()
print(ibom_msg)
if not ibom_ok:
print("[avertissement] ibom.html ne sera généré pour aucune variante (voir message ci-dessus)")
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)])
# PDF 3D interactif : même modèle, mais lisible dans un simple
# lecteur PDF (Acrobat Reader) sans AUCUN logiciel de CAO — encore
# plus accessible que le STEP pour quelqu'un qui veut juste
# inspecter la carte en 3D.
run(KICAD_CLI + ["pcb", "export", "3dpdf", "--output", str(vdir / "pcba-3d.pdf"),
"--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))
# 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
if ibom_ok:
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()