diff --git a/public/css/style.css b/public/css/style.css index 2e142dc..c771b43 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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 +
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; diff --git a/public/moonphase.php b/public/moonphase.php index 3dfe4d9..d256783 100644 --- a/public/moonphase.php +++ b/public/moonphase.php @@ -225,7 +225,51 @@ try { - +
@@ -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', () => { diff --git a/public/py/api.py b/public/py/api.py index aeb688d..5f93493 100644 --- a/public/py/api.py +++ b/public/py/api.py @@ -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), },