Callibration Mathe

This commit is contained in:
chk
2026-06-14 17:47:57 +02:00
parent fdbdb5f1e7
commit 273146c726
8 changed files with 409 additions and 611 deletions

View File

@@ -1,127 +0,0 @@
# Bestimmung der Y-Rotationsachse aus Marker-Positionen
## Problem
Wenn der Roboter um seine Y-Achse rotiert, bewegt sich jeder erkannte Marker auf einem **Kreisbogen** im 3D-Raum. Ziel ist es, Lage und Richtung dieser Rotationsachse aus den beobachteten Marker-Positionen (x, y, z) zu mehreren Zeitstempeln zu berechnen.
---
## Reichen zwei Positionen?
**Nein.** Zwei Punkte P₁, P₂ desselben Markers definieren nur eine Strecke. Die Rotationsachse muss durch die senkrechte Mittelebene dieser Strecke laufen aber *wo* genau in dieser Ebene und in welcher Richtung bleibt unbestimmt. Das System ist unterbestimmt.
| Beobachtungen | Bestimmbar? | Begründung |
|---|---|---|
| 1 Marker, 2 Positionen | Nein | Achse liegt irgendwo auf einer Halbebene |
| 1 Marker, 3 Positionen | **Ja** | Eindeutiger Umkreis → eindeutige Achse |
| N Marker, je ≥ 3 Positionen | Ja, überbestimmt | Least-Squares, robuster gegenüber Messrauschen |
---
## Mathematik: 1 Marker, 3 Positionen
Gegeben: P₁, P₂, P₃ ∈ ℝ³ (derselbe Marker zu drei verschiedenen Rotationswinkeln).
Da die Rotation starr ist, liegen P₁, P₂, P₃ auf einem **Kreis**, dessen Ebene senkrecht zur Rotationsachse steht.
### Schritt 1 Achsenrichtung (Normalenvektor der Kreisebene)
```
v₁ = P₂ P₁
v₂ = P₃ P₁
n = (v₁ × v₂) / |v₁ × v₂| ← Einheitsvektor entlang der Rotationsachse
```
### Schritt 2 Mittelpunkt des Kreises (Umkreismittelpunkt)
Der Umkreismittelpunkt C liegt in der Ebene der drei Punkte und ist von allen drei Punkten gleich weit entfernt. Er ist ein Punkt **auf der Rotationsachse**.
Berechnung über Baryzentrischen Koordinaten:
```
a² = |P₂ P₃|²
b² = |P₁ P₃|²
c² = |P₁ P₂|²
w₁ = a²·(b² + c² a²)
w₂ = b²·(a² + c² b²)
w₃ = c²·(a² + b² c²)
C = (w₁·P₁ + w₂·P₂ + w₃·P₃) / (w₁ + w₂ + w₃)
```
### Schritt 3 Rotationsachse
```
Achse: r(t) = C + t·n, t ∈
```
---
## Mathematik: N Marker, je ≥ 3 Positionen (Least Squares)
Jeder Marker i liefert:
- einen Schätzwert **Cᵢ** (Umkreismittelpunkt, Punkt auf der Achse)
- einen Schätzwert **nᵢ** (Normalenvektor, Achsenrichtung)
### Achsenrichtung (gemittelt / PCA)
Wegen möglichem Vorzeichenambiguität alle nᵢ auf dasselbe Halbraum ausrichten, dann:
```
n̄ = mean(nᵢ) / |mean(nᵢ)|
```
Robuster: **PCA** über die Matrix der nᵢ-Vektoren → erster Hauptkomponentenvektor.
### Achsenposition (Least Squares)
Minimiere die quadratischen Abstände aller Umkreismittelpunkte Cᵢ zur gesuchten Geraden `r(t) = A + t·n̄`:
```
Abstand² von Cᵢ zur Achse = |Cᵢ A|² ((Cᵢ A)·n̄)²
```
Ableitung null setzen liefert:
```
A = C̄ (C̄·n̄)·n̄ + (C̄·n̄)·n̄ = C̄ (Referenzpunkt = Schwerpunkt der Cᵢ)
```
d. h., der **Schwerpunkt der Umkreismittelpunkte** ist der optimale Referenzpunkt auf der Achse. Die Achsenrichtung n̄ bleibt unverändert.
**Verbleibender Fehler** (Residuum pro Marker):
```
εᵢ = |(Cᵢ C̄) ((Cᵢ C̄)·n̄)·n̄|
```
Große εᵢ deuten auf einen fehlerhaften Marker oder eine nicht-rotatorische Bewegungskomponente hin.
---
## Praktische Empfehlung
- **Mindestens 3 Zeitstempel** pro Marker, verteilt über einen Winkelbereich von ≥ 30° (sonst ist der Kreis numerisch schlecht konditioniert).
- **34 Marker** mit je 3 Positionen sind ausreichend für eine stabile Schätzung.
- Die Rotationswinkel müssen nicht bekannt sein nur die 3D-Koordinaten der Marker.
- Bei sehr kleinen Winkeln (< 10°) ist die Bestimmung numerisch instabil; größere Drehungen bevorzugen.
---
## Zusammenfassung
```
Eingabe: Marker-Positionen P[marker][timestamp] ∈ ℝ³
(mind. 3 Timestamps pro Marker, mind. 1 Marker)
Pro Marker:
1. n_i = normalize((P2-P1) × (P3-P1)) ← Achsenrichtung
2. C_i = Umkreismittelpunkt(P1, P2, P3) ← Punkt auf Achse
Kombination:
3. n̄ = normalize(mean(n_i)) ← beste Achsenrichtung
4. C̄ = mean(C_i) ← bester Referenzpunkt
Ergebnis: Rotationsachse r(t) = C̄ + t·n̄
```

View File

