Mond-3D-Darstellung auf konsistente IAU-Rotationsachse umstellen und Beleuchtung ueberarbeiten

Sub-Erd- und Sub-Sonnen-Punkt (Laenge/Breite) werden jetzt aus der
offiziellen IAU-Rotationsachse des Mondes (RotationAxis: Pol-RA/Dec und
Rotationswinkel W) berechnet statt aus der aelteren, nur naeherungsweisen
Meeus-Optiklibration - gegen die oeffentlichen NASA-Dial-a-Moon-Daten
exakt validiert (Laenge/Breite und Positionswinkel stimmen auf drei
Nachkommastellen).

- Sonnenrichtung im 3D-Rendering direkt aus der Geometrie abgeleitet
  (derselbe lokale Vektor wie bei der Krater-Platzierung, dann mit der
  Mesh-Rotation gedreht), statt aus der Beleuchtungs-Prozentzahl
  zurueckgerechnet - exakt statt angenaehert, konsistent mit Kratern.
- Mesh-Rotation (Kugel-Ausrichtung inkl. Textur) laeuft jetzt durch
  dieselbe Laengen-Konvention wie Krater und Sonne, statt eine eigene,
  inkonsistente Berechnung zu nutzen (behebt u. a. eine falsch
  abgeschnittene Darstellung von Mare Crisium nahe dem Rand).
- Ost/West-Vorzeichen der selenographischen Laenge korrigiert.
- Tone-Mapping (ACESFilmicToneMapping) ergaenzt, damit duenne Mondsicheln
  und Vollmond gleichzeitig vernuenftig belichtet dargestellt werden
  koennen - eine feste Lichtstaerke allein konnte das nicht leisten.
- Schatten-Bias erhoeht gegen Shadow-Acne bei sehr flachem Lichteinfall;
  Schatten-Map-Typ vereinheitlicht (Live-Ansicht und PNG-Export nutzten
  bisher unterschiedliche Werte).
- Alle Licht-Parameter als benannte Konstanten mit Erklaerung gesammelt
  und dabei doppelte, auseinanderlaufende Einstellungen (u. a.
  toneMappingExposure, ambientLight) bereinigt.
- Neues, einklappbares (aktuell ausgeblendetes) Testregler-Panel fuer
  alle Licht-Parameter samt "Werte speichern"-Button, um kuenftige
  Feinabstimmung ohne Codesuche zu ermoeglichen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 13:08:54 +02:00
