Inhaltsverzeichnis

Kurs und Lage im dreidimensionalen Raum

Am Boden reicht eine Zahl: ein Kurs von 0 bis 359 Grad. Im Raum gibt es keine ausgezeichnete Ebene, an der man diese Zahl aufhängen könnte — dort braucht es zwei getrennte Größen:

Größe Frage Darstellung
Richtung Wohin fliegt das Fahrzeug? Einheitsvektor Vec3 im Bezugsrahmen
Lage (Attitude) Wie ist es dabei gedreht? Quaternion Quat

Beides ist verschieden: Ein Raumfahrzeug kann rückwärts fliegen und dabei vorwärts blicken. Wer beides in einer Größe führt, verliert genau diese Unterscheidung.

std.astro (vorgesehen: Bahnmechanik, Lage, Guidance) · std.quat · std.matrix · Mathematik-Units · Aerospace & Safety


1. Zuerst den Bezugsrahmen festlegen

Ein Vektor ohne Bezugsrahmen ist bedeutungslos. Üblich sind:

Rahmen Ursprung Achsen Wofür
ECI (inertial) Erdmittelpunkt x zum Frühlingspunkt, z zum Nordpol Bahnmechanik — dreht sich nicht mit der Erde
ECEF Erdmittelpunkt mitrotierend Positionen über Grund, GNSS
LVLH Fahrzeug z nach unten (Nadir), x in Flugrichtung Lageregelung im Orbit
Body Fahrzeug fest am Rumpf Sensoren, Triebwerke

Der Rahmen gehört in den Namen, nicht in einen Kommentar: pos_eci, nase_body. Eine Verwechslung zwischen ECI und ECEF ist kein Rundungsfehler, sondern ein Kurs, der sich pro Stunde um 15 Grad verdreht.

 
Der Compiler kann die Trennung heute nicht erzwingen — die Namensgebung trägt die Last allein.