@@ -1,123 +0,0 @@
# Calibration Roadmap appRobotHoming
> Stand: 2026-06-10
> Ziel: Vor dem Homing muss das System einmalig (oder nach mechanischen Änderungen) kalibriert werden.
---
## Übersicht der Kalibrierungsschritte
```
[1] Camera NPZ → [2] Board → [3] Robot X-Axis → [4] Arm1 / Arm2
```
Jede Stufe baut auf der vorherigen auf. Die Ergebnisse werden als Dateien gespeichert und vom Homing-Prozess geladen.
---
## [1] Camera NPZ Kamerakalibrierung
**Ziel:** Intrinsische Kameraparameter (Brennweite, Verzerrungskoeffizienten, Kameramatrix) für jede Kamera ermitteln und als `.npz`-Datei speichern.
**Vorgehen (noch offen):**
- Schachbrettmuster / ChArUco-Board aus verschiedenen Winkeln fotografieren
- OpenCV `calibrateCamera()` ausführen
- Ergebnis speichern: `cam0_calib.npz`, `cam1_calib.npz`, `cam2_calib.npz`
**Aktionen auf der Seite (geplant):**
- Fotos aufnehmen (mehrere Posen)
- Kalibrierung berechnen
- `.npz`-Datei herunterladen / auf Server speichern
- Reprojektionsfehler anzeigen
**Offene Fragen:**
- Welches Muster wird verwendet (Schachbrett vs. ChArUco)?
- Automatische Erfassung oder manueller Upload der Bilder?
---
## [2] Board Referenz-Board-Kalibrierung
**Ziel:** Die extrinsische Position des Marker-Boards im Kamera-Koordinatensystem bestimmen.
**Vorgehen (noch offen):**
- Board in definierter Position aufstellen
- Foto aufnehmen, Marker erkennen (`aruco.detectMarkers`)
- `solvePnP` → Rotations- und Translationsvektor berechnen
- Transformation Board → Kamera speichern
**Aktionen auf der Seite (geplant):**
- Kamerabild anzeigen mit erkannten Markern
- Pose berechnen und anzeigen
- Kalibrierungsdatei speichern
**Offene Fragen:**
- Feste Board-Position oder Referenzpunkte einmessen?
---
## [3] Robot X-Axis Achsenrichtung kalibrieren
**Ziel:** Die X-Achse des Roboters im Weltkoordinatensystem verorten (Ausrichtung und Nullpunkt).
**Vorgehen (noch offen):**
- Roboter an bekannte X-Positionen fahren (z. B. X=0 und X=max)
- Kamera beobachtet den Endeffektor / Marker am Roboter
- Achsvektor aus zwei Messpunkten berechnen
- Ergebnis speichern
**Aktionen auf der Seite (geplant):**
- Roboter zu Referenzposition 1 fahren → Foto → Marker-Position merken
- Roboter zu Referenzposition 2 fahren → Foto → Marker-Position merken
- Achsvektor berechnen und anzeigen
- Speichern
**Offene Fragen:**
- Wie viele Referenzpunkte werden benötigt?
- Wird die Z-Achse separat kalibriert?
---
## [4] Arm1 / Arm2 Gelenk-Kalibrierung
**Ziel:** Nullposition und Kinematikparameter von Arm1 und Arm2 einmessen.
**Vorgehen (noch offen):**
- Arm in mechanische Nullposition fahren (physischer Anschlag oder Markierung)
- Kamera prüft die tatsächliche Arm-Pose
- Offset zwischen Soll und Ist berechnen und speichern
**Aktionen auf der Seite (geplant):**
- Arm1 auf Nullposition → Foto → Winkel ablesen
- Arm2 auf Nullposition → Foto → Winkel ablesen
- Offset-Korrektur berechnen und speichern
**Offene Fragen:**
- Separater Marker pro Armgelenk?
- Kalibrierung bei jedem Start oder nur nach Umbau?
---
## Dateistruktur (geplant)
```
calibration/
cam0_calib.npz
cam1_calib.npz
cam2_calib.npz
board_pose.json
robot_xaxis.json
arm_offsets.json
```
---
## Status
| Schritt | Status | Anmerkung |
|------------------|--------------|----------------------------------|
| Camera NPZ | offen | Konzept unklar |
| Board | offen | Konzept unklar |
| Robot X-Axis | offen | Konzept unklar |
| Arm1 / Arm2 | offen | Konzept unklar |
| Calibration-UI | in Arbeit | HTML-Seite angelegt |

View File

