# Compétences Claude_QGIS

<span>RAG de toutes les expériences de Claude dans l'utilisation de QGIS par MCP </span>

# REX 06079

## Contexte

Affichage des IRIS de Mandelieu-la-Napoule (06079) depuis PostGIS Alteris, avec population graduée, étiquettes, fond orthophoto IGN Géoplateforme et cadastre vecteur WFS — entièrement piloté depuis Claude Code via le MCP QGIS, sans interaction manuelle dans QGIS.

---

## Données utilisées

| Table / Source | Schéma / Provider | Contenu |
|---|---|---|
| `contours_iris_2025` | `insee_raw` (PostGIS Alteris) | Géométries IRIS France entière |
| `iris_2025_population` | `insee_raw` (PostGIS Alteris) | Population 2022 par IRIS (recensement) |
| Orthophoto IGN | Géoplateforme (WMTS → XYZ) | `HR.ORTHOIMAGERY.ORTHOPHOTOS` — sans clé API |
| Cadastre parcelles | Géoplateforme WFS `CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle` | Parcelles cadastrales vecteur — OGR driver |

### Colonnes clés — pièges à retenir

**`contours_iris_2025`** : colonnes standard sans espace — `code_insee`, `code_iris`, `nom_iris`, `type_iris`, `geom`

**`iris_2025_population`** : colonnes avec **espace traînant** (import INSEE brut) :
- Clé IRIS : `"IRIS "` (avec espace)
- Commune : `"COM "` (avec espace)
- Population totale 2022 : `"P22_POP "` (avec espace)
- Toutes les colonnes sont de type `text` — caster en `::numeric` avant usage

---

## Résultats

9 IRIS pour 06079, population 2022 :

| code_iris | nom_iris | population |
|---|---|---|
| 060790101 | Zone d'activités | 24 |
| 060790102 | IRIS 2 | 2 701 |
| 060790103 | IRIS 3 | 2 000 |
| 060790104 | IRIS 4 | 3 058 |
| 060790105 | IRIS 5 | 2 197 |
| 060790106 | IRIS 6 | 2 432 |
| 060790107 | IRIS 7 | 3 188 |
| 060790108 | IRIS 8 | 2 944 |
| 060790109 | IRIS 9 | 2 656 |