* dim/utype prüfen die Dimension (Länge gegen Zeit), nicht den Rahmen: ECI und ECEF sind beide Längen.
* Ein eigener Struct je Rahmen hilft ebenfalls nicht: Struct-Typen werden bei Zuweisung und Argumentübergabe gar nicht geprüft (#1886). var p: PosEci := vel; übersetzt anstandslos, obwohl VelEci etwas anderes bedeutet — und selbst nimmA(c) mit einem völlig anders geformten Struct läuft durch, mit dem Zeiger als Zahl gelesen. Nachgemessen mit 1.1.14A.

Solange das so ist: Rahmen in den Namen (pos_eci, nase_body), Umrechnungen in eine Funktion je Rahmenpaar bündeln, und diese Funktionen prüfen (Testfall mit bekanntem Ergebnis). Sobald #1886 behoben ist, wird der eigene Struct je Rahmen zur tragenden Lösung.

2. Warum keine Euler-Winkel

Die naheliegende Idee — drei Winkel für Roll, Nick und Gier, jeder als wraps 0..359 — bricht an einer Stelle zusammen, die im Raum regelmäßig vorkommt: bei ±90 Grad Nickwinkel fallen zwei Drehachsen zusammen (Gimbal Lock). Gemessen mit lyxc 1.1.14A gegen std.quat:

import std.quat;
import std.io;

fn zeige(name: pchar, q: Quat): void {
    var g: f64 := 180.0 / 3.14159265358979;
    Print(name); Print(" roll="); Print(FloatToStr(QuatRoll(q)*g, 2));
    Print(" pitch="); Print(FloatToStr(QuatPitch(q)*g, 2));
    Print(" yaw="); PrintLn(FloatToStr(QuatYaw(q)*g, 2));
}

fn main(): int64 {
    var r: f64 := 3.14159265358979 / 180.0;
    zeige("(30,45,60) ->", QuatFromEuler(30.0*r, 45.0*r, 60.0*r));
    zeige("(30,89,0)  ->", QuatFromEuler(30.0*r, 89.0*r,  0.0*r));
    zeige("(30,90,0)  ->", QuatFromEuler(30.0*r, 90.0*r,  0.0*r));
    return 0;
}

(30,45,60) -> roll=30.00 pitch=45.00 yaw=60.00
(30,89,0)  -> roll=30.00 pitch=89.00 yaw=0.00
(30,90,0)  -> roll=31.14 pitch=90.00 yaw=2.05

Bis 89 Grad stimmt die Rückrechnung auf die Nachkommastelle. Bei genau 90 Grad kommen aus (30, 90, 0) plötzlich (31.14, 90, 2.05) heraus — nicht weil die Bibliothek rechnet, sondern weil die Zerlegung dort nicht mehr eindeutig ist: Roll und Gier drehen um dieselbe Achse, unendlich viele Winkelpaare beschreiben dieselbe Lage.

Für ein Flugzeug ist das eine Randlage. Für ein Raumfahrzeug ist „Nase senkrecht zur Bahnebene„ ein Betriebszustand.

Euler-Winkel deshalb nur an der Oberfläche — zum Anzeigen, im Protokoll, als Eingabe. Gerechnet wird mit Quaternionen.


3. Lage als Quaternion, Kurs als Vektor

import std.quat;
import std.matrix;
import std.math;
import std.io;

// Bezugsrahmen: ECI, x = Fruehlingspunkt, z = Nordpol, rechtshaendig.

fn richtung(q: Quat): Vec3 {
    // Wohin zeigt die Nase? Koerpereigene x-Achse in den Bezugsrahmen gedreht.
    var r: Quat := QuatRotateVec3(q, 1.0, 0.0, 0.0);
    return Vec3New(r.x, r.y, r.z);
}

fn winkelZwischen(a: Vec3, b: Vec3): f64 {
    var c: f64 := Vec3Dot(Vec3Normalize(a), Vec3Normalize(b));
    if (c > 1.0)  { c := 1.0; }              // Rundung abfangen: acos(1.0000001) ist NaN
    if (c < -1.0) { c := 0.0 - 1.0; }
    return AcosF64(c) * 180.0 / 3.14159265358979;
}

fn zeige(name: pchar, v: Vec3): void {
    Print(name); Print(" ("); Print(FloatToStr(v.x, 3));
    Print(", "); Print(FloatToStr(v.y, 3));
    Print(", "); Print(FloatToStr(v.z, 3)); PrintLn(")");
}

fn main(): int64 {
    var lage: Quat := QuatIdentity();
    zeige("Ist-Richtung :", richtung(lage));

    var soll: Vec3 := Vec3Normalize(Vec3New(0.866, 0.5, 0.174));
    zeige("Soll-Richtung:", soll);
    Print("Winkelabstand: ");
    PrintLn(FloatToStr(winkelZwischen(richtung(lage), soll), 2));

    // Kuerzeste Drehung dorthin, in fuenf Schritten hinuebergedreht
    var ziel: Quat := QuatFromTo(1.0, 0.0, 0.0, soll.x, soll.y, soll.z);
    var i: int64 := 1;
    while (i <= 5) {
        var t: f64 := (i as f64) / 5.0;
        var zwischen: Quat := QuatSlerp(lage, ziel, t);
        Print("  Schritt "); Print(IntToStr(i)); Print(": Restwinkel ");
        PrintLn(FloatToStr(winkelZwischen(richtung(zwischen), soll), 2));
        i := i + 1;
    }
    return 0;
}

Ist-Richtung : (1.000, 0.000, 0.000)
Soll-Richtung: (0.853, 0.493, 0.171)
Winkelabstand: 31.44
  Schritt 1: Restwinkel 25.15
  Schritt 2: Restwinkel 18.86
  Schritt 3: Restwinkel 12.58
  Schritt 4: Restwinkel 6.29
  Schritt 5: Restwinkel 0.00

Der Restwinkel fällt gleichmäßig — das ist die Eigenschaft, für die es QuatSlerp gibt: die Drehung läuft auf dem kürzesten Bogen und mit gleichbleibender Winkelgeschwindigkeit. QuatNlerp ist billiger, aber ungleichmäßig; für eine Lageregelung ist der Unterschied sichtbar.


4. Die Werkzeuge im Überblick

Aufgabe Funktion
Lage aus Achse und Winkel QuatFromAxisAngle(ax, ay, az, winkel)
Kürzeste Drehung von einer Richtung zur anderen QuatFromTo(ax,ay,az, bx,by,bz)
Vektor mit der Lage drehen QuatRotateVec3(q, x, y, z)
Zwei Lagen gleichmäßig überblenden QuatSlerp(a, b, t)
Lagen verketten (erst a, dann b) QuatMul(b, a)Reihenfolge beachten
Drehung rückgängig QuatConj(q) bei Einheitsquaternion, sonst QuatInverse(q)
Nur zum Anzeigen QuatRoll, QuatPitch, QuatYaw
Sind zwei Lagen dieselbe Drehung? QuatSameRotation(a, b, eps)
Richtung, Abstand, Kreuzprodukt Vec3Normalize, Vec3Dot, Vec3Cross aus std.matrix
 
q und −q beschreiben dieselbe Drehung. Ein Vergleich mit QuatApproxEq meldet sie als verschieden; dafür gibt es QuatSameRotation. Das ist keine Eigenart der Bibliothek, sondern die doppelte Überdeckung der Drehgruppe — dieselbe Lage hat zwei Zahlendarstellungen.

4b. Den Zustand als eigenen Datentyp

Richtung und Lage einzeln herumzureichen ist fehleranfällig — beide gehören zusammen und zu einem Zeitpunkt. Ein Zustandsvektor bündelt das:

import std.quat;
import std.matrix;
import std.io;

// Bezugsrahmen im Namen, nicht im Kommentar (siehe Abschnitt 1)
type PosEci = struct { v: Vec3; }
type VelEci = struct { v: Vec3; }

type Zustand = struct {
    pos:   PosEci;      // Meter, ECI
    vel:   VelEci;      // Meter je Sekunde, ECI
    lage:  Quat;        // Body -> ECI, Einheitsquaternion
    t_ns:  int64;       // Gueltigkeitszeitpunkt, monoton

    // Wohin zeigt die Nase? Koerpereigene x-Achse in den Rahmen gedreht.
    fn Richtung(): Vec3 {
        var r: Quat := QuatRotateVec3(self.lage, 1.0, 0.0, 0.0);
        return Vec3New(r.x, r.y, r.z);
    }

    // Selbstprüfung: eine Lage, die keine Einheitslaenge hat, ist keine Drehung.
    fn LageGueltig(): bool { return QuatIsUnit(self.lage, 0.000001); }
}

fn main(): int64 {
    var z: Zustand;
    z.pos.v := Vec3New(7000000.0, 0.0, 0.0);
    z.vel.v := Vec3New(0.0, 7500.0, 0.0);
    z.lage  := QuatIdentity();
    z.t_ns  := 0;

    var r: Vec3 := z.Richtung();
    Print("Richtung x=");   PrintLn(FloatToStr(r.x, 3));
    Print("Lage gueltig="); PrintLn(IntToStr(BoolI(z.LageGueltig())));
    return 0;
}

fn BoolI(b: bool): int64 { if (b) { return 1; } return 0; }

Richtung x=1.000
Lage gueltig=1

Vier Entwurfsentscheidungen dahinter:

 
Struct-Typen werden inzwischen geprüft (#1886, nachgemessen mit lyxc 1.2.2B): var b: Q := a; mit zwei gleich aufgebauten, verschieden benannten Structs bricht mit sema error: Initialisierung: anderer benannter Typ ab, und dasselbe gilt beim Übergeben als Argument (Argument: anderer benannter Typ).

Für Bezugsrahmen hilft das trotzdem nur bedingt: wer pos_eci und pos_ecef als denselben Typ führt (etwa beide als AVec3), bekommt weiterhin keine Meldung. Die Trennung entsteht erst, wenn man je Rahmen einen eigenen benannten Typ anlegt — dann trägt sie der Compiler mit.

5. Was in sicherheitskritischem Code dazugehört


Letzte Aktualisierung: 2026-09-05 (#1886, nachgemessen mit lyxc 1.2.2B) — Kasten gezogen: Struct-Typen werden geprüft; die Rahmenverwechslung bleibt nur ungeprüft, solange beide denselben Typ tragen.

Vorherige letzte Aktualisierung: 2026-08-30 — Abschnitt „Den Zustand als eigenen Datentyp“ ergänzt; dabei gemessen, dass Struct-Typen nicht geprüft werden (#1886) — der Rat „eigener Struct je Bezugsrahmen„ ist entsprechend richtiggestellt.

Zuvor 2026-08-30 — Seite neu angelegt; alle Beispiele gegen lyxc 1.1.14A gebaut und ausgeführt, die Gimbal-Lock-Zahlen gemessen (nicht gerechnet).