@@ -1,23 +1,22 @@
# Homing Roadmap appRobotHoming
# Homing appRobotHoming
> Stand: 2026-06-13
> Ziel: Aus einem einzigen Kamera-Snapshot die aktuellen Gelenkwinkel/-positionen
> des Roboters bestimmen und an den Controller senden.
> Stand: 2026-06-14
> Homing läuft bei **jedem Einschalten** — schnell, vollautomatisch, ohne mechanische Endschalter.
---
## Was ist Homing?
Homing = der Roboter weiss **nicht**, wo er ist.
Die Kameras schauen auf das Board + die ArUco-Marker am Roboter und berechnen
daraus die vollständige Pose aller Gelenke — ohne mechanische Endschalter.
Der Roboter weiss beim Einschalten nicht, wo er steht.
Die Kameras schauen auf die ArUco-Marker am Roboter und berechnen daraus
die vollständige Pose aller Gelenke.
Homing läuft bei **jedem** Einschalten ab: schnell, robust, vollautomatisch.
Kalibrierung hingegen läuft nur nach mechanischen Änderungen (≈ einmalig).
**Homing** (dieser Prozess): bei jedem Einschalten, automatisch.
**Kalibrierung** (`doc/Kalibrierung.md`): nur nach mechanischen Änderungen.
---
## Kinematik-Kette (aus `robot.json → links`)
## Kinematik-Kette
```
Board (ROOT, fest) ← Referenz aller Kameras
@@ -31,376 +30,121 @@ Board (ROOT, fest) ← Referenz aller Kameras
└── FingerA/B linear e axis=±[1,0,0] ← Greifer (symmetrisch)
```
**Resultat-State:** `{ x_mm, y_deg, z_deg, a_deg, b_deg, c_deg, e_mm }`
**Ergebnis-State:** `{ x_mm, y_deg, z_deg, a_deg, b_deg, c_deg, e_mm }`
---
## Voraussetzungen (Kalibrierung)
## Voraussetzungen
| Was | Mechanismus | Status |
|-----|-------------|--------|
| Kamera-Intrinsik (NPZ) | `calibration.html` → Tab Camera NPZ | ✅ fertig |
| Board-Marker-Positionen | `calibration.html` → Tab Board | ✅ fertig |
| X-Achsen-Richtung | `calibration.html` → Tab Robot X-Axis | ✅ fertig |
| **Arm1 Joint-Origin Y/Z** | `calibration.html` → Tab Arm1 → Button „Joint-Origin Y/Z übernehmen" | ✅ **Button vorhanden, ausführbar** |
| Arm-Marker in robot.json | Manuell eintragen (links.Arm1/Ellbow/Arm2/Hand.markers) | 🔶 Nutzer trägt ein |
Homing setzt eine abgeschlossene Kalibrierung voraus:
> **Kalibrierung gilt als abgeschlossen** sobald der Arm1-Button geklickt und
> die Arm-Marker eingetragen sind.
| Was | Status |
|-----|--------|
| Kamera-Intrinsik (NPZ) | ✅ |
| Board-Marker-Positionen | ✅ |
| X-Achsen-Richtung | ✅ |
| Arm1 Joint-Origin Y/Z | ✅ Button vorhanden und ausführbar |
| Arm-Marker in robot.json | 🔶 Nutzer trägt ein (`links.Arm1/Ellbow/Arm2/Hand.markers`) |
---
## robot.json Ladestrategie
### Aktuell: lokale Datei
```
ROBOT_JSON = process.env.ROBOT_JSON || 'scripts/robot_1781069752019.json'
```
### Geplant: vom Driver per API
Der Driver-Service (ROBOT_URL) kennt die aktuelle Roboter-Konfiguration.
Lademechanismus (bereits implementiertes Muster aus `ROBOT_URL`/`BODYTRACKER_URL`):
## Ablauf
```
GET ROBOT_URL/api/robot/config → robot.json Inhalt
```
**Implementierung (Backend `server.js`):**
```javascript
async function loadRobotConfig() {
if (ROBOT_URL) {
// Vom Driver holen
const res = await fetch(new URL('/api/robot/config', ROBOT_URL));
return res.json();
}
// Fallback: lokale Datei
return JSON.parse(await fs.readFile(ROBOT_JSON, 'utf8'));
}
```
**Konsequenz für Homing:** Das Homing-Script bekommt robot.json als
temporäre Datei (bereits vorhandenes Muster: `ROBOT_JSON` als Pfad an Python).
Falls ROBOT_URL konfiguriert: zuerst fetch → temp-Datei schreiben → Script aufrufen.
**Priorität:** Kann nach dem restlichen Homing implementiert werden.
Solange ROBOT_URL nicht konfiguriert, läuft alles mit der lokalen Datei.
---
## X-Position (Slider) Bestimmung
Die Slider-Position `x` wird **nicht** manuell eingegeben, sondern aus den
triangulierten Marker-Positionen berechnet (nach Schritt 3b).
**Ansatz:** Die absolute X-Position eines bekannten Arm-Markers im
Board-Koordinatensystem enthält direkt die Slider-Information —
alle anderen Gelenke sind rotatorisch und verschieben den Marker nicht
entlang der X-Achse des Boards.
Alternativ: Schwerpunkt der Board-nahen A0-Marker projiziert auf die X-Achse
(robust, braucht keine Arm-Marker).
**In `4b_revolute_angle.py`:** `--x-mm` wird aus `aruco_marker_poses.json`
berechnet und als erstes Argument übergeben. Alle weiteren 4b-Aufrufe
nutzen `--from-state` des vorherigen Schritts.
---
## Homing-Ablauf (Script-Kette)
```
[Foto] → [1_detect] → [2_camera] → [3b_poses]
Foto alle Kameras
[4b Arm1]
[4b Ellbow]
[4b Arm2]
[4b Hand]
1_detect_aruco_observations.py (pro Kamera, mit NPZ)
State-JSON
{x,y,z,a,b,c,e}
2_estimate_camera_from_observations.py (pro Kamera)
3b_corner_marker_poses.py (einmal, benötigt ≥2 Kamera-Posen)
│ → aruco_marker_poses.json
X-Position aus Marker-Positionen schätzen
│ → x_mm (Durchschnitt x der Nicht-Board-Marker)
4b_revolute_angle.py --link Arm1 --x-mm {x_mm}
│ → state_Arm1.json (accumulated_state)
4b_revolute_angle.py --link Ellbow --from-state state_Arm1.json
│ → state_Ellbow.json
4b_revolute_angle.py --link Arm2 --from-state state_Ellbow.json
│ → state_Arm2.json
4b_revolute_angle.py --link Hand --from-state state_Arm2.json
│ → state_Hand.json ← accumulated_state enthält x,y,z,a,b,c,e
POST ROBOT_URL/api/state
```
**Strategie:** `4b_revolute_angle.py` sequenziell, Link für Link von root nach tip.
X-Position (`--x-mm`) wird aus den triangulierten Board-Marker-Positionen bestimmt.
**Schritte 13b** sind dieselbe Board-Pipeline wie in der Kalibrierung.
Sie sind in `runBoardPipeline()` (`server/server.js`) als gemeinsame Funktion ausgelagert.
**4b-Schleife**: sequenziell von root nach tip; jedes Script bekommt den Zustand des
vorherigen Schritts über `--from-state`. Der erste Aufruf erhält die geschätzte
X-Slider-Position über `--x-mm`.
---
## Implementierungsplan: Homing-UI
## Implementierung
### ⚠ Wichtig: Schritte 13b existieren bereits
| Komponente | Datei | Beschreibung |
|-----------|-------|--------------|
| Board-Pipeline | `server/server.js``runBoardPipeline(runDir, send)` | Foto + Scripts 1, 2, 3b; von Board-Run und Homing genutzt |
| X-Schätzung | `server/homingOrchestrator.js``estimateXFromMarkers()` | Mittelwert x der Nicht-Board-Marker aus `aruco_marker_poses.json` |
| Homing-Orchestrator | `server/homingOrchestrator.js``runHoming()` | Kompletter Ablauf als SSE-Stream |
| Backend-Route | `POST /api/homing/run` | SSE-Stream, startet `runHoming()` |
| State senden | `POST /api/homing/send-state` | Weiterleitung an `ROBOT_URL/api/state` |
| Run-Daten | `GET /api/homing/run-data?run=ts` | Debug-Bilder (base64) + finalState |
| Frontend | `public/index.html` + `public/client.js` | Homing-Buttons, Fortschrittsbalken, Tree View |
Die Kette **Foto → 1_detect → 2_camera → 3b_poses** ist bereits vollständig
implementiert und produktiv in `POST /api/board/run` (`server/server.js`,
ca. Zeile 520606). Sie erzeugt `<runDir>/aruco_marker_poses.json`.
**Die echten, funktionierenden Aufrufe (NICHT neu erfinden):**
```javascript
// Pro Kamera geloopt (jede Kamera hat eigene NPZ):
runScript([SCRIPT_1, '-i', imgPath, '-npz', npzPath, '-robot', ROBOT_JSON,
'-cameraId', camId, '-outDir', runDir, '--saveDebugImage']);
runScript([SCRIPT_2, '-i', detJson, '-robot', ROBOT_JSON, '-outDir', runDir]);
// Einmal, nach allen Kameras (braucht ≥2 _camera_pose.json):
runScript([SCRIPT_3B, '--evalDir', runDir, '--robot', ROBOT_JSON]);
// → schreibt <runDir>/aruco_marker_poses.json
```
**Empfehlung:** Die Snapshot+1+2+3b-Logik aus `/api/board/run` in eine
gemeinsame Funktion `runBoardPipeline(runDir, send)` auslagern. Homing ruft
sie auf und hängt nur den 4b-Teil an. So gibt es keine Duplikate und keine
abweichenden Argumente.
**Lauf-Verzeichnisse:** `data/homing/{timestamp}/`
---
### Phase 1 — Backend-Route `POST /api/homing/run`
## SSE-Event-Typen
**Datei:** `server/server.js` (neue Route) + `server/homingOrchestrator.js` (neue Datei)
Das Backend streamt während des Homing-Laufs folgende Events:
**Ablauf als SSE-Stream:**
```javascript
// server/homingOrchestrator.js
export async function runHoming({ robotJsonPath, send }) {
// 13b: bestehende Board-Pipeline wiederverwenden
// (Foto + 1_detect + 2_camera + 3b_poses, pro Kamera geloopt)
send({ type: 'step', step: 1, text: 'Snapshot + Marker-Triangulation …' });
const runDir = await runBoardPipeline(robotJsonPath, send); // aus server.js ausgelagert
const arucoJson = path.join(runDir, 'aruco_marker_poses.json');
// 4. X-Position aus triangulierten Markern bestimmen
const xMm = estimateXFromMarkers(arucoJson); // siehe Abschnitt „X-Position"
// 4b. Gelenkwinkel sequenziell, Link für Link
const links = ['Arm1', 'Ellbow', 'Arm2', 'Hand'];
let fromState = null;
for (const link of links) {
send({ type: 'step', text: `Gelenkwinkel ${link}` });
const args = [SCRIPT_4B,
'--robot', robotJsonPath, '--aruco', arucoJson,
'--link', link,
'--output', path.join(runDir, `state_${link}.json`),
];
if (fromState) args.push('--from-state', fromState);
else args.push('--x-mm', String(xMm));
const exit = await runScript(args, send);
if (exit !== 0) { send({ type: 'error', text: `4b ${link} Exit ${exit}` }); return; }
fromState = path.join(runDir, `state_${link}.json`);
}
// Ergebnis: letztes state_*.json enthält den vollständigen accumulated_state
const finalState = JSON.parse(fs.readFileSync(fromState, 'utf8'));
send({ type: 'done', state: finalState.accumulated_state, runDir });
}
```
> **Hinweis:** `runScript()` gibt den Exit-Code zurück (nicht den Pfad).
> Der State-Pfad wird separat gemerkt (`fromState`).
**Route:**
```javascript
app.post('/api/homing/run', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
try {
await runHoming({ robotJsonPath: ROBOT_JSON, send });
} catch (err) {
send({ type: 'error', text: String(err) });
}
res.end();
});
app.post('/api/homing/send-state', async (req, res) => {
// Sendet { x, y, z, a, b, c, e } an ROBOT_URL/api/state
});
```
| `type` | Felder | Bedeutung |
|--------|--------|-----------|
| `log` | `text` | Zeile aus Script-Ausgabe |
| `step` | `step`, `total`, `text` | Fortschritt (16) |
| `analysis` | `key`, `value` | Zwischenergebnis (x_mm, state_Arm1, …) |
| `error` | `text` | Fehler (Script-Exit ≠ 0 o.ä.) |
| `done` | `exitCode`, `state?`, `runDir` | Abschluss; `state` nur bei Erfolg |
---
### Phase 2 — Frontend `public/homing.html`
Neue Seite (zugänglich von `index.html` via Link-Button, wie `calibration.html`).
**Sektionen (identisches Muster wie `index.html`):**
```
┌──────────────────────────────────────────────────────┐
│ AKTIONEN │
│ [📷 Foto & Homing berechnen] │
│ [✅ An Roboter senden] (disabled bis Ergebnis) │
│ Status-Badge: ○ Warte ● Läuft ✓ Fertig ✗ Fehler │
├──────────────────────────────────────────────────────┤
│ AUSGABE / LOG │
│ Schritt-für-Schritt Log aller Scripts (SSE-Stream) │
│ Fortschritt: ──── Schritt 3/6 ──── │
├──────────────────────────────────────────────────────┤
│ ANALYSIS & REASONING │
│ Zwischenergebnisse je Script als JSON │
│ { camera_reprojection_px, arm1_std_deg, … } │
├──────────────────┬───────────────────────────────────┤
│ RESULT RAW JSON │ RESULT TREE VIEW │
│ { │ x (Slider): 180.0 mm │
│ "x": 180.0, │ y (Arm1): +23.4° │
│ "y": 23.4, │ z (Ellbow): -12.1° │
│ ... │ a (Arm2): +5.0° │
│ } │ b (Hand): 0.0° │
│ │ c (Palm): 0.0° │
│ │ e (Greifer): 0.0 mm │
├──────────────────┴───────────────────────────────────┤
│ SNAPSHOT CSV (Marker-Tabelle) │
│ ID │ Link │ x mm │ y mm │ z mm │ Residual │ │
│ 218 │ Arm2 │ 229.1 │ 118.5 │ 48.3 │ 2.1 mm │ │
├──────────────────────────────────────────────────────┤
│ SNAPSHOTS (annotierte Kamerabilder) │
│ [cam0] [cam1] [cam2] │
└──────────────────────────────────────────────────────┘
```
**Schlüssel-Implementierungsdetails:**
## robot.json — Ladestrategie
**Aktuell:** Lokale Datei
```javascript
// homing.js (client)
// SSE-Stream vom Backend empfangen
async function runHoming() {
const response = await fetch('/api/homing/run', { method: 'POST' });
await readSseStream(response, appendLog, (evt) => {
if (evt.type === 'step') { updateProgress(evt); }
if (evt.type === 'analysis') { showAnalysis(evt.data); }
if (evt.type === 'done') {
showResult(evt.state);
enableSendButton(evt.state);
}
});
}
// Ergebnis an Roboter senden
async function sendToRobot(state) {
await fetch('/api/homing/send-state', {
method: 'POST',
body: JSON.stringify({ state }),
});
}
ROBOT_JSON = process.env.ROBOT_JSON || 'scripts/robot_1781069752019.json'
```
---
### Phase 3 — robot.json via Driver-API
**Voraussetzung:** ROBOT_URL ist konfiguriert und der Driver hat `GET /api/robot/config`.
**Implementierung in `server.js`:**
**Geplant** (wenn Driver `GET ROBOT_URL/api/robot/config` implementiert):
```javascript
// Beim Start oder on-demand: robot.json vom Driver laden
async function fetchRobotConfig() {
if (!ROBOT_URL) return; // lokale Datei reicht
async function loadRobotConfig() {
if (ROBOT_URL) {
const res = await fetch(new URL('/api/robot/config', ROBOT_URL));
if (!res.ok) return; // Fallback auf lokale Datei
const data = await res.json();
// Temporär in data/robot/robot_live.json cachen
await fs.writeFile(ROBOT_JSON_LIVE, JSON.stringify(data, null, 2));
return res.json();
}
return JSON.parse(await fs.readFile(ROBOT_JSON, 'utf8'));
}
```
**Auswirkung:** Nur `ROBOT_JSON` Variable ändern — alle Scripts bekommen
automatisch die aktuelle Konfiguration.
Auswirkung: nur `ROBOT_JSON`-Variable ändern — alle Scripts bekommen automatisch
die aktuelle Konfiguration.
---
## To-Do / Fortschritt
## Offene Punkte
### Voraussetzungen (Kalibrierung abschliessen)
- [x] Kamera-Intrinsik (NPZ) kalibriert
- [x] Board-Marker-Positionen kalibriert
- [x] X-Achsen-Richtung kalibriert
- [x] Arm1 Joint-Origin Y/Z — Button in `calibration_arm.html` ausführbar
- [ ] **Arm-Marker eintragen** (Nutzer): `links.Arm1.markers`, `links.Ellbow.markers`, `links.Arm2.markers`, `links.Hand.markers` in `scripts/robot_*.json`
- [ ] Arm1 Joint-Origin Y/Z Button klicken + in robot.json gespeichert
---
### Phase 0 — Refactor: Board-Pipeline auslagern
- [x] Funktion `runBoardPipeline(runDir, send)` in `server/server.js` extrahieren
- [x] Bestehende Logik aus `POST /api/board/run` in Funktion verschieben
- [x] `POST /api/board/run` ruft `runBoardPipeline()` auf (Verhalten unverändert)
- [ ] Test: Board-Run funktioniert weiterhin identisch
---
### Phase 1 — Backend `POST /api/homing/run`
- [x] Konstante `SCRIPT_4B` in `server/server.js` ergänzen
- [x] `server/homingOrchestrator.js` erstellen
- [x] `estimateXFromMarkers(arucoJsonPath)` implementieren
- [x] `runHoming({ robotJsonPath, homingDir, send, runScript, runBoardPipeline, SCRIPT_4B })` implementieren
- [x] `runBoardPipeline()` aufrufen → `aruco_marker_poses.json`
- [x] X-Position berechnen
- [x] 4b-Schleife: Arm1 → Ellbow → Arm2 → Hand (sequenziell, `--from-state`)
- [x] `send({ type: 'done', state: accumulated_state, runDir })`
- [x] Route `POST /api/homing/run` in `server/server.js` (SSE-Stream)
- [x] Route `POST /api/homing/send-state` in `server/server.js`
- [x] Route `GET /api/homing/run-data` (Bilder + State für Frontend)
- [x] `ROBOT_URL` Konstante in `server/server.js` ergänzen
---
### Phase 2 — Frontend `public/homing.html`
- [x] Datei `public/homing.html` erstellen
- [x] Sektion **Aktionen**: Button „Foto & Homing berechnen", Button „An Roboter senden"
- [x] Sektion **Ausgabe / Log**: SSE-Stream-Ausgabe, Schritt-Fortschritt
- [x] Sektion **Analysis & Reasoning**: Zwischenergebnisse je Script als JSON
- [x] Sektion **Result Raw JSON** + **Result Tree View**: `{ x, y, z, a, b, c, e }`
- [x] Sektion **Snapshot CSV**: Marker-Tabelle (ID, Link, x, y, z)
- [x] Sektion **Snapshots**: annotierte Kamerabilder
- [x] `public/homing.js` erstellen
- [x] `runHoming()`: POST + SSE-Stream lesen, Log befüllen, Fortschrittsbalken
- [x] `showResult(state)`: Tree View + Raw JSON befüllen, Send-Button aktivieren
- [x] `sendToRobot(state)`: POST `/api/homing/send-state`
- [x] `loadRunData(runDir)`: Debug-Bilder nach dem Run laden
- [x] Link-Button von `public/index.html` zu `homing.html` ergänzen
---
### Phase 3 — State an Roboter senden
- [ ] Route `POST /api/homing/send-state` in `server/server.js` registrieren
- [ ] Body: `{ state: { x, y, z, a, b, c, e } }`
- [ ] Weiterleitung an `ROBOT_URL/api/state` (analog zu `robotActions.js`)
- [ ] Fehler wenn `ROBOT_URL` nicht konfiguriert: JSON-Fehler zurückgeben
- [ ] Frontend: Fehler-Feedback wenn kein ROBOT_URL
---
### Phase 4 — robot.json via Driver-API *(nach allem anderen)*
- [ ] `loadRobotConfig()` Funktion in `server/server.js`
- [ ] Wenn `ROBOT_URL` gesetzt: `GET ROBOT_URL/api/robot/config` → temp-Datei cachen
- [ ] Fallback: lokale Datei (Verhalten unverändert)
- [ ] Homing und Board-Run nutzen `loadRobotConfig()` statt direkt `ROBOT_JSON`
- [ ] *(Voraussetzung: Driver implementiert `GET /api/robot/config`)*
---
## Status-Übersicht
| Bereich | Status |
|---------|--------|
| Python-Scripts (1, 2, 3b, 4b) | ✅ vorhanden |
| Kalibrierung (Kamera, Board, X-Achse) | ✅ fertig |
| Arm1 Joint-Origin Button | ✅ ausführbar |
| Arm-Marker in robot.json | 🔶 Nutzer |
| Phase 0 runBoardPipeline() | ✅ fertig |
| Phase 1 /api/homing/run | ✅ fertig |
| Phase 2 homing.html | ✅ fertig |
| Phase 3 /api/homing/send-state | ✅ Route implementiert |
| Phase 4 robot.json via Driver-API | ⏳ später |
- [ ] **Arm-Marker eintragen** (Nutzer): `links.Arm1/Ellbow/Arm2/Hand.markers` in `robot.json`
- [ ] **Erstmals testen**: Homing-Run mit echtem Roboter und eingetragenen Markern durchführen
- [ ] **X-Schätzung verfeinern** (optional): `estimateXFromMarkers()` könnte gelenk-spezifischere Logik nutzen statt einfachem Mittelwert
- [ ] **robot.json via Driver-API** (optional): wenn Driver `GET ROBOT_URL/api/robot/config` bereitstellt