Population min : **24 hab.** (zone d'activités/naturelle) — max : **3 188 hab.**

Cadastre : **26 439 parcelles** après reprojection EPSG:4326 → EPSG:3857 (COUNT=2000 limite la requête WFS initiale à 2000 features, mais la reprojection en mémoire conserve toutes les géométries valides).

---

## Procédure complète

### Prérequis

Tunnel SSH paramiko actif (voir page 229 — Option 2). À relancer à chaque redémarrage de QGIS :

```python
import sys
sys.path.insert(0, r'C:\Users\eliob\AppData\Roaming\Python\Python312\site-packages')
import paramiko
import socket, threading, select

class SSHTunnel(threading.Thread):
    def __init__(self, ssh_host, ssh_user, ssh_password,
                 remote_port, local_port=5433, remote_host='127.0.0.1'):
        super().__init__(daemon=True)
        self.ssh_host = ssh_host; self.ssh_user = ssh_user
        self.ssh_password = ssh_password; self.remote_host = remote_host
        self.remote_port = remote_port; self.local_port = local_port
        self._stop = threading.Event(); self.transport = None; self.server_sock = None

    def run(self):
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(self.ssh_host, username=self.ssh_user, password=self.ssh_password)
        self.transport = client.get_transport()
        self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server_sock.bind(('127.0.0.1', self.local_port))
        self.server_sock.listen(5); self.server_sock.settimeout(1.0)
        while not self._stop.is_set():
            try:
                conn, _ = self.server_sock.accept()
                threading.Thread(target=self._forward, args=(conn,), daemon=True).start()
            except socket.timeout:
                continue
        self.server_sock.close(); client.close()

    def _forward(self, local_conn):
        try:
            chan = self.transport.open_channel('direct-tcpip',
                (self.remote_host, self.remote_port), local_conn.getpeername())
        except Exception:
            local_conn.close(); return
        while True:
            r, _, _ = select.select([local_conn, chan], [], [], 5)
            if local_conn in r:
                data = local_conn.recv(4096)
                if not data: break
                chan.send(data)
            if chan in r:
                data = chan.recv(4096)
                if not data: break
                local_conn.send(data)
        chan.close(); local_conn.close()

    def stop(self):
        self._stop.set()

tunnel = SSHTunnel('79.137.14.202', 'debian', 'RAW+NEXTE!', remote_port=5432, local_port=5433)
tunnel.start()
```

### 1. Charger l'orthophoto IGN (fond de carte)

**Bug QGIS 4.0.3** : `addMapLayer` déclenche `autoSelectAddedLayer` → `identifyMapTool` → access violation quand une couche raster est ajoutée. Deux contournements obligatoires :
1. Passer sur l'outil **Pan** avant d'ajouter la couche
2. Utiliser `addMapLayer(layer, False)` + `root.insertLayer()` (sans auto-sélection)

**Format URI** : le provider WMTS natif de QGIS (`crs=...&layers=...&url=...`) échoue sur la Géoplateforme (pas de capabilities). Solution : encoder l'URL WMTS en **XYZ tiles** avec `{z}/{y}/{x}`.

```python
from qgis.core import QgsProject, QgsRasterLayer
from qgis.gui import QgsMapToolPan
from qgis.utils import iface
import urllib.parse

# 1. Passer sur Pan AVANT d'ajouter la couche raster (évite le crash identifyMapTool)
pan_tool = QgsMapToolPan(iface.mapCanvas())
iface.mapCanvas().setMapTool(pan_tool)

# 2. URI XYZ (le provider WMTS natif échoue sur data.geopf.fr)
base_url = (
    "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile"
    "&VERSION=1.0.0&LAYER=HR.ORTHOIMAGERY.ORTHOPHOTOS"
    "&STYLE=normal&FORMAT=image/jpeg"
    "&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}"
)
encoded = urllib.parse.quote(base_url, safe='')
uri = f"type=xyz&url={encoded}&zmin=0&zmax=19"

layer_ortho = QgsRasterLayer(uri, "Orthophoto IGN", "wms")
# 3. addToLegend=False + insertLayer manuel (évite autoSelectAddedLayer)
QgsProject.instance().addMapLayer(layer_ortho, False)
root = QgsProject.instance().layerTreeRoot()
root.insertLayer(-1, layer_ortho)  # en bas de la pile
```

### 2. Créer la vue PostGIS et charger les IRIS

```python
import psycopg2
conn = psycopg2.connect(host='localhost', port=5433, dbname='alteris_geo',
                        user='alteris_admin', password='Alteris2026')
cur = conn.cursor()
cur.execute("""
    CREATE OR REPLACE VIEW insee_raw.v_iris_mandelieu_pop AS
    SELECT c.fid, c.geom, c.nom_iris, c.code_iris,
           ROUND(p."P22_POP "::numeric, 0)::integer AS population
    FROM insee_raw.contours_iris_2025 c
    LEFT JOIN insee_raw.iris_2025_population p ON p."IRIS " = c.code_iris
    WHERE c.code_insee = '06079'
""")
conn.commit(); conn.close()

from qgis.core import QgsVectorLayer
uri = (
    'host=localhost port=5433 dbname=alteris_geo '
    'user=alteris_admin password=Alteris2026 sslmode=disable '
    'table="insee_raw"."v_iris_mandelieu_pop" (geom) key=fid'
)
layer = QgsVectorLayer(uri, "IRIS Mandelieu-la-Napoule", "postgres")
```

### 3. Graduation par population (semi-transparent)

```python
from qgis.core import QgsGraduatedSymbolRenderer, QgsRendererRange, QgsFillSymbol

colors = ['#EFF3FF', '#BDD7E7', '#6BAED6', '#2171B5', '#084594']
pmin, pmax = 24, 3188
step = (pmax - pmin) / 5
bounds = [pmin + i * step for i in range(6)]

ranges_def = []
for i in range(5):
    lo, hi = bounds[i], bounds[i+1]
    sym = QgsFillSymbol.createSimple({
        'color': colors[i], 'outline_color': '#555555', 'outline_width': '0.3'
    })
    sym.setOpacity(0.6)  # semi-transparent pour laisser voir l'ortho
    ranges_def.append(QgsRendererRange(lo, hi, sym,
        f"{int(lo):,}–{int(hi):,} hab.".replace(',', ' ')))

layer.setRenderer(QgsGraduatedSymbolRenderer('population', ranges_def))
```

### 4. Étiquettes + ajout dans le bon ordre

```python
from qgis.core import (QgsPalLayerSettings, QgsTextFormat,
    QgsTextBufferSettings, QgsVectorLayerSimpleLabeling)
from qgis.PyQt.QtGui import QColor, QFont

pal = QgsPalLayerSettings()
pal.fieldName = "concat(nom_iris, '\n', population, ' hab.')"
pal.isExpression = True
pal.placement = QgsPalLayerSettings.Placement.OverPoint
fmt = QgsTextFormat()
fmt.setFont(QFont("Arial", 8)); fmt.setSize(8); fmt.setColor(QColor('#1a1a1a'))
buf = QgsTextBufferSettings()
buf.setEnabled(True); buf.setSize(1); buf.setColor(QColor('white'))
fmt.setBuffer(buf); pal.setFormat(fmt)
layer.setLabelsEnabled(True)
layer.setLabeling(QgsVectorLayerSimpleLabeling(pal))

# IRIS en haut de la pile (index 0), ortho déjà en bas
QgsProject.instance().addMapLayer(layer, False)
root = QgsProject.instance().layerTreeRoot()
root.insertLayer(0, layer)
```

### 5. Zoomer sur Mandelieu

```python
from qgis.core import QgsRectangle, QgsCoordinateReferenceSystem, QgsCoordinateTransform

src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
dst_crs = QgsCoordinateReferenceSystem("EPSG:3857")
transform = QgsCoordinateTransform(src_crs, dst_crs, QgsProject.instance())
pt_min = transform.transform(6.87, 43.51)
pt_max = transform.transform(6.99, 43.60)
extent = QgsRectangle(pt_min.x(), pt_min.y(), pt_max.x(), pt_max.y())
iface.mapCanvas().setExtent(extent)
iface.mapCanvas().refresh()
```

### 6. Charger le cadastre (WFS vecteur)

**Pièges** :
- Le provider **WFS natif QGIS** est invalide sur `data.geopf.fr` pour ce typename → utiliser le driver **OGR WFS**
- L'**API Carto** (`geo.api.gouv.fr`) a timeout (appel synchrone dans le thread QGIS, 20 s) → abandonné
- Le WFS retourne les données en **EPSG:4326** mais le canevas est en **EPSG:3857** → la couche apparaît dans le panneau mais n'est pas visible sur la carte → reproj obligatoire

```python
import processing
from qgis.core import (QgsVectorLayer, QgsCoordinateReferenceSystem,
                       QgsFillSymbol, QgsSingleSymbolRenderer, QgsProject)

# Driver OGR WFS avec BBOX en paramètre URL (le provider WFS natif QGIS échoue)
url = (
    "WFS:https://data.geopf.fr/wfs/ows?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature"
    "&TYPENAME=CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle"
    "&BBOX=6.87,43.51,6.99,43.60,EPSG:4326&COUNT=2000"
)
layer_wfs = QgsVectorLayer(url, "Cadastre Mandelieu", "ogr")
# → isValid() = True, 2000 features, EPSG:4326

# Reprojection en mémoire EPSG:3857 (sinon couche invisible dans le canevas)
result = processing.run("native:reprojectlayer", {
    'INPUT': layer_wfs,
    'TARGET_CRS': QgsCoordinateReferenceSystem('EPSG:3857'),
    'OUTPUT': 'memory:'
})
layer_cad = result['OUTPUT']
layer_cad.setName("Cadastre Mandelieu")
# → 26 439 features, EPSG:3857

# Symbologie : contour orange, remplissage quasi-transparent
sym = QgsFillSymbol.createSimple({
    'color': '204,68,0,30',      # orange très transparent (alpha 30/255)
    'outline_color': '#CC4400',
    'outline_width': '0.5'
})
layer_cad.setRenderer(QgsSingleSymbolRenderer(sym))

# Ajouter au-dessus des IRIS (index 0)
QgsProject.instance().addMapLayer(layer_cad, False)
root = QgsProject.instance().layerTreeRoot()
root.insertLayer(0, layer_cad)
```

**Ordre final des couches (haut → bas)** :
1. Cadastre Mandelieu (vecteur WFS, EPSG:3857)
2. IRIS Mandelieu-la-Napoule (PostGIS, gradué population)
3. Orthophoto IGN (XYZ, fond)

**Note cadastre** : le BBOX rectangulaire déborde sur les communes voisines (Cannes à l'est, Pégomas au nord). Comportement attendu — pour restreindre au territoire communal strict, il faudrait une intersection post-chargement (pas de filtre `code_commune` disponible côté WFS Géoplateforme).

---

## Points clés et pièges

| Sujet | Constat |
|---|---|
| **Bug QGIS 4.0.3 — raster + identifyMapTool** | `addMapLayer` sur une couche raster → access violation si l'outil Identifier est actif. Fix : passer sur Pan + `addMapLayer(layer, False)` + `insertLayer()` |
| **Ne pas cliquer sur la couche raster dans le panneau** | Même après ajout réussi, cliquer sur la couche raster dans le panneau des couches déclenche `onActiveLayerChanged` → crash. Rester sur l'outil Pan. |
| **Provider WMTS natif QGIS invalide** | `crs=...&layers=...&url=https://data.geopf.fr/wmts` → couche invalide. Fix : encoder en XYZ avec `type=xyz&url=...{z}/{y}/{x}` |
| **Canvas au zoom mondial au démarrage** | Les tuiles ne se chargent pas. Toujours zoomer sur la zone cible après ajout. |
| **Provider WFS natif QGIS invalide** | `QgsVectorLayer(url, name, "WFS")` → couche invalide pour `CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle`. Fix : driver OGR WFS `QgsVectorLayer("WFS:https://...", name, "ogr")` |
| **API Carto timeout** | `geo.api.gouv.fr` : appel HTTP synchrone dans le thread QGIS → 20 s de blocage puis timeout. Driver OGR WFS Géoplateforme préférable. |
| **CRS mismatch WFS** | WFS retourne EPSG:4326, canevas EPSG:3857 → couche présente dans le panneau mais invisible sur la carte. Fix : `processing.run("native:reprojectlayer", ...)` vers EPSG:3857 en mémoire. |
| **Colonnes INSEE avec espace** | `"IRIS "`, `"COM "`, `"P22_POP "` — espace traînant partout dans `iris_2025_population` |
| **Types text** | Toutes les valeurs numériques INSEE sont en `text` — caster avec `::numeric` |
| **Sous-requête URI QGIS** | `QgsDataSourceUri.setDataSource()` sur-échappe les guillemets → couche invalide. Fix : créer une vue PostgreSQL |
| **`sys.executable` QGIS** | Pointe sur `qgis-bin.exe` — pip via subprocess inutilisable. Fix : `pip._internal.cli.main` |
| **`sys.path` paramiko** | QGIS n'inclut pas le dossier utilisateur Python. Ajouter `sys.path.insert(0, ...)` à chaque session |

---

## Généralisation à d'autres communes

```python
code_insee = '06079'  # remplacer par le code cible
cur.execute(f"""
    CREATE OR REPLACE VIEW insee_raw.v_iris_pop AS
    SELECT c.fid, c.geom, c.nom_iris, c.code_iris, c.nom_commune,
           ROUND(p."P22_POP "::numeric, 0)::integer AS population
    FROM insee_raw.contours_iris_2025 c
    LEFT JOIN insee_raw.iris_2025_population p ON p."IRIS " = c.code_iris
    WHERE c.code_insee = '{code_insee}'
""")
```

Pour le cadastre, adapter le BBOX à l'emprise de la commune cible.

---

## REX — 2026-06-21 (second run)

**Résultat : 0 crash — procédure validée reproductible.**

Run complet depuis un projet QGIS vide, piloté intégralement via MCP Claude Code → QGIS :

| Étape | Résultat |
|---|---|
| Tunnel SSH paramiko | OK — PostGIS Alteris accessible localhost:5433 |
| Orthophoto IGN (XYZ) | Valide — rendu fond de carte immédiat |
| Vue PostGIS + couche IRIS | 9 features, graduation 5 classes bleu, étiquettes population |
| Zoom sur Mandelieu | Cadrage correct EPSG:3857 |
| Cadastre WFS OGR + reprojection | 26 439 parcelles EPSG:3857, symbologie orange |

**Conclusion** : la procédure est stable et reproductible. Base solide pour une routine généralisable à n'importe quelle commune (`code_insee` + BBOX comme seuls paramètres variables). Potentiel : automatiser la production de fiches communales IRIS + cadastre + fond ortho à la demande depuis Claude Code.

# Expertises Topologie - Claude et Qgis

## Contexte PLU

Pour une couche de zones PLU topologiquement correcte, trois règles sont non-négociables :
1. **Pas de gaps** — tout le territoire communal doit être couvert, sans trou entre zones
2. **Pas d'overlaps** — une parcelle n'appartient qu'à une seule zone
3. **Géométries valides** — pas de self-intersection, pas de géométrie nulle

Ces trois vérifications + corrections sont pilotables intégralement depuis Claude Code via le MCP QGIS, sans manipulation manuelle.

---

## Bloc 1 — Configuration du snapping avant saisie

À lancer en début de session d'édition sur une couche PLU. Configure l'environnement QGIS pour que la saisie soit topologiquement propre dès le départ.

```python
from qgis.core import QgsSnappingConfig, QgsTolerance, QgsProject

proj = QgsProject.instance()
snap_cfg = proj.snappingConfig()

snap_cfg.setEnabled(True)
snap_cfg.setMode(QgsSnappingConfig.SnappingMode.AllLayers)
snap_cfg.setType(QgsSnappingConfig.SnappingType.VertexAndSegment)
snap_cfg.setTolerance(10.0)
snap_cfg.setUnits(QgsTolerance.UnitType.Pixels)
snap_cfg.setIntersectionSnapping(True)   # accrochage sur les intersections

proj.setSnappingConfig(snap_cfg)
proj.setTopologicalEditing(True)          # partage de noeuds entre couches
proj.setAvoidIntersectionsMode(
    QgsProject.AvoidIntersectionsMode.AvoidIntersectionsLayers
)  # évite automatiquement les overlaps lors de la saisie
```

**Paramètres clés** :

| Paramètre | Valeur | Effet |
|---|---|---|
| `AllLayers` | mode | snap sur toutes les couches visibles |
| `VertexAndSegment` | type | accroche vertex ET bord |
| `10 px` | tolérance | adapté à la saisie courante PLU |
| `TopologicalEditing` | True | noeuds partagés entre polygones adjacents |
| `AvoidIntersectionsLayers` | mode | saisie d'un polygone qui "découpe" automatiquement ses voisins |

**Piège** : `setType()` attend `QgsSnappingConfig.SnappingType` — pas `Qgis.SnappingType` ni `Qgis.SnappingTypes`.

---

## Bloc 2 — Audit topologique

Routine complète : validité + overlaps + gaps sur n'importe quelle couche polygone.

```python
import processing
from qgis.core import QgsProject

def audit_topo(layer, unique_id='fid', gap_threshold=0, min_overlap_area=0):
    """
    Audit topologique d'une couche polygone PLU.
    Retourne un dict avec les couches d'erreurs et un résumé.
    """
    rapport = {}

    # 1. Validité géométrique (GEOS)
    r_val = processing.run("native:checkvalidity", {
        'INPUT_LAYER': layer,   # NOTE : INPUT_LAYER, pas INPUT
        'METHOD': 2,            # GEOS
        'IGNORE_RING_SELF_INTERSECTION': False,
        'VALID_OUTPUT': 'memory:',
        'INVALID_OUTPUT': 'memory:',
        'ERROR_OUTPUT': 'memory:'
    })
    rapport['validite'] = {
        'valides': r_val['VALID_COUNT'],
        'invalides': r_val['INVALID_COUNT'],
        'erreurs': r_val['ERROR_COUNT'],
        'layer_invalides': r_val['INVALID_OUTPUT'],
        'layer_erreurs': r_val['ERROR_OUTPUT']
    }

    # 2. Overlaps
    r_ov = processing.run("native:checkgeometryoverlap", {
        'INPUT': layer,
        'UNIQUE_ID': unique_id,
        'MIN_OVERLAP_AREA': min_overlap_area,
        'TOLERANCE': 8,
        'ERRORS': 'memory:',
        'OUTPUT': 'memory:'
    })
    rapport['overlaps'] = {
        'count': r_ov['ERRORS'].featureCount(),
        'layer_errors': r_ov['ERRORS'],
        'layer_features': r_ov['OUTPUT']
    }

    # 3. Gaps
    r_gap = processing.run("native:checkgeometrygap", {
        'INPUT': layer,
        'UNIQUE_ID': unique_id,
        'GAP_THRESHOLD': gap_threshold,
        'TOLERANCE': 8,
        'NEIGHBORS': 'memory:',
        'ERRORS': 'memory:',
        'OUTPUT': 'memory:'
    })
    rapport['gaps'] = {
        'count': r_gap['OUTPUT'].featureCount(),
        'layer_gaps': r_gap['OUTPUT'],
        'layer_neighbors': r_gap['NEIGHBORS'],  # requis pour fixgeometrygap
        'layer_errors': r_gap['ERRORS']
    }

    # Résumé lisible
    print(f"=== Audit topo : {layer.name()} ({layer.featureCount()} features) ===")
    print(f"Validité  : {rapport['validite']['valides']} OK / "
          f"{rapport['validite']['invalides']} invalides")
    print(f"Overlaps  : {rapport['overlaps']['count']} erreur(s)")
    print(f"Gaps      : {rapport['gaps']['count']} trou(s)")
    ok = (rapport['validite']['invalides'] == 0 and
          rapport['overlaps']['count'] == 0 and
          rapport['gaps']['count'] == 0)
    print(f"Résultat  : {'PROPRE' if ok else 'ERREURS DETECTEES'}")

    return rapport
```

**Usage** :
```python
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("zones_plu")[0]
rapport = audit_topo(layer, unique_id='fid')
```

**Piège critique** : `native:checkvalidity` → paramètre `INPUT_LAYER` (pas `INPUT`). Erreur silencieuse sinon.

---

## Bloc 3 — Correction automatique

S'appuie sur les couches d'erreurs produites par le Bloc 2.

### 3a. Corriger les overlaps

```python
r_fix_ov = processing.run("native:fixgeometryoverlap", {
    'INPUT': layer,
    'ERRORS': rapport['overlaps']['layer_errors'],
    'UNIQUE_ID': 'fid',
    'OVERLAP_FEATURE_UNIQUE_IDX': 'gc_overlap_fid',  # champ produit par checkgeometryoverlap
    'ERROR_VALUE_ID': 'gc_error',
    'OUTPUT': 'memory:',
    'REPORT': 'memory:',
    'TOLERANCE': 8
})
layer_corrige = r_fix_ov['OUTPUT']
print(f"Overlaps corrigés : {r_fix_ov['REPORT'].featureCount()} opérations")
```

### 3b. Combler les gaps

**3 méthodes disponibles** :

| Valeur | Méthode | Usage recommandé |
|---|---|---|
| `0` | Ajouter à la plus longue bordure en commun | PLU — zone voisine qui partage le plus de frontière |
| `1` | Créer une nouvelle entité | si le gap est une zone à part entière |
| `2` | Ajouter à la plus grande zone voisine | quand la surface prime |

```python
r_fix_gap = processing.run("native:fixgeometrygap", {
    'INPUT': layer,
    'NEIGHBORS': rapport['gaps']['layer_neighbors'],  # issu de checkgeometrygap
    'GAPS': rapport['gaps']['layer_gaps'],
    'METHOD': 0,    # 0 = plus longue bordure (recommandé PLU)
    'UNIQUE_ID': 'fid',
    'ERROR_ID_IDX': 'gc_errorid',
    'OUTPUT': 'memory:',
    'REPORT': 'memory:',
    'TOLERANCE': 8
})
layer_sans_gap = r_fix_gap['OUTPUT']
print(f"Gaps comblés : {r_fix_gap['REPORT'].featureCount()} opérations")
```

### 3c. Corriger les géométries invalides (self-intersections, anneaux)

```python
r_fix_val = processing.run("native:fixgeometries", {
    'INPUT': rapport['validite']['layer_invalides'],
    'METHOD': 1,    # 1 = structure linéaire (robuste)
    'OUTPUT': 'memory:'
})
layer_valide = r_fix_val['OUTPUT']
```

---

## Bloc 4 — Accrochage post-saisie (snapgeometries)

Quand une couche a été saisie sans snapping actif, ou importée avec de micro-décalages entre polygones adjacents. Accroche les géométries d'une couche sur une couche de référence (ex : limites communales, autre zonage).

**8 comportements disponibles** :

| Valeur | Comportement | Usage |
|---|---|---|
| `0` | Aligner les nœuds, ajoute sommets si besoin | **défaut recommandé** |
| `1` | Point le plus proche, ajoute sommets | si nœuds mal alignés |
| `2` | Aligner les nœuds, sans ajout de sommet | quand on veut conserver la densité |
| `4` | Déplacer uniquement les extrémités (nœuds) | micro-corrections de bord |
| `6` | Extrémités sur extrémités uniquement | lignes/filaires |

```python
r_snap = processing.run("native:snapgeometries", {
    'INPUT': layer_a_corriger,
    'REFERENCE_LAYER': layer_reference,
    'TOLERANCE': 0.5,    # en unités de la couche (mètres si Lambert 93)
    'BEHAVIOR': 0,
    'OUTPUT': 'memory:'
})
layer_snapped = r_snap['OUTPUT']
```

**Recommandation PLU** : tolérance 0.1–0.5 m en EPSG:2154 (Lambert 93). Trop grande → déformation des géométries.

---

## Bloc 5 — Reconstruction automatique depuis le cadastre

Quand une zone (ex : périmètre PLU, emprise de projet) a été saisie grossièrement et doit être recalée exactement sur les limites parcellaires cadastrales. Claude reconstruit le polygone par dissolution des parcelles qui chevauchent la zone rough ; l'opérateur complète ensuite manuellement les parties sans référence cadastrale (domaine public non-parcellaire : voiries, cours d'eau).

**Validé le 2026-06-21** sur `06079-Minelle.gpkg` (Mandelieu-la-Napoule) : 11 parcelles intersectantes → 2 retenues (overlap ≥ 50%) → 137 004 m², 31 nœuds, géométrie valide.

### Principe

1. Fixer les géométries du cadastre (WFS souvent invalide)
2. Extraire les parcelles qui intersectent la zone rough
3. Calculer le % de chevauchement pour chaque parcelle → garder celles à ≥ 50%
4. Dissoudre → multiparttosingleparts → garder la plus grande part
5. Écrire dans le GeoPackage en retirant d'abord les couches QGIS (file locking Windows)

### Code complet

```python
import processing
import urllib.parse
from qgis.core import (
    QgsProject, QgsVectorFileWriter, QgsWkbTypes,
    QgsFields, QgsMemoryProviderUtils
)

# Pré-requis : layer_cad (cadastre WFS reprojeté EPSG:3857)
#              layer_zone (zone rough à recaler)
#              gpkg_path (chemin du GeoPackage à écrire)

# 1. Fixer les géométries cadastrales (WFS Géoplateforme souvent invalide)
cad_fixed = processing.run("native:fixgeometries", {
    'INPUT': layer_cad,
    'METHOD': 1,    # structure linéaire, robuste
    'OUTPUT': 'memory:'
})['OUTPUT']

# 2. Extraire les parcelles qui intersectent la zone rough
parcelles_in = processing.run("native:extractbylocation", {
    'INPUT': cad_fixed,
    'PREDICATE': [0],   # intersects
    'INTERSECT': layer_zone,
    'OUTPUT': 'memory:'
})['OUTPUT']

# 3. Calculer le % de chevauchement et retenir celles ≥ 50%
geom_zone = next(layer_zone.getFeatures()).geometry()
a_inclure = []
for f in parcelles_in.getFeatures():
    inter = f.geometry().intersection(geom_zone)
    pct = inter.area() / f.geometry().area() * 100
    if pct >= 50:
        a_inclure.append(f['gid'])

print(f"{parcelles_in.featureCount()} parcelles intersectantes → "
      f"{len(a_inclure)} retenues (overlap ≥ 50%)")
print(f"GIDs retenus : {a_inclure}")

# 4. Sélectionner + dissoudre
ids_str = ', '.join(str(i) for i in a_inclure)
parcelles_sel = processing.run("native:extractbyexpression", {
    'INPUT': cad_fixed,
    'EXPRESSION': f'"gid" IN ({ids_str})',
    'OUTPUT': 'memory:'
})['OUTPUT']

dissolved = processing.run("native:dissolve", {
    'INPUT': parcelles_sel,
    'FIELD': [],
    'SEPARATE_DISJOINT': False,
    'OUTPUT': 'memory:'
})['OUTPUT']

# 5. Single part → garder la plus grande
single = processing.run("native:multiparttosingleparts", {
    'INPUT': dissolved,
    'OUTPUT': 'memory:'
})['OUTPUT']

largest = max(single.getFeatures(), key=lambda f: f.geometry().area())
print(f"Résultat : {largest.geometry().area():.0f} m², "
      f"{largest.geometry().constGet().nCoordinates()} nœuds")

layer_main = QgsMemoryProviderUtils.createMemoryLayer(
    "main", QgsFields(), QgsWkbTypes.Type.Polygon, single.crs()
)
layer_main.dataProvider().addFeature(largest)

# 6. Écrire dans le GeoPackage
# CRITIQUE : retirer toutes les couches QGIS référençant le fichier avant écriture
for name in ["06079-Minelle"]:
    for lyr in QgsProject.instance().mapLayersByName(name):
        QgsProject.instance().removeMapLayer(lyr)

options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "GPKG"
options.layerName = "06079-Minelle"
options.actionOnExistingFile = QgsVectorFileWriter.ActionOnExistingFile.CreateOrOverwriteLayer

# writeAsVectorFormatV3 retourne un tuple de 4 valeurs
res = QgsVectorFileWriter.writeAsVectorFormatV3(
    layer_main, gpkg_path, QgsCoordinateTransformContext(), options
)
# res = (error_code, error_message, filename, layername)
if res[0] == 0:
    print(f"Écrit : {gpkg_path} → couche '{options.layerName}'")
else:
    print(f"ERREUR {res[0]} : {res[1]}")

# 7. Recharger dans QGIS pour vérification
layer_reload = QgsVectorLayer(
    f"{gpkg_path}|layername={options.layerName}",
    options.layerName, "ogr"
)
QgsProject.instance().addMapLayer(layer_reload, False)
root = QgsProject.instance().layerTreeRoot()
root.insertLayer(0, layer_reload)
iface.mapCanvas().setExtent(layer_reload.extent())
iface.mapCanvas().refresh()
print("Couche rechargée et zoomée.")
```

### Pièges spécifiques

| Sujet | Constat |
|---|---|
| **`writeAsVectorFormatV3` retourne 4 valeurs** | `(error_code, error_message, filename, layername)` — pas 2. Déstructurer en conséquence. |
| **File locking Windows** | QGIS garde un handle sur le fichier GeoPackage. Retirer toutes les couches référençant le fichier avec `removeMapLayer()` avant d'écrire. |
| **`CreateOrOverwriteLayer` pas `CreateOrOverwriteFile`** | `CreateOrOverwriteFile` supprime tout le fichier (détruisant les autres couches). `CreateOrOverwriteLayer` ne touche qu'à la couche cible. |
| **`native:savefeatures` échoue si fichier existe** | "A file system object already exists" — utiliser `writeAsVectorFormatV3` à la place. |
| **MultiPolygon → `asPolygon()` fail** | Si les parcelles retenues ne sont pas adjacentes, le dissolve produit un MultiPolygon. Appliquer `multiparttosingleparts` + `max(..., key=lambda f: f.geometry().area())` pour garder la plus grande part. |
| **Cadastre WFS invalide** | La Géoplateforme retourne parfois des géométries invalides (ex : feature 15026). Toujours fixer avec `native:fixgeometries METHOD=1` avant usage comme couche de référence. |

### Workflow collaboratif

Claude reconstruit le périmètre sur les limites parcellaires exactes. L'opérateur complète manuellement les parties manquantes non-parcellaires (domaine public : voiries, cours d'eau, espaces naturels non cadastrés). Les deux résultats sont complémentaires : la précision automatique sur le cadastre + le jugement humain sur le hors-cadastre.

