Files
moulin-mapper/server/static/index.html
Flag 59f498f9f3 feat(gui): extraction murs RANSAC + dimensions pièce en direct
GUI live : détecte les segments de mur dans le nuage Ping360 (RANSAC 2D),
trace les murs cotés, estime emprise/surface/longueur murs, toggle vue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 08:31:12 +00:00

758 lines
26 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 Mapper — Live</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background: #161b22;
border-bottom: 1px solid #30363d;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
header h1 { font-size: 15px; font-weight: 600; color: #58a6ff; letter-spacing: 1px; }
#conn-dot { width: 10px; height: 10px; border-radius: 50%; background: #f85149; flex-shrink: 0; }
#conn-dot.ok { background: #3fb950; }
#conn-label { font-size: 12px; }
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Panneau gauche: canvas */
#canvas-wrap {
flex: 1;
position: relative;
background: #010409;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
/* Panneau droit: stats + contrôles */
aside {
width: 260px;
background: #161b22;
border-left: 1px solid #30363d;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
flex-shrink: 0;
}
.section {
padding: 12px 14px;
border-bottom: 1px solid #21262d;
}
.section h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #8b949e;
margin-bottom: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
border-bottom: 1px solid #0d1117;
}
.stat-row:last-child { border-bottom: none; }
.stat-label { color: #8b949e; }
.stat-value { color: #f0f6fc; font-weight: 600; }
.stat-value.accent { color: #58a6ff; }
.stat-value.warn { color: #e3b341; }
input[type=text] {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
color: #c9d1d9;
padding: 5px 8px;
font-family: inherit;
font-size: 12px;
margin-bottom: 8px;
}
input[type=text]:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
}
.btn-danger {
background: #21262d;
border-color: #f85149;
color: #f85149;
}
.btn-danger:hover { background: #3d1c1c; }
.btn-primary {
background: #21262d;
border-color: #58a6ff;
color: #58a6ff;
text-decoration: none;
display: block;
text-align: center;
}
.btn-primary:hover { background: #1c2a3d; }
#log {
font-size: 11px;
color: #8b949e;
max-height: 120px;
overflow-y: auto;
padding: 8px 14px;
flex: 1;
word-break: break-all;
}
#log p { margin-bottom: 2px; line-height: 1.4; }
#log p.err { color: #f85149; }
#log p.ok { color: #3fb950; }
.legend {
display: flex; flex-direction: column; gap: 4px;
font-size: 11px; color: #8b949e;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.leg-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
</style>
</head>
<body>
<header>
<h1>MOULIN MAPPER</h1>
<div id="conn-dot"></div>
<span id="conn-label">Déconnecté</span>
</header>
<div class="main">
<div id="canvas-wrap">
<canvas id="map-canvas"></canvas>
</div>
<aside>
<div class="section">
<h2>Stats</h2>
<div class="stat-row"><span class="stat-label">Enregistrements</span><span class="stat-value accent" id="s-records">0</span></div>
<div class="stat-row"><span class="stat-label">Sweeps</span><span class="stat-value" id="s-sweeps">0</span></div>
<div class="stat-row"><span class="stat-label">Points carte</span><span class="stat-value" id="s-points">0</span></div>
<div class="stat-row"><span class="stat-label">Fermetures boucle</span><span class="stat-value warn" id="s-loops">0</span></div>
<div class="stat-row"><span class="stat-label">Dernier t</span><span class="stat-value" id="s-t"></span></div>
<div class="stat-row"><span class="stat-label">Débit</span><span class="stat-value" id="s-debit">0</span></div>
</div>
<div class="section">
<h2>Géométrie pièce</h2>
<div class="stat-row"><span class="stat-label">Emprise (L×l)</span><span class="stat-value accent" id="s-bbox"></span></div>
<div class="stat-row"><span class="stat-label">Surface au sol</span><span class="stat-value accent" id="s-area"></span></div>
<div class="stat-row"><span class="stat-label">Murs détectés</span><span class="stat-value" id="s-walls">0</span></div>
<div class="stat-row"><span class="stat-label">Long. totale murs</span><span class="stat-value" id="s-wlen"></span></div>
</div>
<div class="section">
<h2>Pose ROV</h2>
<div class="stat-row"><span class="stat-label">x (m)</span><span class="stat-value" id="s-x">0.00</span></div>
<div class="stat-row"><span class="stat-label">y (m)</span><span class="stat-value" id="s-y">0.00</span></div>
<div class="stat-row"><span class="stat-label">cap (°)</span><span class="stat-value" id="s-h">0.0</span></div>
</div>
<div class="section">
<h2>Légende</h2>
<div class="legend">
<div class="legend-item"><div class="leg-dot" style="background:#9ba3ad;"></div><span>Nuage brut (sonar)</span></div>
<div class="legend-item"><div class="leg-dot" style="background:#1f6feb; border-radius:0; width:14px; height:4px;"></div><span>Murs détectés</span></div>
<div class="legend-item"><div class="leg-dot" style="background:#3fb950;"></div><span>Trajectoire corrigée</span></div>
<div class="legend-item"><div class="leg-dot" style="background:#f0f6fc; border-radius:0;"></div><span>ROV courant</span></div>
</div>
</div>
<div class="section">
<h2>Affichage</h2>
<button class="btn-primary" id="view-toggle" onclick="cycleViewMode()">Vue : Les deux</button>
</div>
<div class="section">
<h2>Contrôles</h2>
<input type="text" id="token-input" placeholder="Token (défaut: moulin-2026)" value="moulin-2026">
<button class="btn-danger" onclick="resetSession()">Reset session</button>
<a id="ply-link" class="btn-primary" href="./cloud.ply" download="moulin_cloud.ply">Télécharger nuage .ply</a>
</div>
<div class="section" style="flex:1;padding:0;">
<div id="log"></div>
</div>
</aside>
</div>
<script>
// ---------------------------------------------------------------------------
// Données en mémoire
// ---------------------------------------------------------------------------
let mapPoints = []; // [[x, y], ...]
let trajectory = []; // [[t, x, y, h], ...]
let poseCorrect = [0, 0, 0];
let stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
// Résultats géométrie (murs + métriques)
let walls = []; // [{x1,y1,x2,y2,length,inliers}, ...]
let geom = { bboxW: 0, bboxH: 0, area: 0, wallTotal: 0 };
// Mode affichage : 'both' | 'walls' | 'points'
let viewMode = 'both';
// Throttle recalcul murs
let lastWallCompute = 0;
let lastWallPointCount = 0;
const WALL_RECOMPUTE_MS = 500; // au max toutes les 500 ms
const WALL_RECOMPUTE_DELTA = 30; // ou dès +30 nouveaux points
// ---------------------------------------------------------------------------
// Géométrie : extraction de murs (RANSAC de droites) — JS vanilla
// ---------------------------------------------------------------------------
// Exposé sur window.MoulinGeom pour pouvoir être testé hors-ligne (Node).
const MoulinGeom = (function () {
// RANSAC itératif : extrait des segments de mur depuis un nuage [[x,y],...]
function extractWalls(points, opts) {
opts = opts || {};
const distThresh = opts.distThresh != null ? opts.distThresh : 0.10; // m
const maxWalls = opts.maxWalls != null ? opts.maxWalls : 10;
const minInliers = opts.minInliers != null ? opts.minInliers : 15;
const minLength = opts.minLength != null ? opts.minLength : 0.5; // m
const iters = opts.iters != null ? opts.iters : 400;
// Un mur doit rassembler une fraction minimale du nuage TOTAL initial,
// sinon on s'arrête (évite de "fabriquer" des murs parasites dans le bruit).
const minInlierFrac = opts.minInlierFrac != null ? opts.minInlierFrac : 0.035;
// copie des points encore disponibles
let remaining = points.map(p => [p[0], p[1]]);
const walls = [];
// seuil d'inliers : max(absolu, fraction du nuage total)
const need = Math.max(minInliers, Math.floor(points.length * minInlierFrac));
for (let w = 0; w < maxWalls; w++) {
if (remaining.length < need) break;
const best = ransacLine(remaining, distThresh, iters, minInliers);
if (!best || best.inlierIdx.length < need) break;
// Inliers de la meilleure droite
const inPts = best.inlierIdx.map(i => remaining[i]);
// Direction de la droite (normale (nx,ny) → dir = (-ny, nx))
const dx = -best.ny, dy = best.nx;
// Projette les inliers sur la direction, garde min/max → segment
let tMin = Infinity, tMax = -Infinity, pMin = null, pMax = null;
for (const p of inPts) {
const t = p[0] * dx + p[1] * dy;
if (t < tMin) { tMin = t; pMin = p; }
if (t > tMax) { tMax = t; pMax = p; }
}
const length = Math.hypot(pMax[0] - pMin[0], pMax[1] - pMin[1]);
// Retire les inliers du pool restant (avant filtrage longueur,
// pour ne pas re-tirer la même droite)
const inSet = new Set(best.inlierIdx);
remaining = remaining.filter((_, i) => !inSet.has(i));
if (length < minLength) continue; // segment trop court → ignore
walls.push({
x1: pMin[0], y1: pMin[1],
x2: pMax[0], y2: pMax[1],
length: length,
inliers: inPts.length,
});
}
return walls;
}
// Un tour de RANSAC : meilleure droite (modèle normale (nx,ny), offset c)
// distance point-droite = |nx*x + ny*y - c|
function ransacLine(pts, distThresh, iters, minInliers) {
const n = pts.length;
if (n < 2) return null;
let best = null;
for (let it = 0; it < iters; it++) {
const i = (Math.random() * n) | 0;
let j = (Math.random() * n) | 0;
if (i === j) j = (j + 1) % n;
const a = pts[i], b = pts[j];
let vx = b[0] - a[0], vy = b[1] - a[1];
const len = Math.hypot(vx, vy);
if (len < 1e-6) continue;
vx /= len; vy /= len;
// normale unitaire
const nx = -vy, ny = vx;
const c = nx * a[0] + ny * a[1];
const inlierIdx = [];
for (let k = 0; k < n; k++) {
const d = Math.abs(nx * pts[k][0] + ny * pts[k][1] - c);
if (d < distThresh) inlierIdx.push(k);
}
if (inlierIdx.length >= minInliers &&
(!best || inlierIdx.length > best.inlierIdx.length)) {
best = { nx, ny, c, inlierIdx };
}
}
if (!best) return null;
// Raffinement : refit total-least-squares sur les inliers (PCA)
refitLine(pts, best);
return best;
}
// Refit de la droite par PCA sur les inliers, ré-évalue les inliers
function refitLine(pts, model) {
const idx = model.inlierIdx;
let mx = 0, my = 0;
for (const i of idx) { mx += pts[i][0]; my += pts[i][1]; }
mx /= idx.length; my /= idx.length;
let sxx = 0, syy = 0, sxy = 0;
for (const i of idx) {
const ddx = pts[i][0] - mx, ddy = pts[i][1] - my;
sxx += ddx * ddx; syy += ddy * ddy; sxy += ddx * ddy;
}
// direction principale = vecteur propre de la plus grande valeur propre
const theta = 0.5 * Math.atan2(2 * sxy, sxx - syy);
const dx = Math.cos(theta), dy = Math.sin(theta);
// normale = perpendiculaire à la direction
model.nx = -dy; model.ny = dx;
model.c = model.nx * mx + model.ny * my;
}
// Aire de l'enveloppe convexe (Andrew monotone chain + shoelace)
function convexHullArea(points) {
if (points.length < 3) return 0;
const pts = points.map(p => [p[0], p[1]]).sort((a, b) =>
a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]);
const cross = (o, a, b) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
const upper = [];
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
upper.push(p);
}
const hull = lower.slice(0, -1).concat(upper.slice(0, -1));
// shoelace
let area2 = 0;
for (let i = 0; i < hull.length; i++) {
const a = hull[i], b = hull[(i + 1) % hull.length];
area2 += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(area2) / 2;
}
function bbox(points) {
let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
for (const p of points) {
if (p[0] < minx) minx = p[0]; if (p[0] > maxx) maxx = p[0];
if (p[1] < miny) miny = p[1]; if (p[1] > maxy) maxy = p[1];
}
return { minx, maxx, miny, maxy, w: maxx - minx, h: maxy - miny };
}
return { extractWalls, convexHullArea, bbox };
})();
if (typeof window !== 'undefined') window.MoulinGeom = MoulinGeom;
if (typeof module !== 'undefined') module.exports = MoulinGeom;
// Recalcule murs + métriques (avec throttle)
function computeGeometry(force) {
const now = (typeof performance !== 'undefined') ? performance.now() : Date.now();
const enoughNew = (mapPoints.length - lastWallPointCount) >= WALL_RECOMPUTE_DELTA;
if (!force && !(enoughNew && (now - lastWallCompute) >= WALL_RECOMPUTE_MS)) return;
if (mapPoints.length < 15) { walls = []; geom = { bboxW: 0, bboxH: 0, area: 0, wallTotal: 0 }; return; }
lastWallCompute = now;
lastWallPointCount = mapPoints.length;
walls = MoulinGeom.extractWalls(mapPoints);
const bb = MoulinGeom.bbox(mapPoints);
geom.bboxW = bb.w;
geom.bboxH = bb.h;
geom.area = MoulinGeom.convexHullArea(mapPoints);
geom.wallTotal = walls.reduce((s, w) => s + w.length, 0);
}
// ---------------------------------------------------------------------------
// Canvas + viewport
// ---------------------------------------------------------------------------
const canvas = document.getElementById('map-canvas');
const ctx = canvas.getContext('2d');
let viewX = 4.0; // centre monde visible
let viewY = 4.0;
let viewScale = 50; // px/m
let dragging = false;
let dragStart = null;
let dragView = null;
function worldToCanvas(wx, wy) {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
return [
cx + (wx - viewX) * viewScale,
cy - (wy - viewY) * viewScale,
];
}
function resizeCanvas() {
const wrap = document.getElementById('canvas-wrap');
canvas.width = wrap.clientWidth;
canvas.height = wrap.clientHeight;
draw();
}
window.addEventListener('resize', resizeCanvas);
// Zoom molette
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.85 : 1.18;
viewScale = Math.max(5, Math.min(500, viewScale * factor));
draw();
}, { passive: false });
// Pan souris
canvas.addEventListener('mousedown', (e) => {
dragging = true;
dragStart = [e.clientX, e.clientY];
dragView = [viewX, viewY];
});
canvas.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = (e.clientX - dragStart[0]) / viewScale;
const dy = (e.clientY - dragStart[1]) / viewScale;
viewX = dragView[0] - dx;
viewY = dragView[1] + dy;
draw();
});
canvas.addEventListener('mouseup', () => { dragging = false; });
canvas.addEventListener('mouseleave', () => { dragging = false; });
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Grille légère
ctx.strokeStyle = '#1c2128';
ctx.lineWidth = 1;
const gridStep = 1; // 1m
const xMin = viewX - canvas.width / (2 * viewScale) - gridStep;
const xMax = viewX + canvas.width / (2 * viewScale) + gridStep;
const yMin = viewY - canvas.height / (2 * viewScale) - gridStep;
const yMax = viewY + canvas.height / (2 * viewScale) + gridStep;
for (let gx = Math.floor(xMin); gx <= xMax; gx += gridStep) {
ctx.beginPath();
const [px, ] = worldToCanvas(gx, 0);
ctx.moveTo(px, 0); ctx.lineTo(px, canvas.height);
ctx.stroke();
}
for (let gy = Math.floor(yMin); gy <= yMax; gy += gridStep) {
ctx.beginPath();
const [, py] = worldToCanvas(0, gy);
ctx.moveTo(0, py); ctx.lineTo(canvas.width, py);
ctx.stroke();
}
// Axes origine
ctx.strokeStyle = '#21262d';
ctx.lineWidth = 1;
const [ox, oy] = worldToCanvas(0, 0);
ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox, canvas.height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(canvas.width, oy); ctx.stroke();
// Nuage de points brut (fond léger) — affiché en mode 'points' ou 'both'
if (viewMode === 'points' || viewMode === 'both') {
// gris clair + petit quand les murs sont aussi visibles, plus net sinon
const showWalls = (viewMode === 'both');
const ptRadius = showWalls ? Math.max(0.8, viewScale * 0.02)
: Math.max(1, viewScale * 0.04);
ctx.fillStyle = showWalls ? 'rgba(155, 163, 173, 0.35)'
: 'rgba(88, 166, 255, 0.7)';
for (const [wx, wy] of mapPoints) {
const [px, py] = worldToCanvas(wx, wy);
ctx.beginPath();
ctx.arc(px, py, ptRadius, 0, 2 * Math.PI);
ctx.fill();
}
}
// Murs détectés (segments épais + longueur) — mode 'walls' ou 'both'
if (viewMode === 'walls' || viewMode === 'both') {
ctx.lineCap = 'round';
for (const wll of walls) {
const [ax, ay] = worldToCanvas(wll.x1, wll.y1);
const [bx, by] = worldToCanvas(wll.x2, wll.y2);
ctx.strokeStyle = '#1f6feb';
ctx.lineWidth = Math.max(3, viewScale * 0.06);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
// Étiquettes longueur au milieu de chaque segment
ctx.font = '12px Consolas, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const wll of walls) {
const [ax, ay] = worldToCanvas(wll.x1, wll.y1);
const [bx, by] = worldToCanvas(wll.x2, wll.y2);
const mx = (ax + bx) / 2, my = (ay + by) / 2;
const label = wll.length.toFixed(2) + ' m';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(1, 4, 9, 0.75)';
ctx.fillRect(mx - tw / 2 - 3, my - 8, tw + 6, 16);
ctx.fillStyle = '#79c0ff';
ctx.fillText(label, mx, my);
}
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}
// Trajectoire
if (trajectory.length > 1) {
ctx.strokeStyle = 'rgba(63, 185, 80, 0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
const [x0, y0] = worldToCanvas(trajectory[0][1], trajectory[0][2]);
ctx.moveTo(x0, y0);
for (let i = 1; i < trajectory.length; i++) {
const [px, py] = worldToCanvas(trajectory[i][1], trajectory[i][2]);
ctx.lineTo(px, py);
}
ctx.stroke();
}
// ROV courant
const [rx, ry] = worldToCanvas(poseCorrect[0], poseCorrect[1]);
const hRad = (90 - poseCorrect[2]) * Math.PI / 180; // cap → angle canvas
const arrowLen = Math.max(8, viewScale * 0.3);
ctx.save();
ctx.translate(rx, ry);
ctx.rotate(-hRad);
ctx.fillStyle = '#f0f6fc';
ctx.strokeStyle = '#f0f6fc';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, -arrowLen);
ctx.lineTo(arrowLen * 0.5, arrowLen * 0.5);
ctx.lineTo(0, 0);
ctx.lineTo(-arrowLen * 0.5, arrowLen * 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// ---------------------------------------------------------------------------
// Stats UI
// ---------------------------------------------------------------------------
function updateStats(data) {
if (data.records !== undefined) {
stats.records = data.records;
stats.sweeps = data.sweeps;
stats.loop_closures = data.loop_closures;
stats.last_t = data.last_t;
stats.debit = data.debit || 0;
}
document.getElementById('s-records').textContent = stats.records;
document.getElementById('s-sweeps').textContent = stats.sweeps;
document.getElementById('s-loops').textContent = stats.loop_closures;
document.getElementById('s-t').textContent = stats.last_t ? stats.last_t.toFixed(1) + 's' : '—';
document.getElementById('s-debit').textContent = stats.debit.toFixed(1) + ' rec/s';
document.getElementById('s-points').textContent = mapPoints.length;
// Géométrie pièce
if (geom.bboxW > 0) {
document.getElementById('s-bbox').textContent =
geom.bboxW.toFixed(1) + ' × ' + geom.bboxH.toFixed(1) + ' m';
document.getElementById('s-area').textContent = geom.area.toFixed(1) + ' m²';
}
document.getElementById('s-walls').textContent = walls.length;
document.getElementById('s-wlen').textContent =
geom.wallTotal > 0 ? geom.wallTotal.toFixed(1) + ' m' : '—';
if (data.pose_corrected) {
poseCorrect = data.pose_corrected;
document.getElementById('s-x').textContent = poseCorrect[0].toFixed(2);
document.getElementById('s-y').textContent = poseCorrect[1].toFixed(2);
document.getElementById('s-h').textContent = poseCorrect[2].toFixed(1);
}
}
// ---------------------------------------------------------------------------
// Gestion message WS
// ---------------------------------------------------------------------------
function handleMessage(data) {
if (data.type === 'snapshot') {
// État complet
mapPoints = data.map_points || [];
trajectory = data.trajectory || [];
poseCorrect = data.pose_corrected || [0, 0, 0];
computeGeometry(true); // recalcul complet sur snapshot
updateStats(data);
draw();
log('Snapshot reçu — ' + mapPoints.length + ' pts, ' + trajectory.length + ' poses', 'ok');
} else if (data.type === 'delta') {
// Nouveaux points
const newPts = data.new_map_points || [];
for (const pt of newPts) mapPoints.push(pt);
// Trajectoire : ajout des dernières poses
const tail = data.trajectory_tail || [];
for (const pose of tail) {
// Évite les doublons par t
if (trajectory.length === 0 || trajectory[trajectory.length - 1][0] < pose[0]) {
trajectory.push(pose);
}
}
computeGeometry(false); // throttlé : recalcul murs en direct
updateStats(data);
draw();
} else if (data.type === 'reset') {
mapPoints = [];
trajectory = [];
poseCorrect = [0, 0, 0];
walls = [];
geom = { bboxW: 0, bboxH: 0, area: 0, wallTotal: 0 };
lastWallPointCount = 0;
stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
updateStats({});
draw();
log('Session réinitialisée', 'ok');
} else if (data.type === 'ping') {
// keepalive silencieux
}
}
// ---------------------------------------------------------------------------
// Connexion WS /ws/live
// ---------------------------------------------------------------------------
let ws = null;
let reconnectTimer = null;
function wsConnect() {
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + base + '/ws/live';
log('Connexion ' + url + '…');
ws = new WebSocket(url);
ws.onopen = () => {
document.getElementById('conn-dot').classList.add('ok');
document.getElementById('conn-label').textContent = 'Connecté';
log('WS ouvert', 'ok');
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
};
ws.onmessage = (e) => {
try {
handleMessage(JSON.parse(e.data));
} catch (err) {
console.error('parse WS:', err);
}
};
ws.onclose = () => {
document.getElementById('conn-dot').classList.remove('ok');
document.getElementById('conn-label').textContent = 'Déconnecté — reconnexion dans 3s…';
log('WS fermé, tentative dans 3s…', 'err');
reconnectTimer = setTimeout(wsConnect, 3000);
};
ws.onerror = (e) => {
log('Erreur WS', 'err');
};
}
// ---------------------------------------------------------------------------
// Toggle affichage : Les deux → Murs → Points → …
// ---------------------------------------------------------------------------
function cycleViewMode() {
const order = { both: 'walls', walls: 'points', points: 'both' };
const labels = { both: 'Vue : Les deux', walls: 'Vue : Murs', points: 'Vue : Points' };
viewMode = order[viewMode];
document.getElementById('view-toggle').textContent = labels[viewMode];
draw();
}
// ---------------------------------------------------------------------------
// Reset session
// ---------------------------------------------------------------------------
async function resetSession() {
const token = document.getElementById('token-input').value.trim() || 'moulin-2026';
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
try {
const form = new FormData();
form.append('token', token);
const resp = await fetch(base + '/session/reset', { method: 'POST', body: form });
if (resp.ok) {
log('Reset OK', 'ok');
} else {
log('Reset KO: ' + resp.status, 'err');
}
} catch (e) {
log('Reset erreur: ' + e.message, 'err');
}
}
// ---------------------------------------------------------------------------
// Bouton PLY — chemin relatif
// ---------------------------------------------------------------------------
(function() {
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
document.getElementById('ply-link').href = base + '/cloud.ply';
})();
// ---------------------------------------------------------------------------
// Log
// ---------------------------------------------------------------------------
function log(msg, cls) {
const el = document.getElementById('log');
const p = document.createElement('p');
if (cls) p.className = cls;
const now = new Date();
p.textContent = now.toTimeString().slice(0, 8) + ' ' + msg;
el.appendChild(p);
el.scrollTop = el.scrollHeight;
// Garde les 100 dernières lignes
while (el.children.length > 100) el.removeChild(el.firstChild);
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
resizeCanvas();
wsConnect();
</script>
</body>
</html>