feat(moulin-mapper): section SLAM incrémental — pièce en L, recoins, fermeture de boucle

This commit is contained in:
Flag
2026-06-06 19:33:30 +00:00
parent 7171e58c4d
commit 959783dfa7

View File

@@ -535,6 +535,83 @@
</div>
</section>
<!-- SLAM INCREMENTAL -->
<div id="demoSLAM" style="padding:3.5rem 1rem;background:var(--surface);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div class="inner" style="max-width:1080px;margin:0 auto;">
<h2 style="margin-bottom:.5rem;"><span class="sec-num">07</span> Pièce longue &amp; recoins — SLAM incrémental temps réel</h2>
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:.75rem;">
Pièce en L non convexe avec niches. Portée sonar limitée à ~5 m. Le ROV ne voit jamais toute la pièce d'un coup.
La carte s'accumule submap par submap. Dans le long couloir, la dérive longitudinale persiste (dégénérescence sonar parallèle).
La fermeture de boucle la corrige brutalement.
</p>
<div class="controls" style="margin-bottom:.5rem;">
<button id="slam_btnPlay">⏸ Pause</button>
<button id="slam_btnReset">↺ Réinitialiser</button>
<button id="slam_btnVO" class="toggle-btn">📷 VO caméra OFF</button>
<button id="slam_btnMap">🗺 Masquer carte</button>
<button id="slam_btnGraph">🔗 Masquer pose-graph</button>
</div>
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem;">
<span style="font-size:.8rem;color:var(--text-muted);">Temps:</span>
<input type="range" id="slam_slider" min="0" max="1000" value="0" style="flex:1;max-width:340px;">
<span id="slam_sliderTime" style="font-size:.8rem;color:var(--text-muted);min-width:40px;">0 s</span>
</div>
<div class="demo-layout">
<canvas id="slam_canvas" width="620" height="460" style="border:1px solid var(--border);border-radius:8px;background:#0a0e14;flex:1 1 520px;min-width:280px;"></canvas>
<div class="sidebar">
<div class="ctrl-group">
<label>Distance parcourue</label>
<div class="value" id="slam_dist">0.00 m</div>
</div>
<div class="ctrl-group">
<label>Keyframes</label>
<div class="value" id="slam_kf">0</div>
</div>
<div class="ctrl-group">
<label>État</label>
<div class="value" id="slam_state" style="font-size:.95rem;color:var(--ping360);">Démarrage…</div>
</div>
<div class="error-display">
<div class="err-label">Erreur position estimée</div>
<div style="margin-top:.3rem;">
<span style="font-size:.75rem;color:var(--imu-drift);">Dead-reckoning:</span>
<span class="value bad" id="slam_errDR" style="font-size:1rem;"></span>
</div>
<div class="err-bar-wrap" style="margin-bottom:.5rem;">
<div class="err-bar" id="slam_errBarDR" style="width:0%;background:var(--imu-drift);"></div>
</div>
<div>
<span style="font-size:.75rem;color:var(--icp-ok);">SLAM corrigé:</span>
<span class="value ok" id="slam_errSLAM" style="font-size:1rem;"></span>
</div>
<div class="err-bar-wrap">
<div class="err-bar" id="slam_errBarSLAM" style="width:0%;background:var(--icp-ok);"></div>
</div>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--ping360)"></div><span>Trajet réel</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--imu-drift)"></div><span>Dead-reckoning</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--icp-ok)"></div><span>SLAM estimé</span></div>
<div class="legend-item"><div class="legend-dot" style="background:#a371f7"></div><span>Keyframes</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--wall-pt)"></div><span>Retours sonar</span></div>
</div>
</div>
</div>
<div class="math-box" style="margin-top:1.5rem;">
<h3>Dégénérescence couloir &amp; fermeture de boucle</h3>
<p>Dans un couloir droit, les murs parallèles forment une géométrie <strong>symétrique selon l'axe longitudinal</strong>.
L'ICP ne peut pas distinguer un décalage avant/arrière — il y a une infinité de solutions équivalentes.
La dérive longitudinale persiste même avec scan-matching. La <strong>fermeture de boucle</strong> (loop closure) résout ce problème:
quand le ROV revient près d'un keyframe ancien, on détecte la correspondance par comparaison de scan, on ajoute une contrainte
dans le pose-graph, et une optimisation globale (<code>g2o</code> / linéarisée) corrige <em>toutes</em> les poses d'un coup.</p>
</div>
</div>
</div>
<footer>
moulin-mapper &middot; prototype &middot; Cosma / Silent Flow
</footer>
@@ -1386,6 +1463,843 @@ setTimeout(() => {
}
}, 3000);
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 07 — SLAM INCRÉMENTAL — pièce en L, boucle, dégénérescence couloir
// ═══════════════════════════════════════════════════════════════════════════════
// TOUT est préfixé slam_ pour éviter toute collision avec la démo existante.
(function() {
'use strict';
// ─── GÉOMÉTRIE: pièce en L avec niches ───────────────────────────────────────
// Coordonnées en mètres. Sens trigonométrique. Origine arbitraire (coin haut-gauche).
// La pièce est un L:
// ┌─────────────────────────────┐
// │ couloir long 18m × 3.5m │
// │ ┌──────────┤ niche haute
// │ │ │
// │ │ salle │
// │ niche basse │ carrée │
// └──────────────────┘──────────┘
//
// On définit le polygone dans le sens ANTI-HORAIRE (intérieur à gauche du bord):
// Les segments seront parcourus dans l'ordre pour le raycast.
function slam_buildRoomPolygon() {
// Pièce en L avec 2 niches
// Couloir bas: x=0..18, y=0..3.5
// Salle carrée à droite: x=12..18, y=3.5..10
// Niche haute (dans la salle): x=15..18, y=8..10 (incluse dans salle)
// Niche basse (dans couloir): x=0..3, y=-2.5..0
const pts = [
// coin bas-gauche du couloir
{ x: 0, y: 0 },
// niche basse à gauche
{ x: 0, y: -2.5 },
{ x: 3, y: -2.5 },
{ x: 3, y: 0 },
// mur bas du couloir vers la droite
{ x: 12, y: 0 },
// descente vers salle basse
{ x: 12, y: -1.5 },
{ x: 18, y: -1.5 },
// mur droit de la salle (bas)
{ x: 18, y: 10 },
// mur haut de la salle
{ x: 12, y: 10 },
// retour vers couloir (mur haut de la salle ↔ couloir)
{ x: 12, y: 3.5 },
// mur haut du couloir long
{ x: 0, y: 3.5 },
// retour au départ
{ x: 0, y: 0 },
];
return pts;
}
const slam_POLY = slam_buildRoomPolygon();
// Segments (paires consécutives)
function slam_buildSegments(poly) {
const segs = [];
for (let i = 0; i < poly.length - 1; i++) {
segs.push({ ax: poly[i].x, ay: poly[i].y, bx: poly[i+1].x, by: poly[i+1].y });
}
return segs;
}
const slam_SEGS = slam_buildSegments(slam_POLY);
// ─── RAYCAST SUR LE POLYGONE ─────────────────────────────────────────────────
function slam_raySegIntersect(ox, oy, dx, dy, ax, ay, bx, by) {
const ex = bx - ax, ey = by - ay;
const cross = dx * ey - dy * ex;
if (Math.abs(cross) < 1e-10) return null;
const t = ((ax - ox) * ey - (ay - oy) * ex) / cross;
const s = ((ax - ox) * dy - (ay - oy) * dx) / cross;
if (t >= 0 && s >= 0 && s <= 1) return t;
return null;
}
function slam_castRay(ox, oy, angle, maxDist) {
const dx = Math.cos(angle), dy = Math.sin(angle);
let minT = maxDist;
let hit = null;
for (const seg of slam_SEGS) {
const t = slam_raySegIntersect(ox, oy, dx, dy, seg.ax, seg.ay, seg.bx, seg.by);
if (t !== null && t < minT) {
minT = t;
hit = { x: ox + dx * t, y: oy + dy * t, dist: t };
}
}
return hit;
}
const slam_MAX_RANGE = 5.5; // portée sonar en mètres
const slam_N_RAYS = 90; // rayons par sweep
// ─── POINT-IN-POLYGON (pour valider les positions ROV) ───────────────────────
function slam_pointInPoly(px, py, poly) {
let inside = false;
for (let i = 0, j = poly.length - 2; i < poly.length - 1; j = i++) {
const xi = poly[i].x, yi = poly[i].y;
const xj = poly[j].x, yj = poly[j].y;
if (((yi > py) !== (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
}
// ─── TRAJECTOIRE SCRIPTÉE ────────────────────────────────────────────────────
// Le ROV:
// 1. Part du couloir à gauche (x≈1.5, y≈1.75)
// 2. Traverse tout le couloir vers la droite
// 3. Entre dans la salle, fait le tour
// 4. Explore la niche basse
// 5. REVIENT au point de départ (loop closure)
// Waypoints en (x, y) → interpolés en spline catmull-rom
function slam_buildWaypoints() {
return [
{ x: 1.5, y: 1.75 }, // départ, couloir gauche
{ x: 5, y: 1.75 }, // couloir milieu
{ x: 9, y: 1.75 }, // couloir droit
{ x: 11.5, y: 1.75 }, // approche salle
{ x: 13, y: 0.5 }, // descente vers zone basse
{ x: 16, y: -0.5 }, // niche basse-droite
{ x: 17.5, y: 2.5 }, // salle droite bas
{ x: 17.5, y: 7 }, // salle droite haut
{ x: 14, y: 9 }, // salle haut
{ x: 12.5, y: 5 }, // salle milieu
{ x: 14, y: 1.5 }, // retour vers couloir
{ x: 11, y: 2.5 }, // couloir droit retour
{ x: 7, y: 2.5 }, // couloir milieu retour (légèrement décalé)
{ x: 3.5, y: 1.75 }, // couloir gauche retour
{ x: 1.5, y: 1.75 }, // RETOUR AU DÉPART → loop closure
];
}
// Interpolation Catmull-Rom
function slam_catmullRom(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return {
x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
};
}
function slam_buildTrajectory() {
const wps = slam_buildWaypoints();
const pts = [];
const stepsPerSeg = 80;
for (let seg = 0; seg < wps.length - 1; seg++) {
const p0 = wps[Math.max(0, seg - 1)];
const p1 = wps[seg];
const p2 = wps[seg + 1];
const p3 = wps[Math.min(wps.length - 1, seg + 2)];
for (let s = 0; s < stepsPerSeg; s++) {
const t = s / stepsPerSeg;
const pos = slam_catmullRom(p0, p1, p2, p3, t);
pts.push({ x: pos.x, y: pos.y });
}
}
// Ajouter le dernier waypoint
pts.push({ ...wps[wps.length - 1] });
// Calculer les caps (tangentes) + distance cumulée
let cumDist = 0;
const result = [];
for (let i = 0; i < pts.length; i++) {
const next = pts[Math.min(i + 1, pts.length - 1)];
const prev = pts[Math.max(i - 1, 0)];
const heading = Math.atan2(next.y - prev.y, next.x - prev.x);
if (i > 0) {
cumDist += Math.sqrt((pts[i].x - pts[i-1].x)**2 + (pts[i].y - pts[i-1].y)**2);
}
result.push({ x: pts[i].x, y: pts[i].y, heading, cumDist });
}
return result;
}
const slam_TRAJ = slam_buildTrajectory();
const slam_TOTAL_DIST = slam_TRAJ[slam_TRAJ.length - 1].cumDist;
// ─── ICP 2D (version autonome, pas de dépendance sur l'ICP de la démo 2D) ───
function slam_icp2D(scanPts, mapPts, maxIter) {
maxIter = maxIter || 10;
if (scanPts.length < 4 || mapPts.length < 4) return { tx: 0, ty: 0, dtheta: 0 };
let tx = 0, ty = 0, dtheta = 0;
let pts = scanPts.map(p => ({ x: p.x, y: p.y }));
for (let iter = 0; iter < maxIter; iter++) {
const pairs = [];
for (const sp of pts) {
let best = null, bestDist = Infinity;
for (const mp of mapPts) {
const d = (sp.x - mp.x)**2 + (sp.y - mp.y)**2;
if (d < bestDist) { bestDist = d; best = mp; }
}
if (bestDist < 1.2) pairs.push({ src: sp, dst: best });
}
if (pairs.length < 3) break;
let cx_s = 0, cy_s = 0, cx_d = 0, cy_d = 0;
for (const { src, dst } of pairs) { cx_s += src.x; cy_s += src.y; cx_d += dst.x; cy_d += dst.y; }
cx_s /= pairs.length; cy_s /= pairs.length; cx_d /= pairs.length; cy_d /= pairs.length;
let H_xx = 0, H_xy = 0, H_yx = 0, H_yy = 0;
for (const { src, dst } of pairs) {
const px = src.x - cx_s, py = src.y - cy_s;
const qx = dst.x - cx_d, qy = dst.y - cy_d;
H_xx += px * qx; H_xy += px * qy; H_yx += py * qx; H_yy += py * qy;
}
const dRot = Math.atan2(H_yx - H_xy, H_xx + H_yy);
const cosR = Math.cos(dRot), sinR = Math.sin(dRot);
const dTx = cx_d - (cosR * cx_s - sinR * cy_s);
const dTy = cy_d - (sinR * cx_s + cosR * cy_s);
pts = pts.map(p => ({ x: cosR * p.x - sinR * p.y + dTx, y: sinR * p.x + cosR * p.y + dTy }));
const newTx = cosR * tx - sinR * ty + dTx;
const newTy = sinR * tx + cosR * ty + dTy;
tx = newTx; ty = newTy; dtheta += dRot;
if (Math.abs(dRot) < 5e-5 && Math.abs(dTx) < 0.001 && Math.abs(dTy) < 0.001) break;
}
return { tx, ty, dtheta };
}
// ─── ÉTAT DE LA SIMULATION SLAM ──────────────────────────────────────────────
const slam_s = {
step: 0,
playing: true,
showMap: true,
showGraph: true,
voEnabled: false, // odométrie visuelle
// Pose vraie
trueX: 0, trueY: 0, trueH: 0,
// Dead-reckoning (dérive longitudinale cumulée + bruit)
drX: 0, drY: 0, drH: 0,
drDriftLong: 0, // dérive longitudinale pure (couloir)
drNoiseCum: 0, // bruit latéral cumulé
// SLAM estimé
slamX: 0, slamY: 0, slamH: 0,
// Carte accumulée
mapPts: [],
// Sweep sonar courant
sweepRayIdx: 0,
lastSweepPts: [],
// Keyframes: { x, y, h, scanPts: [] }
keyframes: [],
KF_DIST: 1.5, // distance entre keyframes (m)
lastKfDist: 0,
// Traînées
trailTrue: [],
trailDR: [],
trailSLAM: [],
// Erreurs
errDR: 0,
errSLAM: 0,
// Loop closure
loopClosed: false,
loopFlashFrames: 0,
loopClosedAt: -1, // step où la boucle a été fermée
// Correction globale appliquée à la traînée SLAM lors de la boucle
globalCorrection: { tx: 0, ty: 0 },
// Dégénérescence couloir: dans le couloir droit, on accumule dérive longitudinale
inCorridor: false,
corridorDriftRate: 0,
};
// Détecter si on est dans le couloir (zone x=[0..12], y=[0..3.5])
function slam_isInCorridor(x, y) {
return x > 0 && x < 12 && y > 0 && y < 3.5;
}
function slam_initSim() {
const t0 = slam_TRAJ[0];
Object.assign(slam_s, {
step: 0, loopClosed: false, loopFlashFrames: 0, loopClosedAt: -1,
trueX: t0.x, trueY: t0.y, trueH: t0.heading,
drX: t0.x, drY: t0.y, drH: t0.heading,
slamX: t0.x, slamY: t0.y, slamH: t0.heading,
drDriftLong: 0, drNoiseCum: 0,
mapPts: [], lastSweepPts: [], sweepRayIdx: 0,
keyframes: [], lastKfDist: 0,
trailTrue: [{ x: t0.x, y: t0.y }],
trailDR: [{ x: t0.x, y: t0.y }],
trailSLAM: [{ x: t0.x, y: t0.y }],
errDR: 0, errSLAM: 0,
globalCorrection: { tx: 0, ty: 0 },
corridorDriftRate: (Math.random() - 0.5) * 0.006 + 0.004, // dérive longitudinale positive
});
}
function slam_stepSim() {
if (slam_s.step >= slam_TRAJ.length - 1) return;
slam_s.step++;
const tp = slam_TRAJ[slam_s.step];
// Pose vraie
slam_s.trueX = tp.x;
slam_s.trueY = tp.y;
slam_s.trueH = tp.heading;
// ── Dead-reckoning avec modèle de dérive ──────────────────────────────────
// Le dead-reckoning suit grossièrement la trajectoire vraie mais dérive.
// Dans le couloir: dérive principalement LONGITUDINALE (le sonar ne voit pas de coin → ICP glisse).
// Hors couloir: bruit plus isotrope.
const inCorridor = slam_isInCorridor(slam_s.trueX, slam_s.trueY);
slam_s.inCorridor = inCorridor;
// Bruit isotrope de base
const baseNoise = slam_s.voEnabled ? 0.0008 : 0.0018;
const noiseX = (Math.random() - 0.5) * baseNoise;
const noiseY = (Math.random() - 0.5) * baseNoise;
const noiseH = (Math.random() - 0.5) * 0.0005;
// Dérive longitudinale supplémentaire dans le couloir
let longDrift = 0;
if (inCorridor && !slam_s.voEnabled) {
// Le couloir axe X → dérive selon X si on se déplace dans cette direction
longDrift = slam_s.corridorDriftRate * Math.abs(Math.cos(slam_s.trueH));
} else if (inCorridor && slam_s.voEnabled) {
// VO réduit la dérive longitudinale à ~30%
longDrift = slam_s.corridorDriftRate * 0.3 * Math.abs(Math.cos(slam_s.trueH));
}
slam_s.drDriftLong += longDrift;
slam_s.drX = tp.x + slam_s.drDriftLong + noiseX * slam_s.step * 0.08;
slam_s.drY = tp.y + noiseY * slam_s.step * 0.08;
slam_s.drH = tp.heading + noiseH * slam_s.step * 0.08;
// ── Sweep sonar (un rayon par step) ────────────────────────────────────────
const rayAngle = (slam_s.sweepRayIdx / slam_N_RAYS) * 2 * Math.PI + slam_s.trueH;
const hit = slam_castRay(slam_s.trueX, slam_s.trueY, rayAngle, slam_MAX_RANGE);
if (hit) {
const noise = (Math.random() - 0.5) * 0.06;
const nx = hit.x + noise * Math.cos(rayAngle + Math.PI/2);
const ny = hit.y + noise * Math.sin(rayAngle + Math.PI/2);
slam_s.lastSweepPts.push({ x: nx, y: ny });
slam_s.mapPts.push({ x: nx, y: ny });
if (slam_s.mapPts.length > 12000) slam_s.mapPts.splice(0, 300);
}
slam_s.sweepRayIdx++;
// ── Sweep complet → ICP scan-to-map ───────────────────────────────────────
if (slam_s.sweepRayIdx >= slam_N_RAYS) {
slam_s.sweepRayIdx = 0;
const currentSweep = slam_s.lastSweepPts;
slam_s.lastSweepPts = [];
if (slam_s.mapPts.length > 30 && currentSweep.length > 6) {
const mapSample = slam_s.mapPts.slice(-3000);
const icpRes = slam_icp2D(currentSweep, mapSample);
// Correction bornée — mais dans le couloir, la correction longitudinale est limitée
// (on simule la dégénérescence: ICP ne corrige pas bien le long de l'axe du couloir)
let corrFactor = 0.9;
if (inCorridor) {
// ICP peut corriger latéral (Y) mais PAS longitudinal (X) en couloir droit
// → On brique la correction X dans le couloir
corrFactor = 0.2; // mauvaise correction longitudinale
}
const boundT = 0.15;
const boundR = 0.04;
const cx = Math.max(-boundT, Math.min(boundT, icpRes.tx * corrFactor));
const cy = Math.max(-boundT, Math.min(boundT, icpRes.ty)); // latéral OK
const cR = Math.max(-boundR, Math.min(boundR, icpRes.dtheta));
slam_s.slamX = slam_s.trueX + cx;
slam_s.slamY = slam_s.trueY + cy;
slam_s.slamH = slam_s.trueH + cR;
} else {
slam_s.slamX = slam_s.trueX;
slam_s.slamY = slam_s.trueY;
slam_s.slamH = slam_s.trueH;
}
// ── Keyframe ─────────────────────────────────────────────────────────────
const cumDist = tp.cumDist;
if (cumDist - slam_s.lastKfDist > slam_s.KF_DIST) {
slam_s.lastKfDist = cumDist;
// Échantillon de scan pour matching futur
const scanSnapshot = currentSweep.slice();
slam_s.keyframes.push({
x: slam_s.trueX, y: slam_s.trueY, h: slam_s.trueH,
slamX: slam_s.slamX, slamY: slam_s.slamY,
scan: scanSnapshot,
step: slam_s.step,
});
}
// ── Détection loop closure ────────────────────────────────────────────────
// On cherche si on est revenu près d'un keyframe *ancien* (>5 keyframes en arrière)
if (!slam_s.loopClosed && slam_s.keyframes.length > 6 && currentSweep.length > 10) {
const minKfAge = 6; // ignorer les keyframes récents
for (let ki = 0; ki < slam_s.keyframes.length - minKfAge; ki++) {
const kf = slam_s.keyframes[ki];
const d = Math.sqrt((slam_s.trueX - kf.x)**2 + (slam_s.trueY - kf.y)**2);
if (d < 1.2 && kf.scan.length > 5) {
// Correspondance confirmée par ICP entre scans
const lcRes = slam_icp2D(currentSweep, kf.scan);
const lcErr = Math.sqrt(lcRes.tx**2 + lcRes.ty**2);
if (lcErr < 0.5) {
// FERMETURE DE BOUCLE DÉTECTÉE
slam_s.loopClosed = true;
slam_s.loopClosedAt = slam_s.step;
slam_s.loopFlashFrames = 80;
// Correction globale: on estime l'erreur accumulée de la pose SLAM
// et on la redistribue linéairement sur toute la traînée
const errX = slam_s.trueX - slam_s.slamX;
const errY = slam_s.trueY - slam_s.slamY;
slam_s.globalCorrection = { tx: errX, ty: errY };
// Corriger toutes les poses de la traînée SLAM rétroactivement
const n = slam_s.trailSLAM.length;
for (let ti = 0; ti < n; ti++) {
const frac = ti / n; // correction progressive (0 au départ, 1 maintenant)
slam_s.trailSLAM[ti].x += frac * errX;
slam_s.trailSLAM[ti].y += frac * errY;
}
// Réinitialiser la dérive dead-reckoning aussi
slam_s.drDriftLong = 0;
break;
}
}
}
}
}
// Après fermeture de boucle, SLAM estimé est verrouillé sur la vraie position (bornée)
if (slam_s.loopClosed) {
slam_s.slamX = slam_s.trueX + (Math.random() - 0.5) * 0.04;
slam_s.slamY = slam_s.trueY + (Math.random() - 0.5) * 0.04;
slam_s.drDriftLong *= 0.98; // la dérive se résorbe après boucle
}
// ── Erreurs ───────────────────────────────────────────────────────────────
slam_s.errDR = Math.sqrt((slam_s.drX - slam_s.trueX)**2 + (slam_s.drY - slam_s.trueY)**2);
slam_s.errSLAM = Math.sqrt((slam_s.slamX - slam_s.trueX)**2 + (slam_s.slamY - slam_s.trueY)**2);
// ── Traînées ──────────────────────────────────────────────────────────────
const TRAIL_MAX = 800;
slam_s.trailTrue.push({ x: slam_s.trueX, y: slam_s.trueY });
slam_s.trailDR.push({ x: slam_s.drX, y: slam_s.drY });
slam_s.trailSLAM.push({ x: slam_s.slamX, y: slam_s.slamY });
if (slam_s.trailTrue.length > TRAIL_MAX) { slam_s.trailTrue.shift(); slam_s.trailDR.shift(); slam_s.trailSLAM.shift(); }
if (slam_s.loopFlashFrames > 0) slam_s.loopFlashFrames--;
}
// ─── CANVAS & RENDU ──────────────────────────────────────────────────────────
const slam_canvas = document.getElementById('slam_canvas');
const slam_ctx = slam_canvas.getContext('2d');
// Bounding box de la pièce + marge
const slam_BBOX = { minX: -1, maxX: 19.5, minY: -3.5, maxY: 11 };
function slam_world2px(x, y) {
const W = slam_canvas.width, H = slam_canvas.height;
const margin = 24;
const scaleX = (W - 2 * margin) / (slam_BBOX.maxX - slam_BBOX.minX);
const scaleY = (H - 2 * margin) / (slam_BBOX.maxY - slam_BBOX.minY);
const s = Math.min(scaleX, scaleY);
// Centre la pièce
const offX = margin + (W - 2*margin - s*(slam_BBOX.maxX - slam_BBOX.minX)) / 2;
const offY = margin + (H - 2*margin - s*(slam_BBOX.maxY - slam_BBOX.minY)) / 2;
return {
x: offX + (x - slam_BBOX.minX) * s,
y: H - offY - (y - slam_BBOX.minY) * s, // Y inversé canvas
s,
};
}
function slam_drawRoom() {
const ctx = slam_ctx;
ctx.beginPath();
for (let i = 0; i < slam_POLY.length; i++) {
const p = slam_world2px(slam_POLY[i].x, slam_POLY[i].y);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(18,26,38,0.97)';
ctx.fill();
ctx.strokeStyle = '#5a6476';
ctx.lineWidth = 2.5;
ctx.stroke();
// Label couloir
const lc = slam_world2px(6, 1.75);
ctx.fillStyle = 'rgba(139,148,158,0.45)';
ctx.font = '11px system-ui';
ctx.textAlign = 'center';
ctx.fillText('couloir', lc.x, lc.y);
// Label salle
const ls = slam_world2px(15, 4.5);
ctx.fillText('salle', ls.x, ls.y);
// Label niche basse
const ln = slam_world2px(1.5, -1.25);
ctx.fillText('niche', ln.x, ln.y);
}
function slam_drawMap() {
if (!slam_s.showMap || slam_s.mapPts.length === 0) return;
const ctx = slam_ctx;
const step = Math.max(1, Math.floor(slam_s.mapPts.length / 3000));
for (let i = 0; i < slam_s.mapPts.length; i += step) {
const pt = slam_s.mapPts[i];
const p = slam_world2px(pt.x, pt.y);
ctx.beginPath();
ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,60,60,0.55)';
ctx.fill();
}
}
function slam_drawSweepCone() {
const ctx = slam_ctx;
const rp = slam_world2px(slam_s.trueX, slam_s.trueY);
const s = rp.s;
const rangePx = s * slam_MAX_RANGE;
// Cercle de portée
ctx.save();
ctx.globalAlpha = 0.07;
ctx.beginPath();
ctx.arc(rp.x, rp.y, rangePx, 0, Math.PI * 2);
ctx.fillStyle = '#00bfff';
ctx.fill();
ctx.restore();
// Arc du sweep en cours
const sweepFrac = slam_s.sweepRayIdx / slam_N_RAYS;
if (sweepFrac > 0) {
ctx.save();
ctx.globalAlpha = 0.12;
ctx.beginPath();
ctx.moveTo(rp.x, rp.y);
const startAngle = slam_s.trueH - Math.PI / 2; // canvas Y inversé
const endAngle = startAngle - sweepFrac * 2 * Math.PI;
ctx.arc(rp.x, rp.y, rangePx, startAngle, endAngle, true);
ctx.closePath();
ctx.fillStyle = '#00bfff';
ctx.fill();
ctx.restore();
}
// Points sonar courants
for (const pt of slam_s.lastSweepPts) {
const p = slam_world2px(pt.x, pt.y);
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fillStyle = '#ff5050';
ctx.fill();
}
}
function slam_drawTrail(trail, color, dash, lineWidth) {
if (trail.length < 2) return;
const ctx = slam_ctx;
ctx.beginPath();
ctx.setLineDash(dash || []);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth || 1.5;
for (let i = 0; i < trail.length; i++) {
const p = slam_world2px(trail[i].x, trail[i].y);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.setLineDash([]);
}
function slam_drawROV(x, y, heading, color, alpha) {
const ctx = slam_ctx;
const p = slam_world2px(x, y);
const r = Math.max(5, p.s * 0.2);
ctx.save();
ctx.globalAlpha = alpha || 1;
ctx.translate(p.x, p.y);
ctx.rotate(-heading); // canvas Y inversé → négatif
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(r * 2.2, 0);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
function slam_drawKeyframes() {
const ctx = slam_ctx;
const kfs = slam_s.keyframes;
if (!slam_s.showGraph || kfs.length === 0) return;
// Lignes du pose-graph
ctx.beginPath();
ctx.strokeStyle = 'rgba(163,113,247,0.4)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
for (let i = 0; i < kfs.length; i++) {
const p = slam_world2px(kfs[i].x, kfs[i].y);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.setLineDash([]);
// Marqueurs keyframe
for (let i = 0; i < kfs.length; i++) {
const p = slam_world2px(kfs[i].x, kfs[i].y);
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#a371f7';
ctx.fill();
}
}
function slam_drawDegeneracyLabel() {
if (!slam_s.inCorridor || slam_s.loopClosed) return;
const ctx = slam_ctx;
// Label dégénérescence dans le couloir
const lp = slam_world2px(6, 3.9);
ctx.save();
ctx.fillStyle = 'rgba(210,153,34,0.9)';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.fillText('⚠ dégén. couloir', lp.x, lp.y);
ctx.restore();
}
function slam_drawLoopClosureEvent() {
const ctx = slam_ctx;
if (!slam_s.loopClosed) return;
const flash = slam_s.loopFlashFrames;
if (flash > 0) {
// Flash global
ctx.save();
ctx.globalAlpha = (flash / 80) * 0.22;
ctx.fillStyle = '#56d364';
ctx.fillRect(0, 0, slam_canvas.width, slam_canvas.height);
ctx.restore();
// Cercle pulsé au point de fermeture
const lp = slam_world2px(slam_s.trueX, slam_s.trueY);
const r = lp.s * 2.5 * (1 - flash / 80) + 10;
ctx.save();
ctx.globalAlpha = (flash / 80) * 0.7;
ctx.beginPath();
ctx.arc(lp.x, lp.y, r, 0, Math.PI * 2);
ctx.strokeStyle = '#56d364';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
// Label permanent
const W = slam_canvas.width, H = slam_canvas.height;
ctx.save();
ctx.fillStyle = 'rgba(86,211,100,0.95)';
ctx.font = 'bold 14px system-ui';
ctx.textAlign = 'center';
ctx.fillText('BOUCLE FERMEE ✓', W / 2, 22);
ctx.restore();
}
function slam_drawScale() {
const ctx = slam_ctx;
const W = slam_canvas.width, H = slam_canvas.height;
const p0 = slam_world2px(0, slam_BBOX.minY);
const p1 = slam_world2px(2, slam_BBOX.minY);
const s = p1.x - p0.x; // 2m en px
const sx = W - 12, sy = H - 10;
ctx.save();
ctx.strokeStyle = '#8b949e'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(sx - s, sy); ctx.lineTo(sx, sy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx - s, sy - 4); ctx.lineTo(sx - s, sy + 4); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx, sy - 4); ctx.lineTo(sx, sy + 4); ctx.stroke();
ctx.fillStyle = '#8b949e'; ctx.font = '11px system-ui'; ctx.textAlign = 'center';
ctx.fillText('2 m', sx - s / 2, sy - 6);
ctx.restore();
}
function slam_renderFrame() {
const W = slam_canvas.width, H = slam_canvas.height;
slam_ctx.clearRect(0, 0, W, H);
slam_ctx.fillStyle = '#0a0e14';
slam_ctx.fillRect(0, 0, W, H);
slam_drawRoom();
slam_drawMap();
slam_drawKeyframes();
slam_drawTrail(slam_s.trailTrue, '#00bfff', [], 2);
slam_drawTrail(slam_s.trailDR, 'rgba(255,107,107,0.7)', [5, 4], 1.5);
slam_drawTrail(slam_s.trailSLAM, 'rgba(86,211,100,0.85)', [2, 3], 1.5);
slam_drawSweepCone();
slam_drawROV(slam_s.trueX, slam_s.trueY, slam_s.trueH, '#00bfff', 1);
slam_drawROV(slam_s.drX, slam_s.drY, slam_s.drH, '#ff6b6b', 0.65);
slam_drawROV(slam_s.slamX, slam_s.slamY, slam_s.slamH, '#56d364', 0.8);
slam_drawDegeneracyLabel();
slam_drawLoopClosureEvent();
slam_drawScale();
// Labels temps + état
const tp = slam_TRAJ[slam_s.step];
slam_ctx.fillStyle = 'rgba(139,148,158,0.8)';
slam_ctx.font = '11px monospace';
slam_ctx.textAlign = 'left';
slam_ctx.fillText(`dist: ${tp.cumDist.toFixed(1)} m kf: ${slam_s.keyframes.length}`, 8, 14);
if (slam_s.inCorridor && !slam_s.loopClosed) {
slam_ctx.fillStyle = 'rgba(210,153,34,0.85)';
slam_ctx.fillText('couloir: dérive longitudinale active', 8, 28);
}
}
// ─── MISE À JOUR UI ──────────────────────────────────────────────────────────
function slam_updateUI() {
const tp = slam_TRAJ[slam_s.step];
document.getElementById('slam_dist').textContent = tp.cumDist.toFixed(2) + ' m';
document.getElementById('slam_kf').textContent = slam_s.keyframes.length;
const stateEl = document.getElementById('slam_state');
if (slam_s.loopClosed) {
stateEl.textContent = 'Boucle fermée ✓';
stateEl.style.color = 'var(--icp-ok)';
} else if (slam_s.inCorridor) {
stateEl.textContent = 'Couloir — dégénérescence…';
stateEl.style.color = 'var(--warn)';
} else {
stateEl.textContent = 'Cartographie…';
stateEl.style.color = 'var(--ping360)';
}
const maxErr = 3.0;
const drPct = Math.min(100, (slam_s.errDR / maxErr) * 100);
const slamPct = Math.min(100, (slam_s.errSLAM / maxErr * 4) * 100);
document.getElementById('slam_errDR').textContent = slam_s.errDR.toFixed(3) + ' m';
document.getElementById('slam_errSLAM').textContent = slam_s.errSLAM.toFixed(3) + ' m';
document.getElementById('slam_errBarDR').style.width = drPct + '%';
document.getElementById('slam_errBarSLAM').style.width = slamPct + '%';
const frac = slam_s.step / (slam_TRAJ.length - 1);
document.getElementById('slam_slider').value = Math.round(frac * 1000);
const totalSecs = 180;
document.getElementById('slam_sliderTime').textContent = Math.round(frac * totalSecs) + ' s';
}
// ─── BOUCLE ANIMATION ────────────────────────────────────────────────────────
let slam_animId = null;
function slam_animate() {
if (slam_s.playing && slam_s.step < slam_TRAJ.length - 1) {
for (let i = 0; i < 3; i++) slam_stepSim();
}
slam_renderFrame();
slam_updateUI();
slam_animId = requestAnimationFrame(slam_animate);
}
// ─── CONTRÔLES ───────────────────────────────────────────────────────────────
const slam_btnPlay = document.getElementById('slam_btnPlay');
const slam_btnReset = document.getElementById('slam_btnReset');
const slam_btnVO = document.getElementById('slam_btnVO');
const slam_btnMap = document.getElementById('slam_btnMap');
const slam_btnGraph = document.getElementById('slam_btnGraph');
const slam_slider = document.getElementById('slam_slider');
slam_btnPlay.addEventListener('click', () => {
slam_s.playing = !slam_s.playing;
slam_btnPlay.textContent = slam_s.playing ? '⏸ Pause' : '▶ Play';
});
slam_btnReset.addEventListener('click', () => {
slam_initSim();
slam_s.playing = true;
slam_btnPlay.textContent = '⏸ Pause';
});
slam_btnVO.addEventListener('click', () => {
slam_s.voEnabled = !slam_s.voEnabled;
slam_btnVO.textContent = slam_s.voEnabled ? '📷 VO caméra ON' : '📷 VO caméra OFF';
slam_btnVO.classList.toggle('active', slam_s.voEnabled);
// Reset pour voir l'effet
slam_initSim();
slam_s.playing = true;
slam_btnPlay.textContent = '⏸ Pause';
});
slam_btnMap.addEventListener('click', () => {
slam_s.showMap = !slam_s.showMap;
slam_btnMap.textContent = slam_s.showMap ? '🗺 Masquer carte' : '🗺 Afficher carte';
});
slam_btnGraph.addEventListener('click', () => {
slam_s.showGraph = !slam_s.showGraph;
slam_btnGraph.textContent = slam_s.showGraph ? '🔗 Masquer pose-graph' : '🔗 Afficher pose-graph';
});
slam_slider.addEventListener('input', () => {
slam_s.playing = false;
slam_btnPlay.textContent = '▶ Play';
const targetStep = Math.round((slam_slider.value / 1000) * (slam_TRAJ.length - 1));
slam_initSim();
slam_s.playing = false;
for (let i = 0; i < targetStep; i++) slam_stepSim();
slam_renderFrame();
slam_updateUI();
});
// ─── DÉMARRAGE ───────────────────────────────────────────────────────────────
slam_initSim();
slam_animate();
})(); // IIFE — pas de pollution globale
</script>
</body>
</html>