---

## Routine principale — Audit + Rapport

Fonction complète prête à l'emploi. Appeler par Claude en une seule commande.

```python
import processing
from qgis.core import QgsProject, QgsSnappingConfig, QgsTolerance

def configurer_snapping_plu():
    proj = QgsProject.instance()
    snap_cfg = proj.snappingConfig()
    snap_cfg.setEnabled(True)
    snap_cfg.setMode(QgsSnappingConfig.SnappingMode.AllLayers)
    snap_cfg.setType(QgsSnappingConfig.SnappingType.VertexAndSegment)
    snap_cfg.setTolerance(10.0)
    snap_cfg.setUnits(QgsTolerance.UnitType.Pixels)
    snap_cfg.setIntersectionSnapping(True)
    proj.setSnappingConfig(snap_cfg)
    proj.setTopologicalEditing(True)
    proj.setAvoidIntersectionsMode(
        QgsProject.AvoidIntersectionsMode.AvoidIntersectionsLayers)
    print("Snapping PLU configuré")

def audit_topo(layer, unique_id='fid', gap_threshold=0, min_overlap_area=0):
    r_val = processing.run("native:checkvalidity", {
        'INPUT_LAYER': layer, 'METHOD': 2,
        'IGNORE_RING_SELF_INTERSECTION': False,
        'VALID_OUTPUT': 'memory:', 'INVALID_OUTPUT': 'memory:', 'ERROR_OUTPUT': 'memory:'
    })
    r_ov = processing.run("native:checkgeometryoverlap", {
        'INPUT': layer, 'UNIQUE_ID': unique_id,
        'MIN_OVERLAP_AREA': min_overlap_area, 'TOLERANCE': 8,
        'ERRORS': 'memory:', 'OUTPUT': 'memory:'
    })
    r_gap = processing.run("native:checkgeometrygap", {
        'INPUT': layer, 'UNIQUE_ID': unique_id,
        'GAP_THRESHOLD': gap_threshold, 'TOLERANCE': 8,
        'NEIGHBORS': 'memory:', 'ERRORS': 'memory:', 'OUTPUT': 'memory:'
    })
    rapport = {
        'layer': layer,
        'unique_id': unique_id,
        'validite': {
            'valides': r_val['VALID_COUNT'],
            'invalides': r_val['INVALID_COUNT'],
            'layer_invalides': r_val['INVALID_OUTPUT']
        },
        'overlaps': {
            'count': r_ov['ERRORS'].featureCount(),
            'layer_errors': r_ov['ERRORS'],
            'layer_features': r_ov['OUTPUT']
        },
        'gaps': {
            'count': r_gap['OUTPUT'].featureCount(),
            'layer_gaps': r_gap['OUTPUT'],
            'layer_neighbors': r_gap['NEIGHBORS'],
            'layer_errors': r_gap['ERRORS']
        }
    }
    ok = (r_val['INVALID_COUNT'] == 0 and
          r_ov['ERRORS'].featureCount() == 0 and
          r_gap['OUTPUT'].featureCount() == 0)
    print(f"\n=== Audit topo : {layer.name()} ===")
    print(f"  Géométries invalides : {r_val['INVALID_COUNT']}")
    print(f"  Overlaps             : {r_ov['ERRORS'].featureCount()}")
    print(f"  Gaps                 : {r_gap['OUTPUT'].featureCount()}")
    print(f"  => {'PROPRE' if ok else 'ERREURS — voir rapport'}")
    return rapport

# Usage type
# layer = QgsProject.instance().mapLayersByName("zones_plu")[0]
# configurer_snapping_plu()
# rapport = audit_topo(layer)
```