303
doc/Kalibrierung.md Normal file
View File

@@ -0,0 +1,303 @@
# Kalibrierung appRobotHoming
> Stand: 2026-06-14
> Einmaliger Vorgang — nur nach mechanischen Änderungen wiederholen.
> Jede Stufe baut auf der vorherigen auf.
---
## Übersicht
```
[1] Camera NPZ → [2] Board → [3] X-Achse → [4] Arm1 Y-Achse
```
| Schritt | UI-Tab | Ergebnis | Status |
|---------|--------|----------|--------|
| [1] Camera NPZ | Camera NPZ | `data/calibration/{session}/{cam}_calibration.npz` | ✅ |
| [2] Board | Board | `links.Board.markers[].position` in `robot.json` | ✅ |
| [3] X-Achse | Robot X Axis | Alle Marker-Positionen in `robot.json` rotiert | ✅ |
| [4] Arm1 Y-Achse | Arm1 Y | `links.Arm1.jointToParent.origin[1,2]` in `robot.json` | ✅ |
| Arm-Marker eintragen | — | `links.Arm1/Ellbow/Arm2/Hand.markers` | 🔶 Nutzer |
---
## [1] Camera NPZ — Kameraparameter
**Ziel:** Intrinsische Parameter (Brennweite, Verzerrungskoeffizienten, Kameramatrix)
für jede Kamera als `.npz`-Datei speichern.
**Ablauf:**
1. ChArUco-Board aus verschiedenen Winkeln fotografieren (mehrere Posen)
2. OpenCV `calibrateCamera()` berechnet Kameraparameter
3. Ergebnis als `.npz` speichern und an Webcam-Service übertragen
**Speichert:** `data/calibration/{session}/{cam}_calibration.npz`
**Wird verwendet von:** Script `1_detect_aruco_observations.py` beim Board-Run und Homing
(Argument `-npz`).
---
## [2] Board — Referenz-Marker-Positionen
**Ziel:** Absolute 3D-Positionen aller Board-ArUco-Marker im Welt-Koordinatensystem
bestimmen und in `robot.json` speichern.
**Ablauf:**
1. Foto mit allen Kameras (Snapshot)
2. Script `1_detect_aruco_observations.py``{cam}_aruco_detection.json`
3. Script `2_estimate_camera_from_observations.py``{cam}_camera_pose.json`
4. Script `3b_corner_marker_poses.py``aruco_marker_poses.json` (Triangulierung)
5. Positionen in `robot.json` unter `links.Board.markers` eintragen / bestätigen
**Aktionen im Board-Tab:**
- **Board erkennen**: startet den vollständigen Foto+Script-Durchlauf (SSE-Stream)
- **Marker zuordnen** (Z-Bereich, Set, Link): Marker manuell kategorisieren
- **Sets justieren** (Kabsch 2D+Z): Zwei Marker-Sets aufeinander ausrichten
- **Marker ID zuordnen / entfernen**: einzelne Marker-Korrekturen
**Speichert:** `links.Board.markers` in `robot.json`
---
## [3] X-Achse — Schieberichtung des Sliders
**Ziel:** X-Achse des Roboters (Slider-Richtung `Base → Arm1`) im Board-Koordinatensystem
ausrichten, sodass `[1, 0, 0]` der realen Bewegungsrichtung entspricht.
**Ablauf:**
1. Roboter auf Position A fahren → Board erkennen
2. Roboter auf Position B fahren (mind. 20 mm Unterschied) → Board erkennen
3. Board-Viewer berechnet den Richtungsvektor der Markerbewegungen
4. „X-Achse übernehmen": alle Marker-Positionen in `robot.json` werden rotiert,
sodass die gemessene Richtung zur neuen X-Achse `[1, 0, 0]` wird
**Implementierung:** `editRobot.js``adoptXAxis()` — rotiert alle `links.*.markers[].position`
um den A0-Schwerpunkt als Ursprung.
**Speichert:** alle `links.*.markers[].position` in `robot.json` (rotiert)
---
## [4] Arm1 — Y-Rotationsachse (Schultergelenk)
**Ziel:** Lage und Richtung der Rotationsachse zwischen `Base` und `Arm1` bestimmen
und als `jointToParent.origin` in `robot.json` speichern.
**Ablauf:**
1. Board erkennen mit Arm in **Position A**
2. Arm1 um ≥ 15° drehen
3. Board erkennen (**Position B**)
4. Arm1 nochmals ≥ 15° drehen
5. Board erkennen (**Position C**)
6. Board-Viewer berechnet automatisch die Rotationsachse (magenta Linie im 3D-Viewer)
7. Aktion **„Joint-Origin Y/Z übernehmen"**: speichert Y/Z des Achspunkts in `robot.json`
**Optional — Aktion „Fixe Marker → Link Base":**
Marker mit Bewegung < 10 mm über alle drei Positionen sind physisch am Basis-Körper
befestigt. Diese werden in `links.Base.markers` eingetragen.
**Speichert:**
- `links.Arm1.jointToParent.origin[1]` (Y) und `[2]` (Z) in `robot.json`
- Optional: `links.Base.markers` ergänzt
---
### Mathematik: Bestimmung der Rotationsachse
Bei einer Rotation um die Y-Achse bewegt sich jeder erkannte Marker auf einem
**Kreisbogen** im 3D-Raum. Aus den beobachteten Marker-Positionen zu mehreren
Zeitstempeln lassen sich Lage und Richtung der Rotationsachse berechnen.
#### Wie viele Beobachtungen sind nötig?
Entscheidend ist nicht „2 oder 3 Positionen" allein, sondern die Kombination aus
Anzahl Marker und Anzahl Positionen:
| Beobachtungen | Bestimmbar? | Begründung |
|---|---|---|
| 1 Marker, 2 Positionen | **Nein** | Achse liegt *irgendwo* in der Mittelsenkrechten-Ebene der Sehne; Richtung und Lage bleiben unterbestimmt |
| **2 Marker, je 2 Positionen** | **Ja** | **Verfahren A**: Richtung aus d₁ × d₂, Lage aus Schnitt zweier Mittelsenkrechten-Ebenen |
| 1 Marker, 3 Positionen | **Ja** | **Verfahren B**: drei Punkte definieren einen eindeutigen Umkreis → eindeutige Achse |
| N Marker, je ≥ 2 bzw. ≥ 3 Positionen | Ja, überbestimmt | Least-Squares, robust gegenüber Messrauschen, erlaubt Fehlerabschätzung |
Die häufige Kurzbehauptung „zwei Positionen reichen nicht" gilt nur für **einen
einzigen** Marker. Sobald **zwei** (nicht-entartete) Marker vorliegen, genügen
zwei Positionen — siehe Verfahren A.
> Die aktuelle Implementierung (`public/yAxisCompute.js`) verwendet **Verfahren B**.
#### Verfahren A — Zwei Marker, je zwei Positionen
Gegeben: M₁ₐ, M₁ᵦ (Marker 1 zu Zeit a, b) und M₂ₐ, M₂ᵦ (Marker 2 zu Zeit a, b).
Beide Marker sitzen am selben starren Körper, der zwischen a und b um die gesuchte
Achse rotiert. Verschiebungsvektoren (Sehnen der Kreisbögen):
```
d₁ = M₁ᵦ M₁ₐ
d₂ = M₂ᵦ M₂ₐ
```
**Schritt 1 Achsenrichtung.** Jeder Punkt bewegt sich in einer Ebene **senkrecht**
zur Achse; die Sehne liegt in dieser Ebene, also `d₁ ⊥ n` und `d₂ ⊥ n`:
```
n = (d₁ × d₂) / |d₁ × d₂| ← Einheitsvektor entlang der Rotationsachse
```
**Schritt 2 Achsenlage.** Der Kreismittelpunkt eines Markers ist von Start- und
Endposition gleich weit entfernt, liegt also in der **Mittelsenkrechten-Ebene** der
Sehne. Da zusätzlich `n ⊥ dᵢ`, liegt die **gesamte Achse** in dieser Ebene.
```
Ebene 1: d₁ · (x P₁) = 0, P₁ = (M₁ₐ + M₁ᵦ) / 2
Ebene 2: d₂ · (x P₂) = 0, P₂ = (M₂ₐ + M₂ᵦ) / 2
```
Die Achse ist die **Schnittgerade** beider Ebenen mit Richtung `n`. Ein Punkt A auf
ihr (Ansatz A = α·d₁ + β·d₂, Komponente entlang n zu 0 gesetzt):
```
c₁ = d₁ · P₁, c₂ = d₂ · P₂
D = |d₁|²·|d₂|² (d₁ · d₂)² (= |d₁ × d₂|²)
α = (c₁·|d₂|² c₂·(d₁ · d₂)) / D
β = (c₂·|d₁|² c₁·(d₁ · d₂)) / D
A = α·d₁ + β·d₂ ← Referenzpunkt auf der Achse
```
Ergebnis: `r(t) = A + t·n`.
**Entartung:** `D = 0``d₁ ∥ d₂` ⟺ beide Marker liegen **mit der Achse in einer
gemeinsamen Ebene**. Dann sind die Mittelsenkrechten-Ebenen parallel und schneiden
sich nicht eindeutig. Erkennung über `|d₁ × d₂| / (|d₁|·|d₂|) ≈ 0`; auch ein Marker
direkt auf der Achse liefert `dᵢ ≈ 0`. → anderen Marker wählen oder Verfahren B.
#### Verfahren B — Ein Marker, drei Positionen *(implementiert)*
Gegeben P₁, P₂, P₃ desselben Markers zu drei Drehwinkeln. Die drei Punkte liegen
auf einem Kreis, dessen Ebene senkrecht zur Achse steht.
**Schritt 1 — Achsenrichtung** (Normalenvektor der Kreisebene):
```
v₁ = P₂ P₁
v₂ = P₃ P₁
n = normalize(v₁ × v₂)
```
**Schritt 2 — Umkreismittelpunkt** (Punkt auf der Achse), über baryzentrische Gewichte:
```
a² = |P₂ P₃|², b² = |P₁ P₃|², c² = |P₁ P₂|²
w₁ = a²·(b² + c² a²)
w₂ = b²·(a² + c² b²)
w₃ = c²·(a² + b² c²)
C = (w₁·P₁ + w₂·P₂ + w₃·P₃) / (w₁ + w₂ + w₃)
```
Ergebnis: `r(t) = C + t·n`. **Entartung:** P₁,P₂,P₃ kollinear (zu kleiner Drehwinkel)
`|v₁ × v₂| ≈ 0`, Umkreis numerisch schlecht bestimmt.
#### Kombination über N Marker (Least Squares)
Beide Verfahren lassen sich über mehrere Marker mitteln — robuster und mit
Fehlerabschätzung.
**Achsenrichtung gemeinsam.** Aus `dᵢ ⊥ n` (bzw. Ebenen-Normalen) minimiert die beste
Richtung `Σ (dᵢ · n)²` unter `|n| = 1`:
```
M = Σ dᵢ · dᵢᵀ (3×3-Matrix)
n = Eigenvektor von M zum kleinsten Eigenwert
```
(Verallgemeinert das Kreuzprodukt. Verfahren B alternativ: alle Normalen nᵢ aufs
gleiche Halbraum ausrichten und mitteln.)
**Achsenlage gemeinsam.** Für Verfahren B (Umkreismittelpunkte Cᵢ) ergibt sich der
einfache Schwerpunkt:
```
axisDir = normalize( mean(nᵢ) ) ← Vorzeichen vorher angleichen
axisPoint = mean(Cᵢ) ← Schwerpunkt der Umkreismittelpunkte
```
**Residuum pro Marker** (Verfahren B, Fehler- / Ausreißer-Erkennung):
```
εᵢ = |(Cᵢ C̄) ((Cᵢ C̄)·n̄)·n̄|
```
Große εᵢ deuten auf einen fehlerhaften Marker oder eine nicht-rotatorische
Bewegungskomponente hin.
Die Y/Z-Koordinaten von `axisPoint` geben an, wo die Rotationsachse durch die
YZ-Ebene geht — das ist der gesuchte `jointToParent.origin`.
#### Genauigkeit & Verfahrenswahl
Beide Verfahren sind im **rauschfreien** Fall exakt; die Unterschiede liegen in
Robustheit und Aufwand:
| Kriterium | Verfahren A (2 Marker, 2 Bilder) | Verfahren B (1 Marker, 3 Bilder) |
|---|---|---|
| Mindest-Aufnahmen | 2 Bilder | 3 Bilder |
| Mindest-Marker | 2 (gut separiert) | 1 |
| Eigenständige Fehler-Schätzung | nur über mehrere Marker | pro Marker (Residuum εᵢ) |
| Kritische Entartung | d₁ ∥ d₂ (Marker koplanar mit Achse) | P₁,P₂,P₃ kollinear (kleiner Winkel) |
| Empfindlichkeit | hoch, wenn Sehnen fast parallel oder Drehwinkel klein | hoch, wenn Drehwinkel klein (Bogen fast gerade) |
**Beide** brauchen einen ausreichend großen Drehwinkel: kleine Winkel liefern kurze
Sehnen bzw. fast gerade Bögen, in denen das Messrauschen dominiert.
- **Verfahren A** ist schneller (zwei statt drei Aufnahmen), sinnvoll bei ≥ 2 gut
getrennten Markern.
- **Verfahren B** ist robuster für die **Fehlerrechnung**: jeder Marker liefert
unabhängig eine vollständige Achsen-Schätzung, das Residuum εᵢ erlaubt
Ausreißer-Erkennung, keine Entartung zwischen Markern. Für belastbare Genauigkeit
daher vorzuziehen — und der Grund, weshalb die Implementierung Verfahren B nutzt.
**Praktische Werte (beide Verfahren):**
- Drehwinkel zwischen den Positionen ≥ 15° (besser ≥ 30°), sonst numerisch instabil.
- 34 Marker mit guter räumlicher Verteilung um die Achse.
- Die Rotationswinkel müssen **nicht** bekannt sein nur die 3D-Koordinaten.
**Marker-Filter (`min_movement_mm = 10`):**
Marker, die sich zwischen A/B/C weniger als 10 mm bewegen, sind nicht am rotierenden
Teil befestigt und werden aus der Achsenberechnung ausgeschlossen. Ihre Position A
wird für die optionale Base-Link-Zuweisung gespeichert.
---
### Implementierungsnachweis
Die Implementierung in `public/yAxisCompute.js` setzt **Verfahren B** um:
| Schritt | Implementierung | Verifikation |
|---------|----------------|--------------|
| Normalenvektor | `yAxisCompute.js` Z. 5161 | Kreuzprodukt v₁×v₂, normiert ✓ |
| Umkreismittelpunkt | `yAxisCompute.js` Z. 6376 | Baryzentrische Gewichte ✓ |
| Mittelung Normalen | `yAxisCompute.js` Z. 153162 | Vorzeichen-Alignment + mean + renormieren ✓ |
| Mittelung Achspunkte | `yAxisCompute.js` Z. 164167 | Schwerpunkt der Cᵢ ✓ |
| Origin speichern | `calibration.js``setOriginBtn` sendet `axisPoint[1,2]` | `editRobot.js` schreibt `origin[1,2]` ✓ |
**Ergebnis: Implementierung entspricht der beschriebenen Mathematik (Verfahren B) vollständig.**
---
## Dateistruktur
```
data/
calibration/
{session}/
meta.json
{cam}_calibration.npz ← Kameraparameter
{cam}_aruco_detection.json ← Marker-Erkennung
{cam}_camera_pose.json ← Kamera-Pose
scripts/
robot_1781069752019.json ← robot.json (Haupt-Konfiguration)
links.Board.markers ← Board-Marker-Positionen
links.Base.markers ← fixe Basis-Marker (nach Y-Kalibrierung)
links.Arm1.jointToParent.origin ← Schultergelenk-Position (nach Y-Kalibrierung)
links.Arm1.markers ← Arm1-Marker (Nutzer eingetragen)
...
```