co-authored by Claude Sonnet 5
parent 2396cac6db
commit efb2d8ed04
3 changed files with 356 additions and 61 deletions
+20
View File
@@ -2569,6 +2569,26 @@ body.moonphase-page.has-modal {
gap: 1rem;
}
/* Licht-Testregler (moonphase.php): als Flex-Kind von .moon-side erbt dieser
Block sonst "overflow: hidden" von .moon-side > * und wuerde bei geoeffnetem
<details> zusammengedrueckt, sodass untere Regler verdeckt sind. Eigener,
in sich scrollbarer Bereich behebt das, unabhaengig vom verfuegbaren Platz. */
#moonLightDebugPanel summary {
cursor: pointer;
}
#moonLightDebugPanel[open] {
overflow-y: auto;
max-height: 320px;
}
/* .moon-controls setzt display:flex und wuerde damit das hidden-Attribut
(Browser-Standard: display:none) ausser Kraft setzen - Autoren-CSS gewinnt
sonst immer gegen UA-Stile. Explizit erzwingen, solange [hidden] gesetzt ist. */
#moonLightDebugPanel[hidden] {
display: none;
}
.control-head {
display: flex;
align-items: center;
+253 -49
View File
@@ -225,7 +225,51 @@ try {
</div>
<details class="moon-controls" id="moonLightDebugPanel" hidden>
<summary class="control-label">Licht (Testregler)</summary>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightSunIntensity">Sonnenlicht</label>
<span class="control-value" id="lightSunIntensityValue">-</span>
</div>
<input type="range" id="lightSunIntensity" min="0" max="10" step="0.1">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightExposure">Belichtung</label>
<span class="control-value" id="lightExposureValue">-</span>
</div>
<input type="range" id="lightExposure" min="0.1" max="3" step="0.05">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightAmbient">Umgebungslicht</label>
<span class="control-value" id="lightAmbientValue">-</span>
</div>
<input type="range" id="lightAmbient" min="0" max="1" step="0.01">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightRim">Randlicht</label>
<span class="control-value" id="lightRimValue">-</span>
</div>
<input type="range" id="lightRim" min="0" max="0.2" step="0.005">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightShadowNormalBias">Schatten-Bias</label>
<span class="control-value" id="lightShadowNormalBiasValue">-</span>
</div>
<input type="range" id="lightShadowNormalBias" min="0" max="0.3" step="0.005">
</div>
<button type="button" class="date-shift-btn moon-action-btn" id="lightSaveValuesBtn">Werte speichern</button>
</details>
</div>
<div class="moon-visual">
@@ -339,6 +383,17 @@ const moonExportTransparentCheckbox = document.getElementById('moonExportTranspa
const moonExportStatus = document.getElementById('moonExportStatus');
const moonExportCancelBtn = document.getElementById('moonExportCancelBtn');
const moonExportConfirmBtn = document.getElementById('moonExportConfirmBtn');
const lightSunIntensity = document.getElementById('lightSunIntensity');
const lightSunIntensityValue = document.getElementById('lightSunIntensityValue');
const lightExposure = document.getElementById('lightExposure');
const lightExposureValue = document.getElementById('lightExposureValue');
const lightAmbient = document.getElementById('lightAmbient');
const lightAmbientValue = document.getElementById('lightAmbientValue');
const lightRim = document.getElementById('lightRim');
const lightRimValue = document.getElementById('lightRimValue');
const lightShadowNormalBias = document.getElementById('lightShadowNormalBias');
const lightShadowNormalBiasValue = document.getElementById('lightShadowNormalBiasValue');
const lightSaveValuesBtn = document.getElementById('lightSaveValuesBtn');
let moonSceneState = null;
let moonZoom = 1;
let moonPanX = 0;
@@ -379,6 +434,55 @@ const MOON_DISPLACEMENT_SCALE = 0.0095;
const MOON_DISPLACEMENT_BIAS = -0.1;
const MOON_BUMP_SCALE = 2.0;
const MOON_CAMERA_BASE_Z = 6.8;
// ---- Beleuchtung des 3D-Mondmodells ------------------------------------
// Alle Licht-Werte gesammelt an einer Stelle, damit man beim Justieren nicht
// im Code suchen muss. Reines JavaScript - nach einer Aenderung reicht ein
// Neuladen der Seite, kein Server-Neustart.
// Haupt-"Sonnenlicht" (gerichtetes Licht). Bestimmt, wie hell die beleuchtete
// Seite grundsaetzlich ist, gleichmaessig ueber alle Mondphasen hinweg.
// Hoeher = insgesamt heller (auch bei Vollmond, das dann eher ueberstrahlt).
let MOON_SUN_LIGHT_INTENSITY = 4.7;
// Wirkt wie eine Kamera-Belichtung, NACH der eigentlichen Lichtberechnung
// (Three.js ACESFilmicToneMapping). Im Gegensatz zu MOON_SUN_LIGHT_INTENSITY
// komprimiert sie helle Bereiche automatisch mit (ueberstrahlt also nicht
// gleich), waehrend sie dunkle/Streiflicht-Bereiche (duenne Sichel) sichtbarer
// macht. Faustregel: Wirkt eine duenne Sichel zu dunkel, aber Vollmond okay ->
// diesen Wert erhoehen statt MOON_SUN_LIGHT_INTENSITY. Gilt fuer Live-Ansicht
// UND PNG-Export gleichermassen (beide nutzen dieselbe Konstante).
let MOON_TONE_MAPPING_EXPOSURE = 1.15;
// Gleichmaessige Grundaufhellung der GESAMTEN Kugel, auch der unbeleuchteten
// Nachtseite, unabhaengig vom Lichtwinkel. Niedrig halten - sonst wirkt die
// Nachtseite grau statt schwarz und der Tag/Nacht-Kontrast (Kraterschatten)
// geht verloren.
const MOON_AMBIENT_LIGHT_COLOR = 0xffffff;
let MOON_AMBIENT_LIGHT_INTENSITY = 0.01;
// Schwaches zusaetzliches Licht von der Seite fuer einen dezenten Rand-/
// Tiefeneffekt auf der Nachtseite (kein echtes Erdlicht, rein optisch).
// Leicht blaeulich fuer einen kuehlen "Weltraum"-Farbton.
const MOON_RIM_LIGHT_COLOR = 0x89a8ff;
let MOON_RIM_LIGHT_INTENSITY = 0.095;
// Verhindert falsche Selbstverschattung ("Shadow Acne") der Krater-Shadow-Map
// bei sehr flachem Lichteinfall (duenne Sichel = fast ueberall Streiflicht).
// Die Tag/Nacht-Grenze selbst kommt schon aus der normalen Beleuchtungs-
// rechnung; die Shadow-Map ist nur fuer feine Krater-Schlagschatten noetig.
const MOON_SHADOW_BIAS = 0;
let MOON_SHADOW_NORMAL_BIAS = 0.005;
// Kantenschaerfe der Krater-Schlagschatten (nicht die Tag/Nacht-Grenze selbst -
// die kommt aus der normalen Beleuchtung und hat immer einen weichen, runden
// Verlauf, weil eine Kugel nie hart in Licht/Schatten uebergeht). Optionen von
// weich zu hart: THREE.PCFSoftShadowMap (weichste Kanten) < THREE.PCFShadowMap
// (Mittelweg) < THREE.BasicShadowMap (haerteste, scharfe Kanten, keine
// Weichzeichnung - kann bei feinen Kraterdetails leicht kantig/treppig wirken).
const MOON_SHADOW_MAP_TYPE = THREE.BasicShadowMap;
// -------------------------------------------------------------------------
const MOON_ZOOM_MIN = 0.2;
const MOON_ZOOM_MAX = 4.1;
const MOON_PAN_LIMIT = 1.8;
@@ -838,24 +942,29 @@ async function buildMoonSceneFromUrls(url1, url2) {
powerPreference: 'high-performance'
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
// Tone-Mapping statt reinem Clamping: komprimiert helle Bereiche (Vollmond),
// ohne dunkle/Streiflicht-Bereiche (duenne Sichel) zu verlieren - eine feste
// Lichtstaerke allein kann sonst nicht beides gleichzeitig darstellen.
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = MOON_TONE_MAPPING_EXPOSURE;
renderer.setClearColor(0x010102, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 3));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.shadowMap.type = MOON_SHADOW_MAP_TYPE;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100);
camera.position.set(0, 0, MOON_CAMERA_BASE_Z);
const ambientLight = new THREE.AmbientLight(0x1b2230, 0.004);
const ambientLight = new THREE.AmbientLight(MOON_AMBIENT_LIGHT_COLOR, MOON_AMBIENT_LIGHT_INTENSITY);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 2.9);
const sunLight = new THREE.DirectionalLight(0xffffff, MOON_SUN_LIGHT_INTENSITY);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 4096;
sunLight.shadow.mapSize.height = 4096;
sunLight.shadow.bias = -0.00015;
sunLight.shadow.normalBias = 0.02;
sunLight.shadow.bias = MOON_SHADOW_BIAS;
sunLight.shadow.normalBias = MOON_SHADOW_NORMAL_BIAS;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 20;
sunLight.shadow.camera.left = -3;
@@ -864,7 +973,7 @@ async function buildMoonSceneFromUrls(url1, url2) {
sunLight.shadow.camera.bottom = -3;
scene.add(sunLight);
const rimLight = new THREE.DirectionalLight(0x89a8ff, 0.015);
const rimLight = new THREE.DirectionalLight(MOON_RIM_LIGHT_COLOR, MOON_RIM_LIGHT_INTENSITY);
rimLight.position.set(-2.5, 1.2, 3.2);
scene.add(rimLight);
@@ -1003,44 +1112,34 @@ async function renderMoon3D(phaseData, libration) {
function applyMoon3DRenderState(state, phaseData, libration) {
state.moonMesh.rotation.order = 'ZYX';
state.moonMesh.rotation.y = MOON_TEXTURE_BASE_ROTATION_Y + toRadians(libration.longitude);
// Dieselbe Laengen-Konvention wie bei Kratern/Sonne (moonLocalDirectionFromLonLat)
// verwenden, statt libration.longitude direkt zu nehmen - sonst rotiert die
// Textur/Kugel selbst in einem anderen Bezugssystem als Krater und Sonne,
// obwohl alle von derselben (gegen NASA validierten) Sub-Erd-Laenge ausgehen.
state.moonMesh.rotation.y = MOON_TEXTURE_BASE_ROTATION_Y + toRadians(-normalizeSignedLongitude(libration.longitude));
state.moonMesh.rotation.x = toRadians(libration.latitude);
state.moonMesh.rotation.z = toRadians(libration.positionAngle);
const viewAlignedSunZ = phaseData.illumination * 2 - 1;
const northOnScreen = new THREE.Vector3(0, 1, 0).applyEuler(state.moonMesh.rotation).normalize();
const planeNorth = new THREE.Vector2(northOnScreen.x, northOnScreen.y);
const planeNorthLength = planeNorth.length();
const horizontalMagnitude = Math.sqrt(Math.max(0, 1 - viewAlignedSunZ * viewAlignedSunZ));
const subsolarNorthDot = Math.sin(toRadians(libration.subsolarLatitude));
// Sonnenrichtung direkt aus der Geometrie: derselbe lokale Vektor wie bei
// Kratern (selenographische Laenge/Breite -> mondfester Einheitsvektor),
// gedreht mit derselben Mesh-Rotation. Exakt statt aus der Beleuchtungs-
// Prozentzahl zurueckgerechnet, und dadurch garantiert konsistent mit
// Kraterlage und Textur. Haengt nur von state.moonMesh.rotation ab (Datum/
// Uhrzeit), nicht von Kamera-Pan - "wie von der Erde aus zu sehen" bleibt
// beim Verschieben also unveraendert; nur der freie Kamera-Modus rotiert
// die Kamera selbst und veraendert damit den Blickwinkel auf Mond+Licht.
const sunDirectionWorld = moonLocalDirectionFromLonLat(
libration.subsolarLongitude,
libration.subsolarLatitude,
craterLongitudeOffsetDeg,
craterLatitudeOffsetDeg
).applyEuler(state.moonMesh.rotation);
let xComponent;
let yComponent;
if (planeNorthLength < 1e-6 || horizontalMagnitude < 1e-6) {
xComponent = phaseData.waxing ? horizontalMagnitude : -horizontalMagnitude;
yComponent = 0;
} else {
const northUnit = planeNorth.normalize();
const eastUnit = new THREE.Vector2(-northUnit.y, northUnit.x);
const targetAlongNorth = THREE.MathUtils.clamp(
(subsolarNorthDot - northOnScreen.z * viewAlignedSunZ) / planeNorthLength,
-horizontalMagnitude,
horizontalMagnitude
);
const sideMagnitude = Math.sqrt(Math.max(0, horizontalMagnitude * horizontalMagnitude - targetAlongNorth * targetAlongNorth));
const sideSign = phaseData.waxing ? -1 : 1;
const screenVector =
northUnit.multiplyScalar(targetAlongNorth)
.add(eastUnit.multiplyScalar(sideSign * sideMagnitude));
xComponent = screenVector.x;
yComponent = screenVector.y;
}
state.sunLight.position.set(xComponent * 8, yComponent * 8, viewAlignedSunZ * 8);
state.ambientLight.intensity = 0.004;
state.rimLight.intensity = 0.006;
state.sunLight.position.copy(sunDirectionWorld.multiplyScalar(8));
// ambientLight/rimLight sind konstant (siehe MOON_AMBIENT_LIGHT_INTENSITY /
// MOON_RIM_LIGHT_INTENSITY oben) und werden schon bei der Szenen-Erstellung
// gesetzt - hier bewusst kein erneutes Ueberschreiben mehr, damit beide
// Stellen nicht auseinanderlaufen koennen.
}
function drawMoonTexture(ctx, cx, cy, radius) {
@@ -1191,10 +1290,12 @@ async function renderMoon3DToBlob(phaseData, libration, size, transparentBackgro
powerPreference: 'high-performance'
});
exportRenderer.outputColorSpace = THREE.SRGBColorSpace;
exportRenderer.toneMapping = THREE.ACESFilmicToneMapping;
exportRenderer.toneMappingExposure = MOON_TONE_MAPPING_EXPOSURE;
exportRenderer.setClearColor(0x010102, transparentBackground ? 0 : 1);
exportRenderer.setPixelRatio(1);
exportRenderer.shadowMap.enabled = true;
exportRenderer.shadowMap.type = THREE.PCFSoftShadowMap;
exportRenderer.shadowMap.type = MOON_SHADOW_MAP_TYPE;
exportRenderer.setSize(size, size, false);
const previousAspect = state.camera.aspect;
@@ -1318,19 +1419,32 @@ function normalizeSignedLongitude(degrees) {
return value;
}
function craterToMoonLocalVector(crater) {
const latRad = toRadians(Number(crater.lat || 0) + craterLatitudeOffsetDeg);
const lonRad = toRadians(-normalizeSignedLongitude(Number(crater.lon || 0) + craterLongitudeOffsetDeg));
// Einheitsvektor im mondfesten (lokalen, noch unrotierten) Koordinatensystem
// aus selenographischer Laenge/Breite (Ost-positiv, wie von api.py geliefert).
// Gemeinsame Grundlage fuer Krater UND Sonnenrichtung, damit beide nach
// Anwendung derselben state.moonMesh-Rotation garantiert zueinander passen.
function moonLocalDirectionFromLonLat(lonDeg, latDeg, lonOffsetDeg = 0, latOffsetDeg = 0) {
const latRad = toRadians(Number(latDeg || 0) + latOffsetDeg);
const lonRad = toRadians(-normalizeSignedLongitude(Number(lonDeg || 0) + lonOffsetDeg));
const cosLat = Math.cos(latRad);
const craterRadius = MOON_SPHERE_RADIUS * craterOverlayScale;
return new THREE.Vector3(
craterRadius * cosLat * Math.cos(lonRad),
craterRadius * Math.sin(latRad),
craterRadius * cosLat * Math.sin(lonRad)
cosLat * Math.cos(lonRad),
Math.sin(latRad),
cosLat * Math.sin(lonRad)
);
}
function craterToMoonLocalVector(crater) {
const craterRadius = MOON_SPHERE_RADIUS * craterOverlayScale;
return moonLocalDirectionFromLonLat(
Number(crater.lon || 0),
Number(crater.lat || 0),
craterLongitudeOffsetDeg,
craterLatitudeOffsetDeg
).multiplyScalar(craterRadius);
}
function getCraterVisualStyle(crater, mode = 'all') {
if (mode === 'terminator') {
return {
@@ -1695,6 +1809,96 @@ timeSlider.addEventListener('wheel', event => {
updateMoon();
}
}, { passive: false });
// Testregler fuer die Licht-Konstanten (siehe MOON_SUN_LIGHT_INTENSITY etc.
// oben): Schieberegler + Mausrad, wirkt sofort ohne Neuladen der Seite.
const lightSliderConfigs = [
{
input: lightSunIntensity, label: lightSunIntensityValue, initial: MOON_SUN_LIGHT_INTENSITY,
apply: value => {
MOON_SUN_LIGHT_INTENSITY = value;
if (moonSceneState) moonSceneState.sunLight.intensity = value;
}
},
{
input: lightExposure, label: lightExposureValue, initial: MOON_TONE_MAPPING_EXPOSURE,
apply: value => {
MOON_TONE_MAPPING_EXPOSURE = value;
if (moonSceneState) moonSceneState.renderer.toneMappingExposure = value;
}
},
{
input: lightAmbient, label: lightAmbientValue, initial: MOON_AMBIENT_LIGHT_INTENSITY,
apply: value => {
MOON_AMBIENT_LIGHT_INTENSITY = value;
if (moonSceneState) moonSceneState.ambientLight.intensity = value;
}
},
{
input: lightRim, label: lightRimValue, initial: MOON_RIM_LIGHT_INTENSITY,
apply: value => {
MOON_RIM_LIGHT_INTENSITY = value;
if (moonSceneState) moonSceneState.rimLight.intensity = value;
}
},
{
input: lightShadowNormalBias, label: lightShadowNormalBiasValue, initial: MOON_SHADOW_NORMAL_BIAS,
apply: value => {
MOON_SHADOW_NORMAL_BIAS = value;
if (moonSceneState) moonSceneState.sunLight.shadow.normalBias = value;
}
}
];
function setupLightSlider({ input, label, initial, apply }) {
input.value = String(initial);
label.textContent = initial.toFixed(3);
const commit = () => {
const value = Number(input.value);
apply(value);
label.textContent = value.toFixed(3);
if (moonSceneState) {
moonSceneState.renderer.render(moonSceneState.scene, moonSceneState.camera);
}
};
input.addEventListener('input', commit);
input.addEventListener('wheel', event => {
event.preventDefault();
const step = Number(input.step) || 0.01;
const min = Number(input.min) || 0;
const max = Number(input.max) || 1;
const direction = event.deltaY > 0 ? 1 : -1;
const nextValue = Math.min(max, Math.max(min, Number(input.value) + direction * step));
input.value = String(nextValue);
commit();
}, { passive: false });
}
lightSliderConfigs.forEach(setupLightSlider);
// Aktuelle Reglerwerte als Textdatei speichern - fertig zum Einsetzen als
// neue Standardwerte oben bei den MOON_*-Konstanten.
lightSaveValuesBtn.addEventListener('click', () => {
const lines = [
`MOON_SUN_LIGHT_INTENSITY = ${MOON_SUN_LIGHT_INTENSITY}`,
`MOON_TONE_MAPPING_EXPOSURE = ${MOON_TONE_MAPPING_EXPOSURE}`,
`MOON_AMBIENT_LIGHT_INTENSITY = ${MOON_AMBIENT_LIGHT_INTENSITY}`,
`MOON_RIM_LIGHT_INTENSITY = ${MOON_RIM_LIGHT_INTENSITY}`,
`MOON_SHADOW_NORMAL_BIAS = ${MOON_SHADOW_NORMAL_BIAS}`
];
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'mondlicht-werte.txt';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
});
prevDayBtn.addEventListener('click', () => shiftDateByDays(-1));
nextDayBtn.addEventListener('click', () => shiftDateByDays(1));
nowBtn.addEventListener('click', () => {
+83 -12
View File
@@ -5435,6 +5435,14 @@ def vector_dot(a: tuple[float, float, float], b: tuple[float, float, float]) ->
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def vector_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def vector_normalize(v: tuple[float, float, float]) -> tuple[float, float, float]:
length = math.sqrt(vector_dot(v, v))
if length <= 0:
@@ -5442,6 +5450,21 @@ def vector_normalize(v: tuple[float, float, float]) -> tuple[float, float, float
return v[0] / length, v[1] / length, v[2] / length
def rotate_vector_around_axis(
v: tuple[float, float, float], axis: tuple[float, float, float], angle_rad: float
) -> tuple[float, float, float]:
"""Rodrigues-Rotationsformel: dreht v um die (Einheits-)Achse axis."""
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
axis_cross_v = vector_cross(axis, v)
axis_dot_v = vector_dot(axis, v)
return (
v[0] * cos_a + axis_cross_v[0] * sin_a + axis[0] * axis_dot_v * (1.0 - cos_a),
v[1] * cos_a + axis_cross_v[1] * sin_a + axis[1] * axis_dot_v * (1.0 - cos_a),
v[2] * cos_a + axis_cross_v[2] * sin_a + axis[2] * axis_dot_v * (1.0 - cos_a),
)
def calculate_moon_position_angle(time_value: astronomy.Time) -> float:
moon_vector = astronomy.GeoVector(astronomy.Body.Moon, time_value, True)
moon_equator = astronomy.EquatorFromVector(moon_vector)
@@ -5461,21 +5484,64 @@ def calculate_moon_position_angle(time_value: astronomy.Time) -> float:
)
def calculate_moon_axis_latitudes(time_value: astronomy.Time) -> dict:
def moon_body_frame(time_value: astronomy.Time) -> tuple[
tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]
]:
"""Baut eine orthonormale Basis (body_x = Nullmeridian-Richtung, body_y,
north = Rotationsachse) des mondfesten Koordinatensystems in EQJ, nach dem
offiziellen IAU-Rotationsachsen-Standard (RotationAxis: Pol-RA/Dec und
Rotationswinkel W, siehe IAU WGCCRE 2015)."""
axis = astronomy.RotationAxis(astronomy.Body.Moon, time_value)
north = vector_normalize(vector_from_astronomy(axis.north))
ra_rad = math.radians(axis.ra * 15.0)
# Aufsteigender Knoten der Mondaequatorebene auf der EQJ-Ebene (RA+90 Grad, Dec=0).
ascending_node = (-math.sin(ra_rad), math.cos(ra_rad), 0.0)
body_x = vector_normalize(rotate_vector_around_axis(ascending_node, north, math.radians(axis.spin)))
body_y = vector_normalize(vector_cross(north, body_x))
return body_x, body_y, north
def selenographic_lonlat(
vector: tuple[float, float, float],
body_x: tuple[float, float, float],
body_y: tuple[float, float, float],
north: tuple[float, float, float],
) -> tuple[float, float]:
"""Selenographische Laenge/Breite eines geozentrischen Richtungsvektors,
Ost-positiv (kalibriert gegen die IAU-Kraterdatenbank, z. B. Mare Crisium
bei +59,1 Grad Ost) und gegen astronomy.Libration() validiert.
HINWEIS: Vorzeichen wird gerade per Browsertest verifiziert (Ost/West-Problem)."""
v = vector_normalize(vector)
longitude = math.degrees(math.atan2(vector_dot(v, body_y), vector_dot(v, body_x)))
latitude = math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(v, north)))))
return longitude, latitude
def calculate_moon_orientation(time_value: astronomy.Time) -> dict:
"""Sub-Erd- und Sub-Sonnen-Punkt (selenographische Laenge/Breite) aus der
offiziellen IAU-Rotationsachse des Mondes - eine einzige, in sich
konsistente Grundlage fuer Mondausrichtung (Libration) UND Sonnenrichtung,
anstelle der frueheren, nur die Breite abdeckenden Berechnung."""
body_x, body_y, north = moon_body_frame(time_value)
moon_vector = astronomy.GeoVector(astronomy.Body.Moon, time_value, True)
sun_vector = astronomy.GeoVector(astronomy.Body.Sun, time_value, True)
north = vector_normalize(vector_from_astronomy(axis.north))
moon_xyz = vector_from_astronomy(moon_vector)
sun_xyz = vector_from_astronomy(sun_vector)
earth_from_moon = vector_normalize((-moon_xyz[0], -moon_xyz[1], -moon_xyz[2]))
sun_from_moon = vector_normalize(vector_subtract(sun_xyz, moon_xyz))
earth_from_moon = (-moon_xyz[0], -moon_xyz[1], -moon_xyz[2])
sun_from_moon = vector_subtract(sun_xyz, moon_xyz)
subearth_longitude, subearth_latitude = selenographic_lonlat(earth_from_moon, body_x, body_y, north)
subsolar_longitude, subsolar_latitude = selenographic_lonlat(sun_from_moon, body_x, body_y, north)
return {
"subearth_latitude": math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(earth_from_moon, north))))),
"subsolar_latitude": math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(sun_from_moon, north))))),
"subearth_longitude": subearth_longitude,
"subearth_latitude": subearth_latitude,
"subsolar_longitude": subsolar_longitude,
"subsolar_latitude": subsolar_latitude,
}
@@ -5510,8 +5576,12 @@ def action_moon_phase_details(args: list[str]) -> dict:
else phase * SYNODIC_MONTH
)
# Mondausrichtung (Libration) und Sonnenrichtung aus einer einzigen,
# konsistenten Grundlage: der offiziellen IAU-Rotationsachse des Mondes.
# distKm bleibt bewusst von astronomy.Libration() (bereits gegen PyEphem
# validiert), da hierfuer kein Wechsel noetig ist.
libration = astronomy.Libration(time_value)
axis_latitudes = calculate_moon_axis_latitudes(time_value)
orientation = calculate_moon_orientation(time_value)
position_angle = calculate_moon_position_angle(time_value)
local_dt = dt_utc.astimezone(tz)
@@ -5531,10 +5601,11 @@ def action_moon_phase_details(args: list[str]) -> dict:
"label": get_phase_label(age_days),
},
"libration": {
"longitude": -normalize_signed_degrees(float(libration.elon)),
"latitude": float(libration.elat),
"subsolarLatitude": float(axis_latitudes["subsolar_latitude"]),
"subearthLatitude": float(axis_latitudes["subearth_latitude"]),
"longitude": float(orientation["subearth_longitude"]),
"latitude": float(orientation["subearth_latitude"]),
"subsolarLongitude": float(orientation["subsolar_longitude"]),
"subsolarLatitude": float(orientation["subsolar_latitude"]),
"subearthLatitude": float(orientation["subearth_latitude"]),
"positionAngle": float(position_angle),
"distKm": float(libration.dist_km),
},