---

## Pièges à retenir

| Sujet | Constat |
|---|---|
| **`INPUT_LAYER` vs `INPUT`** | `native:checkvalidity` exige `INPUT_LAYER`, tous les autres utilisent `INPUT`. Erreur silencieuse sinon. |
| **`QgsSnappingConfig.SnappingType`** | `setType()` attend ce type précis — pas `Qgis.SnappingType` ni `Qgis.SnappingTypes`. |
| **NEIGHBORS obligatoire pour fixgeometrygap** | La couche `NEIGHBORS` produite par `checkgeometrygap` doit être conservée et passée à `fixgeometrygap`. |
| **Tolérance snapgeometries en unités couche** | Si la couche est en Lambert 93 (mètres), 0.5 = 50 cm. Ne pas confondre avec la tolérance en pixels du snapping canvas. |
| **METHOD 0 pour PLU gaps** | "Plus longue bordure en commun" est la méthode la plus cohérente pour les zonages PLU (le gap va à la zone avec laquelle il partage le plus de frontière). |
| **AvoidIntersectionsLayers** | Mode le plus adapté PLU — évite les overlaps lors de la saisie mais uniquement sur les couches cochées dans les paramètres du projet. |
| **`writeAsVectorFormatV3` retourne 4 valeurs** | `(error_code, error_message, filename, layername)` — pas 2. |
| **File locking GeoPackage Windows** | Retirer toutes les couches QGIS référençant le fichier avant écriture, utiliser `CreateOrOverwriteLayer`. |

---

## Statut

- **Validé le 2026-06-21** : Blocs 1–5 testés sur QGIS 4.0.3 via MCP Claude Code.
  - Bloc 1 : snapping PLU
  - Bloc 2 : `checkvalidity`, `checkgeometryoverlap`, `checkgeometrygap`
  - Bloc 5 : reconstruction `06079-Minelle.gpkg` depuis cadastre WFS → 137 004 m², 31 nœuds, valide
- **À tester** : `fixgeometryoverlap`, `fixgeometrygap`, `snapgeometries` sur une vraie couche PLU avec erreurs.