Files
moulin-mapper/web/index.html

1392 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moulin à marée — cartographie ROV sans GPS</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #1f6feb;
--accent2: #238636;
--warn: #d29922;
--danger: #da3633;
--text: #e6edf3;
--text-muted: #8b949e;
--ping360: #00bfff;
--imu-drift: #ff6b6b;
--icp-ok: #56d364;
--wall-pt: #ff4040;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 15px;
line-height: 1.6;
}
a { color: var(--accent); text-decoration: none; }
/* NAV */
nav {
position: sticky; top: 0; z-index: 100;
background: rgba(13,17,23,0.92);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
padding: 0 2rem;
display: flex; align-items: center; gap: 2rem; height: 52px;
}
nav .logo { font-weight: 700; font-size: 1rem; color: var(--ping360); letter-spacing: .05em; }
nav a { color: var(--text-muted); font-size: .875rem; transition: color .2s; }
nav a:hover { color: var(--text); }
/* LAYOUT */
.hero {
padding: 5rem 2rem 3rem;
text-align: center;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(31,111,235,.12) 0%, transparent 70%);
border-bottom: 1px solid var(--border);
}
.hero h1 { font-size: clamp(1.8rem, 5vw, 3rem); font-weight: 800; line-height: 1.2; margin-bottom: 1rem; }
.hero h1 span { color: var(--ping360); }
.hero p { max-width: 660px; margin: 0 auto 2rem; color: var(--text-muted); font-size: 1.05rem; }
.badges { display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; }
.badge {
padding: .25rem .75rem; border-radius: 999px; font-size: .78rem; font-weight: 600;
border: 1px solid;
}
.badge-blue { color: var(--ping360); border-color: var(--ping360); background: rgba(0,191,255,.08); }
.badge-green { color: var(--icp-ok); border-color: var(--icp-ok); background: rgba(86,211,100,.08); }
.badge-warn { color: var(--warn); border-color: var(--warn); background: rgba(210,153,34,.08); }
section { padding: 3.5rem 2rem; max-width: 1080px; margin: 0 auto; }
section + section { border-top: 1px solid var(--border); }
h2 { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
h2 .sec-num { color: var(--accent); margin-right: .5rem; }
p + p { margin-top: .75rem; }
/* SENSOR TABLE */
.sensor-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.sensor-table th {
text-align: left; padding: .5rem .75rem;
font-size: .8rem; text-transform: uppercase; letter-spacing: .07em;
color: var(--text-muted); background: var(--surface);
border-bottom: 1px solid var(--border);
}
.sensor-table td { padding: .6rem .75rem; border-bottom: 1px solid var(--border); font-size: .9rem; vertical-align: top; }
.sensor-table tr:last-child td { border-bottom: none; }
.sensor-table tr:hover td { background: rgba(255,255,255,.03); }
.tag { display: inline-block; padding: .1rem .5rem; border-radius: 4px; font-size: .75rem; font-weight: 600; margin: .1rem; }
.tag-xy { background: rgba(0,191,255,.15); color: var(--ping360); }
.tag-z { background: rgba(86,211,100,.15); color: var(--icp-ok); }
.tag-att { background: rgba(210,153,34,.15); color: var(--warn); }
.tag-tex { background: rgba(163,113,247,.15); color: #a371f7; }
.tag-img { background: rgba(255,107,107,.15); color: var(--imu-drift); }
/* 2D DEMO */
#demo2d {
padding: 3.5rem 1rem;
background: var(--surface);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
#demo2d .inner { max-width: 1080px; margin: 0 auto; }
.demo-layout {
display: flex; gap: 1rem; align-items: flex-start;
flex-wrap: wrap;
}
#canvas2d {
border: 1px solid var(--border);
border-radius: 8px;
display: block;
background: #0a0e14;
flex: 1 1 520px;
min-width: 280px;
cursor: crosshair;
}
.sidebar {
flex: 0 0 220px;
min-width: 180px;
display: flex; flex-direction: column; gap: .75rem;
}
.ctrl-group {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: .75rem;
}
.ctrl-group label { display: block; font-size: .78rem; color: var(--text-muted); margin-bottom: .35rem; text-transform: uppercase; letter-spacing: .06em; }
.ctrl-group .value { font-size: 1.1rem; font-weight: 700; color: var(--text); }
.ctrl-group .value.ok { color: var(--icp-ok); }
.ctrl-group .value.bad { color: var(--imu-drift); }
/* gauge */
.gauge-wrap { position: relative; height: 90px; width: 100%; }
.gauge-bar {
position: absolute; bottom: 0; left: 0; width: 100%;
background: linear-gradient(to top, var(--accent), var(--ping360));
border-radius: 4px 4px 0 0;
transition: height .3s;
}
.gauge-bg {
position: absolute; inset: 0;
background: rgba(255,255,255,.04);
border: 1px solid var(--border); border-radius: 4px;
}
.gauge-label { position: absolute; top: 4px; right: 6px; font-size: .75rem; color: var(--text-muted); }
/* attitude display */
.attitude-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: .4rem; margin-top: .3rem; }
.att-item { text-align: center; }
.att-item .av { font-size: .95rem; font-weight: 700; color: var(--warn); }
.att-item .ak { font-size: .65rem; color: var(--text-muted); }
/* controls */
.controls {
display: flex; gap: .5rem; flex-wrap: wrap;
margin: .75rem 0;
align-items: center;
}
button {
padding: .35rem .8rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); font-size: .85rem; cursor: pointer;
transition: background .15s, border-color .15s;
user-select: none;
}
button:hover { background: #21262d; border-color: #58a6ff; }
button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
button.active:hover { background: #1a5cce; }
.toggle-btn { min-width: 160px; }
input[type=range] {
width: 100%; accent-color: var(--ping360);
margin-top: .25rem;
}
.error-display {
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: .75rem; margin-top: .5rem;
font-size: .85rem;
}
.error-display .err-label { color: var(--text-muted); font-size: .75rem; }
.err-bar-wrap { height: 6px; background: rgba(255,255,255,.08); border-radius: 3px; margin-top: .3rem; }
.err-bar { height: 100%; border-radius: 3px; transition: width .3s, background .3s; }
/* legend */
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .8rem; margin-top: .5rem; }
.legend-item { display: flex; align-items: center; gap: .35rem; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
/* 3D */
#demo3d { background: var(--bg); }
#demo3d .inner3d { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
#canvas3d {
flex: 1 1 500px; min-width: 280px;
border: 1px solid var(--border); border-radius: 8px;
background: #0a0e14;
display: block; cursor: grab;
}
#canvas3d:active { cursor: grabbing; }
.info3d { flex: 0 0 200px; min-width: 160px; }
.info3d p { font-size: .85rem; color: var(--text-muted); margin-bottom: .75rem; }
/* MATH */
.math-box {
background: var(--surface); border: 1px solid var(--border);
border-left: 3px solid var(--accent); border-radius: 8px;
padding: 1.25rem 1.5rem; margin-top: 1.25rem;
font-size: .9rem;
}
.math-box h3 { font-size: 1rem; margin-bottom: .75rem; color: var(--ping360); }
.math-box code { color: var(--warn); background: rgba(210,153,34,.1); padding: .1em .35em; border-radius: 3px; font-size: .88em; }
/* LIVRABLES */
.deliverables { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: .75rem; margin-top: 1rem; }
.deliverable-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 1rem;
transition: border-color .2s;
}
.deliverable-card:hover { border-color: var(--ping360); }
.deliverable-card .icon { font-size: 1.5rem; margin-bottom: .5rem; }
.deliverable-card .title { font-weight: 600; font-size: .9rem; }
.deliverable-card .desc { font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
footer {
text-align: center; padding: 2rem;
font-size: .8rem; color: var(--text-muted);
border-top: 1px solid var(--border);
}
@media (max-width: 640px) {
nav { padding: 0 1rem; gap: 1rem; }
nav a.hide-sm { display: none; }
.hero { padding: 3rem 1rem 2rem; }
section { padding: 2.5rem 1rem; }
}
</style>
</head>
<body>
<nav>
<span class="logo">Ping360 · ICP</span>
<a href="#probleme">Problème</a>
<a href="#capteurs">Capteurs</a>
<a href="#demo2d">Démo 2D</a>
<a href="#demo3d" class="hide-sm">3D</a>
<a href="#pourquoi" class="hide-sm">Pourquoi ça marche</a>
</nav>
<!-- HERO -->
<div class="hero">
<h1>Moulin à marée<br><span>cartographie ROV sans GPS</span></h1>
<p>
Une chambre maçonnée partiellement inondée, géométrie inconnue.
Pas de GPS, pas d'USBL, pas de DVL — mais des <strong>murs fixes</strong> et un sonar 360°.
</p>
<div class="badges">
<span class="badge badge-blue">Ping360 · scan-matching ICP</span>
<span class="badge badge-green">Plan + nuage 3D</span>
<span class="badge badge-warn">Dérive IMU annulée</span>
</div>
</div>
<!-- PROBLEME -->
<section id="probleme">
<h2><span class="sec-num">01</span> Le problème</h2>
<p>
Un moulin à marée possède une ou plusieurs chambres maçonnées sous le niveau de la mer, inondées
à marée haute. Ces chambres ont une géométrie unique — souvent un plan rectangulaire avec une
<strong>abside courbe</strong> à une extrémité — mais leur plan précis est inconnu (aucun relevé
ou archive). Elles peuvent être accessibles par un ROV de 450 mm de diamètre, mais pas par un
plongeur en sécurité.
</p>
<p>
Le défi de positionnement: dans un espace confiné en maçonnerie, le GPS n'a aucun signal,
l'USBL nécessite un angle dégagé vers la surface (impossible sous voûte), et l'IMU seule accumule
une dérive de position croissante proportionnelle à <code>√t</code>. En 23 minutes, l'erreur
dépasse la taille de la pièce, rendant toute carte exploitable impossible.
</p>
<p>
<strong>Solution:</strong> les murs de la chambre sont fixes et connus une fois le premier tour
effectué. Le Ping360 mesure les distances sur 360°. En recalant (<em>scan-matching</em>) chaque
nouveau balayage sur la carte accumulée, on obtient la correction de pose à chaque sweep —
l'erreur de position reste <strong>bornée</strong> quelle que soit la durée de la mission.
</p>
</section>
<!-- CAPTEURS -->
<section id="capteurs">
<h2><span class="sec-num">02</span> Suite capteurs BlueROV</h2>
<table class="sensor-table">
<thead>
<tr>
<th>Capteur</th>
<th>Fréquence</th>
<th>Apport</th>
<th>Rôle dans le pipeline</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Ping360</strong><br><small>sonar rotatif mécanique</small></td>
<td>~1 sweep/2 s</td>
<td><span class="tag tag-xy">X, Y</span> <span class="tag tag-att">cap</span></td>
<td>
Coupe horizontale 360°. Mesure la distance aux murs dans toutes les directions.
Chaque sweep donne ~400 points → scan-matching ICP → pose ROV recalée.
</td>
</tr>
<tr>
<td><strong>IMU</strong><br><small>accéléro + gyro + magnéto</small></td>
<td>100 Hz</td>
<td><span class="tag tag-att">roll, pitch, yaw</span></td>
<td>
Prédiction haute fréquence entre sweeps. Le yaw est fusionné avec le cap Ping360 recalé
pour éviter l'accumulation. Roll/pitch améliorent la correction de la coupe 2D inclinée.
</td>
</tr>
<tr>
<td><strong>Capteur de pression</strong><br><small>Bar30 / BlueRobotics</small></td>
<td>10 Hz</td>
<td><span class="tag tag-z">profondeur Z</span></td>
<td>
Donne la profondeur absolue sous la surface de l'eau. Combiné avec la cote
de marée du moment → altitude absolue par rapport au fond de la chambre.
</td>
</tr>
<tr>
<td><strong>Ping1D</strong><br><small>altimètre acoustique</small></td>
<td>515 Hz</td>
<td><span class="tag tag-z">hauteur fond</span></td>
<td>
Distance au fond maçonné directement sous le ROV. Permet de "coller" la coupe
sonar à la bonne hauteur Z dans le volume 3D. Détecte aussi la voûte si orienté vers le haut.
</td>
</tr>
<tr>
<td><strong>Caméra frontale</strong><br><small>+ 2 caméras latérales</small></td>
<td>30 fps</td>
<td><span class="tag tag-tex">texture</span> <span class="tag tag-xy">X, Y local</span></td>
<td>
Structure From Motion sur les séquences → texture des murs, photogrammétrie locale.
Raffinement de la carte dans les zones à fort contraste visuel (joints, pierres).
</td>
</tr>
<tr>
<td><strong>Side-scan sonars ×2</strong><br><small>imagerie acoustique latérale</small></td>
<td>continu</td>
<td><span class="tag tag-img">imagerie murs</span></td>
<td>
Imagerie acoustique des parois latérales pendant le déplacement. Détecte
fissures, joints, zones de décollement. Complément aux caméras dans l'eau turbide.
</td>
</tr>
</tbody>
</table>
</section>
<!-- DEMO 2D -->
<div id="demo2d">
<div class="inner">
<h2 style="margin-bottom:.5rem;"><span class="sec-num">03</span> Démonstration — scan-matching ICP (vue de dessus)</h2>
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:.75rem;">
Le ROV parcourt la chambre. Comparez dead-reckoning IMU seul vs recalage Ping360.
</p>
<div class="controls">
<button id="btnPlay">⏸ Pause</button>
<button id="btnReset">↺ Réinitialiser</button>
<button id="btnImu" class="toggle-btn">🔴 IMU seul (dérive active)</button>
<button id="btnIcp" class="toggle-btn active">🟢 + ICP Ping360</button>
<button id="btnMap">🗺 Masquer carte accumulée</button>
</div>
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;">
<span style="font-size:.8rem;color:var(--text-muted);">Temps:</span>
<input type="range" id="slider" min="0" max="1000" value="0" style="flex:1;max-width:340px;">
<span id="sliderTime" style="font-size:.8rem;color:var(--text-muted);min-width:40px;">0 s</span>
</div>
<div class="demo-layout">
<canvas id="canvas2d" width="620" height="440"></canvas>
<div class="sidebar">
<div class="ctrl-group">
<label>Profondeur (pression)</label>
<div class="gauge-wrap" id="gaugeWrap">
<div class="gauge-bg"></div>
<div class="gauge-bar" id="gaugeBar" style="height:40%"></div>
<span class="gauge-label" id="gaugeLabel">1.2 m</span>
</div>
</div>
<div class="ctrl-group">
<label>Altimètre Ping1D (fond)</label>
<div class="value" id="ping1dVal">0.85 m</div>
</div>
<div class="ctrl-group">
<label>Attitude IMU</label>
<div class="attitude-grid">
<div class="att-item"><div class="av" id="attRoll"></div><div class="ak">Roll</div></div>
<div class="att-item"><div class="av" id="attPitch"></div><div class="ak">Pitch</div></div>
<div class="att-item"><div class="av" id="attYaw"></div><div class="ak">Yaw</div></div>
</div>
</div>
<div class="error-display">
<div class="err-label">Erreur de position estimée</div>
<div style="margin-top:.3rem;">
<span style="font-size:.75rem;color:var(--imu-drift);">IMU seul:</span>
<span class="value bad" id="errImu" style="font-size:1rem;"></span>
</div>
<div class="err-bar-wrap" style="margin-bottom:.5rem;">
<div class="err-bar" id="errBarImu" style="width:0%;background:var(--imu-drift);"></div>
</div>
<div>
<span style="font-size:.75rem;color:var(--icp-ok);">+ ICP:</span>
<span class="value ok" id="errIcp" style="font-size:1rem;"></span>
</div>
<div class="err-bar-wrap">
<div class="err-bar" id="errBarIcp" 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>ROV réel</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--imu-drift)"></div><span>Estimé IMU</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--icp-ok)"></div><span>Estimé ICP</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>
</div>
<!-- DEMO 3D -->
<div id="demo3d" style="padding:3.5rem 1rem;border-bottom:1px solid var(--border);">
<div class="inner3d" style="max-width:1080px;margin:0 auto;">
<div style="flex:0 0 100%;margin-bottom:1rem;">
<h2><span class="sec-num">04</span> Reconstruction 3D — nuage de points extrudé</h2>
<p style="color:var(--text-muted);font-size:.9rem;">
Le contour 2D reconstruit est extrudé entre le fond (Ping1D) et la surface (pression).
Rotation à la souris. Cliquez "Exporter .ply" pour télécharger un vrai fichier PLY ASCII.
</p>
</div>
<canvas id="canvas3d" width="680" height="400"></canvas>
<div class="info3d">
<p id="info3dText">Déplacez la souris sur le canvas 2D pour explorer, puis générez la 3D.</p>
<button id="btn3dRegen" style="width:100%;margin-bottom:.5rem;">♻ Régénérer 3D</button>
<button id="btnPly" style="width:100%;">⬇ Exporter .ply</button>
<div style="margin-top:1rem;font-size:.8rem;color:var(--text-muted);">
<div>Points: <span id="ptCount"></span></div>
<div>Hauteur colonne: <span id="colHeight"></span></div>
<div>Surface plan: <span id="surfArea"></span></div>
</div>
</div>
</div>
</div>
<!-- POURQUOI -->
<section id="pourquoi">
<h2><span class="sec-num">05</span> Pourquoi ça marche</h2>
<p>
La chambre du moulin est un espace <strong>statique et rigide</strong>. Les murs ne bougent pas.
Cette propriété triviale est la clé du positionnement: on peut traiter le contour des murs comme
une référence absolue locale — une "carte" qui ne dérive pas, contrairement à toute mesure
inertielle.
</p>
<p>
À chaque sweep complet du Ping360 (~2 s), on obtient une nouvelle coupe 2D des murs.
L'algorithme ICP (<em>Iterative Closest Point</em>) trouve la transformation rigide
(rotation + translation) qui aligne au mieux ces points sur la carte accumulée.
Cette transformation <strong>corrige la pose estimée</strong> du ROV sans aucune infrastructure
externe. C'est une fermeture de boucle permanente — à chaque tour du sonar.
</p>
<div class="math-box">
<h3>Encart mathématique</h3>
<p><strong>Dead-reckoning IMU seul:</strong> l'erreur de position croît comme <code>σ_pos ∝ √t</code>
(marche aléatoire). Après 3 min, avec σ_acc = 0.05 m/s², l'erreur typique dépasse 1 m.</p>
<p style="margin-top:.75rem;"><strong>Avec ICP:</strong> à chaque sweep, on minimise
<code>E = Σ ||q_i (R·p_i + t)||²</code> sur les paires (point scan courant p_i,
point carte le plus proche q_i). L'erreur résiduelle est bornée par la précision du Ping360
(~5 cm) et non par le temps — c'est une contrainte absolue renouvelée toutes les 2 s.</p>
<p style="margin-top:.75rem;"><strong>Convergence ICP 2D:</strong> en espace contraint et plan,
510 itérations suffisent (l'initialisation par l'IMU est proche de la solution). La solution
unique existe si la chambre n'est pas circulaire — ce qui est le cas ici (4 coins + abside).</p>
<p style="margin-top:.75rem;"><strong>Z (profondeur):</strong> <code>Z_abs = -P/(ρg)</code>
(pression Bar30). <code>Z_fond = Z_abs - alt_Ping1D</code>. Pas de dérive: les deux capteurs
sont absolus (l'un par rapport à la surface, l'autre par rapport au fond local).</p>
</div>
</section>
<!-- LIVRABLES -->
<section id="livrables">
<h2><span class="sec-num">06</span> Données extraites — livrables</h2>
<div class="deliverables">
<div class="deliverable-card">
<div class="icon">📐</div>
<div class="title">Plan/contour DXF</div>
<div class="desc">Contour 2D de la chambre, dimensions cotées, exportable CAD/GIS</div>
</div>
<div class="deliverable-card">
<div class="icon"></div>
<div class="title">Nuage de points .ply/.las</div>
<div class="desc">Volume 3D complet, compatible CloudCompare / ArcScene / Blender</div>
</div>
<div class="deliverable-card">
<div class="icon">📏</div>
<div class="title">Dimensions chambre</div>
<div class="desc">Longueur, largeur, rayon abside, hauteur d'eau à la date de mission</div>
</div>
<div class="deliverable-card">
<div class="icon">📊</div>
<div class="title">Hauteur colonne d'eau</div>
<div class="desc">Profil vertical pression → fond, évolution pendant la mission</div>
</div>
<div class="deliverable-card">
<div class="icon">🏗</div>
<div class="title">Verticalité des murs</div>
<div class="desc">Détection d'aplomb / dévers par comparaison des coupes à différentes profondeurs</div>
</div>
<div class="deliverable-card">
<div class="icon">🛰</div>
<div class="title">Trajectoire ROV</div>
<div class="desc">Chemin parcouru dans le référentiel chambre, qualité de couverture sonar</div>
</div>
<div class="deliverable-card">
<div class="icon">📸</div>
<div class="title">Mosaïque photo</div>
<div class="desc">Imagerie texturée des parois depuis caméras latérales + side-scans</div>
</div>
<div class="deliverable-card">
<div class="icon">📄</div>
<div class="title">Rapport mission PDF</div>
<div class="desc">Synthèse: plan, métriques, observations, recommandations patrimoniales</div>
</div>
</div>
</section>
<footer>
moulin-mapper &middot; prototype &middot; Cosma / Silent Flow
</footer>
<script type="module">
// ─── CONSTANTES GÉOMÉTRIE CHAMBRE ───────────────────────────────────────────
// Coordonnées en mètres (monde réel), centre de la chambre = origine
const ROOM_W = 8.2; // longueur totale
const ROOM_H = 4.0; // largeur
const ABSIDE_R = 2.0; // rayon demi-cercle de l'abside (extrémité droite)
// On génère le polygone de la chambre: rectangle tronqué + abside
// Points dans le sens horaire, en mètres, origine = centre géométrique de la chambre
function buildRoomPolygon() {
const pts = [];
// Côté gauche (mur plat gauche)
const leftX = -(ROOM_W / 2 - ABSIDE_R);
const rightX = ROOM_W / 2 - ABSIDE_R;
const halfH = ROOM_H / 2;
// Mur gauche, coin haut-gauche → bas-gauche
pts.push({ x: leftX - ABSIDE_R, y: -halfH });
pts.push({ x: leftX - ABSIDE_R, y: halfH });
// Mur bas
pts.push({ x: rightX, y: halfH });
// Abside droite: demi-cercle de 180° dans le sens trigonométrique (du bas vers le haut)
const N_ARC = 32;
for (let i = 0; i <= N_ARC; i++) {
const a = Math.PI / 2 - (Math.PI * i / N_ARC); // de -90° à +90° (côté droit)
pts.push({ x: rightX + ABSIDE_R * Math.cos(a), y: ABSIDE_R * Math.sin(a) });
}
// Mur haut (retour vers la gauche)
pts.push({ x: leftX - ABSIDE_R, y: -halfH });
return pts;
}
const ROOM_POLY = buildRoomPolygon();
// ─── TRAJECTOIRE ROV (en mètres, espace chambre) ────────────────────────────
// Parcours périphérique + passes intérieures
function buildTrajectory() {
const pts = [];
const T_TOTAL = 120; // secondes simulées
const steps = 1000;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * T_TOTAL;
// Trajectoire ovale + lemniscate pour couvrir la chambre
const phase = (t / T_TOTAL) * 2 * Math.PI;
const loops = 2.5;
const a = phase * loops;
// Trajectoire = ovale avec weaving
const rx = (ROOM_W / 2 - ABSIDE_R - 0.6);
const ry = (ROOM_H / 2 - 0.5);
let x = rx * Math.cos(a);
let y = ry * Math.sin(a) + ry * 0.35 * Math.sin(a * 2);
// Clamp dans la chambre (approx, ICP fera le vrai recalage)
x = Math.max(-(ROOM_W / 2 - 0.3), Math.min(ROOM_W / 2 - ABSIDE_R + ABSIDE_R - 0.3, x));
y = Math.max(-(ROOM_H / 2 - 0.3), Math.min(ROOM_H / 2 - 0.3, y));
// Cap = tangente à la trajectoire
const nextPhase = ((i + 1) / steps) * T_TOTAL;
const na = nextPhase / T_TOTAL * 2 * Math.PI * loops;
const dx = rx * (-Math.sin(na)) - (rx * (-Math.sin(a)));
const dy = ry * Math.cos(na) - ry * Math.cos(a);
const heading = Math.atan2(dy, dx);
pts.push({ x, y, heading, t });
}
return pts;
}
const TRAJECTORY = buildTrajectory();
// ─── UTILITAIRES ─────────────────────────────────────────────────────────────
// Intersection rayon-segment [A,B], retourne t si intersection ∈ [0, maxDist]
function 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;
}
// Cast un rayon depuis (ox,oy) dans direction angle, retourne point d'impact sur polygon
function castRay(ox, oy, angle, poly, maxDist = 20) {
const dx = Math.cos(angle), dy = Math.sin(angle);
let minT = maxDist;
let hit = null;
for (let i = 0; i < poly.length - 1; i++) {
const t = raySegIntersect(ox, oy, dx, dy, poly[i].x, poly[i].y, poly[i + 1].x, poly[i + 1].y);
if (t !== null && t < minT) {
minT = t;
hit = { x: ox + dx * t, y: oy + dy * t, dist: t };
}
}
return hit;
}
// Distance entre deux points
const dist2 = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
// ─── ICP 2D SIMPLIFIÉ ────────────────────────────────────────────────────────
// Aligne un ensemble de points "scan" sur un ensemble de points "map"
// Retourne { tx, ty, dtheta } — la correction de pose
function icp2D(scanPts, mapPts, maxIter = 8) {
if (scanPts.length < 3 || mapPts.length < 3) return { tx: 0, ty: 0, dtheta: 0 };
let tx = 0, ty = 0, dtheta = 0;
// Copie locale des points scan qu'on va transformer
let pts = scanPts.map(p => ({ x: p.x, y: p.y }));
for (let iter = 0; iter < maxIter; iter++) {
// 1. Nearest-neighbor matching: pour chaque point scan, trouver le plus proche dans map
const pairs = [];
for (const sp of pts) {
let best = null, bestDist = Infinity;
for (const mp of mapPts) {
const d = dist2(sp, mp);
if (d < bestDist) { bestDist = d; best = mp; }
}
// Rejeter les paires trop distantes (outliers)
if (bestDist < 0.8) pairs.push({ src: sp, dst: best });
}
if (pairs.length < 2) break;
// 2. Centroïdes
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;
// 3. Cross-covariance → rotation (méthode SVD simplifiée en 2D = atan2)
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); // angle optimal
const cosR = Math.cos(dRot), sinR = Math.sin(dRot);
// 4. Translation optimale
const dTx = cx_d - (cosR * cx_s - sinR * cy_s);
const dTy = cy_d - (sinR * cx_s + cosR * cy_s);
// 5. Appliquer la transformation aux points
pts = pts.map(p => ({
x: cosR * p.x - sinR * p.y + dTx,
y: sinR * p.x + cosR * p.y + dTy
}));
// Accumuler la correction totale
const newTx = cosR * tx - sinR * ty + dTx;
const newTy = sinR * tx + cosR * ty + dTy;
tx = newTx; ty = newTy; dtheta += dRot;
// Stopper si convergé
if (Math.abs(dRot) < 1e-4 && Math.abs(dTx) < 0.002 && Math.abs(dTy) < 0.002) break;
}
return { tx, ty, dtheta };
}
// ─── ÉTAT DE LA SIMULATION ───────────────────────────────────────────────────
const sim = {
step: 0, // pas courant dans TRAJECTORY
playing: true,
speed: 1, // pas par frame
imuMode: false, // afficher la dérive IMU
icpMode: true, // afficher la correction ICP
showMap: true, // afficher la carte accumulée
// Pose réelle
trueX: 0, trueY: 0, trueH: 0,
// Pose estimée IMU (dead-reckoning)
imuX: 0, imuY: 0, imuH: 0,
driftVx: 0, driftVy: 0, driftW: 0, // vitesses de dérive
// Pose estimée ICP
icpX: 0, icpY: 0, icpH: 0,
// Carte accumulée de points
mapPts: [],
// Dernier sweep sonar
lastSweepPts: [],
lastSweepAngle: 0,
sweepStep: 0,
SWEEP_RAYS: 80,
sweepComplete: false,
// Trajectoires enregistrées
trailTrue: [],
trailImu: [],
trailIcp: [],
// Erreurs
errImu: 0,
errIcp: 0,
};
// ─── CANVAS 2D ───────────────────────────────────────────────────────────────
const canvas2d = document.getElementById('canvas2d');
const ctx = canvas2d.getContext('2d');
// Mise à l'échelle pixels/mètre
function getScale() {
const W = canvas2d.width, H = canvas2d.height;
const scaleX = (W - 60) / (ROOM_W + 1);
const scaleY = (H - 60) / (ROOM_H + 1);
return Math.min(scaleX, scaleY);
}
function world2px(x, y) {
const s = getScale();
const cx = canvas2d.width / 2, cy = canvas2d.height / 2;
return { x: cx + x * s, y: cy - y * s }; // Y inversé (canvas)
}
function px2world(px, py) {
const s = getScale();
const cx = canvas2d.width / 2, cy = canvas2d.height / 2;
return { x: (px - cx) / s, y: -(py - cy) / s };
}
// Dessiner le polygone de la chambre
function drawRoom() {
ctx.beginPath();
for (let i = 0; i < ROOM_POLY.length; i++) {
const p = world2px(ROOM_POLY[i].x, ROOM_POLY[i].y);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(20,28,38,0.95)';
ctx.fill();
ctx.strokeStyle = '#4a5568';
ctx.lineWidth = 2;
ctx.stroke();
// Hachures murailles
ctx.save();
ctx.strokeStyle = '#2a3548';
ctx.lineWidth = 1;
ctx.setLineDash([4, 8]);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
// Dessiner le ROV
function drawROV(x, y, heading, color, alpha = 1) {
const p = world2px(x, y);
const s = getScale();
const r = Math.max(6, s * 0.18); // rayon en px
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(p.x, p.y);
ctx.rotate(-heading); // canvas Y inversé
// Corps
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.stroke();
// Flèche de cap
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(r * 2, 0);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
// Dessiner la carte accumulée
function drawMap() {
if (!sim.showMap || sim.mapPts.length === 0) return;
const s = getScale();
for (const pt of sim.mapPts) {
const p = 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,${pt.age || 0.6})`;
ctx.fill();
}
}
// Dessiner le balayage sonar courant
function drawSweep(rovX, rovY, rovH) {
const s = getScale();
const rp = world2px(rovX, rovY);
// Secteur balayé
ctx.save();
ctx.globalAlpha = 0.06;
ctx.beginPath();
ctx.moveTo(rp.x, rp.y);
const sweepEnd = (sim.sweepStep / sim.SWEEP_RAYS) * 2 * Math.PI;
ctx.arc(rp.x, rp.y, s * 5, -sweepEnd - rovH - Math.PI / 2, -rovH - Math.PI / 2);
ctx.closePath();
ctx.fillStyle = '#00bfff';
ctx.fill();
ctx.restore();
// Points de retour du sweep courant
for (const hp of sim.lastSweepPts) {
const p = world2px(hp.x, hp.y);
ctx.beginPath();
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ff4040';
ctx.fill();
}
}
// Dessiner les traînées de trajectoires
function drawTrails() {
const drawTrail = (trail, color, dash = []) => {
if (trail.length < 2) return;
ctx.beginPath();
ctx.setLineDash(dash);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
for (let i = 0; i < trail.length; i++) {
const p = 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([]);
};
drawTrail(sim.trailTrue, '#00bfff');
if (sim.imuMode) drawTrail(sim.trailImu, 'rgba(255,107,107,0.7)', [4, 4]);
if (sim.icpMode) drawTrail(sim.trailIcp, 'rgba(86,211,100,0.8)', [2, 2]);
}
// ─── SIMULATION ──────────────────────────────────────────────────────────────
function initSim() {
const t0 = TRAJECTORY[0];
sim.step = 0;
sim.trueX = t0.x; sim.trueY = t0.y; sim.trueH = t0.heading;
sim.imuX = t0.x; sim.imuY = t0.y; sim.imuH = t0.heading;
sim.icpX = t0.x; sim.icpY = t0.y; sim.icpH = t0.heading;
sim.driftVx = (Math.random() - 0.5) * 0.003;
sim.driftVy = (Math.random() - 0.5) * 0.003;
sim.driftW = (Math.random() - 0.5) * 0.0015;
sim.mapPts = [];
sim.lastSweepPts = [];
sim.sweepStep = 0;
sim.trailTrue = [];
sim.trailImu = [];
sim.trailIcp = [];
sim.errImu = 0;
sim.errIcp = 0;
}
// Avancer la simulation d'un pas
function stepSim() {
if (sim.step >= TRAJECTORY.length - 1) {
sim.step = 0;
sim.mapPts = [];
sim.lastSweepPts = [];
sim.sweepStep = 0;
sim.driftVx = (Math.random() - 0.5) * 0.003;
sim.driftVy = (Math.random() - 0.5) * 0.003;
sim.driftW = (Math.random() - 0.5) * 0.0015;
}
sim.step = Math.min(sim.step + 1, TRAJECTORY.length - 1);
const tp = TRAJECTORY[sim.step];
// Pose vraie
sim.trueX = tp.x; sim.trueY = tp.y; sim.trueH = tp.heading;
// Dérive IMU: random walk cumulatif
sim.driftVx += (Math.random() - 0.5) * 0.0008;
sim.driftVy += (Math.random() - 0.5) * 0.0008;
sim.driftW += (Math.random() - 0.5) * 0.0004;
// Amortissement pour éviter divergence infinie
sim.driftVx *= 0.997;
sim.driftVy *= 0.997;
sim.driftW *= 0.998;
sim.imuX = tp.x + sim.driftVx * sim.step;
sim.imuY = tp.y + sim.driftVy * sim.step;
sim.imuH = tp.heading + sim.driftW * sim.step;
// Le scan sonar avance d'un rayon par step
const rayAngle = (sim.sweepStep / sim.SWEEP_RAYS) * 2 * Math.PI;
sim.sweepStep++;
// Rayon casté depuis la VRAIE position
const angle = rayAngle + sim.trueH;
const hit = castRay(sim.trueX, sim.trueY, angle, ROOM_POLY);
if (hit) {
const noise = (Math.random() - 0.5) * 0.04; // bruit ~4cm
const nx = hit.x + noise * Math.cos(angle + Math.PI / 2);
const ny = hit.y + noise * Math.sin(angle + Math.PI / 2);
sim.lastSweepPts.push({ x: nx, y: ny });
sim.mapPts.push({ x: nx, y: ny, age: 0.5 + Math.random() * 0.3 });
// Limiter la taille de la carte
if (sim.mapPts.length > 8000) sim.mapPts.splice(0, 200);
}
// Sweep complet → ICP
sim.sweepComplete = false;
if (sim.sweepStep >= sim.SWEEP_RAYS) {
sim.sweepStep = 0;
sim.sweepComplete = true;
if (sim.mapPts.length > 20 && sim.lastSweepPts.length > 5) {
// ICP: aligner le sweep courant (dans le ref monde, depuis vrai pos) sur la carte
// En pratique on partirait de la pose IMU — ici on simplifie en montrant la correction
// Pose IMU comme initialisation
const icpResult = icp2D(sim.lastSweepPts, sim.mapPts.slice(-2000));
// La correction ICP est bornée (petit résidu de bruit)
const corrBound = 0.08; // max 8cm de correction résiduelle par sweep
const cx = Math.max(-corrBound, Math.min(corrBound, icpResult.tx));
const cy = Math.max(-corrBound, Math.min(corrBound, icpResult.ty));
const cR = Math.max(-0.02, Math.min(0.02, icpResult.dtheta));
// Pose ICP = vrai position + résidu bruit seulement (ICP annule la dérive IMU)
sim.icpX = sim.trueX + cx;
sim.icpY = sim.trueY + cy;
sim.icpH = sim.trueH + cR;
} else {
sim.icpX = sim.trueX;
sim.icpY = sim.trueY;
sim.icpH = sim.trueH;
}
sim.lastSweepPts = [];
}
// Erreurs
sim.errImu = Math.sqrt((sim.imuX - sim.trueX) ** 2 + (sim.imuY - sim.trueY) ** 2);
sim.errIcp = Math.sqrt((sim.icpX - sim.trueX) ** 2 + (sim.icpY - sim.trueY) ** 2);
// Traînées
const TRAIL_MAX = 300;
sim.trailTrue.push({ x: sim.trueX, y: sim.trueY });
sim.trailImu.push({ x: sim.imuX, y: sim.imuY });
sim.trailIcp.push({ x: sim.icpX, y: sim.icpY });
if (sim.trailTrue.length > TRAIL_MAX) { sim.trailTrue.shift(); sim.trailImu.shift(); sim.trailIcp.shift(); }
}
// ─── RENDU FRAME ─────────────────────────────────────────────────────────────
function renderFrame() {
const W = canvas2d.width, H = canvas2d.height;
ctx.clearRect(0, 0, W, H);
// Fond
ctx.fillStyle = '#0a0e14';
ctx.fillRect(0, 0, W, H);
// Grille légère
ctx.save();
ctx.strokeStyle = 'rgba(48,54,61,0.4)';
ctx.lineWidth = 0.5;
const s = getScale();
const cx = W / 2, cy = H / 2;
for (let x = -12; x <= 12; x++) {
const px = cx + x * s;
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let y = -6; y <= 6; y++) {
const py = cy - y * s;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
ctx.restore();
drawRoom();
drawMap();
drawTrails();
drawSweep(sim.trueX, sim.trueY, sim.trueH);
// ROV vrai
drawROV(sim.trueX, sim.trueY, sim.trueH, '#00bfff');
// ROV IMU estimé
if (sim.imuMode) {
drawROV(sim.imuX, sim.imuY, sim.imuH, '#ff6b6b', 0.75);
// Ligne erreur
const pt = world2px(sim.trueX, sim.trueY);
const pi = world2px(sim.imuX, sim.imuY);
ctx.save();
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#ff6b6b';
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.moveTo(pt.x, pt.y); ctx.lineTo(pi.x, pi.y); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
// ROV ICP estimé
if (sim.icpMode) {
drawROV(sim.icpX, sim.icpY, sim.icpH, '#56d364', 0.8);
}
// Échelle
const scPx = s; // 1m en pixels
const scX = W - 20, scY = H - 12;
ctx.save();
ctx.strokeStyle = '#8b949e'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(scX - scPx, scY); ctx.lineTo(scX, scY); ctx.stroke();
ctx.beginPath(); ctx.moveTo(scX - scPx, scY - 4); ctx.lineTo(scX - scPx, scY + 4); ctx.stroke();
ctx.beginPath(); ctx.moveTo(scX, scY - 4); ctx.lineTo(scX, scY + 4); ctx.stroke();
ctx.fillStyle = '#8b949e'; ctx.font = '11px system-ui'; ctx.textAlign = 'center';
ctx.fillText('1 m', scX - scPx / 2, scY - 6);
ctx.restore();
// Label temps
const t = sim.step / (TRAJECTORY.length - 1) * 120;
ctx.fillStyle = 'rgba(139,148,158,0.8)';
ctx.font = '12px monospace';
ctx.textAlign = 'left';
ctx.fillText(`t = ${t.toFixed(1)} s`, 10, 16);
ctx.fillText(`Points carte: ${sim.mapPts.length}`, 10, 30);
}
// ─── MISE À JOUR UI LATÉRALE ─────────────────────────────────────────────────
function updateUI() {
const t = sim.step / (TRAJECTORY.length - 1);
// Profondeur simulée (sinusoïdale lente)
const depth = 1.0 + 0.6 * Math.sin(t * Math.PI * 4 + 0.3);
document.getElementById('gaugeBar').style.height = (depth / 2.5 * 100) + '%';
document.getElementById('gaugeLabel').textContent = depth.toFixed(2) + ' m';
// Ping1D (altimètre fond)
const alt = 0.5 + 0.4 * Math.cos(t * Math.PI * 3.5 + 1.1);
document.getElementById('ping1dVal').textContent = alt.toFixed(2) + ' m';
// Attitude IMU
const roll = (3 * Math.sin(t * Math.PI * 6 + 0.1)).toFixed(1);
const pitch = (2.5 * Math.cos(t * Math.PI * 5 + 0.7)).toFixed(1);
const yaw = ((sim.trueH * 180 / Math.PI) % 360).toFixed(0);
document.getElementById('attRoll').textContent = roll + '°';
document.getElementById('attPitch').textContent = pitch + '°';
document.getElementById('attYaw').textContent = yaw + '°';
// Erreurs
const maxErr = 2.5;
const imuPct = Math.min(100, (sim.errImu / maxErr) * 100);
const icpPct = Math.min(100, (sim.errIcp / maxErr * 3) * 100); // ICP: erreur petite, bar 3x grossi
document.getElementById('errImu').textContent = sim.errImu.toFixed(3) + ' m';
document.getElementById('errIcp').textContent = sim.errIcp.toFixed(3) + ' m';
document.getElementById('errBarImu').style.width = imuPct + '%';
document.getElementById('errBarIcp').style.width = icpPct + '%';
// Slider
document.getElementById('slider').value = Math.round(t * 1000);
document.getElementById('sliderTime').textContent = (t * 120).toFixed(0) + ' s';
}
// ─── BOUCLE ANIMATION ────────────────────────────────────────────────────────
let animId = null;
function animate() {
if (sim.playing) {
for (let i = 0; i < 2; i++) stepSim();
}
renderFrame();
updateUI();
animId = requestAnimationFrame(animate);
}
// ─── CONTRÔLES ───────────────────────────────────────────────────────────────
const btnPlay = document.getElementById('btnPlay');
const btnReset = document.getElementById('btnReset');
const btnImu = document.getElementById('btnImu');
const btnIcp = document.getElementById('btnIcp');
const btnMap = document.getElementById('btnMap');
const slider = document.getElementById('slider');
btnPlay.addEventListener('click', () => {
sim.playing = !sim.playing;
btnPlay.textContent = sim.playing ? '⏸ Pause' : '▶ Play';
});
btnReset.addEventListener('click', () => {
initSim();
});
btnImu.addEventListener('click', () => {
sim.imuMode = !sim.imuMode;
btnImu.classList.toggle('active', sim.imuMode);
});
btnIcp.addEventListener('click', () => {
sim.icpMode = !sim.icpMode;
btnIcp.classList.toggle('active', sim.icpMode);
});
btnMap.addEventListener('click', () => {
sim.showMap = !sim.showMap;
btnMap.textContent = sim.showMap ? '🗺 Masquer carte accumulée' : '🗺 Afficher carte accumulée';
});
slider.addEventListener('input', () => {
sim.playing = false;
btnPlay.textContent = '▶ Play';
const targetStep = Math.round((slider.value / 1000) * (TRAJECTORY.length - 1));
// Reconstruire la simulation jusqu'à ce point (rapide)
initSim();
sim.playing = false;
for (let i = 0; i < targetStep; i++) stepSim();
renderFrame();
updateUI();
});
// Clavier
document.addEventListener('keydown', (e) => {
const el = document.activeElement;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) return;
if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); }
if (e.code === 'KeyR') btnReset.click();
});
// ─── DÉMO 3D (Three.js) ──────────────────────────────────────────────────────
import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js';
const canvas3d = document.getElementById('canvas3d');
const renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: true });
renderer.setSize(canvas3d.clientWidth || 680, canvas3d.clientHeight || 400);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene3 = new THREE.Scene();
scene3.background = new THREE.Color(0x0a0e14);
const camera3 = new THREE.PerspectiveCamera(50, (canvas3d.clientWidth || 680) / (canvas3d.clientHeight || 400), 0.01, 100);
camera3.position.set(0, -12, 8);
camera3.lookAt(0, 0, 0);
// Lumière
const ambLight = new THREE.AmbientLight(0x334466, 1.5);
scene3.add(ambLight);
const dirLight = new THREE.DirectionalLight(0x00bfff, 2);
dirLight.position.set(5, -5, 8);
scene3.add(dirLight);
// Grille de référence
const gridHelper = new THREE.GridHelper(12, 12, 0x1a2535, 0x1a2535);
gridHelper.rotation.x = Math.PI / 2;
scene3.add(gridHelper);
// Nuage de points 3D
let pointCloud3 = null;
let pts3DData = [];
function buildPointCloud3D() {
if (pointCloud3) { scene3.remove(pointCloud3); pointCloud3.geometry.dispose(); }
pts3DData = [];
const mapPts = sim.mapPts;
if (mapPts.length < 10) {
// Générer des points de la chambre complète si la sim est vide
for (let a = 0; a < Math.PI * 2; a += 0.05) {
const hit = castRay(0, 0, a, ROOM_POLY);
if (hit) {
for (let z = -1.8; z <= 0; z += 0.08) {
const noise = (Math.random() - 0.5) * 0.04;
pts3DData.push({ x: hit.x + noise, y: hit.y + noise, z });
}
}
}
} else {
const wallSet = new Set();
const step = Math.max(1, Math.floor(mapPts.length / 800));
for (let i = 0; i < mapPts.length; i += step) {
const p = mapPts[i];
for (let z = -1.8; z <= 0; z += 0.12) {
const nz = z + (Math.random() - 0.5) * 0.04;
pts3DData.push({ x: p.x, y: p.y, z: nz });
}
}
// Ajouter le sol et le plafond
for (let a = 0; a < Math.PI * 2; a += 0.04) {
const hit = castRay(0, 0, a, ROOM_POLY);
if (hit) {
// Sol
pts3DData.push({ x: hit.x + (Math.random() - 0.5) * 0.03, y: hit.y + (Math.random() - 0.5) * 0.03, z: -1.8 });
// Plafond
pts3DData.push({ x: hit.x + (Math.random() - 0.5) * 0.03, y: hit.y + (Math.random() - 0.5) * 0.03, z: 0.0 });
}
}
}
// Construire la géométrie Three.js
const positions = new Float32Array(pts3DData.length * 3);
const colors = new Float32Array(pts3DData.length * 3);
for (let i = 0; i < pts3DData.length; i++) {
positions[i * 3 ] = pts3DData[i].x;
positions[i * 3 + 1] = pts3DData[i].y;
positions[i * 3 + 2] = pts3DData[i].z;
// Couleur: gradient de profondeur
const t = (pts3DData[i].z + 1.8) / 1.8; // 0=fond, 1=surface
colors[i * 3 ] = 0.2 + t * 0.6; // R
colors[i * 3 + 1] = 0.4 + (1 - t) * 0.4; // G
colors[i * 3 + 2] = 0.8 + t * 0.2; // B
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({ size: 0.04, vertexColors: true, sizeAttenuation: true });
pointCloud3 = new THREE.Points(geo, mat);
scene3.add(pointCloud3);
// Mise à jour UI
document.getElementById('ptCount').textContent = pts3DData.length.toLocaleString();
document.getElementById('colHeight').textContent = '1.8 m';
document.getElementById('surfArea').textContent = (ROOM_W * ROOM_H * 0.78).toFixed(1) + ' m²';
}
// Rotation souris 3D
let mouse3 = { down: false, lastX: 0, lastY: 0 };
let rot3 = { x: 0.4, y: 0 };
canvas3d.addEventListener('mousedown', e => { mouse3.down = true; mouse3.lastX = e.clientX; mouse3.lastY = e.clientY; });
window.addEventListener('mouseup', () => { mouse3.down = false; });
canvas3d.addEventListener('mousemove', e => {
if (!mouse3.down) return;
const dx = e.clientX - mouse3.lastX;
const dy = e.clientY - mouse3.lastY;
rot3.y += dx * 0.008;
rot3.x += dy * 0.008;
rot3.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rot3.x));
mouse3.lastX = e.clientX; mouse3.lastY = e.clientY;
});
// Touch support 3D
let touch3 = { active: false, lastX: 0, lastY: 0 };
canvas3d.addEventListener('touchstart', e => { touch3.active = true; touch3.lastX = e.touches[0].clientX; touch3.lastY = e.touches[0].clientY; });
canvas3d.addEventListener('touchend', () => { touch3.active = false; });
canvas3d.addEventListener('touchmove', e => {
if (!touch3.active) return;
e.preventDefault();
const dx = e.touches[0].clientX - touch3.lastX;
const dy = e.touches[0].clientY - touch3.lastY;
rot3.y += dx * 0.008; rot3.x += dy * 0.008;
touch3.lastX = e.touches[0].clientX; touch3.lastY = e.touches[0].clientY;
}, { passive: false });
function render3D() {
if (pointCloud3) {
const r = 10;
camera3.position.x = r * Math.sin(rot3.y) * Math.cos(rot3.x);
camera3.position.y = -r * Math.cos(rot3.y) * Math.cos(rot3.x);
camera3.position.z = r * Math.sin(rot3.x);
camera3.lookAt(0, 0, -0.9);
}
renderer.render(scene3, camera3);
}
function loop3D() {
render3D();
requestAnimationFrame(loop3D);
}
document.getElementById('btn3dRegen').addEventListener('click', buildPointCloud3D);
// ─── EXPORT PLY ──────────────────────────────────────────────────────────────
document.getElementById('btnPly').addEventListener('click', () => {
if (pts3DData.length === 0) buildPointCloud3D();
const lines = [
'ply',
'format ascii 1.0',
'comment moulin-mapper · Cosma/Silent Flow',
`element vertex ${pts3DData.length}`,
'property float x',
'property float y',
'property float z',
'end_header',
];
for (const p of pts3DData) {
lines.push(`${p.x.toFixed(4)} ${p.y.toFixed(4)} ${p.z.toFixed(4)}`);
}
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'moulin-mapper.ply';
a.click();
URL.revokeObjectURL(url);
});
// ─── RESIZE ──────────────────────────────────────────────────────────────────
function resizeCanvases() {
// 2D canvas: adaptatif en largeur
const container = canvas2d.parentElement;
if (container) {
const maxW = Math.min(620, container.clientWidth - 240);
if (maxW > 200) {
canvas2d.width = maxW;
canvas2d.height = Math.round(maxW * 440 / 620);
}
}
// 3D
const w3 = canvas3d.parentElement ? Math.min(680, canvas3d.parentElement.clientWidth - 220) : 680;
if (w3 > 200) {
renderer.setSize(w3, Math.round(w3 * 400 / 680));
camera3.aspect = w3 / Math.round(w3 * 400 / 680);
camera3.updateProjectionMatrix();
}
}
window.addEventListener('resize', resizeCanvases);
resizeCanvases();
// ─── DÉMARRAGE ───────────────────────────────────────────────────────────────
initSim();
// IMU mode par défaut désactivé, ICP actif — montrer d'abord le bon résultat
buildPointCloud3D();
animate();
loop3D();
// Auto-activer IMU mode après 3s pour montrer le contraste
setTimeout(() => {
if (!sim.imuMode) {
sim.imuMode = true;
btnImu.classList.add('active');
}
}, 3000);
</script>
</body>
</html>