HTML split
This commit is contained in:
173
public/calibration.css
Normal file
173
public/calibration.css
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
|
||||||
|
/* ===== LAYOUT ===== */
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HEADER (gleicher Stil wie .section auf index.html) ===== */
|
||||||
|
|
||||||
|
.calib-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calib-topbar a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calib-topbar a:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calib-topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BODY: SIDEBAR + CONTENT ===== */
|
||||||
|
|
||||||
|
.calib-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== LESEZEICHEN-TABS LINKS ===== */
|
||||||
|
|
||||||
|
.tab-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding-top: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 148px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: #0e1a2b;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: #132c44;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--panel); /* gleiche Farbe wie Content-Area */
|
||||||
|
border-color: #334155;
|
||||||
|
border-right: none;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 13px; /* kompensiert den dickeren linken Rand */
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CONTENT AREA ===== */
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0 8px 8px 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TAB PANELS ===== */
|
||||||
|
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* ===== SECTION OVERRIDE: kein äusserer Rand in der Content-Area ===== */
|
||||||
|
|
||||||
|
.tab-content .section {
|
||||||
|
background: #0e1a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PLACEHOLDER NOTE ===== */
|
||||||
|
|
||||||
|
.placeholder-note {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px dashed #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATUS BADGE ===== */
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1e293b;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.status-badge.open { color: #f59e0b; }
|
||||||
|
.status-badge.done { color: #34d399; background: #064e3b; }
|
||||||
|
.status-badge.wip { color: #60a5fa; }
|
||||||
|
|
||||||
|
/* ===== INFO GRID ===== */
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons: aktiv vs. deaktiviert visuell unterscheiden */
|
||||||
|
.controls button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -5,182 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kalibrierung – appRobotHoming</title>
|
<title>Kalibrierung – appRobotHoming</title>
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css">
|
||||||
<style>
|
<link rel="stylesheet" href="/calibration.css">
|
||||||
|
|
||||||
/* ===== LAYOUT ===== */
|
|
||||||
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== HEADER (gleicher Stil wie .section auf index.html) ===== */
|
|
||||||
|
|
||||||
.calib-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 14px 20px;
|
|
||||||
background: var(--panel);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calib-topbar a {
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13px;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
transition: border-color 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calib-topbar a:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calib-topbar h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== BODY: SIDEBAR + CONTENT ===== */
|
|
||||||
|
|
||||||
.calib-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 16px;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== LESEZEICHEN-TABS LINKS ===== */
|
|
||||||
|
|
||||||
.tab-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
padding-top: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 148px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn {
|
|
||||||
background: #0e1a2b;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
border-right: none;
|
|
||||||
border-radius: 6px 0 0 6px;
|
|
||||||
padding: 11px 16px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: #132c44;
|
|
||||||
border-color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--panel); /* gleiche Farbe wie Content-Area */
|
|
||||||
border-color: #334155;
|
|
||||||
border-right: none;
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
padding-left: 13px; /* kompensiert den dickeren linken Rand */
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== CONTENT AREA ===== */
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 0 8px 8px 8px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== TAB PANELS ===== */
|
|
||||||
|
|
||||||
.tab-panel { display: none; }
|
|
||||||
.tab-panel.active { display: block; }
|
|
||||||
|
|
||||||
/* ===== SECTION OVERRIDE: kein äusserer Rand in der Content-Area ===== */
|
|
||||||
|
|
||||||
.tab-content .section {
|
|
||||||
background: #0e1a2b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== PLACEHOLDER NOTE ===== */
|
|
||||||
|
|
||||||
.placeholder-note {
|
|
||||||
margin-top: 14px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px dashed #334155;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== STATUS BADGE ===== */
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #1e293b;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-left: 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.status-badge.open { color: #f59e0b; }
|
|
||||||
.status-badge.done { color: #34d399; background: #064e3b; }
|
|
||||||
.status-badge.wip { color: #60a5fa; }
|
|
||||||
|
|
||||||
/* ===== INFO GRID ===== */
|
|
||||||
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 160px 1fr;
|
|
||||||
gap: 6px 12px;
|
|
||||||
margin-top: 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
color: var(--text);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons: aktiv vs. deaktiviert visuell unterscheiden */
|
|
||||||
.controls button:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -195,430 +20,22 @@
|
|||||||
|
|
||||||
<!-- LESEZEICHEN-TABS -->
|
<!-- LESEZEICHEN-TABS -->
|
||||||
<nav class="tab-sidebar" id="tabSidebar">
|
<nav class="tab-sidebar" id="tabSidebar">
|
||||||
<button class="tab-btn active" data-tab="camera-npz">Camera NPZ</button>
|
<button class="tab-btn active" data-tab="camera-npz" data-src="/calibration_camera.html">Camera NPZ</button>
|
||||||
<button class="tab-btn" data-tab="board">Board</button>
|
<button class="tab-btn" data-tab="board" data-src="/calibration_board.html">Board</button>
|
||||||
<button class="tab-btn" data-tab="robot-x-axis">Robot X Axis</button>
|
<button class="tab-btn" data-tab="robot-x-axis" data-src="/calibration_xaxis.html">Robot X Axis</button>
|
||||||
<button class="tab-btn" data-tab="arm">Arm1 / Arm2</button>
|
<button class="tab-btn" data-tab="arm" data-src="/calibration_arm.html">Arm1 / Arm2</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT (Panels werden lazy per fetch befüllt) -->
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
<div class="tab-panel active" id="tab-camera-npz"></div>
|
||||||
<!-- PANEL: Camera NPZ -->
|
<div class="tab-panel" id="tab-board"></div>
|
||||||
<div class="tab-panel active" id="tab-camera-npz">
|
<div class="tab-panel" id="tab-robot-x-axis"></div>
|
||||||
<div class="sections">
|
<div class="tab-panel" id="tab-arm"></div>
|
||||||
|
|
||||||
<!-- Info-Box: Aktuelle Kalibrierung -->
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Aktuelle Kalibrierung</h2>
|
|
||||||
<div id="calib-info" class="info-grid">
|
|
||||||
<span class="info-label">Timestamp</span>
|
|
||||||
<span class="info-value" id="info-timestamp">–</span>
|
|
||||||
|
|
||||||
<span class="info-label">Erstellt am</span>
|
|
||||||
<span class="info-value" id="info-created">–</span>
|
|
||||||
|
|
||||||
<span class="info-label">Bilder / Kameras</span>
|
|
||||||
<span class="info-value" id="info-images">–</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aktionen -->
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Aktionen</h2>
|
|
||||||
<div class="controls" style="margin-top: 14px;">
|
|
||||||
<button id="btn-new-calib">Neue Kalibrierung anlegen</button>
|
|
||||||
<button id="btn-foto-calib">Foto aufnehmen</button>
|
|
||||||
|
|
||||||
<select id="cam-select-calib" title="Kamera für Kalibrierung wählen">
|
|
||||||
<option value="">– Kamera –</option>
|
|
||||||
</select>
|
|
||||||
<button id="btn-compute-calib">Kalibrierung berechnen</button>
|
|
||||||
|
|
||||||
<button id="btn-upload-npz">NPZ speichern</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ausgabe -->
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Ausgabe / Log</h2>
|
|
||||||
<textarea id="log-camera" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PANEL: Board -->
|
|
||||||
<div class="tab-panel" id="tab-board">
|
|
||||||
<div class="sections">
|
|
||||||
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Board – ArUco & Kamera-Pose</h2>
|
|
||||||
<div class="info-grid" style="margin-top: 14px;">
|
|
||||||
<span class="info-label">Ablauf</span>
|
|
||||||
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
|
|
||||||
Foto aufnehmen → ArUco erkennen → Kamera-Pose schätzen
|
|
||||||
</span>
|
|
||||||
<span class="info-label">Schritte</span>
|
|
||||||
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
|
|
||||||
1_detect_aruco_observations → 2_estimate_camera_from_observations
|
|
||||||
</span>
|
|
||||||
<span class="info-label">Letzter Run</span>
|
|
||||||
<span class="info-value" id="board-last-run">–</span>
|
|
||||||
</div>
|
|
||||||
<div class="controls" style="margin-top: 16px;">
|
|
||||||
<button id="btn-board-run">Board erkennen</button>
|
|
||||||
<button disabled title="Folgt später">Ergebnis anzeigen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Ausgabe / Log</h2>
|
|
||||||
<textarea id="log-board" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PANEL: Robot X Axis -->
|
|
||||||
<div class="tab-panel" id="tab-robot-x-axis">
|
|
||||||
<div class="sections">
|
|
||||||
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Robot X Axis <span class="status-badge open">offen</span></h2>
|
|
||||||
<div class="placeholder-note">
|
|
||||||
Ziel: X-Achse des Roboters im Weltkoordinatensystem verorten (Richtungsvektor und
|
|
||||||
Nullpunkt). Roboter fährt zwei bekannte Positionen an, Kamera beobachtet den
|
|
||||||
Endeffektor-Marker.<br><br>
|
|
||||||
Geplante Aktionen: Referenzposition 1 anfahren · Foto · Marker merken ·
|
|
||||||
Referenzposition 2 anfahren · Foto · Achsvektor berechnen · Speichern.<br><br>
|
|
||||||
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
|
|
||||||
</div>
|
|
||||||
<div class="controls" style="margin-top: 14px;">
|
|
||||||
<button disabled>Pos 1 anfahren</button>
|
|
||||||
<button disabled>Foto Pos 1</button>
|
|
||||||
<button disabled>Pos 2 anfahren</button>
|
|
||||||
<button disabled>Foto Pos 2</button>
|
|
||||||
<button disabled>Achse berechnen</button>
|
|
||||||
<button disabled>Speichern</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Ausgabe / Log</h2>
|
|
||||||
<textarea id="log-xaxis" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PANEL: Arm1 / Arm2 -->
|
|
||||||
<div class="tab-panel" id="tab-arm">
|
|
||||||
<div class="sections">
|
|
||||||
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Arm1 / Arm2 <span class="status-badge open">offen</span></h2>
|
|
||||||
<div class="placeholder-note">
|
|
||||||
Ziel: Nullposition und Kinematikparameter von Arm1 und Arm2 einmessen.
|
|
||||||
Arm fährt in mechanische Nullposition, Kamera prüft die tatsächliche Pose,
|
|
||||||
Offset wird berechnet und gespeichert.<br><br>
|
|
||||||
Geplante Aktionen: Arm1 → Nullpos → Foto → Winkel · Arm2 → Nullpos → Foto →
|
|
||||||
Winkel · Offset-Korrektur speichern.<br><br>
|
|
||||||
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
|
|
||||||
</div>
|
|
||||||
<div class="controls" style="margin-top: 14px;">
|
|
||||||
<button disabled>Arm1 → Nullpos</button>
|
|
||||||
<button disabled>Foto Arm1</button>
|
|
||||||
<button disabled>Arm2 → Nullpos</button>
|
|
||||||
<button disabled>Foto Arm2</button>
|
|
||||||
<button disabled>Offsets berechnen</button>
|
|
||||||
<button disabled>Speichern</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section full">
|
|
||||||
<h2>Ausgabe / Log</h2>
|
|
||||||
<textarea id="log-arm" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div><!-- /.tab-content -->
|
|
||||||
</div><!-- /.calib-body -->
|
</div><!-- /.calib-body -->
|
||||||
|
|
||||||
<script>
|
<script src="/calibration.js"></script>
|
||||||
// ── Tab-Switching ──────────────────────────────────────────────────────────
|
|
||||||
document.getElementById('tabSidebar').addEventListener('click', e => {
|
|
||||||
const btn = e.target.closest('.tab-btn');
|
|
||||||
if (!btn) return;
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Section collapse ───────────────────────────────────────────────────────
|
|
||||||
document.querySelectorAll('.section h2').forEach(h2 => {
|
|
||||||
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Camera NPZ ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const logCamera = document.getElementById('log-camera');
|
|
||||||
|
|
||||||
function logC(msg) {
|
|
||||||
const ts = new Date().toLocaleTimeString('de-CH');
|
|
||||||
logCamera.value += `[${ts}] ${msg}\n`;
|
|
||||||
logCamera.scrollTop = logCamera.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(isoString) {
|
|
||||||
if (!isoString) return '–';
|
|
||||||
return new Date(isoString).toLocaleString('de-CH');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCalibInfo(meta) {
|
|
||||||
const sel = document.getElementById('cam-select-calib');
|
|
||||||
|
|
||||||
if (!meta) {
|
|
||||||
document.getElementById('info-timestamp').textContent = '(keine Session vorhanden)';
|
|
||||||
document.getElementById('info-created').textContent = '–';
|
|
||||||
document.getElementById('info-images').textContent = '–';
|
|
||||||
// Selector leeren
|
|
||||||
sel.innerHTML = '<option value="">– Kamera –</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.getElementById('info-timestamp').textContent = meta.timestamp ?? '–';
|
|
||||||
document.getElementById('info-created').textContent = formatDate(meta.createdAt);
|
|
||||||
const cameras = meta.cameras ?? [];
|
|
||||||
const imgTxt = meta.imageCount != null
|
|
||||||
? `${meta.imageCount} Bilder total. ${cameras.length} Kamera(s) verwendet.`
|
|
||||||
: '–';
|
|
||||||
document.getElementById('info-images').textContent = imgTxt;
|
|
||||||
|
|
||||||
// Kamera-Selector aktualisieren
|
|
||||||
const prev = sel.value;
|
|
||||||
sel.innerHTML = '<option value="">– Kamera –</option>';
|
|
||||||
for (const cam of cameras) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = cam;
|
|
||||||
opt.textContent = cam;
|
|
||||||
if (cam === prev) opt.selected = true;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
}
|
|
||||||
// Falls nur eine Kamera vorhanden – automatisch vorwählen
|
|
||||||
if (cameras.length === 1) sel.value = cameras[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beim Laden aktuelle Session holen
|
|
||||||
async function loadCalibCurrent() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/calibration/current');
|
|
||||||
const d = await r.json();
|
|
||||||
updateCalibInfo(d.meta);
|
|
||||||
if (d.session) logC(`Session geladen: ${d.session}`);
|
|
||||||
else logC('Noch keine Kalibrierungs-Session vorhanden.');
|
|
||||||
} catch (err) {
|
|
||||||
logC(`Fehler beim Laden: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadCalibCurrent();
|
|
||||||
|
|
||||||
// "Neue Kalibrierung anlegen"
|
|
||||||
document.getElementById('btn-new-calib').addEventListener('click', async () => {
|
|
||||||
logC('Neue Kalibrierung wird angelegt …');
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/calibration/new', { method: 'POST' });
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.error) { logC(`Fehler: ${d.error}`); return; }
|
|
||||||
updateCalibInfo(d.meta);
|
|
||||||
if (d.warning) logC(`Warnung: ${d.warning}`);
|
|
||||||
else logC(`Session angelegt: ${d.session} | Fotos: ${(d.savedFiles ?? []).join(', ')}`);
|
|
||||||
} catch (err) {
|
|
||||||
logC(`Fehler: ${err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Foto aufnehmen"
|
|
||||||
document.getElementById('btn-foto-calib').addEventListener('click', async () => {
|
|
||||||
logC('Fotos werden aufgenommen …');
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/calibration/foto', { method: 'POST' });
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.error) { logC(`Fehler: ${d.error}`); return; }
|
|
||||||
updateCalibInfo(d.meta);
|
|
||||||
logC(`Gespeichert: ${(d.savedFiles ?? []).join(', ')}`);
|
|
||||||
} catch (err) {
|
|
||||||
logC(`Fehler: ${err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hilfsfunktion: fetch-Response sicher als Text lesen und in lesbaren Fehler umwandeln
|
|
||||||
async function safeJson(r) {
|
|
||||||
const raw = await r.text().catch(() => '');
|
|
||||||
try { return { ok: r.ok, status: r.status, data: JSON.parse(raw) }; }
|
|
||||||
catch { return { ok: r.ok, status: r.status, data: null, raw: raw.slice(0, 300) }; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// "NPZ speichern" → an Webcam-Service übertragen
|
|
||||||
document.getElementById('btn-upload-npz').addEventListener('click', async () => {
|
|
||||||
const camera = document.getElementById('cam-select-calib').value;
|
|
||||||
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
|
|
||||||
|
|
||||||
logC(`NPZ wird an Webcam-Service übertragen (${camera}) …`);
|
|
||||||
try {
|
|
||||||
const { ok, status, data, raw } = await safeJson(await fetch('/api/calibration/upload-npz', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ camera }),
|
|
||||||
}));
|
|
||||||
if (!ok || data?.error) {
|
|
||||||
logC(`❌ HTTP ${status}: ${data?.error ?? raw ?? '(kein Fehlertext)'}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logC(`✅ Gespeichert: ${data.webcam?.saved} (${data.size} Bytes)`);
|
|
||||||
logC(` calibrationUrl: ${data.webcam?.calibrationUrl}`);
|
|
||||||
} catch (err) {
|
|
||||||
logC(`❌ Fehler: ${err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Kalibrierung berechnen" – SSE-Stream lesen
|
|
||||||
document.getElementById('btn-compute-calib').addEventListener('click', async () => {
|
|
||||||
const camera = document.getElementById('cam-select-calib').value;
|
|
||||||
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
|
|
||||||
|
|
||||||
logC(`Starte Kalibrierung für ${camera} …`);
|
|
||||||
const btn = document.getElementById('btn-compute-calib');
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/calibration/compute', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ camera }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const raw = await response.text().catch(() => '');
|
|
||||||
let msg;
|
|
||||||
try { msg = JSON.parse(raw).error || raw; }
|
|
||||||
catch { msg = raw.slice(0, 300) || response.statusText; }
|
|
||||||
logC(`❌ HTTP ${response.status}: ${msg || '(kein Fehlertext – evtl. Server neu starten?)'}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE-Stream zeilenweise verarbeiten
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
|
|
||||||
// Vollständige SSE-Ereignisse (getrennt durch \n\n) extrahieren
|
|
||||||
const parts = buffer.split('\n\n');
|
|
||||||
buffer = parts.pop(); // letztes unvollständiges Fragment behalten
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
for (const line of part.split('\n')) {
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
|
||||||
try {
|
|
||||||
const evt = JSON.parse(line.slice(6));
|
|
||||||
if (evt.type === 'log') {
|
|
||||||
if (evt.text !== '') logC(evt.text);
|
|
||||||
} else if (evt.type === 'done') {
|
|
||||||
logC(evt.exitCode === 0
|
|
||||||
? '✅ Kalibrierung abgeschlossen.'
|
|
||||||
: `❌ Script beendet mit Exit-Code ${evt.exitCode}`);
|
|
||||||
}
|
|
||||||
} catch { /* ungültiges JSON überspringen */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logC(`Fehler: ${err}`);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Board ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const logBoard = document.getElementById('log-board');
|
|
||||||
|
|
||||||
function logB(msg) {
|
|
||||||
const ts = new Date().toLocaleTimeString('de-CH');
|
|
||||||
logBoard.value += `[${ts}] ${msg}\n`;
|
|
||||||
logBoard.scrollTop = logBoard.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE-Stream lesen (gleiche Logik wie compute)
|
|
||||||
async function readSseStream(response, logFn, onDone) {
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const parts = buffer.split('\n\n');
|
|
||||||
buffer = parts.pop();
|
|
||||||
for (const part of parts) {
|
|
||||||
for (const line of part.split('\n')) {
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
|
||||||
try {
|
|
||||||
const evt = JSON.parse(line.slice(6));
|
|
||||||
if (evt.type === 'log') {
|
|
||||||
if (evt.text !== '') logFn(evt.text);
|
|
||||||
} else if (evt.type === 'done') {
|
|
||||||
onDone(evt);
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-board-run').addEventListener('click', async () => {
|
|
||||||
logB('Board-Erkennung wird gestartet …');
|
|
||||||
const btn = document.getElementById('btn-board-run');
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/board/run', { method: 'POST' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const raw = await response.text().catch(() => '');
|
|
||||||
let msg;
|
|
||||||
try { msg = JSON.parse(raw).error || raw; }
|
|
||||||
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
|
|
||||||
logB(`❌ HTTP ${response.status}: ${msg}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await readSseStream(response, logB, (evt) => {
|
|
||||||
if (evt.exitCode === 0) {
|
|
||||||
logB('✅ Board-Run abgeschlossen.');
|
|
||||||
if (evt.runDir) {
|
|
||||||
document.getElementById('board-last-run').textContent = evt.runDir;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
logB(`❌ Fehler: ${err}`);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
264
public/calibration.js
Normal file
264
public/calibration.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/* calibration.js – Kalibrierungs-Frontend */
|
||||||
|
|
||||||
|
// ── Panel-Loading (lazy) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _panelLoaded = new Set();
|
||||||
|
|
||||||
|
async function loadPanel(tab, src) {
|
||||||
|
if (_panelLoaded.has(tab)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(src);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
document.getElementById('tab-' + tab).innerHTML = await r.text();
|
||||||
|
_panelLoaded.add(tab);
|
||||||
|
|
||||||
|
// h2-Klick → Section ein-/ausklappen
|
||||||
|
document.getElementById('tab-' + tab).querySelectorAll('.section h2').forEach(h2 => {
|
||||||
|
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab-spezifische Initialisierung
|
||||||
|
if (tab === 'camera-npz') initCameraNpz();
|
||||||
|
else if (tab === 'board') initBoard();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('tab-' + tab).innerHTML =
|
||||||
|
`<p style="color:#f87171;margin:16px">Panel konnte nicht geladen werden: ${err}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab-Switching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('tabSidebar').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.tab-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||||
|
loadPanel(btn.dataset.tab, btn.dataset.src);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Erstes Tab sofort laden
|
||||||
|
(function () {
|
||||||
|
const first = document.querySelector('.tab-btn.active');
|
||||||
|
if (first) loadPanel(first.dataset.tab, first.dataset.src);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Shared: SSE-Stream lesen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function readSseStream(response, logFn, onDone) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop(); // unvollständiges letztes Fragment behalten
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
for (const line of part.split('\n')) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
try {
|
||||||
|
const evt = JSON.parse(line.slice(6));
|
||||||
|
if (evt.type === 'log') { if (evt.text !== '') logFn(evt.text); }
|
||||||
|
else if (evt.type === 'done') { onDone(evt); }
|
||||||
|
} catch { /* ungültiges JSON überspringen */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Camera NPZ ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initCameraNpz() {
|
||||||
|
const logCamera = document.getElementById('log-camera');
|
||||||
|
|
||||||
|
function logC(msg) {
|
||||||
|
const ts = new Date().toLocaleTimeString('de-CH');
|
||||||
|
logCamera.value += `[${ts}] ${msg}\n`;
|
||||||
|
logCamera.scrollTop = logCamera.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(isoString) {
|
||||||
|
if (!isoString) return '–';
|
||||||
|
return new Date(isoString).toLocaleString('de-CH');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCalibInfo(meta) {
|
||||||
|
const sel = document.getElementById('cam-select-calib');
|
||||||
|
if (!meta) {
|
||||||
|
document.getElementById('info-timestamp').textContent = '(keine Session vorhanden)';
|
||||||
|
document.getElementById('info-created').textContent = '–';
|
||||||
|
document.getElementById('info-images').textContent = '–';
|
||||||
|
sel.innerHTML = '<option value="">– Kamera –</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('info-timestamp').textContent = meta.timestamp ?? '–';
|
||||||
|
document.getElementById('info-created').textContent = formatDate(meta.createdAt);
|
||||||
|
const cameras = meta.cameras ?? [];
|
||||||
|
const imgTxt = meta.imageCount != null
|
||||||
|
? `${meta.imageCount} Bilder total. ${cameras.length} Kamera(s) verwendet.`
|
||||||
|
: '–';
|
||||||
|
document.getElementById('info-images').textContent = imgTxt;
|
||||||
|
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">– Kamera –</option>';
|
||||||
|
for (const cam of cameras) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = cam; opt.textContent = cam;
|
||||||
|
if (cam === prev) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (cameras.length === 1) sel.value = cameras[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalibCurrent() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/calibration/current');
|
||||||
|
const d = await r.json();
|
||||||
|
updateCalibInfo(d.meta);
|
||||||
|
if (d.session) logC(`Session geladen: ${d.session}`);
|
||||||
|
else logC('Noch keine Kalibrierungs-Session vorhanden.');
|
||||||
|
} catch (err) {
|
||||||
|
logC(`Fehler beim Laden: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeJson(r) {
|
||||||
|
const raw = await r.text().catch(() => '');
|
||||||
|
try { return { ok: r.ok, status: r.status, data: JSON.parse(raw) }; }
|
||||||
|
catch { return { ok: r.ok, status: r.status, data: null, raw: raw.slice(0, 300) }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktuelle Session laden
|
||||||
|
loadCalibCurrent();
|
||||||
|
|
||||||
|
// "Neue Kalibrierung anlegen"
|
||||||
|
document.getElementById('btn-new-calib').addEventListener('click', async () => {
|
||||||
|
logC('Neue Kalibrierung wird angelegt …');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/calibration/new', { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { logC(`Fehler: ${d.error}`); return; }
|
||||||
|
updateCalibInfo(d.meta);
|
||||||
|
if (d.warning) logC(`Warnung: ${d.warning}`);
|
||||||
|
else logC(`Session angelegt: ${d.session} | Fotos: ${(d.savedFiles ?? []).join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
logC(`Fehler: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Foto aufnehmen"
|
||||||
|
document.getElementById('btn-foto-calib').addEventListener('click', async () => {
|
||||||
|
logC('Fotos werden aufgenommen …');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/calibration/foto', { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { logC(`Fehler: ${d.error}`); return; }
|
||||||
|
updateCalibInfo(d.meta);
|
||||||
|
logC(`Gespeichert: ${(d.savedFiles ?? []).join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
logC(`Fehler: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Kalibrierung berechnen" – SSE
|
||||||
|
document.getElementById('btn-compute-calib').addEventListener('click', async () => {
|
||||||
|
const camera = document.getElementById('cam-select-calib').value;
|
||||||
|
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
|
||||||
|
logC(`Starte Kalibrierung für ${camera} …`);
|
||||||
|
const btn = document.getElementById('btn-compute-calib');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/calibration/compute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ camera }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const raw = await response.text().catch(() => '');
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw).error || raw; }
|
||||||
|
catch { msg = raw.slice(0, 300) || response.statusText; }
|
||||||
|
logC(`❌ HTTP ${response.status}: ${msg || '(kein Fehlertext – evtl. Server neu starten?)'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await readSseStream(response, logC, (evt) => {
|
||||||
|
if (evt.exitCode === 0) logC('✅ Kalibrierung abgeschlossen.');
|
||||||
|
else logC(`❌ Script beendet mit Exit-Code ${evt.exitCode}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logC(`Fehler: ${err}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// "NPZ speichern" → an Webcam-Service übertragen
|
||||||
|
document.getElementById('btn-upload-npz').addEventListener('click', async () => {
|
||||||
|
const camera = document.getElementById('cam-select-calib').value;
|
||||||
|
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
|
||||||
|
logC(`NPZ wird an Webcam-Service übertragen (${camera}) …`);
|
||||||
|
try {
|
||||||
|
const { ok, status, data, raw } = await safeJson(await fetch('/api/calibration/upload-npz', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ camera }),
|
||||||
|
}));
|
||||||
|
if (!ok || data?.error) {
|
||||||
|
logC(`❌ HTTP ${status}: ${data?.error ?? raw ?? '(kein Fehlertext)'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logC(`✅ Gespeichert: ${data.webcam?.saved} (${data.size} Bytes)`);
|
||||||
|
logC(` calibrationUrl: ${data.webcam?.calibrationUrl}`);
|
||||||
|
} catch (err) {
|
||||||
|
logC(`❌ Fehler: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initBoard() {
|
||||||
|
const logBoard = document.getElementById('log-board');
|
||||||
|
|
||||||
|
function logB(msg) {
|
||||||
|
const ts = new Date().toLocaleTimeString('de-CH');
|
||||||
|
logBoard.value += `[${ts}] ${msg}\n`;
|
||||||
|
logBoard.scrollTop = logBoard.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-board-run').addEventListener('click', async () => {
|
||||||
|
logB('Board-Erkennung wird gestartet …');
|
||||||
|
const btn = document.getElementById('btn-board-run');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/board/run', { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const raw = await response.text().catch(() => '');
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw).error || raw; }
|
||||||
|
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
|
||||||
|
logB(`❌ HTTP ${response.status}: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await readSseStream(response, logB, (evt) => {
|
||||||
|
if (evt.exitCode === 0) {
|
||||||
|
logB('✅ Board-Run abgeschlossen.');
|
||||||
|
if (evt.runDir) document.getElementById('board-last-run').textContent = evt.runDir;
|
||||||
|
} else {
|
||||||
|
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logB(`❌ Fehler: ${err}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
28
public/calibration_arm.html
Normal file
28
public/calibration_arm.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<div class="sections">
|
||||||
|
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Arm1 / Arm2 <span class="status-badge open">offen</span></h2>
|
||||||
|
<div class="placeholder-note">
|
||||||
|
Ziel: Nullposition und Kinematikparameter von Arm1 und Arm2 einmessen.
|
||||||
|
Arm fährt in mechanische Nullposition, Kamera prüft die tatsächliche Pose,
|
||||||
|
Offset wird berechnet und gespeichert.<br><br>
|
||||||
|
Geplante Aktionen: Arm1 → Nullpos → Foto → Winkel · Arm2 → Nullpos → Foto →
|
||||||
|
Winkel · Offset-Korrektur speichern.<br><br>
|
||||||
|
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
|
||||||
|
</div>
|
||||||
|
<div class="controls" style="margin-top: 14px;">
|
||||||
|
<button disabled>Arm1 → Nullpos</button>
|
||||||
|
<button disabled>Foto Arm1</button>
|
||||||
|
<button disabled>Arm2 → Nullpos</button>
|
||||||
|
<button disabled>Foto Arm2</button>
|
||||||
|
<button disabled>Offsets berechnen</button>
|
||||||
|
<button disabled>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Ausgabe / Log</h2>
|
||||||
|
<textarea id="log-arm" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
28
public/calibration_board.html
Normal file
28
public/calibration_board.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<div class="sections">
|
||||||
|
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Board – ArUco & Kamera-Pose</h2>
|
||||||
|
<div class="info-grid" style="margin-top: 14px;">
|
||||||
|
<span class="info-label">Ablauf</span>
|
||||||
|
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
|
||||||
|
Foto aufnehmen → ArUco erkennen → Kamera-Pose schätzen
|
||||||
|
</span>
|
||||||
|
<span class="info-label">Schritte</span>
|
||||||
|
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
|
||||||
|
1_detect_aruco_observations → 2_estimate_camera_from_observations
|
||||||
|
</span>
|
||||||
|
<span class="info-label">Letzter Run</span>
|
||||||
|
<span class="info-value" id="board-last-run">–</span>
|
||||||
|
</div>
|
||||||
|
<div class="controls" style="margin-top: 16px;">
|
||||||
|
<button id="btn-board-run">Board erkennen</button>
|
||||||
|
<button disabled title="Folgt später">Ergebnis anzeigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Ausgabe / Log</h2>
|
||||||
|
<textarea id="log-board" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
40
public/calibration_camera.html
Normal file
40
public/calibration_camera.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="sections">
|
||||||
|
|
||||||
|
<!-- Info-Box: Aktuelle Kalibrierung -->
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Aktuelle Kalibrierung</h2>
|
||||||
|
<div id="calib-info" class="info-grid">
|
||||||
|
<span class="info-label">Timestamp</span>
|
||||||
|
<span class="info-value" id="info-timestamp">–</span>
|
||||||
|
|
||||||
|
<span class="info-label">Erstellt am</span>
|
||||||
|
<span class="info-value" id="info-created">–</span>
|
||||||
|
|
||||||
|
<span class="info-label">Bilder / Kameras</span>
|
||||||
|
<span class="info-value" id="info-images">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aktionen -->
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Aktionen</h2>
|
||||||
|
<div class="controls" style="margin-top: 14px;">
|
||||||
|
<button id="btn-new-calib">Neue Kalibrierung anlegen</button>
|
||||||
|
<button id="btn-foto-calib">Foto aufnehmen</button>
|
||||||
|
|
||||||
|
<select id="cam-select-calib" title="Kamera für Kalibrierung wählen">
|
||||||
|
<option value="">– Kamera –</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-compute-calib">Kalibrierung berechnen</button>
|
||||||
|
|
||||||
|
<button id="btn-upload-npz">NPZ speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ausgabe -->
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Ausgabe / Log</h2>
|
||||||
|
<textarea id="log-camera" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
28
public/calibration_xaxis.html
Normal file
28
public/calibration_xaxis.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<div class="sections">
|
||||||
|
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Robot X Axis <span class="status-badge open">offen</span></h2>
|
||||||
|
<div class="placeholder-note">
|
||||||
|
Ziel: X-Achse des Roboters im Weltkoordinatensystem verorten (Richtungsvektor und
|
||||||
|
Nullpunkt). Roboter fährt zwei bekannte Positionen an, Kamera beobachtet den
|
||||||
|
Endeffektor-Marker.<br><br>
|
||||||
|
Geplante Aktionen: Referenzposition 1 anfahren · Foto · Marker merken ·
|
||||||
|
Referenzposition 2 anfahren · Foto · Achsvektor berechnen · Speichern.<br><br>
|
||||||
|
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
|
||||||
|
</div>
|
||||||
|
<div class="controls" style="margin-top: 14px;">
|
||||||
|
<button disabled>Pos 1 anfahren</button>
|
||||||
|
<button disabled>Foto Pos 1</button>
|
||||||
|
<button disabled>Pos 2 anfahren</button>
|
||||||
|
<button disabled>Foto Pos 2</button>
|
||||||
|
<button disabled>Achse berechnen</button>
|
||||||
|
<button disabled>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Ausgabe / Log</h2>
|
||||||
|
<textarea id="log-xaxis" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
995
public/sceneViewer.html
Normal file
995
public/sceneViewer.html
Normal file
@@ -0,0 +1,995 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Robot FK Viewer</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0d0f13;
|
||||||
|
--panel: #161920;
|
||||||
|
--border: #2a2d35;
|
||||||
|
--text: #c8cdd8;
|
||||||
|
--accent: #4a9eff;
|
||||||
|
--muted: #555b6e;
|
||||||
|
--ok: #3ecf6b;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--err: #ff4f4f;
|
||||||
|
--panel-w: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 13px/1.5 'IBM Plex Mono', 'Cascadia Code', 'Courier New', monospace;
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── sidebar ── */
|
||||||
|
#sidebar {
|
||||||
|
width: var(--panel-w);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* file inputs */
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.file-row label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 58px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.file-row input[type=file] {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.file-row input[type=file]::file-selector-button {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* joint sliders */
|
||||||
|
.slider-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px 1fr 48px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.slider-row span { color: var(--accent); font-size: 12px; }
|
||||||
|
input[type=range] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.val-box {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text);
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toggles */
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.toggle-label { font-size: 11px; color: var(--text); }
|
||||||
|
.toggle {
|
||||||
|
position: relative; width: 36px; height: 18px;
|
||||||
|
}
|
||||||
|
.toggle input { display: none; }
|
||||||
|
.slider-track {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.slider-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 2px; top: 2px;
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform .2s, background .2s;
|
||||||
|
}
|
||||||
|
.toggle input:checked + .slider-track { background: var(--accent); }
|
||||||
|
.toggle input:checked + .slider-track::after {
|
||||||
|
transform: translateX(18px);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stats */
|
||||||
|
#stats-content {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.stat-line { display: flex; justify-content: space-between; }
|
||||||
|
.stat-val { color: var(--accent); }
|
||||||
|
.stat-ok { color: var(--ok); }
|
||||||
|
.stat-warn { color: var(--warn); }
|
||||||
|
.stat-err { color: var(--err); }
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* canvas */
|
||||||
|
#canvas-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://unpkg.com/three@0.162.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://unpkg.com/three@0.162.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="sidebar">
|
||||||
|
|
||||||
|
<!-- files -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Data Files</h3>
|
||||||
|
<div class="file-row">
|
||||||
|
<label>robot.json</label>
|
||||||
|
<input type="file" id="fRobot" accept=".json">
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<label>aruco</label>
|
||||||
|
<input type="file" id="fAruco" accept=".json">
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<label>solution</label>
|
||||||
|
<input type="file" id="fSolution" accept=".json">
|
||||||
|
</div>
|
||||||
|
<button id="btnJump" disabled style="width:100%;margin-top:8px;background:var(--accent);
|
||||||
|
color:#06101a;border:none;border-radius:3px;padding:6px;font:inherit;font-size:11px;
|
||||||
|
font-weight:bold;cursor:pointer;opacity:0.45;">Auf Lösung springen</button>
|
||||||
|
<div id="solInfo" style="font-size:10px;color:var(--muted);min-height:12px;margin-top:5px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- test poses -->
|
||||||
|
<div class="section" id="poseSection" style="display:none">
|
||||||
|
<h3>Test Poses</h3>
|
||||||
|
<select id="poseSelect" style="
|
||||||
|
width:100%; background:var(--bg); border:1px solid var(--border);
|
||||||
|
color:var(--text); font:inherit; font-size:11px; padding:4px 6px;
|
||||||
|
border-radius:3px; cursor:pointer; margin-bottom:4px;">
|
||||||
|
<option value="">— select pose —</option>
|
||||||
|
</select>
|
||||||
|
<div id="poseInfo" style="font-size:10px;color:var(--muted);min-height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- joint sliders -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Joint Values</h3>
|
||||||
|
<div id="sliders"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- display toggles -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Display</h3>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Skeleton</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tSkeleton" checked><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Model markers</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tModel" checked><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Model normals</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tNormals"><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Observed markers</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tObserved" checked><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Observed normals</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tObsNormals"><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Error lines</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tErrors" checked><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Board plane</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tBoard" checked><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Cameras</span>
|
||||||
|
<label class="toggle"><input type="checkbox" id="tCameras" checked><span class="slider-track"></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- stats -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Error Statistics</h3>
|
||||||
|
<div id="stats-content"><span style="color:var(--muted)">Load both files…</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status-bar">Drag to orbit · Scroll to zoom · Right-drag to pan</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="canvas-wrap">
|
||||||
|
<canvas id="cv"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// FK in plain JavaScript — mirrors robot_fk.py exactly
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function norm(v) {
|
||||||
|
const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
||||||
|
return n < 1e-12 ? v.map(() => 0) : v.map(x => x / n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mm3(A, B) { // 3×3 row-major mat-mat
|
||||||
|
const C = new Array(9).fill(0);
|
||||||
|
for (let i = 0; i < 3; i++)
|
||||||
|
for (let j = 0; j < 3; j++)
|
||||||
|
for (let k = 0; k < 3; k++)
|
||||||
|
C[i*3+j] += A[i*3+k] * B[k*3+j];
|
||||||
|
return C;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotAA(axis, deg) { // Rodrigues
|
||||||
|
const [x, y, z] = norm(axis);
|
||||||
|
const r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r), t = 1 - c;
|
||||||
|
return [t*x*x+c, t*x*y-s*z, t*x*z+s*y,
|
||||||
|
t*x*y+s*z, t*y*y+c, t*y*z-s*x,
|
||||||
|
t*x*z-s*y, t*y*z+s*x, t*z*z+c];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotXYZ(rx, ry, rz) {
|
||||||
|
return mm3(mm3(rotAA([0,0,1],rz), rotAA([0,1,0],ry)), rotAA([1,0,0],rx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4×4 row-major
|
||||||
|
function makeT(R3, tx, ty, tz) {
|
||||||
|
return [R3[0],R3[1],R3[2],tx, R3[3],R3[4],R3[5],ty,
|
||||||
|
R3[6],R3[7],R3[8],tz, 0,0,0,1];
|
||||||
|
}
|
||||||
|
function I4() { return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; }
|
||||||
|
|
||||||
|
function mm4(A, B) {
|
||||||
|
const C = new Array(16).fill(0);
|
||||||
|
for (let i = 0; i < 4; i++)
|
||||||
|
for (let j = 0; j < 4; j++)
|
||||||
|
for (let k = 0; k < 4; k++)
|
||||||
|
C[i*4+j] += A[i*4+k] * B[k*4+j];
|
||||||
|
return C;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyT(T, p) {
|
||||||
|
return [T[0]*p[0]+T[1]*p[1]+T[2]*p[2]+T[3],
|
||||||
|
T[4]*p[0]+T[5]*p[1]+T[6]*p[2]+T[7],
|
||||||
|
T[8]*p[0]+T[9]*p[1]+T[10]*p[2]+T[11]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTopoOrder(links) {
|
||||||
|
const parent = Object.fromEntries(Object.entries(links).map(([n,d]) => [n, d.parent||null]));
|
||||||
|
const visited = new Set(), order = [];
|
||||||
|
const visit = n => {
|
||||||
|
if (visited.has(n)) return;
|
||||||
|
visited.add(n);
|
||||||
|
if (parent[n]) visit(parent[n]);
|
||||||
|
order.push(n);
|
||||||
|
};
|
||||||
|
Object.keys(links).forEach(visit);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFK(robotData, joints) {
|
||||||
|
const links = robotData.links || {};
|
||||||
|
const state = {x:0,y:0,z:0,a:0,b:0,c:0,e:0, ...joints};
|
||||||
|
const T = {};
|
||||||
|
|
||||||
|
for (const lname of buildTopoOrder(links)) {
|
||||||
|
const ld = links[lname];
|
||||||
|
const par = ld.parent;
|
||||||
|
const Tp = T[par] || I4();
|
||||||
|
|
||||||
|
const mp = ld.mountPosition || [0,0,0];
|
||||||
|
const mr = ld.mountRotation || [0,0,0];
|
||||||
|
const Tm = makeT(rotXYZ(...mr), ...mp);
|
||||||
|
|
||||||
|
const jt = ld.jointToParent || {};
|
||||||
|
const jo = jt.origin || [0,0,0];
|
||||||
|
const jr = jt.rotation || [0,0,0];
|
||||||
|
const Tj = makeT(rotXYZ(...jr), ...jo);
|
||||||
|
|
||||||
|
const type = (jt.type||'').toLowerCase();
|
||||||
|
const axis = jt.axis || [1,0,0];
|
||||||
|
const v = state[jt.variable] || 0;
|
||||||
|
|
||||||
|
let Tmot = I4();
|
||||||
|
if (type === 'revolute') {
|
||||||
|
Tmot = makeT(rotAA(axis, v), 0, 0, 0);
|
||||||
|
} else if (type === 'linear') {
|
||||||
|
const [ax, ay, az] = norm(axis);
|
||||||
|
Tmot = makeT([1,0,0,0,1,0,0,0,1], ax*v, ay*v, az*v);
|
||||||
|
}
|
||||||
|
|
||||||
|
T[lname] = mm4(mm4(mm4(Tp, Tm), Tj), Tmot);
|
||||||
|
}
|
||||||
|
return T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerWorld(T, link, local) {
|
||||||
|
return applyT(T[link] || I4(), local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Coordinate conversion robot-mm → Three.js scene (metres)
|
||||||
|
// robot: x=right, y=backward, z=up
|
||||||
|
// Three.js: x=right, y=up, z=toward viewer
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
const S = 1 / 1000;
|
||||||
|
function r2v(rx, ry, rz) { return new THREE.Vector3(rx*S, rz*S, -ry*S); }
|
||||||
|
function r2vArr(p) { return r2v(p[0], p[1], p[2]); }
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Three.js scene
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const canvas = document.getElementById('cv');
|
||||||
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
|
renderer.setPixelRatio(devicePixelRatio);
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 0.9;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0d0f13);
|
||||||
|
scene.fog = new THREE.FogExp2(0x0d0f13, 0.35);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.001, 20);
|
||||||
|
camera.position.set(0.35, 0.55, 1.1);
|
||||||
|
camera.lookAt(0.2, 0.05, 0);
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, canvas);
|
||||||
|
controls.target.set(0.2, 0.05, 0);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
|
||||||
|
// Lighting
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||||||
|
const sun = new THREE.DirectionalLight(0xfff4e0, 1.4);
|
||||||
|
sun.position.set(-0.8, 1.2, 0.9);
|
||||||
|
sun.castShadow = true;
|
||||||
|
scene.add(sun);
|
||||||
|
const fill = new THREE.DirectionalLight(0xc0d8ff, 0.4);
|
||||||
|
fill.position.set(0.6, 0.3, -0.5);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
const grid = new THREE.GridHelper(3, 30, 0x1e2230, 0x1a1e28);
|
||||||
|
grid.position.y = -0.028;
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
// Axes helper (world origin)
|
||||||
|
const axes = new THREE.AxesHelper(0.1);
|
||||||
|
axes.position.set(0, 0, 0);
|
||||||
|
scene.add(axes);
|
||||||
|
|
||||||
|
// ─── geometry helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeCylinder(p1, p2, radius, color, opacity=1) {
|
||||||
|
const dir = p2.clone().sub(p1);
|
||||||
|
const len = dir.length();
|
||||||
|
if (len < 0.0001) return null;
|
||||||
|
const geo = new THREE.CylinderGeometry(radius, radius, len, 8, 1);
|
||||||
|
const mat = new THREE.MeshPhongMaterial({
|
||||||
|
color, transparent: opacity < 1, opacity,
|
||||||
|
shininess: 60
|
||||||
|
});
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
m.castShadow = true;
|
||||||
|
m.position.copy(p1.clone().add(p2).multiplyScalar(0.5));
|
||||||
|
m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.normalize());
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSphere(pos, radius, color) {
|
||||||
|
const geo = new THREE.SphereGeometry(radius, 12, 8);
|
||||||
|
const mat = new THREE.MeshPhongMaterial({ color, shininess: 80 });
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
m.position.copy(pos);
|
||||||
|
m.castShadow = true;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformDirByT(T, dir) {
|
||||||
|
return [
|
||||||
|
T[0]*dir[0] + T[1]*dir[1] + T[2]*dir[2],
|
||||||
|
T[4]*dir[0] + T[5]*dir[1] + T[6]*dir[2],
|
||||||
|
T[8]*dir[0] + T[9]*dir[1] + T[10]*dir[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMarkerSquare(pos, normal, size, color) {
|
||||||
|
const geo = new THREE.BoxGeometry(size, size, size * 0.1);
|
||||||
|
const mat = new THREE.MeshPhongMaterial({
|
||||||
|
color,
|
||||||
|
shininess: 40
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
m.position.copy(pos);
|
||||||
|
|
||||||
|
// Fallback falls keine gültige Normale vorhanden
|
||||||
|
let nx = 0, ny = 0, nz = 1;
|
||||||
|
|
||||||
|
if (Array.isArray(normal) && normal.length >= 3) {
|
||||||
|
nx = Number(normal[0]) || 0;
|
||||||
|
ny = Number(normal[1]) || 0;
|
||||||
|
nz = Number(normal[2]) || 1;
|
||||||
|
} else if (normal instanceof THREE.Vector3) {
|
||||||
|
nx = normal.x;
|
||||||
|
ny = normal.y;
|
||||||
|
nz = normal.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = new THREE.Vector3(nx, ny, nz);
|
||||||
|
|
||||||
|
if (n.lengthSq() > 1e-12) {
|
||||||
|
n.normalize();
|
||||||
|
m.quaternion.setFromUnitVectors(
|
||||||
|
new THREE.Vector3(0, 0, 1),
|
||||||
|
n
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLine(p1, p2, color) {
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||||
|
const mat = new THREE.LineBasicMaterial({ color });
|
||||||
|
return new THREE.Line(geo, mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── link colours ─────────────────────────────────────────────
|
||||||
|
const LINK_COLORS = {
|
||||||
|
Board: 0x8b6528,
|
||||||
|
Base: 0xc8c8c8,
|
||||||
|
Arm1: 0x3355cc,
|
||||||
|
Ellbow: 0xcccccc,
|
||||||
|
Arm2: 0xbbbbbb,
|
||||||
|
Hand: 0xaaaaaa,
|
||||||
|
Palm: 0x999999,
|
||||||
|
FingerA: 0xdddddd,
|
||||||
|
FingerB: 0xdddddd,
|
||||||
|
};
|
||||||
|
function linkColor(name) { return LINK_COLORS[name] ?? 0x888888; }
|
||||||
|
|
||||||
|
// ─── scene groups ─────────────────────────────────────────────
|
||||||
|
const gSkeleton = new THREE.Group();
|
||||||
|
const gModel = new THREE.Group();
|
||||||
|
const gNormals = new THREE.Group();
|
||||||
|
const gObserved = new THREE.Group();
|
||||||
|
const gObsNormals= new THREE.Group();
|
||||||
|
const gErrors = new THREE.Group();
|
||||||
|
const gBoard = new THREE.Group();
|
||||||
|
const gCameras = new THREE.Group();
|
||||||
|
scene.add(gSkeleton, gModel, gNormals, gObserved, gObsNormals, gErrors, gBoard, gCameras);
|
||||||
|
|
||||||
|
// ─── toggle wiring ────────────────────────────────────────────
|
||||||
|
const toggles = {
|
||||||
|
tSkeleton: gSkeleton,
|
||||||
|
tModel: gModel,
|
||||||
|
tNormals: gNormals,
|
||||||
|
tObserved: gObserved,
|
||||||
|
tObsNormals:gObsNormals,
|
||||||
|
tErrors: gErrors,
|
||||||
|
tBoard: gBoard,
|
||||||
|
tCameras: gCameras,
|
||||||
|
};
|
||||||
|
for (const [id, grp] of Object.entries(toggles)) {
|
||||||
|
document.getElementById(id).addEventListener('change', e => {
|
||||||
|
grp.visible = e.target.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// State
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let robotData = null;
|
||||||
|
let arucoData = null;
|
||||||
|
let solutionData = null;
|
||||||
|
let cameraData = [];
|
||||||
|
const joints = { x:0, y:0, z:0, a:0, b:0, c:0, e:0 };
|
||||||
|
|
||||||
|
// ─── slider setup ─────────────────────────────────────────────
|
||||||
|
const JOINT_DEFS = [
|
||||||
|
{ key:'x', label:'x', min:-50, max:600, step:1, unit:'mm' },
|
||||||
|
{ key:'y', label:'y', min:-180, max:180, step:0.5,unit:'°' },
|
||||||
|
{ key:'z', label:'z', min:-180, max:180, step:0.5,unit:'°' },
|
||||||
|
{ key:'a', label:'a', min:-180, max:180, step:0.5,unit:'°' },
|
||||||
|
{ key:'b', label:'b', min:-180, max:180, step:0.5,unit:'°' },
|
||||||
|
{ key:'c', label:'c', min:-180, max:180, step:0.5,unit:'°' },
|
||||||
|
{ key:'e', label:'e', min:0, max:60, step:0.5,unit:'mm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sliderEls = {}, valEls = {};
|
||||||
|
const slidersDiv = document.getElementById('sliders');
|
||||||
|
|
||||||
|
for (const d of JOINT_DEFS) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'slider-row';
|
||||||
|
|
||||||
|
const lbl = document.createElement('span');
|
||||||
|
lbl.textContent = d.key;
|
||||||
|
|
||||||
|
const sl = document.createElement('input');
|
||||||
|
sl.type = 'range';
|
||||||
|
sl.min = d.min; sl.max = d.max; sl.step = d.step; sl.value = 0;
|
||||||
|
|
||||||
|
const vb = document.createElement('input');
|
||||||
|
vb.type = 'text'; vb.className = 'val-box'; vb.value = '0';
|
||||||
|
vb.style.width = '58px';
|
||||||
|
|
||||||
|
sl.addEventListener('input', () => {
|
||||||
|
joints[d.key] = parseFloat(sl.value);
|
||||||
|
vb.value = parseFloat(sl.value).toFixed(d.step < 1 ? 1 : 0);
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
vb.addEventListener('change', () => {
|
||||||
|
const v = parseFloat(vb.value);
|
||||||
|
if (!isNaN(v)) {
|
||||||
|
joints[d.key] = v;
|
||||||
|
sl.value = v;
|
||||||
|
rebuild();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(lbl, sl, vb);
|
||||||
|
slidersDiv.appendChild(row);
|
||||||
|
sliderEls[d.key] = sl;
|
||||||
|
valEls[d.key] = vb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSlider(key, val) {
|
||||||
|
joints[key] = val;
|
||||||
|
sliderEls[key].value = val;
|
||||||
|
valEls[key].value = val.toFixed(JOINT_DEFS.find(d=>d.key===key)?.step < 1 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── file loading ──────────────────────────────────────────────
|
||||||
|
function readJSON(file) {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = e => { try { res(JSON.parse(e.target.result)); } catch(err) { rej(err); } };
|
||||||
|
r.onerror = rej;
|
||||||
|
r.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('fRobot').addEventListener('change', async e => {
|
||||||
|
if (!e.target.files[0]) return;
|
||||||
|
robotData = await readJSON(e.target.files[0]);
|
||||||
|
// init sliders from defaultPosition if present
|
||||||
|
const dp = robotData.defaultPosition || {};
|
||||||
|
for (const k of Object.keys(joints)) {
|
||||||
|
if (dp[k] != null) setSlider(k, dp[k]);
|
||||||
|
}
|
||||||
|
populatePoses(robotData);
|
||||||
|
setStatus('robot.json loaded');
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('fAruco').addEventListener('change', async e => {
|
||||||
|
if (!e.target.files[0]) return;
|
||||||
|
arucoData = await readJSON(e.target.files[0]);
|
||||||
|
// Kamera-Posen aus derselben Datei übernehmen (falls vorhanden) → Frusta
|
||||||
|
cameraData = (arucoData.cameras || []).map(c => ({
|
||||||
|
pos_mm: c.position_mm || (c.position_m ? c.position_m.map(v => v * 1000) : null),
|
||||||
|
dir: c.direction,
|
||||||
|
id: c.camera_id
|
||||||
|
})).filter(c => c.pos_mm && c.dir);
|
||||||
|
setStatus('aruco geladen' + (cameraData.length ? ` (+ ${cameraData.length} Kameras)` : ''));
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── solution (robot_state.json) → set joint sliders ───────────
|
||||||
|
function applySolution() {
|
||||||
|
if (!solutionData) { setStatus('keine Lösung geladen'); return; }
|
||||||
|
const mv = solutionData.movements || {};
|
||||||
|
const done = [];
|
||||||
|
for (const k of Object.keys(joints)) {
|
||||||
|
const m = mv[k];
|
||||||
|
if (m == null) continue;
|
||||||
|
let v = (typeof m === 'object') ? (m.value ?? m.value_mm ?? m.value_deg) : m;
|
||||||
|
if (v == null || isNaN(v)) continue;
|
||||||
|
setSlider(k, Number(v));
|
||||||
|
const conf = (typeof m === 'object') ? (m.confidence || '') : '';
|
||||||
|
done.push(k + ((conf === 'low' || conf === 'none') ? '⚠' : ''));
|
||||||
|
}
|
||||||
|
rebuild();
|
||||||
|
const info = document.getElementById('solInfo');
|
||||||
|
if (info) info.textContent = done.length
|
||||||
|
? ('gesetzt: ' + done.join(' ') + ' (⚠ = geringe Konfidenz)')
|
||||||
|
: 'keine movements in der Datei gefunden';
|
||||||
|
setStatus('Auf Lösung gesprungen');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('fSolution').addEventListener('change', async e => {
|
||||||
|
if (!e.target.files[0]) return;
|
||||||
|
solutionData = await readJSON(e.target.files[0]);
|
||||||
|
const btn = document.getElementById('btnJump');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.style.opacity = '1';
|
||||||
|
setStatus('solution (robot_state.json) loaded');
|
||||||
|
applySolution(); // direkt anwenden
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btnJump').addEventListener('click', applySolution);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Test-pose dropdown
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
function populatePoses(data) {
|
||||||
|
const poses = data.robot_test_poses || {};
|
||||||
|
const keys = Object.keys(poses);
|
||||||
|
const section = document.getElementById('poseSection');
|
||||||
|
const sel = document.getElementById('poseSelect');
|
||||||
|
const info = document.getElementById('poseInfo');
|
||||||
|
|
||||||
|
if (keys.length === 0) { section.style.display = 'none'; return; }
|
||||||
|
section.style.display = '';
|
||||||
|
|
||||||
|
// Rebuild options
|
||||||
|
sel.innerHTML = '<option value="">— select pose —</option>';
|
||||||
|
keys.forEach(k => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = k; o.textContent = k;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove old listener by replacing element
|
||||||
|
const newSel = sel.cloneNode(true);
|
||||||
|
sel.parentNode.replaceChild(newSel, sel);
|
||||||
|
|
||||||
|
newSel.addEventListener('change', () => {
|
||||||
|
const k = newSel.value;
|
||||||
|
if (!k) { info.textContent = ''; return; }
|
||||||
|
const p = poses[k];
|
||||||
|
info.textContent = Object.entries(p).map(([a,b])=>`${a}:${b}`).join(' ');
|
||||||
|
for (const [key, val] of Object.entries(p)) {
|
||||||
|
if (key in joints) setSlider(key, Number(val));
|
||||||
|
}
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── clear groups ──────────────────────────────────────────────
|
||||||
|
function clearGroup(g) {
|
||||||
|
while (g.children.length) {
|
||||||
|
const c = g.children[0];
|
||||||
|
c.geometry?.dispose?.();
|
||||||
|
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose?.());
|
||||||
|
else c.material?.dispose?.();
|
||||||
|
g.remove(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Normal-arrow helper
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
function makeNormalArrow(posThreeJS, normalRobot, length, hexColor) {
|
||||||
|
// normalRobot is in robot coords → convert to Three.js direction
|
||||||
|
const dir = new THREE.Vector3(normalRobot[0], normalRobot[2], -normalRobot[1]).normalize();
|
||||||
|
const arrow = new THREE.ArrowHelper(
|
||||||
|
dir,
|
||||||
|
posThreeJS,
|
||||||
|
length,
|
||||||
|
hexColor,
|
||||||
|
length * 0.40, // cone head length
|
||||||
|
length * 0.20 // cone head width
|
||||||
|
);
|
||||||
|
return arrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── rebuild scene ─────────────────────────────────────────────
|
||||||
|
function r2vDir(rx, ry, rz) {
|
||||||
|
return new THREE.Vector3(rx, rz, -ry).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Camera frustum (halbtransparente Pyramide, Spitze = Linse)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
function makeCameraFrustum(posThree, dirThree, size, hexColor) {
|
||||||
|
const geo = new THREE.ConeGeometry(size * 0.6, size, 4);
|
||||||
|
geo.translate(0, -size / 2, 0); // Spitze (Linse) an den lokalen Ursprung
|
||||||
|
geo.rotateY(Math.PI / 4); // Pyramidenkanten ausrichten
|
||||||
|
const mat = new THREE.MeshPhongMaterial({
|
||||||
|
color: hexColor, transparent: true, opacity: 0.28,
|
||||||
|
side: THREE.DoubleSide, depthWrite: false
|
||||||
|
});
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
m.position.copy(posThree);
|
||||||
|
const d = dirThree.clone().normalize();
|
||||||
|
if (d.lengthSq() > 1e-9) m.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), d);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuild() {
|
||||||
|
clearGroup(gSkeleton);
|
||||||
|
clearGroup(gModel);
|
||||||
|
clearGroup(gNormals);
|
||||||
|
clearGroup(gObserved);
|
||||||
|
clearGroup(gObsNormals);
|
||||||
|
clearGroup(gErrors);
|
||||||
|
clearGroup(gBoard);
|
||||||
|
clearGroup(gCameras);
|
||||||
|
|
||||||
|
// Kamera-Frusta hängen nicht vom Roboterzustand ab
|
||||||
|
for (const cam of cameraData) {
|
||||||
|
gCameras.add(makeCameraFrustum(r2vArr(cam.pos_mm), r2vDir(...cam.dir), 0.05, 0x9b7bff));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!robotData) return;
|
||||||
|
|
||||||
|
const T = computeFK(robotData, joints);
|
||||||
|
|
||||||
|
// ── board plane ──
|
||||||
|
const boardSize = robotData.links?.Board?.size || [1000, 200, 25];
|
||||||
|
{
|
||||||
|
const w = boardSize[0]*S, d = boardSize[1]*S, h = boardSize[2]*S;
|
||||||
|
const geo = new THREE.BoxGeometry(w, h, d);
|
||||||
|
const mat = new THREE.MeshPhongMaterial({ color:0x7a5520, transparent:true, opacity:0.5 });
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
m.position.set(w/2, -h/2, -d/2);
|
||||||
|
m.receiveShadow = true;
|
||||||
|
gBoard.add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── skeleton ──
|
||||||
|
const links = robotData.links || {};
|
||||||
|
for (const [lname, ld] of Object.entries(links)) {
|
||||||
|
const sk = ld.skeleton;
|
||||||
|
if (!sk) continue;
|
||||||
|
const from = markerWorld(T, lname, sk.from || [0,0,0]);
|
||||||
|
const to = markerWorld(T, lname, sk.to || [0,0,0]);
|
||||||
|
const r = (sk.radius || 4) * S * 0.8;
|
||||||
|
const col = sk.color ? new THREE.Color(...sk.color) : new THREE.Color(linkColor(lname));
|
||||||
|
const cyl = makeCylinder(r2vArr(from), r2vArr(to), r, col);
|
||||||
|
if (cyl) gSkeleton.add(cyl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── model markers + normals ──
|
||||||
|
const modelPositions = {};
|
||||||
|
const modelNormals = {};
|
||||||
|
for (const [lname, ld] of Object.entries(links)) {
|
||||||
|
const col = linkColor(lname);
|
||||||
|
for (const m of (ld.markers||[])) {
|
||||||
|
if (!m.position) continue;
|
||||||
|
const mid = m.id;
|
||||||
|
const wp = markerWorld(T, lname, m.position);
|
||||||
|
const nLocal = m.normal || [0,0,1];
|
||||||
|
const nWorld = transformDirByT(T[lname] || I4(), nLocal);
|
||||||
|
|
||||||
|
modelPositions[mid] = wp;
|
||||||
|
modelNormals[mid] = nWorld;
|
||||||
|
|
||||||
|
const sq = makeMarkerSquare(r2vArr(wp), r2vDir(...nWorld), 0.022, col);
|
||||||
|
gModel.add(sq);
|
||||||
|
|
||||||
|
// normal arrow (length = half a marker size = ~12.5mm → 0.0125m)
|
||||||
|
const arr = makeNormalArrow(r2vArr(wp), nWorld, 0.018, col);
|
||||||
|
gNormals.add(arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observed markers + normals + error lines ──
|
||||||
|
const obs = {};
|
||||||
|
if (arucoData) {
|
||||||
|
for (const m of (arucoData.markers||[])) {
|
||||||
|
const mid = m.marker_id ?? m.id;
|
||||||
|
if (mid == null) continue;
|
||||||
|
let pos, nor = null;
|
||||||
|
if (m.position_mm) pos = m.position_mm;
|
||||||
|
else if (m.position_m) pos = m.position_m.map(v=>v*1000);
|
||||||
|
else continue;
|
||||||
|
// optional orientation from step-3 output or render markers.json
|
||||||
|
if (m.normal_world) nor = m.normal_world;
|
||||||
|
else if (m.normal) nor = m.normal;
|
||||||
|
obs[mid] = { pos, nor };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const normalErrors = [];
|
||||||
|
for (const [midStr, {pos: opos, nor: oNor}] of Object.entries(obs)) {
|
||||||
|
const mid = parseInt(midStr);
|
||||||
|
const mpos = modelPositions[mid];
|
||||||
|
const op = r2vArr(opos);
|
||||||
|
|
||||||
|
const errMm = mpos
|
||||||
|
? Math.sqrt(opos.reduce((s,v,i) => s+(v-mpos[i])**2, 0))
|
||||||
|
: null;
|
||||||
|
const col = errMm == null ? 0x888888
|
||||||
|
: errMm < 2 ? 0x3ecf6b
|
||||||
|
: errMm < 5 ? 0xf59e0b
|
||||||
|
: 0xff4f4f;
|
||||||
|
|
||||||
|
gObserved.add(makeSphere(op, 0.006, col));
|
||||||
|
|
||||||
|
// observed normal arrow — coloured by angular deviation from the model normal
|
||||||
|
if (oNor) {
|
||||||
|
try {
|
||||||
|
let normCol = 0xffaa00; // no model to compare -> orange
|
||||||
|
const mn = modelNormals[mid];
|
||||||
|
if (mn) {
|
||||||
|
const dot = (oNor[0]*mn[0] + oNor[1]*mn[1] + oNor[2]*mn[2]) /
|
||||||
|
((Math.hypot(oNor[0],oNor[1],oNor[2]) * Math.hypot(mn[0],mn[1],mn[2])) || 1);
|
||||||
|
let a = Math.acos(Math.max(-1, Math.min(1, dot))) * 180 / Math.PI;
|
||||||
|
a = Math.min(a, 180 - a); // flip-invariant
|
||||||
|
normalErrors.push(a);
|
||||||
|
normCol = a < 2 ? 0x3ecf6b : a < 5 ? 0xf59e0b : 0xff4f4f;
|
||||||
|
}
|
||||||
|
gObsNormals.add(makeNormalArrow(op, oNor, 0.018, normCol));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Invalid normal for marker', mid, oNor, e);
|
||||||
|
}
|
||||||
|
} else if (modelNormals[mid]) {
|
||||||
|
// fallback: show model normal at observed position (grey = no obs normal data)
|
||||||
|
const obsArrow = makeNormalArrow(op, modelNormals[mid], 0.014, 0x666666);
|
||||||
|
gObsNormals.add(obsArrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mpos) {
|
||||||
|
errors.push(errMm);
|
||||||
|
const mp = r2vArr(mpos);
|
||||||
|
gErrors.add(makeLine(mp, op, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── stats ──
|
||||||
|
updateStats(errors, normalErrors, Object.keys(obs).length, Object.keys(modelPositions).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── statistics panel ──────────────────────────────────────────
|
||||||
|
function updateStats(errArr, normArr, nObs, nModel) {
|
||||||
|
const el = document.getElementById('stats-content');
|
||||||
|
normArr = normArr || [];
|
||||||
|
if (errArr.length === 0) {
|
||||||
|
el.innerHTML = `<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
|
||||||
|
<div class="stat-line"><span>Model:</span><span class="stat-val">${nModel}</span></div>
|
||||||
|
<div class="stat-line"><span style="color:var(--muted)">No matches found</span></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sorted = [...errArr].sort((a,b)=>a-b);
|
||||||
|
const mean = errArr.reduce((s,v)=>s+v,0) / errArr.length;
|
||||||
|
const rms = Math.sqrt(errArr.reduce((s,v)=>s+v*v,0) / errArr.length);
|
||||||
|
const p50 = sorted[Math.floor(sorted.length*0.5)];
|
||||||
|
const p90 = sorted[Math.floor(sorted.length*0.9)];
|
||||||
|
const max = sorted[sorted.length-1];
|
||||||
|
|
||||||
|
const cls = v => v < 2 ? 'stat-ok' : v < 5 ? 'stat-warn' : 'stat-err';
|
||||||
|
|
||||||
|
let normHtml = '';
|
||||||
|
if (normArr.length) {
|
||||||
|
const nmean = normArr.reduce((s,v)=>s+v,0) / normArr.length;
|
||||||
|
const nmax = Math.max(...normArr);
|
||||||
|
const ncls = v => v < 2 ? 'stat-ok' : v < 5 ? 'stat-warn' : 'stat-err';
|
||||||
|
normHtml = `<div class="stat-line" style="margin-top:4px"><span>Normal mean:</span><span class="${ncls(nmean)}">${nmean.toFixed(1)}°</span></div>
|
||||||
|
<div class="stat-line"><span>Normal max:</span><span class="${ncls(nmax)}">${nmax.toFixed(1)}°</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
|
||||||
|
<div class="stat-line"><span>Matched:</span><span class="stat-val">${errArr.length}</span></div>
|
||||||
|
<div class="stat-line"><span>Mean error:</span><span class="${cls(mean)}">${mean.toFixed(1)} mm</span></div>
|
||||||
|
<div class="stat-line"><span>RMS error:</span><span class="${cls(rms)}">${rms.toFixed(1)} mm</span></div>
|
||||||
|
<div class="stat-line"><span>Median:</span><span class="${cls(p50)}">${p50.toFixed(1)} mm</span></div>
|
||||||
|
<div class="stat-line"><span>p90:</span><span class="${cls(p90)}">${p90.toFixed(1)} mm</span></div>
|
||||||
|
<div class="stat-line"><span>Max:</span><span class="${cls(max)}">${max.toFixed(1)} mm</span></div>
|
||||||
|
${normHtml}
|
||||||
|
<div style="margin-top:6px;font-size:10px;color:var(--muted)">
|
||||||
|
🟢 <2mm/° 🟡 2–5 🔴 >5</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
document.getElementById('status-bar').textContent = msg + ' — Drag to orbit · Scroll to zoom';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Render loop
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
function onResize() {
|
||||||
|
const w = canvas.parentElement.clientWidth;
|
||||||
|
const h = canvas.parentElement.clientHeight;
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
onResize();
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user