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>
758 lines
26 KiB
HTML
758 lines
26 KiB
HTML
<!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>
|