View File

@@ -23,7 +23,7 @@
title="Erst Homing ausführen">
✅ An Roboter senden
</button>
<span id="homing-status" class="status-badge open">○ Warte</span>
<span id="homing-status" class="status-badge idle">○ Warte</span>
</div>
<!-- Fortschrittsbalken -->

View File

@@ -237,8 +237,9 @@ textarea {
background: #1e293b;
color: #94a3b8;
}
.status-badge.idle { color: #60a5fa; }
.status-badge.wip { color: #93c5fd; }
.status-badge.open { color: #f59e0b; }
.status-badge.wip { color: #60a5fa; }
.status-badge.done { color: #34d399; background: #064e3b; }
/* ===== HOMING FORTSCHRITTSBALKEN ===== */

View File

@@ -60,7 +60,7 @@
const n = cross.map(c => c / crossLen);
// Baryzentrische Gewichte → Umkreismittelpunkt (doc/04_y_achse.md)
// Baryzentrische Gewichte → Umkreismittelpunkt (doc/Kalibrierung.md → [4] Arm1, Verfahren B)
const a2 = dist2(P2, P3), b2 = dist2(P1, P3), c2 = dist2(P1, P2);
const w1 = a2 * (b2 + c2 - a2);
const w2 = b2 * (a2 + c2 - b2);

View File

@@ -10,7 +10,7 @@ Gegeben drei Messungen (Pos A, B, C) in denen dieselben fremd-Marker
- Referenzpunkt auf der Achse (axisPoint_mm)
- Residuen pro Punkt (Qualitätsmass)
Methode (doc/04_y_achse.md):
Methode (doc/Kalibrierung.md → [4] Arm1, Verfahren B):
Jeder Marker bewegt sich auf einem Kreisbogen. Die Ebene des Kreises
steht senkrecht zur Rotationsachse → Normalenvektor = Achsenrichtung.
Der Umkreismittelpunkt des Dreiecks P1-P2-P3 liegt auf der Achse.