# Spectrum 28 bandes — Plan d'implémentation des livrables

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Produire, à partir du spec `docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md`, tous les fichiers dont Yan a besoin pour construire la tour : dessins cotés, gabarit papier 1:1, fichiers DXF, projets KiCad + Gerbers des deux PCB, BOM, guide d'assemblage.

**Architecture:** Un seul module de paramètres (`params.py`, classe `Geom`) porte toutes les cotes du spec, y compris les trois valeurs à mesurer (épaisseur des rondelles, longueur des barres d'alu, bagues des pots). Tous les générateurs (DXF, PDF, KiCad, BOM) lisent `Geom` et se régénèrent d'une commande (`build.sh`) quand les mesures arrivent. Chaque générateur est une fonction pure « Geom → fichier » testée avec pytest.

**Tech Stack:** Python 3.13 (`/usr/local/bin/python3`, qui voit aussi le module `pcbnew`), ezdxf 1.4 (DXF), reportlab 5 (PDF 1:1), matplotlib 3.11 (dessins cotés), KiCad 7.0.11 (`pcbnew` Python + `kicad-cli`) avec `kicad-footprints` installés dans `/usr/share/kicad/footprints`, poppler (`pdfinfo`, `pdftotext`) pour les tests, pytest 9, git.

## Global Constraints

- Toutes les cotes internes en **mm** ; l'impérial n'apparaît que dans les textes (ex. « 1½ po »). `IN = 25.4`.
- Les valeurs nominales à mesurer restent des paramètres : `t_rondelle = 1.65`, `l_barre = 1524.0`, `d_bague_pot = 7.5`, `d_bague_inter = 6.5`. Aucun générateur ne code en dur une valeur dérivée de celles-ci.
- Règles de longueur du spec §5.4 : marge tour 25 si `l_barre ≥ 1481` sinon 10 ; fente 5/16 po si `l_barre ≥ 1451` sinon ¼ po ; zone de contrôle minimum 150.
- Géométrie du spec (§3) : bloc 38,1 × 12,7 × 63,5 ; entraxe tiges 19,05 ; trous bloc Ø 7,14 ; trous plaque Ø 7,94 ; trous câble Ø 12,7 ; plaque 127 de profond ; face avant des blocs à y = 20 ; câbles à y = 93 ; barre du haut 76,2 de large ; alu 3,175 d'épais ; 28 colonnes × 20 rangées ; 4 rondelles par joint.
- Barrette (spec §6.2) : largeur 20, longueur `19·p + 23`, LED à `11,5 + k·p`, JST-XH coudés en face arrière, 330 Ω en série sur DATA IN, 100 nF par LED, masque noir, 2 couches. Broches JST : 1 = +5V, 2 = DATA, 3 = GND.
- Backplane (spec §6.3) : 321,3 × 30, 7 prises JST-XH verticales à `22,52 + k·46,04`, IN/OUT aux bouts, 8 cavaliers à souder (patrons A/B), bornier 5,08, 1000 µF, SN74AHCT1G125 optionnel avec cavalier de contournement, 4 trous M3.
- Fichiers KiCad : construits avec `pcbnew` (`CreateEmptyBoard`), **sauvegardés puis rechargés avant de remplir les zones** (le `ZONE_FILLER` plante sur une carte non sauvegardée en 7.0.11), `fp.Flip()` seulement **après** `board.Add(fp)`.
- DRC : zéro violation de sévérité `error`, zéro élément non connecté. Les avertissements sont tolérés.
- Langue des livrables : français (Québec). Code et identifiants en anglais/français mixte acceptés ; commentaires en français.
- Dépôt git local `/home/claude/spectrum` ; commit à la fin de chaque tâche. Les sorties générées vont dans `out/` (versionné, c'est le livrable).

---

## Structure des fichiers

```
/home/claude/spectrum/
  params.py                      # Geom : toutes les cotes + dérivées (§3 du spec)
  build.sh                       # régénère out/ au complet
  drawings/__init__.py
  drawings/dxf_parts.py          # DXF : bloc, plaque du dessus, barre du haut
  drawings/template_1to1.py      # PDF 1:1 tuilé lettre (reportlab)
  drawings/views.py              # PDF cotés matplotlib : bloc+gabarit, plaque, assemblage
  pcb/__init__.py
  pcb/kicad_helpers.py           # enveloppe pcbnew : Board, place_by_pad, track, via, zone, DRC, export
  pcb/gen_barrette.py            # génère out/pcb/barrette-28b/
  pcb/gen_backplane.py           # génère out/pcb/backplane-28b/
  docs/gen_bom.py                # génère out/BOM.md et out/BOM.csv depuis Geom
  docs/GUIDE-ASSEMBLAGE.md       # statique
  tests/test_params.py
  tests/test_dxf.py
  tests/test_template.py
  tests/test_views.py
  tests/test_pcb_barrette.py
  tests/test_pcb_backplane.py
  tests/test_bom.py
  out/                           # livrables générés
```

---

### Task 1: Dépôt, paramètres `Geom` et tests

**Files:**
- Create: `params.py`
- Create: `tests/test_params.py`
- Create: `.gitignore`, `pytest.ini`

**Interfaces:**
- Produces: `params.IN: float`, `params.Geom` (dataclass gelée) avec les propriétés `marge_tour, fente, pas_col, entretoise, pas_rang, largeur_tour, zone_tour, zone_controle, hauteur_pile, y_tiges, y_face_arriere, longueur_tige, barrette_long, z_bas_barrette, blocs_par_bande, n_blocs, bandes_necessaires, n_rondelles, n_tiges, l_base, x_controle` et les méthodes `x_col(c) -> float`, `z_rang(n) -> float`, `tiges() -> list[tuple[float,float]]`, `cables() -> list[tuple[float,float]]`, `trous_bloc() -> list[tuple[float,float]]`, `trous_controle() -> list[tuple[float,float,float]]`.

- [ ] **Step 1: Initialiser le dépôt**

```bash
cd /home/claude/spectrum
git init -q
printf '__pycache__/\n*.pyc\n.pytest_cache/\n*-backups/\nfp-info-cache\n' > .gitignore
printf '[pytest]\ntestpaths = tests\n' > pytest.ini
git add -A && git commit -qm "chore: dépôt spectrum, spec et plan" && git log --oneline | head -1
```

- [ ] **Step 2: Écrire le test des paramètres (qui échoue)**

Créer `tests/test_params.py` :

```python
import math
import pytest
from params import Geom, IN


def test_nominal_geometry_matches_spec():
    g = Geom()
    assert g.fente == pytest.approx(7.9375)
    assert g.pas_col == pytest.approx(46.0375)
    assert g.entretoise == pytest.approx(6.6)
    assert g.pas_rang == pytest.approx(19.3)
    assert g.largeur_tour == pytest.approx(27 * 46.0375 + 38.1)
    assert g.zone_tour == pytest.approx(1331.1125)
    assert g.hauteur_pile == pytest.approx(392.6)
    assert g.x_col(1) == pytest.approx(44.05)
    assert g.x_col(28) == pytest.approx(44.05 + 27 * 46.0375)
    assert g.z_rang(1) == pytest.approx(12.95)
    assert g.z_rang(20) == pytest.approx(12.95 + 19 * 19.3)
    assert g.y_tiges == pytest.approx(51.75)
    assert g.y_face_arriere == pytest.approx(83.5)
    assert g.barrette_long == pytest.approx(19 * 19.3 + 23)
    assert g.z_bas_barrette == pytest.approx(1.45)


def test_counts():
    g = Geom()
    assert g.n_blocs == 560
    assert g.blocs_par_bande == 59
    assert g.bandes_necessaires == 10
    assert g.n_tiges == 56
    assert len(g.tiges()) == 56
    assert len(g.cables()) == 28
    assert g.n_rondelles == 4 * 21 * 56 + 112
    assert g.longueur_tige == pytest.approx(3.175 + 7 + 392.6 + 3.175 + 10 + 16)


def test_tige_and_cable_positions():
    g = Geom()
    t = g.tiges()
    assert t[0] == pytest.approx((44.05 - 9.525, 51.75))
    assert t[1] == pytest.approx((44.05 + 9.525, 51.75))
    assert t[-1] == pytest.approx((g.x_col(28) + 9.525, 51.75))
    assert g.cables()[0] == pytest.approx((44.05, 93.0))
    h = g.trous_bloc()
    assert h[0] == pytest.approx((9.525, 31.75))
    assert h[1] == pytest.approx((28.575, 31.75))


def test_pas_rang_follows_measured_washer():
    g = Geom(t_rondelle=1.52)
    assert g.pas_rang == pytest.approx(12.7 + 4 * 1.52)
    assert g.barrette_long == pytest.approx(19 * (12.7 + 6.08) + 23)


def test_length_rules():
    assert Geom(l_barre=1524).marge_tour == 25
    assert Geom(l_barre=1524).fente == pytest.approx(5 / 16 * IN)
    assert Geom(l_barre=1470).marge_tour == 10
    assert Geom(l_barre=1470).fente == pytest.approx(5 / 16 * IN)
    assert Geom(l_barre=1440).marge_tour == 10
    assert Geom(l_barre=1440).fente == pytest.approx(0.25 * IN)
    assert Geom(l_barre=1524).zone_controle == pytest.approx(1524 - 1331.1125)
    assert Geom(l_barre=1524).l_base == pytest.approx(1524)


def test_control_zone_holes():
    g = Geom()
    holes = g.trous_controle()
    assert len(holes) == 5
    xs = [h[0] for h in holes]
    assert all(g.zone_tour < x < g.l_base for x in xs)
    assert holes[0][2] == pytest.approx(7.5)
    assert holes[4][2] == pytest.approx(6.5)
```

- [ ] **Step 3: Lancer le test, vérifier l'échec**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_params.py -q 2>&1 | tail -3`
Expected: `ModuleNotFoundError: No module named 'params'`

- [ ] **Step 4: Écrire `params.py`**

```python
"""Cotes du Spectrum 28 bandes (spec docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md).

Toutes les cotes sont en mm. Les quatre premiers champs sont des mesures à
remplacer par les valeurs réelles avant de régénérer les livrables.
"""
from __future__ import annotations

import math
from dataclasses import dataclass
from functools import cached_property

IN = 25.4


@dataclass(frozen=True)
class Geom:
    # --- À MESURER (nominal en attendant) -----------------------------------
    t_rondelle: float = 1.65        # épaisseur moyenne d'une rondelle ¼ SAE (pile de 40 ÷ 40)
    l_barre: float = 60 * IN        # longueur réelle des barres d'aluminium ⅛ × 5 po
    d_bague_pot: float = 7.5        # Ø trou pour la bague des potentiomètres
    d_bague_inter: float = 6.5      # Ø trou pour la bague de l'interrupteur
    pas_pots: float = 22.0          # entraxe des 4 pots sur la carte PLATINUM (à mesurer)
    y_controles: float = 40.0       # distance bord avant → axe des pots sur le dessus de la base
    # --- Fixes (spec §3) ----------------------------------------------------
    bloc_l: float = 1.5 * IN
    bloc_h: float = 0.5 * IN
    bloc_p: float = 2.5 * IN
    n_col: int = 28
    n_rang: int = 20
    n_rond: int = 4
    entraxe_tiges: float = 0.75 * IN
    d_trou_bloc: float = 9 / 32 * IN
    d_trou_plaque: float = 5 / 16 * IN
    d_trou_cable: float = 0.5 * IN
    plaque_prof: float = 5 * IN
    y_face_avant: float = 20.0
    y_cable: float = 93.0
    ep_alu: float = IN / 8
    barre_haut_larg: float = 3 * IN
    zone_controle_min: float = 150.0
    marge_barrette: float = 11.5
    barrette_larg: float = 20.0
    kerf_scie: float = IN / 8
    bande_long: float = 96 * IN
    rechange_blocs: int = 30

    # --- Règles de longueur (spec §5.4) --------------------------------------
    @cached_property
    def marge_tour(self) -> float:
        return 25.0 if self.l_barre >= 1481 else 10.0

    @cached_property
    def fente(self) -> float:
        return (5 / 16) * IN if self.l_barre >= 1451 else 0.25 * IN

    # --- Dérivées ------------------------------------------------------------
    @property
    def pas_col(self) -> float:
        return self.bloc_l + self.fente

    @property
    def entretoise(self) -> float:
        return self.n_rond * self.t_rondelle

    @property
    def pas_rang(self) -> float:
        return self.bloc_h + self.entretoise

    @property
    def largeur_tour(self) -> float:
        return (self.n_col - 1) * self.pas_col + self.bloc_l

    @property
    def zone_tour(self) -> float:
        return self.largeur_tour + 2 * self.marge_tour

    @property
    def l_base(self) -> float:
        return self.l_barre

    @property
    def zone_controle(self) -> float:
        return self.l_base - self.zone_tour

    @property
    def x_controle(self) -> float:
        """Centre de la zone de contrôle (à droite de la tour)."""
        return self.zone_tour + self.zone_controle / 2

    @property
    def hauteur_pile(self) -> float:
        return self.n_rang * self.bloc_h + (self.n_rang + 1) * self.entretoise

    @property
    def y_tiges(self) -> float:
        return self.y_face_avant + self.bloc_p / 2

    @property
    def y_face_arriere(self) -> float:
        return self.y_face_avant + self.bloc_p

    @property
    def longueur_tige(self) -> float:
        # plaque + écrou/rondelle dessous + pile + barre du haut + écrou borgne + marge
        return self.ep_alu + 7.0 + self.hauteur_pile + self.ep_alu + 10.0 + 16.0

    @property
    def barrette_long(self) -> float:
        return (self.n_rang - 1) * self.pas_rang + 2 * self.marge_barrette

    @property
    def z_bas_barrette(self) -> float:
        return self.z_rang(1) - self.marge_barrette

    @property
    def n_blocs(self) -> int:
        return self.n_col * self.n_rang

    @property
    def blocs_par_bande(self) -> int:
        return int((self.bande_long + self.kerf_scie) // (self.bloc_l + self.kerf_scie))

    @property
    def bandes_necessaires(self) -> int:
        return math.ceil((self.n_blocs + self.rechange_blocs) / self.blocs_par_bande)

    @property
    def n_tiges(self) -> int:
        return 2 * self.n_col

    @property
    def n_rondelles(self) -> int:
        # 4 par joint, 21 joints par tige, + 2 par tige sous les écrous (bas et haut)
        return self.n_rond * (self.n_rang + 1) * self.n_tiges + 2 * self.n_tiges

    # --- Positions -----------------------------------------------------------
    def x_col(self, c: int) -> float:
        """Centre de la colonne c (1..28) depuis le bout gauche de la base."""
        return self.marge_tour + self.bloc_l / 2 + (c - 1) * self.pas_col

    def z_rang(self, n: int) -> float:
        """Centre de la rangée n (1..20) au-dessus du dessus de la plaque de base."""
        return self.entretoise + self.bloc_h / 2 + (n - 1) * self.pas_rang

    def tiges(self) -> list[tuple[float, float]]:
        return [
            (self.x_col(c) + s * self.entraxe_tiges / 2, self.y_tiges)
            for c in range(1, self.n_col + 1)
            for s in (-1, +1)
        ]

    def cables(self) -> list[tuple[float, float]]:
        return [(self.x_col(c), self.y_cable) for c in range(1, self.n_col + 1)]

    def trous_bloc(self) -> list[tuple[float, float]]:
        """Trous du bloc : (x depuis la face latérale gauche, y depuis la face avant)."""
        return [
            (self.bloc_l / 2 - self.entraxe_tiges / 2, self.bloc_p / 2),
            (self.bloc_l / 2 + self.entraxe_tiges / 2, self.bloc_p / 2),
        ]

    def trous_controle(self) -> list[tuple[float, float, float]]:
        """(x, y, Ø) des 4 pots et de l'interrupteur sur le dessus de la base."""
        xs = [self.x_controle + (i - 2.0) * self.pas_pots for i in range(4)]
        holes = [(x, self.y_controles, self.d_bague_pot) for x in xs]
        holes.append((self.x_controle + 2.0 * self.pas_pots, self.y_controles, self.d_bague_inter))
        return holes
```

- [ ] **Step 5: Lancer les tests, vérifier le succès**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_params.py -q 2>&1 | tail -3`
Expected: `6 passed`

- [ ] **Step 6: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "feat: paramètres Geom du spectrum 28 bandes" && git log --oneline | head -1
```

---

### Task 2: Fichiers DXF (bloc, plaque du dessus, barre du haut)

**Files:**
- Create: `drawings/__init__.py` (vide)
- Create: `drawings/dxf_parts.py`
- Create: `tests/test_dxf.py`

**Interfaces:**
- Consumes: `params.Geom`
- Produces: `dxf_bloc(g, path) -> str`, `dxf_plaque_dessus(g, path) -> str`, `dxf_barre_haut(g, path) -> str` (retournent le chemin écrit). Calques : `CONTOUR` (polyligne fermée), `TROUS_TIGES`, `TROUS_CABLES`, `TROUS_CONTROLES`, `TEXTE`.

- [ ] **Step 1: Test qui échoue**

Créer `tests/test_dxf.py` :

```python
import ezdxf
import pytest
from params import Geom
from drawings.dxf_parts import dxf_bloc, dxf_plaque_dessus, dxf_barre_haut


def circles(path, layer):
    msp = ezdxf.readfile(path).modelspace()
    return [c for c in msp.query("CIRCLE") if c.dxf.layer == layer]


def test_dxf_bloc(tmp_path):
    g = Geom()
    p = dxf_bloc(g, str(tmp_path / "bloc.dxf"))
    cs = circles(p, "TROUS_TIGES")
    assert len(cs) == 2
    assert sorted(c.dxf.center.x for c in cs) == pytest.approx([9.525, 28.575])
    assert all(c.dxf.center.y == pytest.approx(31.75) for c in cs)
    assert all(c.dxf.radius == pytest.approx(g.d_trou_bloc / 2) for c in cs)
    poly = ezdxf.readfile(p).modelspace().query("LWPOLYLINE[layer=='CONTOUR']")
    assert len(poly) == 1 and poly[0].closed
    xs = [pt[0] for pt in poly[0].get_points()]
    ys = [pt[1] for pt in poly[0].get_points()]
    assert max(xs) == pytest.approx(38.1) and max(ys) == pytest.approx(63.5)


def test_dxf_plaque_dessus(tmp_path):
    g = Geom()
    p = dxf_plaque_dessus(g, str(tmp_path / "plaque.dxf"))
    assert len(circles(p, "TROUS_TIGES")) == 56
    assert len(circles(p, "TROUS_CABLES")) == 28
    assert len(circles(p, "TROUS_CONTROLES")) == 5
    first = min(circles(p, "TROUS_TIGES"), key=lambda c: c.dxf.center.x)
    assert first.dxf.center.x == pytest.approx(44.05 - 9.525)
    assert first.dxf.radius == pytest.approx(g.d_trou_plaque / 2)
    poly = ezdxf.readfile(p).modelspace().query("LWPOLYLINE[layer=='CONTOUR']")[0]
    xs = [pt[0] for pt in poly.get_points()]
    ys = [pt[1] for pt in poly.get_points()]
    assert max(xs) == pytest.approx(g.l_base) and max(ys) == pytest.approx(127.0)


def test_dxf_barre_haut(tmp_path):
    g = Geom()
    p = dxf_barre_haut(g, str(tmp_path / "barre.dxf"))
    assert len(circles(p, "TROUS_TIGES")) == 56
    assert len(circles(p, "TROUS_CABLES")) == 0
    poly = ezdxf.readfile(p).modelspace().query("LWPOLYLINE[layer=='CONTOUR']")[0]
    xs = [pt[0] for pt in poly.get_points()]
    ys = [pt[1] for pt in poly.get_points()]
    assert max(xs) == pytest.approx(g.zone_tour) and max(ys) == pytest.approx(76.2)
    # les trous de la barre sont au même x que ceux de la plaque, et y mesuré depuis
    # le bord avant de la barre (bord avant de la barre = face avant des blocs)
    c = min(circles(p, "TROUS_TIGES"), key=lambda c: c.dxf.center.x)
    assert c.dxf.center.y == pytest.approx(g.y_tiges - g.y_face_avant)
```

- [ ] **Step 2: Vérifier l'échec**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_dxf.py -q 2>&1 | tail -3`
Expected: `ModuleNotFoundError: No module named 'drawings'`

- [ ] **Step 3: Écrire `drawings/dxf_parts.py`**

```python
"""Fichiers DXF (R2010, mm) des pièces à percer/couper.

Origine : coin avant-gauche de chaque pièce, X vers la droite, Y vers l'arrière.
"""
from __future__ import annotations

import ezdxf

from params import Geom

LAYERS = {
    "CONTOUR": 7,
    "TROUS_TIGES": 1,
    "TROUS_CABLES": 3,
    "TROUS_CONTROLES": 5,
    "TEXTE": 8,
}


def _doc():
    doc = ezdxf.new("R2010", setup=True)
    doc.header["$INSUNITS"] = 4  # mm
    for name, color in LAYERS.items():
        doc.layers.add(name, color=color)
    return doc


def _rect(msp, w, h):
    msp.add_lwpolyline([(0, 0), (w, 0), (w, h), (0, h)], close=True, dxfattribs={"layer": "CONTOUR"})


def _holes(msp, pts, d, layer):
    for x, y in pts:
        msp.add_circle((x, y), d / 2, dxfattribs={"layer": layer})
        # croix de centrage pour le pointeau
        msp.add_line((x - d, y), (x + d, y), dxfattribs={"layer": layer})
        msp.add_line((x, y - d), (x, y + d), dxfattribs={"layer": layer})


def dxf_bloc(g: Geom, path: str) -> str:
    doc = _doc()
    msp = doc.modelspace()
    _rect(msp, g.bloc_l, g.bloc_p)
    _holes(msp, g.trous_bloc(), g.d_trou_bloc, "TROUS_TIGES")
    msp.add_text(
        f"BLOC {g.bloc_l:.1f} x {g.bloc_p:.1f} x {g.bloc_h:.1f} mm, 2 trous D{g.d_trou_bloc:.2f}",
        dxfattribs={"layer": "TEXTE", "height": 2.0},
    ).set_placement((0, -5))
    doc.saveas(path)
    return path


def dxf_plaque_dessus(g: Geom, path: str) -> str:
    doc = _doc()
    msp = doc.modelspace()
    _rect(msp, g.l_base, g.plaque_prof)
    _holes(msp, g.tiges(), g.d_trou_plaque, "TROUS_TIGES")
    _holes(msp, g.cables(), g.d_trou_cable, "TROUS_CABLES")
    for x, y, d in g.trous_controle():
        _holes(msp, [(x, y)], d, "TROUS_CONTROLES")
    msp.add_line((g.zone_tour, 0), (g.zone_tour, g.plaque_prof), dxfattribs={"layer": "TEXTE"})
    msp.add_text(
        f"PLAQUE DU DESSUS {g.l_base:.0f} x {g.plaque_prof:.0f} mm - 56 trous D{g.d_trou_plaque:.2f} - "
        f"28 trous D{g.d_trou_cable:.1f} - zone tour {g.zone_tour:.1f} - zone controle {g.zone_controle:.1f}",
        dxfattribs={"layer": "TEXTE", "height": 4.0},
    ).set_placement((0, -8))
    doc.saveas(path)
    return path


def dxf_barre_haut(g: Geom, path: str) -> str:
    doc = _doc()
    msp = doc.modelspace()
    _rect(msp, g.zone_tour, g.barre_haut_larg)
    pts = [(x, y - g.y_face_avant) for x, y in g.tiges()]
    _holes(msp, pts, g.d_trou_plaque, "TROUS_TIGES")
    msp.add_text(
        f"BARRE DU HAUT {g.zone_tour:.1f} x {g.barre_haut_larg:.1f} mm - 56 trous D{g.d_trou_plaque:.2f} - "
        "bord avant = face avant des blocs",
        dxfattribs={"layer": "TEXTE", "height": 4.0},
    ).set_placement((0, -8))
    doc.saveas(path)
    return path
```

Créer `drawings/__init__.py` vide : `touch drawings/__init__.py`.

- [ ] **Step 4: Vérifier le succès**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_dxf.py -q 2>&1 | tail -3`
Expected: `3 passed`

- [ ] **Step 5: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "feat: DXF bloc, plaque du dessus, barre du haut" && git log --oneline | head -1
```

---

### Task 3: Gabarit papier 1:1 tuilé (PDF lettre)

**Files:**
- Create: `drawings/template_1to1.py`
- Create: `tests/test_template.py`

**Interfaces:**
- Consumes: `params.Geom`
- Produces: `tuiles(longueur: float, pas: float = 240.0) -> list[float]` (origines x des pages), `mm2pt(v: float) -> float`, `pdf_gabarit_plaque(g, path) -> str`.

- [ ] **Step 1: Test qui échoue**

Créer `tests/test_template.py` :

```python
import subprocess
import pytest
from params import Geom
from drawings.template_1to1 import tuiles, mm2pt, pdf_gabarit_plaque


def test_tuiles():
    assert tuiles(1524) == [0.0, 240.0, 480.0, 720.0, 960.0, 1200.0, 1440.0]
    assert tuiles(240) == [0.0]
    assert tuiles(241) == [0.0, 240.0]


def test_scale():
    assert mm2pt(25.4) == pytest.approx(72.0)
    assert mm2pt(46.0375) == pytest.approx(130.5, abs=0.05)


def test_pdf_pages_and_text(tmp_path):
    g = Geom()
    p = pdf_gabarit_plaque(g, str(tmp_path / "gabarit.pdf"))
    info = subprocess.run(["pdfinfo", p], capture_output=True, text=True).stdout
    assert "Pages:" in info
    pages = int([l for l in info.splitlines() if l.startswith("Pages:")][0].split()[1])
    assert pages == len(tuiles(g.l_base))
    size = [l for l in info.splitlines() if l.startswith("Page size:")][0]
    assert "792 x 612" in size  # lettre paysage, points
    txt = subprocess.run(["pdftotext", p, "-"], capture_output=True, text=True).stdout
    assert "ECHELLE 100 mm" in txt
    assert "Page 1 / " in txt
    assert "IMPRIMER A 100 %" in txt
```

- [ ] **Step 2: Vérifier l'échec**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_template.py -q 2>&1 | tail -3`
Expected: `ModuleNotFoundError: No module named 'drawings.template_1to1'`

- [ ] **Step 3: Écrire `drawings/template_1to1.py`**

```python
"""Gabarit papier à l'échelle 1:1 de la plaque du dessus, tuilé sur des pages lettre paysage.

Chaque page couvre 240 mm de longueur utile (+ 19 mm de recouvrement à droite).
On colle les pages bord à bord sur les traits de raccord, puis on colle le tout
sur la plaque d'aluminium et sur la barre du haut (serrées ensemble) pour pointer
les 56 trous de tiges, les 28 trous de câbles et les trous de contrôle.
"""
from __future__ import annotations

import math

from reportlab.lib.pagesizes import landscape, letter
from reportlab.pdfgen import canvas

from params import Geom

PT_PER_MM = 72.0 / 25.4
MARGE = 10.0          # marge d'impression, mm
PAS_TUILE = 240.0     # avancée par page, mm
LARG_UTILE = 259.0    # largeur dessinée par page, mm (240 + recouvrement)


def mm2pt(v: float) -> float:
    return v * PT_PER_MM


def tuiles(longueur: float, pas: float = PAS_TUILE) -> list[float]:
    n = max(1, math.ceil(longueur / pas))
    return [i * pas for i in range(n)]


def _croix(c, x, y, d):
    c.line(x - d, y, x + d, y)
    c.line(x, y - d, x, y + d)


def pdf_gabarit_plaque(g: Geom, path: str) -> str:
    page_w, page_h = landscape(letter)
    c = canvas.Canvas(path, pagesize=(page_w, page_h))
    c.setTitle("Gabarit 1:1 plaque du dessus - Spectrum 28 bandes")
    pages = tuiles(g.l_base)
    y0 = mm2pt(MARGE + 30)  # la plaque est dessinée à 30 mm au-dessus de la marge (place pour le texte)

    for i, x0 in enumerate(pages):
        c.saveState()
        c.translate(mm2pt(MARGE) - mm2pt(x0), y0)
        # zone de dessin de cette page : x0 .. x0 + LARG_UTILE (clip)
        p = c.beginPath()
        p.rect(mm2pt(x0), -mm2pt(5), mm2pt(LARG_UTILE), mm2pt(g.plaque_prof + 10))
        c.clipPath(p, stroke=0)
        # contour de la plaque
        c.setLineWidth(1.0)
        c.rect(0, 0, mm2pt(g.l_base), mm2pt(g.plaque_prof))
        # limite zone tour / zone de contrôle
        c.setDash(3, 3)
        c.line(mm2pt(g.zone_tour), 0, mm2pt(g.zone_tour), mm2pt(g.plaque_prof))
        c.setDash()
        # trous
        c.setLineWidth(0.5)
        for (x, y), d in [(t, g.d_trou_plaque) for t in g.tiges()] + [(t, g.d_trou_cable) for t in g.cables()]:
            c.circle(mm2pt(x), mm2pt(y), mm2pt(d / 2))
            _croix(c, mm2pt(x), mm2pt(y), mm2pt(d))
        for x, y, d in g.trous_controle():
            c.circle(mm2pt(x), mm2pt(y), mm2pt(d / 2))
            _croix(c, mm2pt(x), mm2pt(y), mm2pt(d))
        # numéros de colonnes
        c.setFont("Helvetica", 7)
        for col in range(1, g.n_col + 1):
            c.drawCentredString(mm2pt(g.x_col(col)), mm2pt(g.plaque_prof - 6), str(col))
        # traits de raccord (gauche = x0, droite = x0 + PAS_TUILE)
        c.setLineWidth(0.3)
        c.setDash(1, 2)
        for xr in (x0, x0 + PAS_TUILE):
            c.line(mm2pt(xr), -mm2pt(5), mm2pt(xr), mm2pt(g.plaque_prof + 5))
        c.setDash()
        c.restoreState()

        # cartouche
        c.setFont("Helvetica-Bold", 11)
        c.drawString(mm2pt(MARGE), mm2pt(MARGE + 18), f"GABARIT 1:1 PLAQUE DU DESSUS  -  Page {i + 1} / {len(pages)}  -  x de {x0:.0f} a {x0 + PAS_TUILE:.0f} mm")
        c.setFont("Helvetica", 9)
        c.drawString(mm2pt(MARGE), mm2pt(MARGE + 12),
                     "IMPRIMER A 100 % (sans ajustement a la page). Coller les pages bord a bord sur les traits de raccord pointilles.")
        c.drawString(mm2pt(MARGE), mm2pt(MARGE + 7),
                     f"Trous tiges D{g.d_trou_plaque:.2f} mm x56, trous cables D{g.d_trou_cable:.1f} mm x28, "
                     f"pots D{g.d_bague_pot:.1f} x4, interrupteur D{g.d_bague_inter:.1f}. Pas colonne {g.pas_col:.2f} mm.")
        # barre d'échelle 100 mm
        xs = mm2pt(MARGE)
        ys = mm2pt(MARGE + 2)
        c.setLineWidth(1.0)
        c.line(xs, ys, xs + mm2pt(100), ys)
        _croix(c, xs, ys, mm2pt(2))
        _croix(c, xs + mm2pt(100), ys, mm2pt(2))
        c.drawString(xs + mm2pt(102), ys - 3, "ECHELLE 100 mm (verifier a la regle)")
        c.showPage()
    c.save()
    return path
```

- [ ] **Step 4: Vérifier le succès**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_template.py -q 2>&1 | tail -3`
Expected: `3 passed`

- [ ] **Step 5: Contrôle visuel**

Run:
```bash
cd /home/claude/spectrum && python3 -c "
from params import Geom; from drawings.template_1to1 import pdf_gabarit_plaque
pdf_gabarit_plaque(Geom(), '/tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit.pdf')" && pdftoppm -r 50 -png -f 1 -l 1 /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit.pdf /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit
```
Puis ouvrir `/tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit-1.png` avec Read : on doit voir le contour de la plaque, 5 colonnes de 2 trous + 1 trou de câble, le cartouche et la barre d'échelle. Corriger les chevauchements de texte s'il y en a.

- [ ] **Step 6: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "feat: gabarit papier 1:1 tuilé de la plaque du dessus" && git log --oneline | head -1
```

---

### Task 4: Dessins cotés (bloc + gabarit de perçage, plaque/barre, assemblage)

**Files:**
- Create: `drawings/views.py`
- Create: `tests/test_views.py`

**Interfaces:**
- Consumes: `params.Geom`
- Produces: `pdf_bloc_et_gabarit(g, path) -> str`, `pdf_plaque_et_barre(g, path) -> str`, `pdf_assemblage(g, path) -> str` (PDF multi-pages matplotlib, texte extractible).

- [ ] **Step 1: Test qui échoue**

Créer `tests/test_views.py` :

```python
import subprocess
from params import Geom
from drawings.views import pdf_bloc_et_gabarit, pdf_plaque_et_barre, pdf_assemblage


def text_of(p):
    return subprocess.run(["pdftotext", "-layout", p, "-"], capture_output=True, text=True).stdout


def pages_of(p):
    info = subprocess.run(["pdfinfo", p], capture_output=True, text=True).stdout
    return int([l for l in info.splitlines() if l.startswith("Pages:")][0].split()[1])


def test_bloc_et_gabarit(tmp_path):
    p = pdf_bloc_et_gabarit(Geom(), str(tmp_path / "bloc.pdf"))
    t = text_of(p)
    assert pages_of(p) == 2
    assert "38,1" in t and "63,5" in t and "12,7" in t
    assert "9,525" in t and "31,75" in t and "7,14" in t
    assert "180" in t  # gabarit : tourner le bloc de 180°


def test_plaque_et_barre(tmp_path):
    g = Geom()
    p = pdf_plaque_et_barre(g, str(tmp_path / "plaque.pdf"))
    t = text_of(p)
    assert pages_of(p) == 2
    assert "46,04" in t and "19,05" in t and "51,75" in t and "93" in t
    assert f"{g.zone_tour:.1f}".replace(".", ",") in t


def test_assemblage(tmp_path):
    g = Geom()
    p = pdf_assemblage(g, str(tmp_path / "asm.pdf"))
    t = text_of(p)
    assert pages_of(p) == 3
    assert f"{g.largeur_tour:.1f}".replace(".", ",") in t
    assert f"{g.hauteur_pile:.1f}".replace(".", ",") in t
    assert "19,3" in t  # pas_rang nominal, format fr()
    assert "83,5" in t and "85,5" in t
```

- [ ] **Step 2: Vérifier l'échec**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_views.py -q 2>&1 | tail -3`
Expected: `ModuleNotFoundError: No module named 'drawings.views'`

- [ ] **Step 3: Écrire `drawings/views.py`**

```python
"""Dessins cotés (PDF, matplotlib). Virgule décimale, cotes en mm sauf mention."""
from __future__ import annotations

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt  # noqa: E402
from matplotlib.backends.backend_pdf import PdfPages  # noqa: E402
from matplotlib.patches import Circle, Rectangle  # noqa: E402

from params import Geom  # noqa: E402


def fr(v: float, nd: int = 2) -> str:
    return f"{v:.{nd}f}".rstrip("0").rstrip(".").replace(".", ",")


def cote(ax, x1, y1, x2, y2, texte, off=0.0, vertical=False, fs=7):
    """Ligne de cote avec flèches et texte. off décale la ligne de cote."""
    if vertical:
        ax.annotate("", (x1 + off, y1), (x1 + off, y2), arrowprops=dict(arrowstyle="<->", lw=0.6))
        ax.plot([x1, x1 + off], [y1, y1], lw=0.3, color="k")
        ax.plot([x2, x2 + off], [y2, y2], lw=0.3, color="k")
        ax.text(x1 + off, (y1 + y2) / 2, " " + texte, fontsize=fs, va="center", ha="left")  # horizontal : pdftotext le lit
    else:
        ax.annotate("", (x1, y1 + off), (x2, y1 + off), arrowprops=dict(arrowstyle="<->", lw=0.6))
        ax.plot([x1, x1], [y1, y1 + off], lw=0.3, color="k")
        ax.plot([x2, x2], [y2, y2 + off], lw=0.3, color="k")
        ax.text((x1 + x2) / 2, y1 + off, texte, fontsize=fs, va="bottom", ha="center")


def _page(pdf, titre, sous_titre=""):
    fig = plt.figure(figsize=(11, 8.5))
    fig.text(0.02, 0.97, titre, fontsize=13, weight="bold", va="top")
    if sous_titre:
        fig.text(0.02, 0.935, sous_titre, fontsize=8, va="top")
    fig.text(0.98, 0.02, "Spectrum 28 bandes - toutes cotes en mm", fontsize=7, ha="right")
    return fig


def _rect(ax, x, y, w, h, **kw):
    ax.add_patch(Rectangle((x, y), w, h, fill=False, lw=kw.pop("lw", 1.0), **kw))


def pdf_bloc_et_gabarit(g: Geom, path: str) -> str:
    with PdfPages(path) as pdf:
        # Page 1 : le bloc, vue de dessus + vue de face
        fig = _page(pdf, "Bloc d'acrylique - vue de dessus et vue de face",
                    f"{g.n_blocs} blocs + {g.rechange_blocs} de rechange = {g.bandes_necessaires} bandes de 96 po "
                    f"({g.blocs_par_bande} blocs par bande, lame {fr(g.kerf_scie)}). Bloc symétrique : aucune orientation à respecter.")
        ax = fig.add_axes([0.08, 0.35, 0.5, 0.55])
        ax.set_aspect("equal"); ax.axis("off")
        _rect(ax, 0, 0, g.bloc_l, g.bloc_p)
        for x, y in g.trous_bloc():
            ax.add_patch(Circle((x, y), g.d_trou_bloc / 2, fill=False))
            ax.plot([x - 4, x + 4], [y, y], lw=0.3, color="k"); ax.plot([x, x], [y - 4, y + 4], lw=0.3, color="k")
        cote(ax, 0, 0, g.bloc_l, 0, f"{fr(g.bloc_l)} (1½ po)", off=-8)
        cote(ax, 0, 0, 0, g.bloc_p, f"{fr(g.bloc_p)} (2½ po)", off=-8, vertical=True)
        h = g.trous_bloc()
        cote(ax, 0, g.bloc_p, h[0][0], g.bloc_p, f"{fr(h[0][0], 3)} (⅜ po)", off=5)
        cote(ax, h[0][0], g.bloc_p, h[1][0], g.bloc_p, f"{fr(g.entraxe_tiges)} (¾ po)", off=12)
        cote(ax, g.bloc_l, 0, g.bloc_l, h[0][1], f"{fr(h[0][1])} (1¼ po)", off=6, vertical=True)
        ax.text(g.bloc_l / 2, h[0][1] + 10, f"2 trous Ø {fr(g.d_trou_bloc)} (9/32 po)\nrepli Ø 7,94 (5/16 po) si ça coince",
                ha="center", fontsize=7)
        ax.text(g.bloc_l / 2, -16, "FACE AVANT (en bas du dessin)", ha="center", fontsize=7)
        ax.set_xlim(-25, g.bloc_l + 25); ax.set_ylim(-22, g.bloc_p + 22)
        ax2 = fig.add_axes([0.62, 0.55, 0.33, 0.3])
        ax2.set_aspect("equal"); ax2.axis("off")
        _rect(ax2, 0, 0, g.bloc_l, g.bloc_h)
        cote(ax2, 0, 0, g.bloc_l, 0, f"{fr(g.bloc_l)}", off=-5)
        cote(ax2, g.bloc_l, 0, g.bloc_l, g.bloc_h, f"{fr(g.bloc_h)} (½ po)", off=4, vertical=True)
        ax2.text(g.bloc_l / 2, g.bloc_h + 4, "Vue de face : faces dessus/dessous = lustrées d'usine,\navant/arrière/côtés = coupes de scie (givrées)",
                 ha="center", fontsize=7)
        ax2.set_xlim(-10, g.bloc_l + 20); ax2.set_ylim(-10, g.bloc_h + 16)
        pdf.savefig(fig); plt.close(fig)

        # Page 2 : gabarit de perçage
        fig = _page(pdf, "Gabarit de perçage des blocs (drill press)",
                    "Butée arrière contre la face avant du bloc, butée latérale contre une face latérale. "
                    "Percer, tourner le bloc de 180° autour de l'axe vertical, replaquer contre les mêmes butées, percer.")
        ax = fig.add_axes([0.08, 0.2, 0.84, 0.65])
        ax.set_aspect("equal"); ax.axis("off")
        bx, by = 30, 30
        _rect(ax, bx - 60, by - 12, g.bloc_l + 120, 12, hatch="////", lw=0.5)      # butée arrière
        _rect(ax, bx - 12, by, 12, g.bloc_p + 20, hatch="////", lw=0.5)             # butée latérale
        _rect(ax, bx, by, g.bloc_l, g.bloc_p, lw=1.2)
        hx, hy = g.trous_bloc()[0]
        ax.add_patch(Circle((bx + hx, by + hy), g.d_trou_bloc / 2, fill=True, color="k"))
        ax.add_patch(Circle((bx + g.trous_bloc()[1][0], by + hy), g.d_trou_bloc / 2, fill=False, ls="--"))
        ax.text(bx + hx, by + hy + 8, "mèche", ha="center", fontsize=7)
        ax.text(bx + g.trous_bloc()[1][0], by + hy + 8, "2e trou après rotation 180°", ha="center", fontsize=7)
        cote(ax, bx, by, bx + hx, by, f"{fr(hx, 3)} de la butée latérale", off=-18)
        cote(ax, bx + g.bloc_l + 20, by, bx + g.bloc_l + 20, by + hy, f"{fr(hy)} de la butée arrière", off=0, vertical=True)
        ax.text(bx - 55, by - 6, "BUTÉE ARRIÈRE", fontsize=7, va="center")
        ax.text(bx - 10, by + g.bloc_p + 10, "BUTÉE\nLATÉRALE", fontsize=7, ha="center")
        ax.text(bx + g.bloc_l / 2, by - 30,
                f"Mèche {fr(g.d_trou_bloc)} mm (9/32 po) à plastique ou réaffûtée à 0°, basse vitesse, martyr MDF sous le bloc, papier protecteur en place.\n"
                "Essai sur 60 blocs et 3 colonnes avant la série. L'entraxe des deux trous est garanti identique sur les 560 blocs par la rotation 180°.",
                ha="center", fontsize=7)
        ax.set_xlim(bx - 70, bx + g.bloc_l + 60); ax.set_ylim(by - 45, by + g.bloc_p + 30)
        pdf.savefig(fig); plt.close(fig)
    return path


def pdf_plaque_et_barre(g: Geom, path: str) -> str:
    with PdfPages(path) as pdf:
        fig = _page(pdf, "Plaque du dessus de la base (aluminium ⅛ × 5 po) - vue de dessus",
                    f"Longueur {fr(g.l_base, 1)} = zone tour {fr(g.zone_tour, 1)} + zone de contrôle {fr(g.zone_controle, 1)}. "
                    f"56 trous Ø {fr(g.d_trou_plaque)} (tiges), 28 trous Ø {fr(g.d_trou_cable, 1)} (câbles), 4 pots Ø {fr(g.d_bague_pot, 1)}, interrupteur Ø {fr(g.d_bague_inter, 1)}.")
        ax = fig.add_axes([0.03, 0.3, 0.94, 0.5])
        ax.set_aspect("equal"); ax.axis("off")
        _rect(ax, 0, 0, g.l_base, g.plaque_prof)
        for x, y in g.tiges():
            ax.add_patch(Circle((x, y), g.d_trou_plaque / 2, fill=False, lw=0.4))
        for x, y in g.cables():
            ax.add_patch(Circle((x, y), g.d_trou_cable / 2, fill=False, lw=0.4))
        for x, y, d in g.trous_controle():
            ax.add_patch(Circle((x, y), d / 2, fill=False, lw=0.4))
        ax.plot([g.zone_tour, g.zone_tour], [0, g.plaque_prof], lw=0.4, ls="--", color="k")
        cote(ax, 0, 0, g.l_base, 0, f"{fr(g.l_base, 1)}", off=-40)
        cote(ax, 0, 0, g.zone_tour, 0, f"zone tour {fr(g.zone_tour, 1)}", off=-22)
        cote(ax, g.zone_tour, 0, g.l_base, 0, f"contrôle {fr(g.zone_controle, 1)}", off=-22)
        cote(ax, 0, g.plaque_prof, g.x_col(1), g.plaque_prof, f"{fr(g.x_col(1))}", off=12)
        cote(ax, g.x_col(1), g.plaque_prof, g.x_col(2), g.plaque_prof, f"pas {fr(g.pas_col)}", off=12)
        t = g.tiges()
        cote(ax, t[0][0], g.plaque_prof, t[1][0], g.plaque_prof, f"{fr(g.entraxe_tiges)}", off=30)
        cote(ax, g.l_base, 0, g.l_base, g.y_tiges, f"{fr(g.y_tiges)}", off=15, vertical=True)
        cote(ax, g.l_base, 0, g.l_base, g.y_cable, f"{fr(g.y_cable)}", off=35, vertical=True)
        cote(ax, g.l_base, 0, g.l_base, g.plaque_prof, f"{fr(g.plaque_prof)}", off=55, vertical=True)
        ax.text(g.l_base / 2, -70, "Bord avant en bas. Face avant des blocs à y = 20 ; les trous de câbles (y = 93) sont derrière les barrettes.",
                ha="center", fontsize=7)
        ax.set_xlim(-30, g.l_base + 90); ax.set_ylim(-80, g.plaque_prof + 45)
        pdf.savefig(fig); plt.close(fig)

        fig = _page(pdf, "Barre du haut (aluminium ⅛ × 3 po) - vue de dessus",
                    f"{fr(g.zone_tour, 1)} × {fr(g.barre_haut_larg, 1)}, 56 trous Ø {fr(g.d_trou_plaque)} au même patron que la plaque. "
                    "Bord avant = face avant des blocs. Percer serrée sur la plaque du dessus avec le gabarit 1:1.")
        ax = fig.add_axes([0.03, 0.3, 0.94, 0.5])
        ax.set_aspect("equal"); ax.axis("off")
        _rect(ax, 0, 0, g.zone_tour, g.barre_haut_larg)
        for x, y in g.tiges():
            ax.add_patch(Circle((x, y - g.y_face_avant), g.d_trou_plaque / 2, fill=False, lw=0.4))
        cote(ax, 0, 0, g.zone_tour, 0, f"{fr(g.zone_tour, 1)}", off=-25)
        cote(ax, g.zone_tour, 0, g.zone_tour, g.y_tiges - g.y_face_avant, f"{fr(g.y_tiges - g.y_face_avant)}", off=15, vertical=True)
        cote(ax, g.zone_tour, 0, g.zone_tour, g.barre_haut_larg, f"{fr(g.barre_haut_larg, 1)}", off=35, vertical=True)
        ax.set_xlim(-30, g.zone_tour + 70); ax.set_ylim(-50, g.barre_haut_larg + 30)
        pdf.savefig(fig); plt.close(fig)
    return path


def pdf_assemblage(g: Geom, path: str) -> str:
    y_bar = g.y_face_arriere + 2.0           # ruban mousse 2 mm
    y_bar_arr = y_bar + 1.6                  # face arrière de la barrette
    with PdfPages(path) as pdf:
        # Vue de face
        fig = _page(pdf, "Assemblage - vue de face",
                    f"{g.n_col} colonnes × {g.n_rang} rangées. Largeur des blocs {fr(g.largeur_tour, 1)}, pile {fr(g.hauteur_pile, 1)}, "
                    f"pas colonne {fr(g.pas_col)}, pas rangée {fr(g.pas_rang)} (bloc {fr(g.bloc_h)} + {g.n_rond} rondelles × {fr(g.t_rondelle)}).")
        ax = fig.add_axes([0.03, 0.15, 0.94, 0.7])
        ax.set_aspect("equal"); ax.axis("off")
        _rect(ax, 0, -70, g.l_base, 70, lw=1.0)   # base (hauteur 2¾ po = 69,9)
        for c in range(1, g.n_col + 1):
            x = g.x_col(c)
            for n in range(1, g.n_rang + 1):
                _rect(ax, x - g.bloc_l / 2, g.z_rang(n) - g.bloc_h / 2, g.bloc_l, g.bloc_h, lw=0.3)
            for s in (-1, 1):
                ax.plot([x + s * g.entraxe_tiges / 2] * 2, [0, g.hauteur_pile + g.ep_alu + 6], lw=0.4, color="0.4")
        _rect(ax, g.marge_tour, g.hauteur_pile, g.largeur_tour, g.ep_alu, lw=0.8)  # barre du haut
        cote(ax, g.x_col(1) - g.bloc_l / 2, 0, g.x_col(28) + g.bloc_l / 2, 0, f"{fr(g.largeur_tour, 1)}", off=-85)
        cote(ax, g.l_base + 10, 0, g.l_base + 10, g.hauteur_pile, f"{fr(g.hauteur_pile, 1)}", off=0, vertical=True)
        cote(ax, g.l_base + 10, g.z_rang(1), g.l_base + 10, g.z_rang(2), f"{fr(g.pas_rang)}", off=25, vertical=True)
        cote(ax, g.x_col(1), g.hauteur_pile + 10, g.x_col(2), g.hauteur_pile + 10, f"{fr(g.pas_col)}", off=10)
        ax.set_xlim(-20, g.l_base + 70); ax.set_ylim(-95, g.hauteur_pile + 40)
        pdf.savefig(fig); plt.close(fig)

        # Vue de côté (coupe dans une colonne)
        fig = _page(pdf, "Assemblage - vue de côté (coupe dans une colonne)",
                    f"Face avant des blocs à y = {fr(g.y_face_avant)}, tiges à y = {fr(g.y_tiges)}, face arrière à y = {fr(g.y_face_arriere)}, "
                    f"ruban mousse 2 mm, barrette de {fr(y_bar)} à {fr(y_bar_arr)}, trou de câble à y = {fr(g.y_cable)}.")
        ax = fig.add_axes([0.1, 0.1, 0.8, 0.8])
        ax.set_aspect("equal"); ax.axis("off")
        _rect(ax, 0, -70, g.plaque_prof, 70, lw=1.0)
        for n in range(1, g.n_rang + 1):
            _rect(ax, g.y_face_avant, g.z_rang(n) - g.bloc_h / 2, g.bloc_p, g.bloc_h, lw=0.3)
            # rondelles (4 par joint) représentées par un rectangle
            _rect(ax, g.y_tiges - 7.9, g.z_rang(n) + g.bloc_h / 2, 15.8, g.entretoise, lw=0.2)
        ax.plot([g.y_tiges, g.y_tiges], [-10, g.hauteur_pile + 12], lw=0.8, color="0.3")
        _rect(ax, y_bar, g.z_bas_barrette, 1.6, g.barrette_long, lw=0.6)
        for n in range(1, g.n_rang + 1):
            ax.plot(y_bar, g.z_rang(n), marker="s", ms=3, color="k")
        _rect(ax, g.y_face_avant, g.hauteur_pile, g.barre_haut_larg, g.ep_alu, lw=0.8)
        ax.add_patch(Circle((g.y_cable, -3), g.d_trou_cable / 2, fill=False, lw=0.4))
        cote(ax, 0, 0, g.y_face_avant, 0, f"{fr(g.y_face_avant)}", off=-20)
        cote(ax, 0, 0, g.y_tiges, 0, f"{fr(g.y_tiges)}", off=-32)
        cote(ax, 0, 0, g.y_face_arriere, 0, f"{fr(g.y_face_arriere)}", off=-44)
        cote(ax, 0, 0, y_bar, 0, f"{fr(y_bar)} (barrette)", off=-56)
        cote(ax, 0, 0, g.y_cable, 0, f"{fr(g.y_cable)} (câble)", off=-68)
        cote(ax, 0, 0, g.plaque_prof, 0, f"{fr(g.plaque_prof)}", off=-80)
        cote(ax, g.plaque_prof + 15, 0, g.plaque_prof + 15, g.z_rang(1), f"{fr(g.z_rang(1))}", off=0, vertical=True)
        cote(ax, g.plaque_prof + 15, 0, g.plaque_prof + 15, g.hauteur_pile, f"{fr(g.hauteur_pile, 1)}", off=18, vertical=True)
        ax.set_xlim(-15, g.plaque_prof + 60); ax.set_ylim(-95, g.hauteur_pile + 30)
        pdf.savefig(fig); plt.close(fig)

        # Vue de dessus de la base (position de la tour, zone de contrôle)
        fig = _page(pdf, "Assemblage - vue de dessus de la base",
                    f"Zone tour {fr(g.zone_tour, 1)} à gauche, zone de contrôle {fr(g.zone_controle, 1)} à droite (4 pots + interrupteur sur le dessus, jacks à l'arrière).")
        ax = fig.add_axes([0.03, 0.3, 0.94, 0.5])
        ax.set_aspect("equal"); ax.axis("off")
        _rect(ax, 0, 0, g.l_base, g.plaque_prof)
        for c in range(1, g.n_col + 1):
            _rect(ax, g.x_col(c) - g.bloc_l / 2, g.y_face_avant, g.bloc_l, g.bloc_p, lw=0.3)
            _rect(ax, g.x_col(c) - g.barrette_larg / 2, y_bar, g.barrette_larg, 1.6, lw=0.3)
        for x, y, d in g.trous_controle():
            ax.add_patch(Circle((x, y), d / 2, fill=False, lw=0.4))
        ax.plot([g.zone_tour, g.zone_tour], [0, g.plaque_prof], lw=0.4, ls="--", color="k")
        cote(ax, 0, 0, g.zone_tour, 0, f"{fr(g.zone_tour, 1)}", off=-25)
        cote(ax, g.zone_tour, 0, g.l_base, 0, f"{fr(g.zone_controle, 1)}", off=-25)
        ax.set_xlim(-20, g.l_base + 20); ax.set_ylim(-45, g.plaque_prof + 20)
        pdf.savefig(fig); plt.close(fig)
    return path
```

- [ ] **Step 4: Vérifier le succès**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_views.py -q 2>&1 | tail -3`
Expected: `3 passed`

- [ ] **Step 5: Contrôle visuel des 7 pages**

Run:
```bash
cd /home/claude/spectrum && S=/tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad && python3 -c "
from params import Geom; from drawings.views import *
g=Geom(); pdf_bloc_et_gabarit(g,'$S/v1.pdf'); pdf_plaque_et_barre(g,'$S/v2.pdf'); pdf_assemblage(g,'$S/v3.pdf')" && for f in v1 v2 v3; do pdftoppm -r 45 -png $S/$f.pdf $S/$f; done && ls $S/v*.png
```
Ouvrir chaque PNG avec Read. Corriger les cotes qui se chevauchent (ajuster `off`) ou les textes coupés (ajuster `set_xlim/set_ylim`). Relancer les tests après chaque correction.

- [ ] **Step 6: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "feat: dessins cotés bloc/gabarit, plaque/barre, assemblage" && git log --oneline | head -1
```

---

### Task 5: Enveloppe pcbnew + barrette LED (KiCad, DRC, Gerbers)

**Files:**
- Create: `pcb/__init__.py` (vide)
- Create: `pcb/kicad_helpers.py`
- Create: `pcb/gen_barrette.py`
- Create: `tests/test_pcb_barrette.py`

**Interfaces:**
- Consumes: `params.Geom`
- Produces dans `pcb/kicad_helpers.py` : `FP_DIR`, `mm`, `V(x, y)`, `tomm(v) -> tuple`, classe `Board` avec `net(name)`, `footprint(lib, name, ref, x, y, rot=0, back=False, value="")`, `place_by_pad(fp, pad_no, x, y)`, `connect(fp, pad_no, net)`, `pad_xy(fp, pad_no)`, `track(pts, width, net, layer=F_Cu)`, `via(x, y, net)`, `rect_edge(x0, y0, x1, y1)`, `zone(layer, net, pts)`, `text(s, x, y, layer=F_SilkS, size=1.0, rot=0)`, `save(path)` ; fonctions `fill_and_save(path)`, `run_drc(path) -> tuple[int, int]` (erreurs, non-connectés), `export_gerbers(path, outdir) -> list[str]`, `courtyard_bbox(fp) -> tuple[float, float, float, float]`.
- Produces dans `pcb/gen_barrette.py` : `build_barrette(g, outdir) -> str` (chemin du `.kicad_pcb`), constantes `X_GND = 3.5`, `X_5V = 16.5`.

- [ ] **Step 1: Test qui échoue**

Créer `tests/test_pcb_barrette.py` :

```python
import os
import pcbnew
import pytest
from params import Geom
from pcb.kicad_helpers import run_drc, export_gerbers, courtyard_bbox, tomm
from pcb.gen_barrette import build_barrette


@pytest.fixture(scope="module")
def board_path(tmp_path_factory):
    out = tmp_path_factory.mktemp("barrette")
    return build_barrette(Geom(), str(out))


def test_counts_and_size(board_path):
    b = pcbnew.LoadBoard(board_path)
    refs = [fp.GetReference() for fp in b.GetFootprints()]
    assert sum(r.startswith("D") for r in refs) == 20
    assert sum(r.startswith("C") for r in refs) == 20
    assert refs.count("R1") == 1 and refs.count("J1") == 1 and refs.count("J2") == 1
    bb = b.GetBoardEdgesBoundingBox()
    g = Geom()
    assert pcbnew.ToMM(bb.GetWidth()) == pytest.approx(g.barrette_larg, abs=0.2)
    assert pcbnew.ToMM(bb.GetHeight()) == pytest.approx(g.barrette_long, abs=0.2)


def test_led_pitch_and_chain(board_path):
    b = pcbnew.LoadBoard(board_path)
    g = Geom()
    fps = {fp.GetReference(): fp for fp in b.GetFootprints()}
    ys = [tomm(fps[f"D{k}"].GetPosition())[1] for k in range(1, 21)]
    # LED1 est en bas (y grand), LED20 en haut ; pas = pas_rang ; symétrie
    assert ys[0] == pytest.approx(g.barrette_long - g.marge_barrette)
    assert ys[19] == pytest.approx(g.marge_barrette)
    for k in range(19):
        assert ys[k] - ys[k + 1] == pytest.approx(g.pas_rang)
    for k in range(1, 20):
        assert fps[f"D{k}"].FindPadByNumber("2").GetNetname() == fps[f"D{k+1}"].FindPadByNumber("4").GetNetname()
    assert fps["J1"].FindPadByNumber("1").GetNetname() == "+5V"
    assert fps["J1"].FindPadByNumber("3").GetNetname() == "GND"
    assert fps["J1"].FindPadByNumber("2").GetNetname() == "DIN"
    assert fps["J2"].FindPadByNumber("2").GetNetname() == "D20"
    assert fps["D1"].FindPadByNumber("4").GetNetname() == "D0"
    assert fps["R1"].FindPadByNumber("1").GetNetname() == "DIN"   # pad 1 (x = 9,05) côté J1
    assert fps["R1"].FindPadByNumber("2").GetNetname() == "D0"    # pad 2 (x = 10,95) vers LED1


def test_connectors_inside_board_and_symmetric(board_path):
    b = pcbnew.LoadBoard(board_path)
    g = Geom()
    fps = {fp.GetReference(): fp for fp in b.GetFootprints()}
    for ref in ("J1", "J2"):
        assert fps[ref].IsFlipped()
        x0, y0, x1, y1 = courtyard_bbox(fps[ref])
        assert x0 >= 0 and y0 >= 0 and x1 <= g.barrette_larg and y1 <= g.barrette_long
    j1 = tomm(fps["J1"].FindPadByNumber("2").GetPosition())
    j2 = tomm(fps["J2"].FindPadByNumber("2").GetPosition())
    assert j1 == pytest.approx((5.0, g.barrette_long - 5.5))
    assert j2 == pytest.approx((15.0, 5.5))


def test_drc_clean(board_path):
    errors, unconnected = run_drc(board_path)
    assert unconnected == 0
    assert errors == 0


def test_gerbers(board_path):
    files = export_gerbers(board_path, os.path.join(os.path.dirname(board_path), "gerbers"))
    names = [os.path.basename(f) for f in files]
    for layer in ("F_Cu", "B_Cu", "F_Paste", "F_Silkscreen", "B_Silkscreen", "F_Mask", "B_Mask", "Edge_Cuts"):
        assert any(layer in n for n in names), layer
    assert any(n.endswith(".drl") for n in names)
    assert any(n.endswith(".gbrjob") for n in names)
```

- [ ] **Step 2: Vérifier l'échec**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_barrette.py -q 2>&1 | tail -3`
Expected: `ModuleNotFoundError: No module named 'pcb'`

- [ ] **Step 3: Écrire `pcb/kicad_helpers.py`**

```python
"""Enveloppe mince autour de pcbnew (KiCad 7.0.11) pour construire des cartes par script.

Pièges connus en 7.0.11 :
- fp.Flip() plante si le footprint n'est pas encore sur la carte : toujours board.Add(fp) avant.
- ZONE_FILLER.Fill() plante sur une carte créée en mémoire : sauvegarder, recharger, remplir, resauvegarder.
- VECTOR2I n'a pas d'opérateurs arithmétiques fiables côté Python : calculer en entiers.
"""
from __future__ import annotations

import glob
import os
import re
import subprocess

import pcbnew

FP_DIR = "/usr/share/kicad/footprints"
mm = pcbnew.FromMM


def V(x: float, y: float) -> pcbnew.VECTOR2I:
    return pcbnew.VECTOR2I(mm(x), mm(y))


def tomm(v) -> tuple[float, float]:
    return (pcbnew.ToMM(v.x), pcbnew.ToMM(v.y))


class Board:
    def __init__(self, clearance: float = 0.2, track: float = 0.3, edge_clearance: float = 0.3):
        self.b = pcbnew.CreateEmptyBoard()
        ds = self.b.GetDesignSettings()
        ds.SetCopperLayerCount(2)
        nc = ds.m_NetSettings.m_DefaultNetClass
        nc.SetClearance(mm(clearance))
        nc.SetTrackWidth(mm(track))
        nc.SetViaDiameter(mm(0.8))
        nc.SetViaDrill(mm(0.4))
        ds.m_TrackMinWidth = mm(0.2)
        ds.m_ViasMinSize = mm(0.6)
        ds.m_MinThroughDrill = mm(0.3)
        ds.m_CopperEdgeClearance = mm(edge_clearance)
        self.nets: dict[str, pcbnew.NETINFO_ITEM] = {}

    # --- nets / footprints ---------------------------------------------------
    def net(self, name: str) -> pcbnew.NETINFO_ITEM:
        if name not in self.nets:
            ni = pcbnew.NETINFO_ITEM(self.b, name)
            self.b.Add(ni)
            self.nets[name] = ni
        return self.nets[name]

    def footprint(self, lib: str, name: str, ref: str, x: float, y: float,
                  rot: float = 0, back: bool = False, value: str = "") -> pcbnew.FOOTPRINT:
        fp = pcbnew.FootprintLoad(f"{FP_DIR}/{lib}.pretty", name)
        if fp is None:
            raise FileNotFoundError(f"{lib}:{name}")
        fp.SetReference(ref)
        if value:
            fp.SetValue(value)
        self.b.Add(fp)                 # AVANT Flip (sinon segfault)
        fp.SetPosition(V(x, y))
        if back:
            fp.Flip(V(x, y), False)
        fp.SetOrientationDegrees(rot)
        return fp

    def place_by_pad(self, fp: pcbnew.FOOTPRINT, pad_no: str, x: float, y: float) -> None:
        """Déplace le footprint pour que le pad `pad_no` tombe en (x, y)."""
        p = fp.FindPadByNumber(pad_no).GetPosition()
        pos = fp.GetPosition()
        fp.SetPosition(pcbnew.VECTOR2I(pos.x + (mm(x) - p.x), pos.y + (mm(y) - p.y)))

    def connect(self, fp: pcbnew.FOOTPRINT, pad_no: str, net: str) -> None:
        fp.FindPadByNumber(pad_no).SetNet(self.net(net))

    def pad_xy(self, fp: pcbnew.FOOTPRINT, pad_no: str) -> tuple[float, float]:
        return tomm(fp.FindPadByNumber(pad_no).GetPosition())

    # --- cuivre ------------------------------------------------------------
    def track(self, pts, width: float, net: str, layer=pcbnew.F_Cu) -> None:
        for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
            t = pcbnew.PCB_TRACK(self.b)
            t.SetStart(V(x1, y1)); t.SetEnd(V(x2, y2))
            t.SetWidth(mm(width)); t.SetLayer(layer); t.SetNet(self.net(net))
            self.b.Add(t)

    def via(self, x: float, y: float, net: str, drill: float = 0.4, dia: float = 0.8) -> None:
        v = pcbnew.PCB_VIA(self.b)
        v.SetPosition(V(x, y)); v.SetDrill(mm(drill)); v.SetWidth(mm(dia))
        v.SetViaType(pcbnew.VIATYPE_THROUGH); v.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
        v.SetNet(self.net(net))
        self.b.Add(v)

    def zone(self, layer, net: str, pts, clearance: float = 0.3, min_w: float = 0.25) -> pcbnew.ZONE:
        z = pcbnew.ZONE(self.b)
        z.SetLayer(layer); z.SetNet(self.net(net))
        z.SetLocalClearance(mm(clearance)); z.SetMinThickness(mm(min_w))
        z.SetPadConnection(pcbnew.ZONE_CONNECTION_THERMAL)
        ol = z.Outline(); ol.NewOutline()
        for x, y in pts:
            ol.Append(mm(x), mm(y))
        self.b.Add(z)
        return z

    # --- divers ------------------------------------------------------------
    def rect_edge(self, x0: float, y0: float, x1: float, y1: float) -> None:
        s = pcbnew.PCB_SHAPE(self.b)
        s.SetShape(pcbnew.SHAPE_T_RECT)
        s.SetStart(V(x0, y0)); s.SetEnd(V(x1, y1))
        s.SetLayer(pcbnew.Edge_Cuts); s.SetWidth(mm(0.1))
        self.b.Add(s)

    def text(self, s: str, x: float, y: float, layer=pcbnew.F_SilkS, size: float = 1.0, rot: float = 0) -> None:
        t = pcbnew.PCB_TEXT(self.b)
        t.SetText(s); t.SetPosition(V(x, y)); t.SetLayer(layer)
        t.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size)))
        t.SetTextThickness(mm(max(0.12, size * 0.15)))
        t.SetTextAngleDegrees(rot)
        if layer in (pcbnew.B_SilkS, pcbnew.B_Cu, pcbnew.B_Fab):
            t.SetMirrored(True)
        self.b.Add(t)

    def save(self, path: str) -> str:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        pcbnew.SaveBoard(path, self.b)
        return path


def fill_and_save(path: str) -> None:
    b = pcbnew.LoadBoard(path)
    pcbnew.ZONE_FILLER(b).Fill(b.Zones())
    b.BuildConnectivity()
    pcbnew.SaveBoard(path, b)


def run_drc(path: str) -> tuple[int, int]:
    """Retourne (nb de violations de sévérité error, nb d'éléments non connectés)."""
    b = pcbnew.LoadBoard(path)
    pcbnew.ZONE_FILLER(b).Fill(b.Zones())
    b.BuildConnectivity()
    report = path.replace(".kicad_pcb", "-drc.txt")
    pcbnew.WriteDRCReport(b, report, pcbnew.EDA_UNITS_MILLIMETRES, True)
    txt = open(report, encoding="utf-8").read()
    unconnected = int(re.search(r"Found (\d+) unconnected", txt).group(1))
    # on ne compte que les violations de sévérité error dans la section DRC
    drc_section = txt.split("** Found")[1] if "** Found" in txt else ""
    errors = len(re.findall(r"Severity: error", drc_section.split("unconnected")[0]))
    return errors, unconnected


def export_gerbers(path: str, outdir: str) -> list[str]:
    os.makedirs(outdir, exist_ok=True)
    layers = "F.Cu,B.Cu,F.Paste,B.Paste,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts"
    subprocess.run(["kicad-cli", "pcb", "export", "gerbers", "--output", outdir + "/",
                    "--layers", layers, "--no-protel-ext", "--subtract-soldermask", path],
                   check=True, capture_output=True)
    subprocess.run(["kicad-cli", "pcb", "export", "drill", "--output", outdir + "/",
                    "--format", "excellon", "--excellon-units", "mm", "--generate-map",
                    "--map-format", "pdf", path], check=True, capture_output=True)
    return sorted(glob.glob(os.path.join(outdir, "*")))


def courtyard_bbox(fp: pcbnew.FOOTPRINT) -> tuple[float, float, float, float]:
    layer = pcbnew.B_CrtYd if fp.IsFlipped() else pcbnew.F_CrtYd
    bb = fp.GetCourtyard(layer).BBox()
    return (pcbnew.ToMM(bb.GetLeft()), pcbnew.ToMM(bb.GetTop()),
            pcbnew.ToMM(bb.GetRight()), pcbnew.ToMM(bb.GetBottom()))
```

Créer `pcb/__init__.py` vide : `touch pcb/__init__.py`.

- [ ] **Step 4: Écrire `pcb/gen_barrette.py`**

Repères KiCad : X vers la droite, **Y vers le bas**. Le bout IN est en bas de la carte (y = L), LED1 juste au-dessus, data vers le haut (−Y). LED tournées de 180° : pad 4 (DIN) en bas-gauche, pad 2 (DOUT) en haut-droite, pad 1 (VDD) en bas-droite, pad 3 (VSS) en haut-gauche. Rail +5V à droite (x = 16,5), rail GND à gauche (x = 3,5), zone GND sur B.Cu.

```python
"""Barrette LED 28B-COL : 20 × WS2812B au pas vertical p, JST-XH coudés au dos.

Usage : python3 pcb/gen_barrette.py [outdir]
Sortie : outdir/barrette-28b/barrette-28b.kicad_pcb (+ gerbers/ si --gerbers)
"""
from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import pcbnew  # noqa: E402

from params import Geom  # noqa: E402
from pcb.kicad_helpers import Board, fill_and_save, export_gerbers  # noqa: E402

X_GND = 3.5
X_5V = 16.5
X_C = 10.0          # axe des LED
RAIL_W = 2.0
STUB_W = 0.5
DATA_W = 0.3


def build_barrette(g: Geom, outdir: str) -> str:
    L = g.barrette_long
    W = g.barrette_larg
    p = g.pas_rang
    a = g.marge_barrette
    bd = Board()
    bd.rect_edge(0, 0, W, L)

    # --- connecteurs JST-XH coudés, face arrière -----------------------------
    # J1 = IN en bas (pads vers +Y depuis le pad 1, boîtier vers +X, pins à x = 5)
    j1 = bd.footprint("Connector_JST", "JST_XH_S3B-XH-A_1x03_P2.50mm_Horizontal", "J1", 5, L - 8,
                      rot=270, back=True, value="IN 5V-DATA-GND")
    bd.place_by_pad(j1, "1", 5, L - 8)          # pad2 (5, L-5.5), pad3 (5, L-3)
    # J2 = OUT en haut (pads vers −Y depuis le pad 1, boîtier vers −X, pins à x = 15)
    j2 = bd.footprint("Connector_JST", "JST_XH_S3B-XH-A_1x03_P2.50mm_Horizontal", "J2", 15, 8,
                      rot=90, back=True, value="OUT 5V-DATA-GND")
    bd.place_by_pad(j2, "1", 15, 8)             # pad2 (15, 5.5), pad3 (15, 3)
    for j in (j1, j2):
        bd.connect(j, "1", "+5V"); bd.connect(j, "3", "GND")
    bd.connect(j1, "2", "DIN")
    bd.connect(j2, "2", f"D{g.n_rang}")

    # --- résistance série 330R sur DATA IN ------------------------------------
    r1 = bd.footprint("Resistor_SMD", "R_0805_2012Metric", "R1", 10, L - 5.5, value="330R")
    bd.connect(r1, "1", "DIN"); bd.connect(r1, "2", "D0")      # pad1 (9.05) côté J1, pad2 (10.95) vers LED1
    bd.track([bd.pad_xy(j1, "2"), bd.pad_xy(r1, "1")], DATA_W, "DIN")   # y = L-5.5, s'arrête au pad 1

    # --- rails ----------------------------------------------------------------
    y_top = a - 1.65            # VSS de LED20 (haut)
    bd.track([(X_5V, 8.0), (X_5V, L - 2.0)], RAIL_W, "+5V")
    bd.track([(X_GND, y_top), (X_GND, L - a - 1.65)], RAIL_W, "GND")
    bd.track([bd.pad_xy(j2, "1"), (X_5V, 8.0)], STUB_W, "+5V")                 # J2 pad1 → rail
    bd.track([bd.pad_xy(j1, "1"), (X_5V, L - 8.0)], STUB_W, "+5V", layer=pcbnew.B_Cu)  # J1 pad1 → via, sous la zone GND
    bd.via(X_5V, L - 8.0, "+5V")

    # --- LED + condensateurs ----------------------------------------------------
    leds = []
    for k in range(1, g.n_rang + 1):
        y = L - a - (k - 1) * p
        d = bd.footprint("LED_SMD", "LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm", f"D{k}", X_C, y, rot=180, value="WS2812B")
        bd.connect(d, "1", "+5V"); bd.connect(d, "3", "GND")
        bd.connect(d, "4", f"D{k-1}"); bd.connect(d, "2", f"D{k}")
        p1 = bd.pad_xy(d, "1"); p3 = bd.pad_xy(d, "3")
        bd.track([p1, (X_5V, p1[1])], STUB_W, "+5V")
        bd.track([p3, (X_GND, p3[1])], STUB_W, "GND")
        if k % 4 == 0:
            bd.via(X_GND, p3[1] - 3.0, "GND")
        leds.append(d)
        # condensateur : entre cette LED et la suivante (au-dessus), côté +5V.
        # rot=90 → pad 2 en haut (+5V, stub vers le rail), pad 1 en bas (GND, via vers le bas).
        # C20 : coin bas-droite à côté de R1, rot=270 pour que le via GND parte vers le haut (pas vers le bord).
        if k < g.n_rang:
            yc, rot = y - p / 2, 90
        else:
            yc, rot = L - 3.0, 270
        c = bd.footprint("Capacitor_SMD", "C_0805_2012Metric", f"C{k}", 13.5, yc, rot=rot, value="100nF")
        bd.connect(c, "2", "+5V"); bd.connect(c, "1", "GND")
        c1 = bd.pad_xy(c, "1"); c2 = bd.pad_xy(c, "2")
        bd.track([c2, (X_5V, c2[1])], 0.4, "+5V")
        vy = c1[1] - 1.6 if c1[1] < c2[1] else c1[1] + 1.6
        bd.track([c1, (c1[0], vy)], 0.4, "GND")
        bd.via(c1[0], vy, "GND")

    # --- chaîne data -------------------------------------------------------------
    bd.track([bd.pad_xy(r1, "2"), bd.pad_xy(leds[0], "4")], DATA_W, "D0")
    for k in range(g.n_rang - 1):
        bd.track([bd.pad_xy(leds[k], "2"), bd.pad_xy(leds[k + 1], "4")], DATA_W, f"D{k+1}")
    bd.track([bd.pad_xy(leds[-1], "2"), bd.pad_xy(j2, "2")], DATA_W, f"D{g.n_rang}")

    # --- zone GND dessous ----------------------------------------------------------
    bd.zone(pcbnew.B_Cu, "GND", [(0, 0), (W, 0), (W, L), (0, L)])

    # --- sérigraphie ----------------------------------------------------------------
    bd.text("IN", 10, L - 2.0, size=1.2)
    bd.text("OUT", 10, 2.0, size=1.2)
    bd.text("^ DATA ^", 10, L / 2, size=1.0, rot=90)
    bd.text(f"28B-COL v1 pas {p:.2f}", 10, L / 2 + 25, size=0.8, rot=90)
    for pad, lab in (("1", "5V"), ("2", "D"), ("3", "G")):
        x, y = bd.pad_xy(j1, pad); bd.text(lab, x + 2.2, y, layer=pcbnew.B_SilkS, size=0.7)
        x, y = bd.pad_xy(j2, pad); bd.text(lab, x - 2.2, y, layer=pcbnew.B_SilkS, size=0.7)
    for k, d in enumerate(leds, start=1):
        x, y = bd.pad_xy(d, "4")
        bd.text(str(k), X_C, y + 2.6, size=0.7)

    path = bd.save(os.path.join(outdir, "barrette-28b", "barrette-28b.kicad_pcb"))
    fill_and_save(path)
    return path


if __name__ == "__main__":
    out = sys.argv[1] if len(sys.argv) > 1 else "out/pcb"
    pth = build_barrette(Geom(), out)
    print(pth)
    if "--gerbers" in sys.argv:
        for f in export_gerbers(pth, os.path.join(os.path.dirname(pth), "gerbers")):
            print(f)
```

- [ ] **Step 5: Lancer les tests, lire le rapport DRC**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_barrette.py -q -x 2>&1 | tail -15`
Expected: `5 passed`. Si `test_drc_clean` échoue, lire le rapport : `cat /tmp/pytest-of-root/pytest-current/barrette*/barrette-28b/barrette-28b-drc.txt | grep -B1 -A3 "Severity: error" | head -40`. Causes typiques et correctifs :
- `clearance` entre le stub VDD d'une LED et le pad d'un condensateur → décaler `yc` de 0,5 mm (`y - p/2 - 0.5`).
- `clearance` entre la piste data et une piste de stub → augmenter l'écart en passant le stub concerné à y ± 0,3.
- `courtyards_overlap` → vérifier que `J1`/`J2` sont bien `back=True` et que `C20` à `(13.5, L - 3)` ne touche pas `R1` (courtyard de R1 : x 8,5-11,5) ; au besoin `C20` à `(14.0, L - 3)`.
- via GND de `C20` qui part vers le bord → vérifier `rot=270` pour `C20` (pad 1 en haut).
- `unconnected_items` sur +5V/GND → un stub ne touche pas le rail : vérifier que `X_5V`/`X_GND` sont bien dans l'intervalle des `y` des rails (`8 … L-4` et `y_top … L-a-1.65`).
Corriger, relancer jusqu'à `5 passed`.

- [ ] **Step 6: Rendu visuel pour contrôle**

Run:
```bash
cd /home/claude/spectrum && python3 pcb/gen_barrette.py /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest --gerbers | tail -1 && kicad-cli pcb export pdf --output /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-F.pdf --layers F.Cu,F.SilkS,Edge.Cuts /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-28b/barrette-28b.kicad_pcb && pdftoppm -r 80 -png /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-F.pdf /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-F
```
Ouvrir le PNG avec Read (c'est une barrette de 390 × 20 : zoomer avec `convert -crop` sur les deux bouts si nécessaire). Vérifier : les deux JST dans la carte, les 20 LED alignées, les diagonales data qui ne touchent rien, « IN » en bas, « OUT » en haut.

- [ ] **Step 7: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "feat: barrette LED 28B-COL (KiCad), DRC propre, export Gerbers" && git log --oneline | head -1
```

---

### Task 6: Backplane 4 segments (KiCad, DRC, Gerbers)

**Files:**
- Create: `pcb/gen_backplane.py`
- Create: `tests/test_pcb_backplane.py`

**Interfaces:**
- Consumes: `params.Geom`, `pcb.kicad_helpers.*`
- Produces: `build_backplane(g, outdir) -> str`, constante `JUMPERS = ["JA0", "JB0", "JA1", "JB1", "JA2", "JB2", "JA3", "JB3"]` et dict `JUMPER_NETS` (nom → (net pad1, net pad2)).

Patron A (segments 1 et 3) : fermer JA0 (INB–D1), JA1 (D2–D3), JA2 (D4–D5), JA3 (D6–D7). Patron B (segments 2 et 4) : fermer JB0 (D1–D2), JB1 (D3–D4), JB2 (D5–D6), JB3 (D7–OUT). JBYP (IN–INB) fermé si U1 absent.

- [ ] **Step 1: Test qui échoue**

Créer `tests/test_pcb_backplane.py` :

```python
import os
import pcbnew
import pytest
from params import Geom
from pcb.kicad_helpers import run_drc, export_gerbers, tomm
from pcb.gen_backplane import build_backplane, JUMPER_NETS


@pytest.fixture(scope="module")
def board_path(tmp_path_factory):
    return build_backplane(Geom(), str(tmp_path_factory.mktemp("backplane")))


def test_headers_positions_and_nets(board_path):
    b = pcbnew.LoadBoard(board_path)
    g = Geom()
    fps = {fp.GetReference(): fp for fp in b.GetFootprints()}
    for k in range(1, 8):
        h = fps[f"H{k}"]
        x, y = tomm(h.FindPadByNumber("2").GetPosition())
        assert x == pytest.approx(22.52 + (k - 1) * g.pas_col, abs=0.05)
        assert y == pytest.approx(10.0)
        assert h.FindPadByNumber("1").GetNetname() == "+5V"
        assert h.FindPadByNumber("2").GetNetname() == f"D{k}"
        assert h.FindPadByNumber("3").GetNetname() == "GND"
    assert fps["JIN"].FindPadByNumber("2").GetNetname() == "IN"
    assert fps["JOUT"].FindPadByNumber("2").GetNetname() == "OUT"
    assert fps["U1"].FindPadByNumber("2").GetNetname() == "IN"
    assert fps["U1"].FindPadByNumber("4").GetNetname() == "INB"
    assert fps["U1"].FindPadByNumber("1").GetNetname() == "GND"
    assert fps["U1"].FindPadByNumber("5").GetNetname() == "+5V"
    for ref, (n1, n2) in JUMPER_NETS.items():
        assert fps[ref].FindPadByNumber("1").GetNetname() == n1, ref
        assert fps[ref].FindPadByNumber("2").GetNetname() == n2, ref
    assert sum(r.startswith("MH") for r in fps) == 4
    bb = b.GetBoardEdgesBoundingBox()
    assert pcbnew.ToMM(bb.GetWidth()) == pytest.approx(7 * g.pas_col - 1.0, abs=0.2)
    assert pcbnew.ToMM(bb.GetHeight()) == pytest.approx(30.0, abs=0.2)


def test_drc_clean(board_path):
    errors, unconnected = run_drc(board_path)
    assert unconnected == 0
    assert errors == 0


def test_gerbers(board_path):
    files = export_gerbers(board_path, os.path.join(os.path.dirname(board_path), "gerbers"))
    assert any(f.endswith(".drl") for f in files)
    assert any("B_Cu" in f for f in files)
```

- [ ] **Step 2: Vérifier l'échec**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_backplane.py -q 2>&1 | tail -3`
Expected: `ModuleNotFoundError: No module named 'pcb.gen_backplane'`

- [ ] **Step 3: Écrire `pcb/gen_backplane.py`**

```python
"""Backplane 28B-BP : 1 segment de 7 colonnes (4 identiques par tour).

Usage : python3 pcb/gen_backplane.py [outdir] [--gerbers]
"""
from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import pcbnew  # noqa: E402

from params import Geom  # noqa: E402
from pcb.kicad_helpers import Board, fill_and_save, export_gerbers  # noqa: E402

H = 30.0
Y_HDR = 10.0        # axe des broches des prises
Y_JMP = 19.0        # axe des cavaliers
JUMPERS = ["JA0", "JB0", "JA1", "JB1", "JA2", "JB2", "JA3", "JB3"]
JUMPER_NETS = {
    "JA0": ("INB", "D1"), "JB0": ("D1", "D2"),
    "JA1": ("D2", "D3"), "JB1": ("D3", "D4"),
    "JA2": ("D4", "D5"), "JB2": ("D5", "D6"),
    "JA3": ("D6", "D7"), "JB3": ("D7", "OUT"),
    "JBYP": ("IN", "INB"),
}
JMP_LIB = ("Jumper", "SolderJumper-2_P1.3mm_Open_RoundedPad1.0x1.5mm")


def build_backplane(g: Geom, outdir: str) -> str:
    W = 7 * g.pas_col - 1.0
    x_h = [22.52 + k * g.pas_col for k in range(7)]      # pad 2 (DATA) des prises
    bd = Board()
    bd.rect_edge(0, 0, W, H)

    # --- prises JST verticales --------------------------------------------------
    hdr = []
    for k, x in enumerate(x_h, start=1):
        h = bd.footprint("Connector_JST", "JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical", f"H{k}", x, Y_HDR, value=f"COL {k}")
        bd.place_by_pad(h, "2", x, Y_HDR)
        bd.connect(h, "1", "+5V"); bd.connect(h, "2", f"D{k}"); bd.connect(h, "3", "GND")
        hdr.append(h)
    jin = bd.footprint("Connector_JST", "JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical", "JIN", 7.5, Y_HDR, value="IN")
    bd.place_by_pad(jin, "2", 7.5, Y_HDR)
    jout = bd.footprint("Connector_JST", "JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical", "JOUT", W - 7.5, Y_HDR, value="OUT")
    bd.place_by_pad(jout, "2", W - 7.5, Y_HDR)
    for j, net in ((jin, "IN"), (jout, "OUT")):
        bd.connect(j, "1", "+5V"); bd.connect(j, "2", net); bd.connect(j, "3", "GND")

    # --- cavaliers entre les prises -----------------------------------------------
    jm = {}
    xs_j = [15.0] + [(x_h[i] + x_h[i + 1]) / 2 for i in range(6)] + [W - 15.0]
    for name, x in zip(JUMPERS, xs_j):
        j = bd.footprint(*JMP_LIB, name, x, Y_JMP, value=name)
        n1, n2 = JUMPER_NETS[name]
        bd.connect(j, "1", n1); bd.connect(j, "2", n2)
        jm[name] = j
    # descentes D_k : prise → ligne des cavaliers, puis segments horizontaux vers les cavaliers voisins
    for k, (h, x) in enumerate(zip(hdr, x_h), start=1):
        bd.track([bd.pad_xy(h, "2"), (x, Y_JMP)], 0.3, f"D{k}")
        left, right = JUMPERS[k - 1], JUMPERS[k]          # cavalier à gauche / à droite de la prise k
        bd.track([(x, Y_JMP), bd.pad_xy(jm[left], "2")], 0.3, f"D{k}")
        bd.track([(x, Y_JMP), bd.pad_xy(jm[right], "1")], 0.3, f"D{k}")
    # OUT : JB3 pad2 → prise OUT
    bd.track([bd.pad_xy(jm["JB3"], "2"), (W - 7.5, Y_JMP), bd.pad_xy(jout, "2")], 0.3, "OUT")

    # --- tampon optionnel 74AHCT1G125 + contournement ----------------------------------
    u1 = bd.footprint("Package_TO_SOT_SMD", "SOT-23-5", "U1", 12.0, 26.0, value="SN74AHCT1G125 (option)")
    bd.connect(u1, "1", "GND"); bd.connect(u1, "2", "IN"); bd.connect(u1, "3", "GND")
    bd.connect(u1, "4", "INB"); bd.connect(u1, "5", "+5V")
    c1 = bd.footprint("Capacitor_SMD", "C_0805_2012Metric", "C1", 19.5, 27.5, value="100nF")   # à droite du trajet INB (x = 16,5)
    bd.connect(c1, "1", "+5V"); bd.connect(c1, "2", "GND")
    jbyp = bd.footprint(*JMP_LIB, "JBYP", 15.0, 22.5, value="JBYP")
    bd.connect(jbyp, "1", "IN"); bd.connect(jbyp, "2", "INB")
    # IN : prise IN → U1.A et → JBYP.1
    bd.track([bd.pad_xy(jin, "2"), (7.5, 26.0), bd.pad_xy(u1, "2")], 0.3, "IN")
    bd.track([(7.5, 22.5), bd.pad_xy(jbyp, "1")], 0.3, "IN")
    # INB : U1.Y → JBYP.2 → JA0.1
    y4 = bd.pad_xy(u1, "4")
    bd.track([y4, (16.5, y4[1]), (16.5, 23.5), bd.pad_xy(jbyp, "2")], 0.3, "INB")
    bd.track([bd.pad_xy(jbyp, "2"), (15.65, 21.0), (14.35, 19.9), bd.pad_xy(jm["JA0"], "1")], 0.3, "INB")
    # GND de U1 (pads 1 et 3) par vias ; +5V par la zone ; C1 pad2 par via
    p1 = bd.pad_xy(u1, "1"); p3 = bd.pad_xy(u1, "3")
    bd.track([p1, (p1[0], p1[1] - 1.4)], 0.3, "GND"); bd.via(p1[0], p1[1] - 1.4, "GND")   # via à y ≈ 23,65, 0,6 sous la piste IN (y = 22,5)
    bd.track([p3, (p3[0], p3[1] + 1.8)], 0.3, "GND"); bd.via(p3[0], p3[1] + 1.8, "GND")   # via à y ≈ 28,75, 0,85 du bord
    c2 = bd.pad_xy(c1, "2")
    bd.track([c2, (c2[0] + 1.6, c2[1])], 0.3, "GND"); bd.via(c2[0] + 1.6, c2[1], "GND")

    # --- alimentation : bornier + 1000 µF --------------------------------------------------
    tb = bd.footprint("TerminalBlock", "TerminalBlock_bornier-2_P5.08mm", "TB1", W / 2 - 2.54, 25.0, value="5V GND 16AWG")
    bd.connect(tb, "1", "+5V"); bd.connect(tb, "2", "GND")
    cp = bd.footprint("Capacitor_THT", "CP_Radial_D10.0mm_P5.00mm", "C2", W / 2 + 15.0, 24.0, value="1000uF 10V")
    bd.connect(cp, "1", "+5V"); bd.connect(cp, "2", "GND")

    # --- trous de montage M3 ------------------------------------------------------------------
    for i, (x, y) in enumerate([(5.0, 4.0), (W - 5.0, 4.0), (W - 5.0, 26.0), (W / 2 - 25.0, 26.0)], start=1):
        bd.footprint("MountingHole", "MountingHole_3.2mm_M3", f"MH{i}", x, y)

    # --- zones : +5V dessus, GND dessous -----------------------------------------------------------
    bd.zone(pcbnew.F_Cu, "+5V", [(0, 0), (W, 0), (W, H), (0, H)])
    bd.zone(pcbnew.B_Cu, "GND", [(0, 0), (W, 0), (W, H), (0, H)])

    # --- sérigraphie ----------------------------------------------------------------------------------
    bd.text("28B-BP v1  patron A: JA0-JA3 (seg 1,3)   patron B: JB0-JB3 (seg 2,4)   JBYP ferme si U1 absent", W / 2, 2.0, size=0.9)
    for k, x in enumerate(x_h, start=1):
        bd.text(f"C{k}", x, Y_HDR + 5.0, size=0.8)
    bd.text("IN", 7.5, 15.5, size=0.9); bd.text("OUT", W - 7.5, 15.5, size=0.9)
    bd.text("5V  GND", W / 2, 29.0, size=0.8)

    path = bd.save(os.path.join(outdir, "backplane-28b", "backplane-28b.kicad_pcb"))
    fill_and_save(path)
    return path


if __name__ == "__main__":
    out = sys.argv[1] if len(sys.argv) > 1 else "out/pcb"
    pth = build_backplane(Geom(), out)
    print(pth)
    if "--gerbers" in sys.argv:
        for f in export_gerbers(pth, os.path.join(os.path.dirname(pth), "gerbers")):
            print(f)
```

- [ ] **Step 4: Lancer les tests, corriger le DRC**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_backplane.py -q -x 2>&1 | tail -15`
Expected: `3 passed`. Si le DRC échoue : `grep -B1 -A3 "Severity: error" /tmp/pytest-of-root/pytest-current/backplane*/backplane-28b/backplane-28b-drc.txt | head -40`. Correctifs typiques : un dégagement entre la piste IN à y = 22,5 et la piste INB → passer la verticale INB de x = 16,5 à 17,0 ; cavalier JA0 trop près de la prise H1 (courtyard) → JA0 à x = 14,0 ; bornier/condensateur qui touchent le trou MH4 → MH4 à `(W/2 - 30, 26)`.

- [ ] **Step 5: Rendu visuel**

Run:
```bash
cd /home/claude/spectrum && S=/tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest && python3 pcb/gen_backplane.py $S --gerbers | tail -1 && kicad-cli pcb export pdf --output $S/bp-F.pdf --layers F.Cu,F.SilkS,Edge.Cuts $S/backplane-28b/backplane-28b.kicad_pcb && pdftoppm -r 80 -png $S/bp-F.pdf $S/bp-F
```
Ouvrir le PNG avec Read. Vérifier les 7 prises, les 8 cavaliers alternés A/B, IN à gauche, OUT à droite.

- [ ] **Step 6: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "feat: backplane 28B-BP (KiCad), DRC propre, export Gerbers" && git log --oneline | head -1
```

---

### Task 7: BOM générée et guide d'assemblage

**Files:**
- Create: `docs/gen_bom.py`
- Create: `docs/GUIDE-ASSEMBLAGE.md`
- Create: `tests/test_bom.py`

**Interfaces:**
- Consumes: `params.Geom`
- Produces: `bom_rows(g) -> list[dict]` (clés `poste, qte, unite, detail, fournisseur, prix_cad`), `write_bom(g, md_path, csv_path) -> None`.

- [ ] **Step 1: Test qui échoue**

Créer `tests/test_bom.py` :

```python
import csv
from params import Geom
from docs.gen_bom import bom_rows, write_bom


def test_quantities_follow_geom():
    g = Geom()
    rows = {r["poste"]: r for r in bom_rows(g)}
    assert rows["Rondelles plates 1/4 po SAE zinguées (un seul lot)"]["qte"] == 5000
    assert rows["Tiges filetées 1/4-20 x 36 po zinguées"]["qte"] == 28
    assert rows["Écrous nylock 1/4-20"]["qte"] == 56
    assert rows["Écrous borgnes 1/4-20"]["qte"] == 56
    assert rows["WS2812B-5050 (5 V)"]["qte"] == 630
    assert rows["PCB barrette 28B-COL (PCBWay)"]["qte"] == 30
    assert rows["PCB backplane 28B-BP (PCBWay)"]["qte"] == 5
    assert rows["Bandes d'acrylique 2 1/2 po x 96 po (déjà en main)"]["qte"] == g.bandes_necessaires
    assert rows["Câbles JST-XH 3 fils femelle-femelle 15 cm"]["qte"] == 28
    assert rows["Câbles JST-XH 3 fils femelle-femelle 5 cm"]["qte"] == 14


def test_write_bom(tmp_path):
    g = Geom()
    md = tmp_path / "BOM.md"; cs = tmp_path / "BOM.csv"
    write_bom(g, str(md), str(cs))
    t = md.read_text(encoding="utf-8")
    assert "| Poste |" in t and "Rondelles" in t and "Total" in t
    with open(cs, encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    assert len(rows) >= 30
    assert set(rows[0].keys()) == {"poste", "qte", "unite", "detail", "fournisseur", "prix_cad"}
```

- [ ] **Step 2: Vérifier l'échec**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_bom.py -q 2>&1 | tail -3`
Expected: `ModuleNotFoundError: No module named 'docs.gen_bom'` (créer aussi `docs/__init__.py` vide à l'étape suivante).

- [ ] **Step 3: Écrire `docs/gen_bom.py`**

```python
"""BOM globale du Spectrum 28 bandes, quantités calculées depuis Geom.

Usage : python3 docs/gen_bom.py out/BOM.md out/BOM.csv
Les prix sont des ordres de grandeur en CAD (2026), à confirmer à l'achat.
"""
from __future__ import annotations

import csv
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from params import Geom  # noqa: E402


def _r(poste, qte, unite, detail, fournisseur, prix):
    return {"poste": poste, "qte": qte, "unite": unite, "detail": detail, "fournisseur": fournisseur, "prix_cad": prix}


def bom_rows(g: Geom) -> list[dict]:
    n_led = g.n_col * g.n_rang + 70          # 560 + rechange pour 30 barrettes
    return [
        # --- déjà en main ---
        _r("Bandes d'acrylique 2 1/2 po x 96 po (déjà en main)", g.bandes_necessaires, "bandes",
           f"{g.blocs_par_bande} blocs par bande, {g.n_blocs} blocs + {g.rechange_blocs} de rechange", "Industries FM (déjà acheté)", 0),
        _r("Barres aluminium 1/8 x 5 x ~60 po (déjà en main)", 4, "barres",
           "1 dessus, 1 dessous, 1 refendue 3 po (barre du haut), 1 refendue 2 x 2 1/2 po (faces)", "déjà acheté", 0),
        _r("Carte PLATINUM ESP32 28 bandes (déjà en main)", 1, "carte", "4x MSGEQ7 + 2x Si5351, borne LEDS", "déjà acheté", 0),
        # --- structure ---
        _r("Tiges filetées 1/4-20 x 36 po zinguées", g.n_tiges // 2, "tiges", f"coupées en 2 x {g.longueur_tige:.0f} mm (17 po)", "Home Depot / Canadian Tire / Fastenal", 110),
        _r("Écrous nylock 1/4-20", g.n_tiges, "pcs", "sous la plaque de base", "Fastenal / Amazon.ca", 10),
        _r("Écrous borgnes 1/4-20", g.n_tiges, "pcs", "dessus la barre du haut (look référence)", "Fastenal / Amazon.ca", 25),
        _r("Rondelles plates 1/4 po SAE zinguées (un seul lot)", 5000, "pcs",
           f"OD 5/8 po, {g.n_rondelles} nécessaires ; mesurer une pile de 40 avant le PCB", "Fastenal / Spaenaur / Amazon.ca (5 lb)", 150),
        _r("Cornière aluminium 1/2 x 1/2 x 1/16 po x 6 pi", 2, "pcs", "arêtes de la boîte de base", "Home Depot / Metal Supermarkets", 25),
        _r("Rivets pop 3/16 alu + vis #6 / M4", 1, "lot", "assemblage de la boîte, dessous vissé", "Home Depot", 20),
        _r("Acrylique 1/2 po pour bouts de la base (retailles)", 2, "pcs", "2 1/2 x 4 3/4 po, peints noir", "retailles en main", 0),
        _r("Ruban mousse double face acrylique 2 mm (VHB ou équiv.)", 25, "m", "en 6 mm de large, 2 bandes par barrette", "Amazon.ca / Uline", 40),
        _r("Pieds caoutchouc, peinture noire mate, alcool isopropylique", 1, "lot", "", "Canadian Tire", 30),
        # --- outillage ---
        _r("Mèche 9/32 po et 5/16 po (plastique ou réaffûtée 0°), mèche étagée 1/2 po, lame 80 dents", 1, "lot", "", "Home Depot / Amazon.ca", 60),
        # --- PCB ---
        _r("PCB barrette 28B-COL (PCBWay)", 30, "pcs", f"{g.barrette_long:.1f} x {g.barrette_larg:.0f} mm, 2 couches, masque noir, HASL-LF", "PCBWay", 90),
        _r("PCB backplane 28B-BP (PCBWay)", 5, "pcs", "321.3 x 30 mm, 2 couches, masque noir", "PCBWay", 30),
        _r("Stencil acier sans cadre pour la barrette (dessus)", 1, "pcs", "commandé avec les PCB", "PCBWay", 25),
        _r("Transport PCBWay", 1, "", "DHL/FedEx vers le Québec", "PCBWay", 50),
        # --- composants ---
        _r("WS2812B-5050 (5 V)", n_led, "pcs", "boîtier 5050 4 pads, bobine", "LCSC / DigiKey Canada / AliExpress", 45),
        _r("Condensateur 100 nF 0805 X7R 16 V", n_led, "pcs", "1 par LED + U1 backplane", "LCSC / DigiKey Canada", 6),
        _r("Résistance 330 ohm 0805", 30, "pcs", "DATA IN de chaque barrette", "LCSC / DigiKey Canada", 2),
        _r("JST S3B-XH-A (XH 3 broches coudé)", 60, "pcs", "2 par barrette, face arrière", "LCSC / DigiKey Canada", 12),
        _r("JST B3B-XH-A (XH 3 broches vertical)", 40, "pcs", "9 par backplane (7 + IN + OUT), 4 backplanes", "LCSC / DigiKey Canada", 8),
        _r("Bornier à vis 2 pôles 5,08 mm", 4, "pcs", "injection 5 V/GND par segment", "LCSC / DigiKey Canada", 4),
        _r("Condensateur 1000 uF 10 V radial D10 P5", 4, "pcs", "1 par segment", "DigiKey Canada", 4),
        _r("SN74AHCT1G125DBVR (SOT-23-5)", 4, "pcs", "tampon optionnel, 1 par segment", "DigiKey Canada", 4),
        _r("Câbles JST-XH 3 fils femelle-femelle 15 cm", g.n_col, "pcs", "barrette → backplane", "Amazon.ca / AliExpress (pré-sertis)", 20),
        _r("Câbles JST-XH 3 fils femelle-femelle 5 cm", g.n_col // 2, "pcs", "liens du haut (1→2, 3→4 ...)", "Amazon.ca / AliExpress", 10),
        _r("Câbles JST-XH 3 fils 10 cm (segments 2→3) + 2 fils carte → IN", 2, "pcs", "", "Amazon.ca", 5),
        _r("Pâte à souder Sn-Bi basse température (138 °C) + flux", 1, "lot", "refusion 165-180 °C, plaque chauffante", "Amazon.ca / DigiKey Canada", 30),
        # --- alimentation ---
        _r("Adaptateur 5 V 30 A (150 W) de marque, sortie fil nu", 1, "pcs", "20 A minimum ; fusible 30 A en ligne", "Amazon.ca / DigiKey Canada (Mean Well)", 70),
        _r("XT60 panneau femelle + XT60 mâle + porte-fusible 30 A", 1, "lot", "", "Amazon.ca", 12),
        _r("Fil 16 AWG rouge/noir (5 m) + 18 AWG + WAGO 221-415 (5)", 1, "lot", "distribution dans la base", "Home Depot / Amazon.ca", 25),
    ]


def write_bom(g: Geom, md_path: str, csv_path: str) -> None:
    rows = bom_rows(g)
    total = sum(r["prix_cad"] for r in rows)
    os.makedirs(os.path.dirname(md_path) or ".", exist_ok=True)
    with open(md_path, "w", encoding="utf-8") as f:
        f.write("# BOM — Spectrum 28 bandes\n\n")
        f.write(f"Générée depuis `params.py` (t_rondelle = {g.t_rondelle} mm, pas vertical = {g.pas_rang:.2f} mm, "
                f"l_barre = {g.l_barre:.0f} mm). Prix approximatifs en CAD, à confirmer.\n\n")
        f.write("| Poste | Qté | Unité | Détail | Fournisseur | Prix approx. (CAD) |\n|---|---|---|---|---|---|\n")
        for r in rows:
            f.write(f"| {r['poste']} | {r['qte']} | {r['unite']} | {r['detail']} | {r['fournisseur']} | {r['prix_cad']} |\n")
        f.write(f"| **Total (hors ce qui est déjà en main)** | | | | | **≈ {total} $** |\n")
    with open(csv_path, "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=["poste", "qte", "unite", "detail", "fournisseur", "prix_cad"])
        w.writeheader()
        w.writerows(rows)


if __name__ == "__main__":
    write_bom(Geom(), sys.argv[1] if len(sys.argv) > 1 else "out/BOM.md",
              sys.argv[2] if len(sys.argv) > 2 else "out/BOM.csv")
```

Créer `docs/__init__.py` vide : `touch docs/__init__.py`.

- [ ] **Step 4: Vérifier le succès**

Run: `cd /home/claude/spectrum && python3 -m pytest tests/test_bom.py -q 2>&1 | tail -3`
Expected: `2 passed`

- [ ] **Step 5: Écrire `docs/GUIDE-ASSEMBLAGE.md`**

```markdown
# Guide d'assemblage — Spectrum 28 bandes

Référence : spec `docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md`. Dessins dans `out/`. Toutes les cotes en mm sauf mention.

## 0. Avant tout : les trois mesures

1. **Barres d'aluminium** : mesurer la longueur réelle des 4 barres. Mettre la valeur dans `params.py` (`l_barre`). Si < 1481, la marge de la zone tour passe à 10 ; si < 1451, la fente entre blocs passe à ¼ po. `./build.sh` régénère tout.
2. **Rondelles** : acheter les 5000 rondelles ¼ po SAE d'un seul lot. Empiler 40 rondelles, mesurer la pile au vernier, diviser par 40 → `t_rondelle`. Régénérer. Le pas vertical des LED sur la barrette dépend de cette valeur : **ne pas commander les PCB avant**.
3. **Carte PLATINUM** : mesurer le diamètre des bagues des 4 pots et de l'interrupteur, et leur entraxe → `d_bague_pot`, `d_bague_inter`, `pas_pots`.

## 1. Test de sens et de signal (10 min, avant la commande PCB)

Brancher un bout de ruban WS2812B (≥ 40 LED) sur la borne LEDS de la carte (5 V, DATA, GND). Jouer une toune avec des basses. Noter : la LED 0 et les LED 0-19 forment la colonne 1 ; les LED 20-39 la colonne 2 en sens inverse. Si la colonne 1 réagit aux basses → la chaîne part à gauche (segments 1 et 3 en patron A, 2 et 4 en patron B). Si elle réagit aux aigus et qu'on veut les basses à gauche → la chaîne part à droite : on inverse l'ordre des segments, on les tourne de 180°, et on inverse les patrons. Aucun PCB à changer. Si une LED scintille : prévoir U1 (74AHCT) sur le backplane.

## 2. Commande PCB (PCBWay)

- `out/pcb/barrette-28b/gerbers/` → 30 pcs, 2 couches, 1,6 mm, 1 oz, masque **noir**, sérigraphie blanche, HASL sans plomb, + **stencil dessus** sans cadre.
- `out/pcb/backplane-28b/gerbers/` → 5 pcs, mêmes options, pas de stencil (soudure au fer).
- Ouvrir les `.kicad_pcb` dans KiCad (7, 8 ou 9) pour vérifier avant d'envoyer. Les fichiers Gerber + `.drl` + `.gbrjob` se zippent tels quels.
- Composants : voir `out/BOM.md`.

## 3. Blocs (560 + rechange)

1. Table saw, lame 80 dents, butée de longueur à 38,1 (1½ po). Mesurer les 5 premiers blocs au vernier.
2. Perçage : gabarit `out/dessins-bloc-gabarit.pdf` page 2. Butée arrière + butée latérale, mèche 9/32, basse vitesse, martyr MDF. Percer un trou, tourner le bloc de 180°, percer l'autre.
3. **Essai** : 60 blocs, 3 colonnes enfilées (4 rondelles, bloc, 4 rondelles…). Mesurer la hauteur de 20 rangées : doit être à ±1 mm de 19 × pas. Si ça coince : mèche 5/16.
4. Faire le reste. Optionnel : poncer dessus/dessous au 220-320 pour le look givré (tester sur 3 blocs).

## 4. Base en aluminium

1. Refendre la barre 3 à 3 po (barre du haut) et la barre 4 en 2 × 2½ po (faces avant/arrière). Lame carbure non-ferreux ou faire faire.
2. Imprimer `out/gabarit-1to1-plaque.pdf` à **100 %**, vérifier la barre de 100 mm à la règle, coller les pages bord à bord sur les traits pointillés.
3. Coller le gabarit sur la plaque du dessus ; serrer la barre du haut dessous, alignée sur le bord avant des blocs (y = 20 du gabarit = bord avant de la barre). Pointer et percer les 56 trous de tiges (5/16) à travers les deux pièces. Puis, plaque seule : 28 trous de câbles (½ po, mèche étagée) et les trous de contrôle.
4. Assembler la boîte : cornières ½ po rivetées le long des arêtes, dessus et faces rivetés, dessous vissé. Bouts en acrylique ½ po peints.
5. Face arrière : découpe XT60, trous jacks/DC de la carte, accès USB-C. Peinture si désiré.
6. Monter la carte PLATINUM sous le dessus par les écrous de ses pots. Backplanes sur entretoises M3 sous les trous de câbles. XT60 → porte-fusible → WAGO → 4 paires 16 AWG vers les borniers + 18 AWG vers le jack DC de la carte. DATA + GND de la borne LEDS vers IN du segment 1 (torsadés).

## 5. Barrettes

1. 3 barrettes d'abord : stencil, pâte Sn-Bi, poser 20 LED + 20 condos (attention au repère du pad 1 de la LED, coin coupé), refusion 165-180 °C. Souder R1 et les 2 JST au fer.
2. Tester sur la carte avec un câble JST : les 20 LED répondent. Puis les 25 autres (+2 rechange).
3. Cavaliers des backplanes : segments 1 et 3 → fermer JA0, JA1, JA2, JA3 ; segments 2 et 4 → JB0, JB1, JB2, JB3. JBYP fermé si U1 absent.

## 6. Tour

1. 56 tiges de 17 po : écrou nylock + rondelle, passer par-dessous la plaque.
2. Par colonne : 4 rondelles, bloc, 4 rondelles, … bloc 20, 4 rondelles. Les deux tiges d'une colonne doivent recevoir le même nombre de rondelles.
3. Barre du haut, rondelle, écrou borgne sur chaque tige. Serrer à la main + ¼ de tour, en croisant.
4. Nettoyer la face arrière de chaque colonne à l'alcool. Deux bandes de ruban mousse 2 mm le long des bords de la barrette (jamais sur les LED). Coller : bout IN **en bas** pour les colonnes impaires, **en haut** pour les colonnes paires (barrette tournée de 180°). Bas de la barrette à ~1,5 mm au-dessus de la plaque. Presser 30 s, laisser 24 h avant de câbler.
5. Câbles 15 cm des bas de barrettes vers les prises C1…C7 des backplanes par les trous de câbles. Liens 5 cm en haut : 1→2, 3→4 … 27→28. Segment 2 OUT → segment 3 IN.

## 7. Tests finaux

- [ ] 5 V présent sur les 4 borniers, polarité vérifiée avant de brancher les barrettes.
- [ ] Chaîne : les 560 pixels s'allument en séquence (mode test de la carte ou musique).
- [ ] Aucune LED qui scintille ; sinon activer U1.
- [ ] Alignement : chaque LED au centre de son bloc (±2 mm). Sinon, ajuster les rondelles de la colonne.
- [ ] Réglage des pots (sensibilité, peak, amplitude, luminosité) ; limiter la luminosité si l'adaptateur chauffe.
```

- [ ] **Step 6: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "feat: BOM générée et guide d'assemblage" && git log --oneline | head -1
```

---

### Task 8: `build.sh`, génération de `out/` et livraison

**Files:**
- Create: `build.sh`
- Create: `out/` (généré)
- Modify: `docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md` (statut)

**Interfaces:**
- Consumes: tout ce qui précède.
- Produces: `out/dessins-bloc-gabarit.pdf`, `out/dessins-plaque-barre.pdf`, `out/dessins-assemblage.pdf`, `out/gabarit-1to1-plaque.pdf`, `out/dxf/{bloc,plaque-dessus,barre-haut}.dxf`, `out/pcb/barrette-28b/{barrette-28b.kicad_pcb,gerbers/,barrette-28b-drc.txt}`, `out/pcb/backplane-28b/...`, `out/BOM.md`, `out/BOM.csv`, `out/GUIDE-ASSEMBLAGE.md`, `out/spectrum-28-bandes-livrables.zip`.

- [ ] **Step 1: Écrire `build.sh`**

```bash
#!/usr/bin/env bash
# Régénère tous les livrables dans out/ à partir de params.py.
set -euo pipefail
cd "$(dirname "$0")"
rm -rf out && mkdir -p out/dxf out/pcb
python3 - <<'EOF'
from params import Geom
from drawings.dxf_parts import dxf_bloc, dxf_plaque_dessus, dxf_barre_haut
from drawings.template_1to1 import pdf_gabarit_plaque
from drawings.views import pdf_bloc_et_gabarit, pdf_plaque_et_barre, pdf_assemblage
g = Geom()
print("Geom:", f"t_rondelle={g.t_rondelle} pas_rang={g.pas_rang:.3f} l_barre={g.l_barre} zone_tour={g.zone_tour:.1f} zone_controle={g.zone_controle:.1f} barrette={g.barrette_long:.2f}")
dxf_bloc(g, "out/dxf/bloc.dxf"); dxf_plaque_dessus(g, "out/dxf/plaque-dessus.dxf"); dxf_barre_haut(g, "out/dxf/barre-haut.dxf")
pdf_gabarit_plaque(g, "out/gabarit-1to1-plaque.pdf")
pdf_bloc_et_gabarit(g, "out/dessins-bloc-gabarit.pdf"); pdf_plaque_et_barre(g, "out/dessins-plaque-barre.pdf"); pdf_assemblage(g, "out/dessins-assemblage.pdf")
EOF
python3 pcb/gen_barrette.py out/pcb --gerbers > /dev/null
python3 pcb/gen_backplane.py out/pcb --gerbers > /dev/null
python3 - <<'EOF'
from pcb.kicad_helpers import run_drc
for p in ("out/pcb/barrette-28b/barrette-28b.kicad_pcb", "out/pcb/backplane-28b/backplane-28b.kicad_pcb"):
    e, u = run_drc(p); print(f"DRC {p}: {e} erreurs, {u} non connectés"); assert e == 0 and u == 0
EOF
python3 docs/gen_bom.py out/BOM.md out/BOM.csv
cp docs/GUIDE-ASSEMBLAGE.md out/
cp docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md out/SPEC-DESIGN.md
( cd out && rm -f spectrum-28-bandes-livrables.zip && zip -qr spectrum-28-bandes-livrables.zip . -x "*.zip" )
echo "OK: $(find out -type f | wc -l) fichiers dans out/"
```

- [ ] **Step 2: Lancer la suite complète puis le build**

Run: `cd /home/claude/spectrum && chmod +x build.sh && python3 -m pytest -q 2>&1 | tail -3 && ./build.sh 2>&1 | tail -6`
Expected: tous les tests `passed`, puis `DRC ...: 0 erreurs, 0 non connectés` × 2 et `OK: N fichiers dans out/` (N ≥ 35).
Si la suite complète plante (segfault SWIG en créant deux cartes dans le même processus), lancer les fichiers de tests PCB séparément : `python3 -m pytest tests/test_pcb_barrette.py -q && python3 -m pytest tests/test_pcb_backplane.py -q && python3 -m pytest tests -q --ignore=tests/test_pcb_barrette.py --ignore=tests/test_pcb_backplane.py`.

- [ ] **Step 3: Mettre le spec à jour**

Dans `docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md`, remplacer la ligne `Statut : validé section par section avec Yan, en attente de relecture finale` par `Statut : approuvé par Yan le 2026-11-04 ; livrables générés dans out/ (voir docs/superpowers/plans/2026-11-04-spectrum-28-bandes.md)`.

- [ ] **Step 4: Commit**

```bash
cd /home/claude/spectrum && git add -A && git commit -qm "build: génération complète des livrables dans out/" && git log --oneline | head -1
```

- [ ] **Step 5: Livrer à Yan (dossier `D:\Yetii\spectrum\build\` + projet Yetii)**

1. Copier `out/` et les sources dans `/mnt/user-data/outputs/spectrum-build/` (`cp -r out params.py build.sh drawings pcb docs tests pytest.ini /mnt/user-data/outputs/spectrum-build/`), puis `SendUserFile` sur `/mnt/user-data/outputs/spectrum-build/out/spectrum-28-bandes-livrables.zip` et sur les 4 PDF (`dessins-*.pdf`, `gabarit-1to1-plaque.pdf`).
2. `device_commit_files` avec `stagedPath` = chaque fichier de `/mnt/user-data/outputs/spectrum-build/out/` (PDF, DXF, BOM, guide, zip, les deux `.kicad_pcb` et leurs dossiers `gerbers/`) vers `D:\Yetii\spectrum\build\out\...`, et les sources (`params.py`, `build.sh`, `drawings/*.py`, `pcb/*.py`, `docs/gen_bom.py`, `tests/*.py`) vers `D:\Yetii\spectrum\build\...` (≤ 50 fichiers par appel).
3. `Projects.project_write` : `spectrum/BOM.md` (depuis `out/BOM.md`), `spectrum/GUIDE-ASSEMBLAGE.md`, et une note `spectrum/ETAT-SPECTRUM.md` (≤ 40 lignes) : état du projet, les 3 mesures attendues de Yan, comment régénérer (`params.py` → `build.sh`), emplacement des fichiers.
4. Dire à Yan, en une phrase par item, ce qui est où, et rappeler les 3 mesures.

---

## Self-review (fait à l'écriture du plan)

**Couverture du spec :** §3 géométrie → Task 1 ; §4 blocs/gabarit → Task 4 (dessins) + guide ; §5.1-5.2 tiges/rondelles/barre du haut → Task 2 (DXF barre), Task 4, BOM ; §5.3 collage barrettes → guide + vue de côté ; §5.4 base alu → Task 2 (DXF plaque), Task 3 (gabarit 1:1), Task 4 ; §6.2 barrette → Task 5 ; §6.3 backplane → Task 6 ; §6.4-6.5 câbles/alim → BOM + guide ; §6.6 test de sens → guide §1 ; §6.7 soudure → guide §5 ; §7 BOM → Task 7 ; §8 ordre → guide ; §9 livrables → Task 8. Non couvert volontairement : dessin de la face arrière (positions des jacks à mesurer sur la carte) — mentionné dans le guide §4.5.

**Placeholders :** aucun TBD ; les valeurs « à mesurer » sont des paramètres nominaux explicites de `Geom`.

**Cohérence des noms :** `Geom.pas_rang / pas_col / barrette_long / marge_barrette / zone_tour / l_base / trous_controle` utilisés à l'identique dans les Tasks 2-8 ; `Board.place_by_pad / pad_xy / track / via / zone / text / rect_edge` identiques entre `kicad_helpers`, `gen_barrette`, `gen_backplane` ; `run_drc` retourne `(erreurs, non_connectés)` partout ; nets JST `1=+5V, 2=DATA, 3=GND` identiques barrette/backplane.
