[education/kstars] /: Allow the user to rotate the sky map, and also allow some standard settings like inverted view

Jasem Mutlaq null at kde.org
Fri May 19 05:43:51 BST 2023


Git commit d3b1bf84b9a22f85b37768b03c946aeed03b2027 by Jasem Mutlaq, on behalf of Akarsh Simha.
Committed on 19/05/2023 at 04:43.
Pushed by mutlaqja into branch 'master'.

Allow the user to rotate the sky map, and also allow some standard settings like inverted view

M  +27   -0    doc/config.docbook
M  +5    -0    kstars/auxiliary/dms.h
M  +1    -0    kstars/data/kstarsui.rc
M  +3    -0    kstars/kstars.cpp
M  +8    -0    kstars/kstars.h
M  +12   -0    kstars/kstars.kcfg
M  +38   -0    kstars/kstarsactions.cpp
M  +50   -0    kstars/kstarsinit.cpp
M  +76   -48   kstars/projections/equirectangularprojector.cpp
M  +60   -30   kstars/projections/projector.cpp
M  +57   -1    kstars/projections/projector.h
M  +10   -3    kstars/skycomponents/hipscomponent.cpp
M  +2    -2    kstars/skycomponents/hipscomponent.h
M  +78   -0    kstars/skymap.cpp
M  +20   -0    kstars/skymap.h
M  +78   -0    kstars/skymapdrawabstract.cpp
M  +6    -0    kstars/skymapdrawabstract.h
M  +48   -0    kstars/skymapevents.cpp
M  +1    -0    kstars/skymaplite.cpp
M  +25   -4    kstars/skyobjects/skypoint.cpp
M  +21   -0    kstars/skyobjects/skypoint.h
M  +2    -0    kstars/terrain/terrainrenderer.cpp

https://invent.kde.org/education/kstars/commit/d3b1bf84b9a22f85b37768b03c946aeed03b2027

diff --git a/doc/config.docbook b/doc/config.docbook
index 20682164b6..77aa15bd23 100644
--- a/doc/config.docbook
+++ b/doc/config.docbook
@@ -1451,6 +1451,33 @@ from the <menuchoice><guimenu>Settings</guimenu><guisubmenu>FOV Symbols</guisubm
 
 </sect1>
 
+<sect1 id="skymap_orientation">
+  <title>Adjusting orientation of the sky map</title>
+  <para>
+    You can tweak various settings to make the orientation of the sky map match the view through your optical instrument, provided (as of this version) the instrument does not mirror the field-of-view (as is done by prisms used with SCTs and refractors).
+  </para>
+  <para>
+    First, pick the coordinate system that matches your mount. For an equatorially mounted instrument, switch to the Equatorial Coordinate mode in the <guimenu>View</guimenu> menu. The option to toggle the coordinate system should read <guilabel>Switch to Horizonal View (Horizontal Coordinates)</guilabel> when the current mode is Equatorial Coordinates. For an altazimuth-mounted instrument or naked-eye viewing, switch to Horizontal Coordinates, so that the option in the <guimenu>View</guimenu> menu reads <guilabel>Switch to Star Globe View (Equatorial Coordinates)</guilabel>. This sets the base coordinate system used to render the sky map, and also sets the reference for the orientation of the skymap: zenith or north.
+  </para>
+  <para>
+    To rotate the sky map freely, you can hold down the Shift key and drag the mouse on the sky map. A temporary overlay will appear showing the direction of north and zenith at the point, and displaying the angle they make with the vertical in a counterclockwise sense. The orientations of zenith and north will update as you rotate the sky map. Letting go of Shift or the mouse button will stop the rotation operation. As you pan the sky map or focus it on different objects, the rotation you set is retained as an offset from the reference direction. The reference direction is north when using Equatorial Coordinates and zenith when using Horizontal Coordinates. As a reminder, the reference direction is solid and brighter in the temporary overlay. For the two common orientations of erect and inverted, the rotation can be set / reset using the <menuchoice><guimenu>View</guimenu><guisubmenu>Skymap Orientation</guisubmenu></menuchoice> submenu. Select "North Down" or "Zenith Down" as is applicable to set an orientation of 180 degrees.
+  </para>
+  <para>
+    If you are visually observing through an eyepiece of an instrument, you may need to do some more correction. For the common case of a large Dobsonian telescope (or more generally a Newtonian design mounted on an altazimuth mount), a systematic additional correction is of help. This correction applies because we stand erect while using the telescope irrespective of the angle the telescope tube is making with the ground. So as we move the telescope in altitude, an additional correction depending on the altitude of the object needs to be applied to make the sky map match the view through the eyepiece. This correction is enabled by checking the <guilabel>Erect observer correction</guilabel> checkbox in the <menuchoice><guimenu>View</guimenu><guisubmenu>Skymap Orientation</guisubmenu></menuchoice> submenu. This correction only makes sense in Horizontal Coordinate mode and is disabled when using equatorial coordinates.
+  </para>
+  <para>
+    Finally we provide some examples of how to use these settings for various instruments:
+    <itemizedlist>
+      <listitem><para>Naked-eye observing: Choose Horizontal Coordinates and a <guilabel>Zenith Up</guilabel> orientation under <menuchoice><guimenu>View</guimenu><guisubmenu>Skymap Orientation</guisubmenu></menuchoice>.</para></listitem>
+      <listitem><para>Camera on an equatorially mounted telescope: Choose Equatorial Coordinates and adjust the orientation of the sky map so that it matches your camera. As your mount points to different regions of the sky, the orientation should be rendered correctly.</para></listitem>
+      <listitem><para>Using binoculars: Same settings as Naked-eye observing</para></listitem>
+      <listitem><para>Using a RACI finder scope on an altazimuth mounted telescope: Same settings as Naked-eye observing, except you may need to tweak the orientation manually once if you have it mounted at an angle</para></listitem>
+      <listitem><para>Using a straight-through (inverted view) finder scope on an altazimuth mounted telescope: Choose Horizontal Coordinates and a sky-map orientation of <guilabel>Zenith Down</guilabel> in <menuchoice><guimenu>View</guimenu><guisubmenu>Skymap Orientation</guisubmenu></menuchoice> submenu</para></listitem>
+      <listitem><para>Eyepiece of a Dobsonian telescope: Choose Horizontal Coordinates, and in the <menuchoice><guimenu>View</guimenu><guisubmenu>Skymap Orientation</guisubmenu></menuchoice> submenue, select <guilabel>Zenith Down</guilabel> and check the <guilabel>Erect observer correction</guilabel> option. Then adjust the orientation manually once to match your telescope eyepiece view, and it should henceforth track it correctly.</para></listitem>
+    </itemizedlist>
+  </para>
+</sect1>
+
 &hips;
 
 </chapter>
diff --git a/kstars/auxiliary/dms.h b/kstars/auxiliary/dms.h
index 655432d3da..3cf7a43114 100644
--- a/kstars/auxiliary/dms.h
+++ b/kstars/auxiliary/dms.h
@@ -314,6 +314,11 @@ class dms
 #endif
     }
 
+    /**
+     * @short Convenience method to return tangent of the angle
+     */
+    inline double tan() const { return sin()/cos(); }
+
     /** @short Express the angle in radians.
          * @return the angle in radians (double)
          */
diff --git a/kstars/data/kstarsui.rc b/kstars/data/kstarsui.rc
index fc92303c7f..07ec232903 100644
--- a/kstars/data/kstarsui.rc
+++ b/kstars/data/kstarsui.rc
@@ -47,6 +47,7 @@
                 <Action name="fullscreen" />
                 <Separator />
                 <Action name="coordsys" />
+                <Action name="skymap_orientation" />
                 <Action name="toggle_terrain" />
                 <Menu name="projection"><text>&Projection</text>
                         <Action name="project_lambert"/>
diff --git a/kstars/kstars.cpp b/kstars/kstars.cpp
index 8e9c7ea63d..c0f8f342d8 100644
--- a/kstars/kstars.cpp
+++ b/kstars/kstars.cpp
@@ -166,6 +166,7 @@ KStars::KStars(bool doSplash, bool clockrun, const QString &startdate)
     telescopeGroup->setExclusive(false);
     domeGroup       = new QActionGroup(this);
     domeGroup->setExclusive(false);
+    skymapOrientationGroup = new QActionGroup(this);
 
 
     m_KStarsData = KStarsData::Create();
@@ -356,6 +357,8 @@ void KStars::applyConfig(bool doApplyFocus)
     actionCollection()->action("show_flags")->setChecked(Options::showFlags());
     actionCollection()->action("show_supernovae")->setChecked(Options::showSupernovae());
     actionCollection()->action("show_satellites")->setChecked(Options::showSatellites());
+    actionCollection()->action("erect_observer_correction")->setChecked(Options::erectObserverCorrection());
+    actionCollection()->action("erect_observer_correction")->setEnabled(Options::useAltAz());
     statusBar()->setVisible(Options::showStatusBar());
 
     //color scheme
diff --git a/kstars/kstars.h b/kstars/kstars.h
index 4d93597068..7fc910fe85 100644
--- a/kstars/kstars.h
+++ b/kstars/kstars.h
@@ -6,6 +6,7 @@
 #pragma once
 
 #include "config-kstars.h"
+#include "nan.h"
 
 #include <KXmlGuiWindow>
 #include <KLocalizedString>
@@ -197,6 +198,8 @@ class KStars : public KXmlGuiWindow
         /** Load HIPS information and repopulate menu. */
         void repopulateHIPS();
 
+        void repopulateOrientation();
+
         WIEquipSettings *getWIEquipSettings()
         {
             return m_WIEquipmentSettings;
@@ -734,6 +737,9 @@ class KStars : public KXmlGuiWindow
         /** Set the map projection according to the menu selection */
         void slotMapProjection();
 
+        /** Set the orientation parameters of the sky map */
+        void slotSkyMapOrientation();
+
         /** Toggle display of the observing list tool*/
         void slotObsList();
 
@@ -840,6 +846,7 @@ class KStars : public KXmlGuiWindow
         KActionMenu *colorActionMenu { nullptr };
         KActionMenu *fovActionMenu { nullptr };
         KActionMenu *hipsActionMenu { nullptr };
+        KActionMenu *orientationActionMenu { nullptr };
 
         KStarsData *m_KStarsData { nullptr };
         SkyMap *m_SkyMap { nullptr };
@@ -885,6 +892,7 @@ class KStars : public KXmlGuiWindow
         //#endif
 
         QActionGroup *projectionGroup { nullptr };
+        QActionGroup *skymapOrientationGroup { nullptr };
         QActionGroup *cschemeGroup { nullptr };
         QActionGroup *hipsGroup { nullptr };
         QActionGroup *telescopeGroup { nullptr };
diff --git a/kstars/kstars.kcfg b/kstars/kstars.kcfg
index 18439ce097..b053b28b6d 100644
--- a/kstars/kstars.kcfg
+++ b/kstars/kstars.kcfg
@@ -794,6 +794,18 @@
          <min>250.</min>
          <max>5000000.</max>
       </entry>
+      <entry name="SkyRotation" type="Double">
+         <label>Angle by which the sky map is rotated</label>
+         <whatsthis>The angle by which the sky map is rotated from its standard orientation (north up if using equatorial coordinates, zenith up if using horizontal coordinates).</whatsthis>
+         <default>0.</default>
+         <min>0.</min>
+         <max>359.9999</max>
+      </entry>
+      <entry name="ErectObserverCorrection" type="Bool">
+         <label>Orients the sky-map to account for an erect observer at the eyepiece</label>
+         <whatsthis>Enable this if you are using your eye at the eyepiece in an altazimuth mounted Newtonian telescope. This accounts for the fact that the observer stands erect as the telescope moves up and down, so that the orientation of the sky map will track what is seen in your eyepiece once it is set up correctly.</whatsthis>
+         <default>false</default>
+      </entry>
       <entry name="ZoomScrollFactor" type="Double">
          <label>Zoom scroll sensitivity.</label>
          <whatsthis>When zooming in or out, change zoom speed factor by this multiplier.</whatsthis>
diff --git a/kstars/kstarsactions.cpp b/kstars/kstarsactions.cpp
index cdeca4ec46..1c186a7c02 100644
--- a/kstars/kstarsactions.cpp
+++ b/kstars/kstarsactions.cpp
@@ -1688,6 +1688,15 @@ void KStars::slotCoordSys()
         actionCollection()
         ->action("coordsys")
         ->setText(i18n("Switch to Horizonal View (Horizontal &Coordinates)"));
+        actionCollection()
+        ->action("up_orientation")
+        ->setText(i18nc("Orientation of the sky map", "North &Up"));
+        actionCollection()
+        ->action("down_orientation")
+        ->setText(i18nc("Orientation of the sky map", "North &Down"));
+        actionCollection()
+        ->action("erect_observer_correction")
+        ->setEnabled(false);
     }
     else
     {
@@ -1700,7 +1709,36 @@ void KStars::slotCoordSys()
         actionCollection()
         ->action("coordsys")
         ->setText(i18n("Switch to Star Globe View (Equatorial &Coordinates)"));
+        actionCollection()
+        ->action("up_orientation")
+        ->setText(i18nc("Orientation of the sky map", "Zenith &Up"));
+        actionCollection()
+        ->action("down_orientation")
+        ->setText(i18nc("Orientation of the sky map", "Zenith &Down"));
+        actionCollection()
+        ->action("erect_observer_correction")
+        ->setEnabled(true);
+    }
+    map()->forceUpdate();
+}
+
+void KStars::slotSkyMapOrientation()
+{
+    if (sender() == actionCollection()->action("up_orientation"))
+    {
+        Options::setSkyRotation(0.0);
+    }
+    else if (sender() == actionCollection()->action("down_orientation"))
+    {
+        Options::setSkyRotation(180.0);
     }
+    else
+    {
+        Q_ASSERT(false && "Unhandled orientation action");
+        qCWarning(KSTARS) << "Unhandled orientation action";
+    }
+
+    Options::setErectObserverCorrection(actionCollection()->action("erect_observer_correction")->isChecked());
     map()->forceUpdate();
 }
 
diff --git a/kstars/kstarsinit.cpp b/kstars/kstarsinit.cpp
index 8fec0d3fea..9e822b1996 100644
--- a/kstars/kstarsinit.cpp
+++ b/kstars/kstarsinit.cpp
@@ -372,6 +372,12 @@ void KStars::initActions()
     HIPSManager::Instance()->readSources();
     repopulateHIPS();
 
+    orientationActionMenu = actionCollection()->add<KActionMenu>("skymap_orientation");
+    orientationActionMenu->setText(i18n("Skymap Orientation"));
+    orientationActionMenu->setDelayed(false);
+    orientationActionMenu->setIcon(QIcon::fromTheme("screen-rotate-auto-on"));
+    repopulateOrientation();
+
     actionCollection()->addAction("geolocation", this, SLOT(slotGeoLocator()))
             << i18nc("Location on Earth", "&Geographic...")
             << QIcon::fromTheme("kstars_xplanet")
@@ -693,6 +699,50 @@ void KStars::initActions()
 #endif
 }
 
+void KStars::repopulateOrientation()
+{
+    double rot = dms{Options::skyRotation()}.reduce().Degrees();
+    bool useAltAz = Options::useAltAz();
+    // TODO: Allow adding preset orientations, e.g. for finder scope, main scope etc.
+    orientationActionMenu->menu()->clear();
+    orientationActionMenu->addAction(
+        actionCollection()->addAction(
+            "up_orientation", this, SLOT(slotSkyMapOrientation()))
+        << (useAltAz ? i18nc("Orientation of the sky map", "Zenith &Up") : i18nc("Orientation of the sky map", "North &Up"))
+        << AddToGroup(skymapOrientationGroup)
+        << Checked(rot == 0.)
+        << ToolTip(i18nc("Orientation of the sky map",
+                         "Select this for erect view of the sky map, where north (in Equatorial Coordinate mode) or zenith (in Horizontal Coordinate mode) is vertically up. This would be the natural choice for an erect image finder scope or naked-eye view.")));
+
+    orientationActionMenu->addAction(
+        actionCollection()->addAction(
+            "down_orientation", this, SLOT(slotSkyMapOrientation()))
+        << (useAltAz ? i18nc("Orientation of the sky map", "Zenith &Down") : i18nc("Orientation of the sky map", "North &Down"))
+        << AddToGroup(skymapOrientationGroup)
+        << Checked(rot == 180.)
+        << ToolTip(i18nc("Orientation of the sky map",
+                         "Select this for inverted view of the sky map, where north (in Equatorial Coordinate mode) or zenith (in Horizontal Coordinate mode) is vertically down. This would be the natural choice for an inverted image finder scope, refractor/cassegrain without erector prism, or Dobsonian.")));
+
+    orientationActionMenu->addAction(
+        actionCollection()->addAction(
+            "arbitrary_orientation", this, SLOT(slotSkyMapOrientation()))
+        << i18nc("Orientation of the sky map is arbitrary as it has been adjusted by the user", "Arbitrary")
+        << AddToGroup(skymapOrientationGroup)
+        << Checked(rot != 180. && rot != 0.)
+        << ToolTip(i18nc("Orientation of the sky map",
+                         "This mode is selected automatically if you manually rotated the sky map using Shift + Drag mouse action, to inform you that the orientation is arbitrary")));
+
+
+    orientationActionMenu->addSeparator();
+    QAction *erectObserverAction = newToggleAction(
+                                       actionCollection(), "erect_observer_correction",
+                                       i18nc("Orient sky map for an erect observer", "Erect observer correction"),
+                                       this, SLOT(slotSkyMapOrientation()));
+    erectObserverAction << ToolTip(i18nc("Orient sky map for an erect observer",
+                                         "Enable this mode if you are visually using a Newtonian telescope on an altazimuth mount. It will correct the orientation of the sky-map to account for the observer remaining erect as the telescope moves up and down, unlike a camera which would rotate with the telescope. This only makes sense in Horizontal Coordinate mode and is disabled when using Equatorial Coordinates. Typically makes sense to combine this with Zenith Down orientation."));
+    orientationActionMenu->addAction(erectObserverAction);
+}
+
 void KStars::repopulateFOV()
 {
     // Read list of all FOVs
diff --git a/kstars/projections/equirectangularprojector.cpp b/kstars/projections/equirectangularprojector.cpp
index 8663b7d2bd..37e1bd93f6 100644
--- a/kstars/projections/equirectangularprojector.cpp
+++ b/kstars/projections/equirectangularprojector.cpp
@@ -29,6 +29,7 @@ Eigen::Vector2f EquirectangularProjector::toScreenVec(const SkyPoint *o, bool oR
 {
     double Y, dX;
     Eigen::Vector2f p;
+    double x, y;
 
     oRefract &= m_vp.useRefraction;
     if (m_vp.useAltAz)
@@ -38,18 +39,20 @@ Eigen::Vector2f EquirectangularProjector::toScreenVec(const SkyPoint *o, bool oR
         Y0 = SkyPoint::refract(m_vp.focus->alt(), oRefract).radians();
         dX = m_vp.focus->az().reduce().radians() - o->az().reduce().radians();
 
-        p[1] = 0.5 * m_vp.height - m_vp.zoomFactor * (Y - Y0);
+        y = (Y - Y0);
     }
     else
     {
         dX   = o->ra().reduce().radians() - m_vp.focus->ra().reduce().radians();
         Y    = o->dec().radians();
-        p[1] = 0.5 * m_vp.height - m_vp.zoomFactor * (Y - m_vp.focus->dec().radians());
+        y = (Y - m_vp.focus->dec().radians());
     }
 
     dX = KSUtils::reduceAngle(dX, -dms::PI, dms::PI);
 
-    p[0] = 0.5 * m_vp.width - m_vp.zoomFactor * dX;
+    x = dX;
+
+    p = rst(x, y);
 
     if (onVisibleHemisphere)
         *onVisibleHemisphere = (p[0] > 0 && p[0] < m_vp.width);
@@ -62,8 +65,9 @@ SkyPoint EquirectangularProjector::fromScreen(const QPointF &p, dms *LST, const
     SkyPoint result;
 
     //Convert pixel position to x and y offsets in radians
-    double dx = (0.5 * m_vp.width - p.x()) / m_vp.zoomFactor;
-    double dy = (0.5 * m_vp.height - p.y()) / m_vp.zoomFactor;
+    auto p_ = derst(p.x(), p.y());
+    double dx = p_[0];
+    double dy = p_[1];
 
     if (m_vp.useAltAz)
     {
@@ -92,51 +96,54 @@ SkyPoint EquirectangularProjector::fromScreen(const QPointF &p, dms *LST, const
 
 bool EquirectangularProjector::unusablePoint(const QPointF &p) const
 {
-    double dx = (0.5 * m_vp.width - p.x()) / m_vp.zoomFactor;
-    double dy = (0.5 * m_vp.height - p.y()) / m_vp.zoomFactor;
+    auto p_ = derst(p.x(), p.y());
+    double dx = p_[0];
+    double dy = p_[1];
     return (dx * dx > M_PI * M_PI / 4.0) || (dy * dy > M_PI * M_PI / 4.0);
 }
 
 QVector<Eigen::Vector2f> EquirectangularProjector::groundPoly(SkyPoint *labelpoint, bool *drawLabel) const
 {
     float x0 = m_vp.width / 2.;
-    float y0 = m_vp.width / 2.;
     if (m_vp.useAltAz)
     {
-        float dX = m_vp.zoomFactor * M_PI;
-        float dY = m_vp.zoomFactor * M_PI;
+        float dX = M_PI;
+
+        // N.B. alt ranges from -π/2 to π/2, but the focus can be at
+        // either extreme, so the Y-range of the map is actually -π to
+        // π -- asimha
+        float dY = M_PI;
+
         SkyPoint belowFocus;
         belowFocus.setAz(m_vp.focus->az().Degrees());
         belowFocus.setAlt(0.0);
 
+        // Compute the ends of the horizon line
         Eigen::Vector2f obf = toScreenVec(&belowFocus, false);
+        auto obf_derst = derst(obf.x(), obf.y());
+        auto corner1 = rst(obf_derst[0] - dX,
+                           obf_derst[1]);
+        auto corner2 = rst(obf_derst[0] + dX,
+                           obf_derst[1]);
 
-        //If the horizon is off the bottom edge of the screen,
-        //we can return immediately
-        if (obf.y() > m_vp.height)
-        {
-            if (drawLabel)
-                *drawLabel = false;
-            return QVector<Eigen::Vector2f>();
-        }
-
-        //We can also return if the horizon is off the top edge,
-        //as long as the ground poly is not being drawn
-        if (obf.y() < 0. && m_vp.fillGround == false)
-        {
-            if (drawLabel)
-                *drawLabel = false;
-            return QVector<Eigen::Vector2f>();
-        }
+        auto corner3 = rst(obf_derst[0] + dX,
+                           -dY);
+        auto corner4 = rst(obf_derst[0] - dX,
+                           -dY);
 
         QVector<Eigen::Vector2f> ground;
         //Construct the ground polygon, which is a simple rectangle in this case
-        ground << Eigen::Vector2f(x0 - dX, obf.y()) << Eigen::Vector2f(x0 + dX, obf.y()) << Eigen::Vector2f(x0 + dX, y0 + dY)
-               << Eigen::Vector2f(x0 - dX, y0 + dY);
+        ground << corner1
+               << corner2;
+        if (m_vp.fillGround) {
+               ground << corner3
+                      << corner4;
+        }
 
         if (labelpoint)
         {
-            QPointF pLabel(x0 - dX - 50., obf.y());
+            auto pLabel_ = corner2 - 50. * (corner1 - corner2).normalized();
+            QPointF pLabel(pLabel_[0], pLabel_[1]);
             KStarsData *data = KStarsData::Instance();
             *labelpoint      = fromScreen(pLabel, data->lst(), data->geo()->lat());
         }
@@ -147,23 +154,26 @@ QVector<Eigen::Vector2f> EquirectangularProjector::groundPoly(SkyPoint *labelpoi
     }
     else
     {
-        float dX = m_vp.zoomFactor * M_PI / 2;
-        float dY = m_vp.zoomFactor * M_PI / 2;
+        float dX = m_vp.zoomFactor * M_PI; // RA ranges from 0 to 2π, so half-length is π
+        float dY = m_vp.zoomFactor * M_PI;
         QVector<Eigen::Vector2f> ground;
 
         static const QString horizonLabel = i18n("Horizon");
         float marginLeft, marginRight, marginTop, marginBot;
         SkyLabeler::Instance()->getMargins(horizonLabel, &marginLeft, &marginRight, &marginTop, &marginBot);
-        double daz = 90.;
+
+        double daz = 180.;
         double faz = m_vp.focus->az().Degrees();
         double az1 = faz - daz;
         double az2 = faz + daz;
 
+        bool inverted = ((m_vp.rotationAngle + 90.0_deg).reduce().Degrees() > 180.);
         bool allGround = true;
         bool allSky    = true;
 
         double inc = 1.0;
         //Add points along horizon
+        std::vector<Eigen::Vector2f> groundPoints;
         for (double az = az1; az <= az2 + inc; az += inc)
         {
             SkyPoint p   = pointAt(az);
@@ -171,7 +181,7 @@ QVector<Eigen::Vector2f> EquirectangularProjector::groundPoly(SkyPoint *labelpoi
             Eigen::Vector2f o   = toScreenVec(&p, false, &visible);
             if (visible)
             {
-                ground.append(o);
+                groundPoints.push_back(o);
                 //Set the label point if this point is onscreen
                 if (labelpoint && o.x() < marginRight && o.y() > marginTop && o.y() < marginBot)
                     *labelpoint = p;
@@ -183,6 +193,9 @@ QVector<Eigen::Vector2f> EquirectangularProjector::groundPoly(SkyPoint *labelpoi
             }
         }
 
+        if (inverted)
+            std::swap(allGround, allSky);
+
         if (allSky)
         {
             if (drawLabel)
@@ -190,18 +203,30 @@ QVector<Eigen::Vector2f> EquirectangularProjector::groundPoly(SkyPoint *labelpoi
             return QVector<Eigen::Vector2f>();
         }
 
-        if (allGround)
+        const Eigen::Vector2f slope {m_vp.rotationAngle.cos(), m_vp.rotationAngle.sin()};
+        std::sort(groundPoints.begin(), groundPoints.end(), [&](const Eigen::Vector2f & a,
+                  const Eigen::Vector2f & b)
         {
-            ground.clear();
-            ground.append(Eigen::Vector2f(x0 - dX, y0 - dY));
-            ground.append(Eigen::Vector2f(x0 + dX, y0 - dY));
-            ground.append(Eigen::Vector2f(x0 + dX, y0 + dY));
-            ground.append(Eigen::Vector2f(x0 - dX, y0 + dY));
-            if (drawLabel)
-                *drawLabel = false;
-            return ground;
+            return a.dot(slope) < b.dot(slope);
+        });
+
+        for (auto point : groundPoints)
+        {
+            ground.append(point);
         }
 
+        // if (allGround)
+        // {
+        //     ground.clear();
+        //     ground.append(Eigen::Vector2f(x0 - dX, y0 - dY));
+        //     ground.append(Eigen::Vector2f(x0 + dX, y0 - dY));
+        //     ground.append(Eigen::Vector2f(x0 + dX, y0 + dY));
+        //     ground.append(Eigen::Vector2f(x0 - dX, y0 + dY));
+        //     if (drawLabel)
+        //         *drawLabel = false;
+        //     return ground;
+        // }
+
         if (labelpoint)
         {
             QPointF pLabel(x0 - dX - 50., ground.last().y());
@@ -211,11 +236,14 @@ QVector<Eigen::Vector2f> EquirectangularProjector::groundPoly(SkyPoint *labelpoi
         if (drawLabel)
             *drawLabel = true;
 
-        //Now add points along the ground
-        ground.append(Eigen::Vector2f(x0 + dX, ground.last().y()));
-        ground.append(Eigen::Vector2f(x0 + dX, y0 + dY));
-        ground.append(Eigen::Vector2f(x0 - dX, y0 + dY));
-        ground.append(Eigen::Vector2f(x0 - dX, ground.first().y()));
+        const auto lat = KStarsData::Instance()->geo()->lat();
+        const Eigen::Vector2f perpendicular {-m_vp.rotationAngle.sin(), m_vp.rotationAngle.cos()};
+        const double sgn = (lat->Degrees() > 0 ? 1. : -1.);
+        if (m_vp.fillGround)
+        {
+            ground.append(groundPoints.back() + perpendicular * sgn * dY);
+            ground.append(groundPoints.front() + perpendicular * sgn * dY);
+        }
         return ground;
     }
 }
diff --git a/kstars/projections/projector.cpp b/kstars/projections/projector.cpp
index 7d13d422e8..d8ee191e6a 100644
--- a/kstars/projections/projector.cpp
+++ b/kstars/projections/projector.cpp
@@ -247,8 +247,10 @@ double Projector::findNorthPA(const SkyPoint *o, float x, float y) const
     if (m_vp.useAltAz)
         test.EquatorialToHorizontal(data->lst(), data->geo()->lat());
     Eigen::Vector2f t = toScreenVec(&test);
-    float dx   = t.x() - x;
-    float dy   = y - t.y(); //backwards because QWidget Y-axis increases to the bottom
+    float dx    = t.x() - x;
+    float dy    = y - t.y(); //backwards because QWidget Y-axis increases to the bottom (FIXME: Check)
+    // float dx = dx_ * m_vp.rotationAngle.cos() - dy_ * m_vp.rotationAngle.sin();
+    // float dy = dx_ * m_vp.rotationAngle.sin() + dy_ * m_vp.rotationAngle.cos();
     float north;
     if (dy)
     {
@@ -267,6 +269,39 @@ double Projector::findPA(const SkyObject *o, float x, float y) const
     return (findNorthPA(o, x, y) + o->pa());
 }
 
+// FIXME: There should be a MUCH more efficient way to do this (see EyepieceField for example)
+double Projector::findZenithPA(const SkyPoint *o, float x, float y) const
+{
+    //Find position angle of North using a test point displaced to the north
+    //displace by 100/zoomFactor radians (so distance is always 100 pixels)
+    //this is 5730/zoomFactor degrees
+    KStarsData *data = KStarsData::Instance();
+    double newAlt    = o->alt().Degrees() + 5730.0 / m_vp.zoomFactor;
+    if (newAlt > 90.0)
+        newAlt = 90.0;
+    SkyPoint test;
+    test.setAlt(newAlt);
+    test.setAz(o->az().Degrees());
+    if (!m_vp.useAltAz)
+        test.HorizontalToEquatorial(data->lst(), data->geo()->lat());
+    Eigen::Vector2f t = toScreenVec(&test);
+    float dx    = t.x() - x;
+    float dy    = y - t.y(); //backwards because QWidget Y-axis increases to the bottom (FIXME: Check)
+    // float dx = dx_ * m_vp.rotationAngle.cos() - dy_ * m_vp.rotationAngle.sin();
+    // float dy = dx_ * m_vp.rotationAngle.sin() + dy_ * m_vp.rotationAngle.cos();
+    float zenith;
+    if (dy)
+    {
+        zenith = atan2f(dx, dy) * 180.0 / dms::PI;
+    }
+    else
+    {
+        zenith = (dx > 0.0 ? -90.0 : 90.0);
+    }
+
+    return zenith;
+}
+
 QVector<Eigen::Vector2f> Projector::groundPoly(SkyPoint *labelpoint, bool *drawLabel) const
 {
     QVector<Eigen::Vector2f> ground;
@@ -277,9 +312,9 @@ QVector<Eigen::Vector2f> Projector::groundPoly(SkyPoint *labelpoint, bool *drawL
 
     //daz is 1/2 the width of the sky in degrees
     double daz = 90.;
-    if (m_vp.useAltAz)
+    if (m_vp.useAltAz && m_vp.rotationAngle.reduce().Degrees() == 0.0)
     {
-        daz = 0.5 * m_vp.width * 57.3 / m_vp.zoomFactor; //center to edge, in degrees
+        daz = 0.5 * m_vp.width / (dms::DegToRad * m_vp.zoomFactor); //center to edge, in degrees
         if (type() == Projector::Orthographic)
         {
             daz = daz * 1.4;
@@ -294,6 +329,8 @@ QVector<Eigen::Vector2f> Projector::groundPoly(SkyPoint *labelpoint, bool *drawL
     bool allGround = true;
     bool allSky    = true;
 
+    bool inverted = ((m_vp.rotationAngle + 90.0_deg).reduce().Degrees() > 180.);
+
     double inc = 1.0;
     //Add points along horizon
     for (double az = az1; az <= az2 + inc; az += inc)
@@ -309,9 +346,9 @@ QVector<Eigen::Vector2f> Projector::groundPoly(SkyPoint *labelpoint, bool *drawL
                 *labelpoint = p;
 
             if (o.y() > 0.)
-                allGround = false;
+                (inverted ? allSky : allGround) = false;
             if (o.y() < m_vp.height)
-                allSky = false;
+                (inverted ? allGround : allSky) = false;
         }
     }
 
@@ -339,9 +376,11 @@ QVector<Eigen::Vector2f> Projector::groundPoly(SkyPoint *labelpoint, bool *drawL
     //FIXME: not just gnomonic
     if (daz < 25.0 || type() == Projector::Gnomonic)
     {
+        const float completion_height = (inverted ?
+                                         -10.f : m_vp.height + 10.f);
         ground.append(Eigen::Vector2f(m_vp.width + 10.f, ground.last().y()));
-        ground.append(Eigen::Vector2f(m_vp.width + 10.f, m_vp.height + 10.f));
-        ground.append(Eigen::Vector2f(-10.f, m_vp.height + 10.f));
+        ground.append(Eigen::Vector2f(m_vp.width + 10.f, completion_height));
+        ground.append(Eigen::Vector2f(-10.f, completion_height));
         ground.append(Eigen::Vector2f(-10.f, ground.first().y()));
     }
     else
@@ -398,8 +437,12 @@ bool Projector::unusablePoint(const QPointF &p) const
         return false;
     //At low zoom, we have to determine whether the point is beyond the sky horizon
     //Convert pixel position to x and y offsets in radians
-    double dx = (0.5 * m_vp.width - p.x()) / m_vp.zoomFactor;
-    double dy = (0.5 * m_vp.height - p.y()) / m_vp.zoomFactor;
+
+    // N.B. Technically, rotation does not affect the dx² + dy²
+    // metric, but we use the derst method for uniformity; this
+    // function is not perf critical
+    auto p_ = derst(p.x(), p.y());
+    double dx = p_[0], dy = p_[1];
     return (dx * dx + dy * dy) > r0 * r0;
 }
 
@@ -412,8 +455,8 @@ SkyPoint Projector::fromScreen(const QPointF &p, dms *LST, const dms *lat, bool
       */
     double sinY0, cosY0;
     //Convert pixel position to x and y offsets in radians
-    double dx = (0.5 * m_vp.width - p.x()) / m_vp.zoomFactor;
-    double dy = (0.5 * m_vp.height - p.y()) / m_vp.zoomFactor;
+    auto p_ = derst(p.x(), p.y());
+    double dx = p_[0], dy = p_[1];
 
     double r = sqrt(dx * dx + dy * dy);
     c.setRadians(projectionL(r));
@@ -527,26 +570,13 @@ Eigen::Vector2f Projector::toScreenVec(const SkyPoint *o, bool oRefract, bool *o
 
     double k = projectionK(c);
 
-    double origX = m_vp.width / 2;
-    double origY = m_vp.height / 2;
+    auto p = rst(k * cosY * sindX, k * (m_cosY0 * sinY - m_sinY0 * cosY * cosdX));
 
-    double x = origX - m_vp.zoomFactor * k * cosY * sindX;
-    double y = origY - m_vp.zoomFactor * k * (m_cosY0 * sinY - m_sinY0 * cosY * cosdX);
 #ifdef KSTARS_LITE
     double skyRotation = SkyMapLite::Instance()->getSkyRotation();
-    if (skyRotation != 0)
-    {
-        dms rotation(skyRotation);
-        double cosT, sinT;
-
-        rotation.SinCos(sinT, cosT);
-
-        double newX = origX + (x - origX) * cosT - (y - origY) * sinT;
-        double newY = origY + (x - origX) * sinT + (y - origY) * cosT;
-
-        x = newX;
-        y = newY;
-    }
+    // FIXME: Port above to change the m_vp.rotationAngle, or
+    // deprecate it
+    Q_ASSERT(false);
 #endif
-    return Eigen::Vector2f(x, y);
+    return p;
 }
diff --git a/kstars/projections/projector.h b/kstars/projections/projector.h
index 2c669eab11..4d2b437a89 100644
--- a/kstars/projections/projector.h
+++ b/kstars/projections/projector.h
@@ -38,11 +38,12 @@ class ViewParams
     public:
         float width, height;
         float zoomFactor;
+        CachingDms rotationAngle;
         bool useRefraction;
         bool useAltAz;
         bool fillGround; ///<If the ground is filled, then points below horizon are invisible
         SkyPoint *focus;
-        ViewParams() : width(0), height(0), zoomFactor(0),
+        ViewParams() : width(0), height(0), zoomFactor(0), rotationAngle(0),
             useRefraction(false), useAltAz(false), fillGround(false),
             focus(nullptr) {}
 };
@@ -226,6 +227,19 @@ class Projector
          */
         double findPA(const SkyObject *o, float x, float y) const;
 
+        /**
+         * Determine the on-screen angle of a SkyPoint with respect to Zenith.
+         *
+         * @note Similar to @see findNorthPA
+         * @note It is assumed that EquatorialToHorizontal has been called on @p o
+         *
+         * @description This is determined by constructing a test
+         * point with the same Azimuth but a slightly increased
+         * Altitude, and calculating the angle w.r.t. the Y-axis of
+         * the line connecting the object to its test point.
+         */
+        double findZenithPA(const SkyPoint *o, float x, float y) const;
+
         /**
          * Get the ground polygon
          * @param labelpoint This point will be set to something suitable for attaching a label
@@ -283,6 +297,48 @@ class Projector
             return 0;
         }
 
+        /**
+         * Transform proj (x, y) to screen (x, y) accounting for scale and rotation
+         *
+         * Transforms the Cartesian position given by the projector
+         * algorithm into the screen coordinate by applying the scale
+         * factor, rotation and shift from SkyMap origin
+         *
+         * rst stands for rotate-scale-translate
+         *
+         */
+        inline Eigen::Vector2f rst(double x, double y) const
+        {
+            return
+            {
+                m_vp.width / 2 - m_vp.zoomFactor * (x * m_vp.rotationAngle.cos() - y * m_vp.rotationAngle.sin()),
+                m_vp.height / 2 - m_vp.zoomFactor * (x * m_vp.rotationAngle.sin() + y * m_vp.rotationAngle.cos())
+            };
+        }
+
+        /**
+         * Transform screen (x, y) to projector (x, y) accounting for scale, rotation
+         *
+         * Transforms the Cartesian position on the screen to the
+         * Cartesian position accepted by the projector algorithm by
+         * applying th escale factor, rotation and shift from SkyMap
+         * origin
+         *
+         * rst stands for rotate-scale-translate
+         *
+         * @see rst
+         */
+        inline Eigen::Vector2f derst(double x, double y) const
+        {
+            const double X = (m_vp.width / 2 - x) / m_vp.zoomFactor;
+            const double Y = (m_vp.height / 2 - y) / m_vp.zoomFactor;
+            return
+            {
+                m_vp.rotationAngle.cos() * X + m_vp.rotationAngle.sin() * Y,
+                -m_vp.rotationAngle.sin() * X + m_vp.rotationAngle.cos() * Y
+            };
+        }
+
         /**
          * Helper function for drawing ground.
          * @return the point with Alt = 0, az = @p az
diff --git a/kstars/skycomponents/hipscomponent.cpp b/kstars/skycomponents/hipscomponent.cpp
index ce7e760e03..a42d444dc8 100644
--- a/kstars/skycomponents/hipscomponent.cpp
+++ b/kstars/skycomponents/hipscomponent.cpp
@@ -38,8 +38,15 @@ void HIPSComponent::draw(SkyPainter *skyp)
     // restart the timer.
 
     // Keep track of zoom level and redraw if changes.
-    double newZoom = Options::zoomFactor();
-    if (std::abs(newZoom - m_LastZoom) == 0. && Options::isTracking() && SkyMap::IsFocused())
+    ViewParams view = SkyMap::Instance()->projector()->viewParams();
+    bool sameView = (
+                        view.width == m_previousViewParams.width &&
+                        view.height == m_previousViewParams.height &&
+                        view.zoomFactor == m_previousViewParams.zoomFactor &&
+                        view.rotationAngle == m_previousViewParams.rotationAngle &&
+                        view.useAltAz == m_previousViewParams.useAltAz
+                    );
+    if (sameView && Options::isTracking() && SkyMap::IsFocused())
     {
         // We can draw the cache when two conditions are met.
         // 1. It is not yet time to re-draw
@@ -65,7 +72,7 @@ void HIPSComponent::draw(SkyPainter *skyp)
     else
         skyp->drawHips(false);
 
-    m_LastZoom = newZoom;
+    m_previousViewParams = view;
 #else
     Q_UNUSED(skyp);
 #endif
diff --git a/kstars/skycomponents/hipscomponent.h b/kstars/skycomponents/hipscomponent.h
index fae347bf19..15c761095e 100644
--- a/kstars/skycomponents/hipscomponent.h
+++ b/kstars/skycomponents/hipscomponent.h
@@ -10,8 +10,8 @@
 
 #pragma once
 
-#include "hipscomponent.h"
 #include "skycomponent.h"
+#include "projections/projector.h"
 
 /**
  * @class HIPSComponent
@@ -34,6 +34,6 @@ class HIPSComponent : public SkyComponent
         QElapsedTimer m_ElapsedTimer, m_RefreshTimer;
         static constexpr uint32_t HIPS_REDRAW_PERIOD {5000};
         static constexpr uint32_t HIPS_REFRESH_PERIOD {2000};
-        double m_LastZoom {1};
+        ViewParams m_previousViewParams;
         QString m_LastFocusedObjectName;
 };
diff --git a/kstars/skymap.cpp b/kstars/skymap.cpp
index 897cc70536..8173b8ffae 100644
--- a/kstars/skymap.cpp
+++ b/kstars/skymap.cpp
@@ -51,6 +51,7 @@
 #include <KToolBar>
 
 #include <QBitmap>
+#include <QPainterPath>
 #include <QToolTip>
 #include <QClipboard>
 #include <QInputDialog>
@@ -79,6 +80,37 @@ QBitmap zoomCursorBitmap(int width)
     return b;
 }
 
+// Draw bitmap for rotation cursor
+QBitmap rotationCursorBitmap(int width)
+{
+    constexpr int size = 32;
+    constexpr int mx = size / 2, my = size / 2;
+    QBitmap b(size, size);
+    b.fill(Qt::color0);
+    const int pad = 4;
+
+    QPainter p;
+    p.begin(&b);
+    p.setPen(QPen(Qt::color1, width));
+
+    QPainterPath arc1;
+    arc1.moveTo(mx, pad);
+    arc1.arcTo(QRect(pad, pad, size - 2 * pad, size - 2 * pad), 90, 90);
+    auto arcEnd1 = arc1.currentPosition();
+    arc1.lineTo(arcEnd1.x() - pad / 2, arcEnd1.y() - pad);
+    p.drawPath(arc1);
+
+    QPainterPath arc2;
+    arc2.moveTo(mx, size - pad);
+    arc2.arcTo(QRect(pad, pad, size - 2 * pad, size - 2 * pad), 270, 90);
+    auto arcEnd2 = arc2.currentPosition();
+    arc2.lineTo(arcEnd2.x() + pad / 2, arcEnd2.y() + pad);
+    p.drawPath(arc2);
+
+    p.end();
+    return b;
+}
+
 // Draw bitmap for default cursor. Width is size of pen to draw with.
 QBitmap defaultCursorBitmap(int width)
 {
@@ -1167,6 +1199,42 @@ float SkyMap::fov()
     return diagonalPixels / (2 * Options::zoomFactor() * dms::DegToRad);
 }
 
+dms SkyMap::determineSkyRotation()
+{
+    // Note: The erect observer correction accounts for the fact that
+    // an observer remains erect despite the tube of an
+    // Altazmith-mounted Newtonian moving up and down, so an
+    // additional rotation of altitude applies to match the
+    // orientation of the field. This would not apply to a CCD camera
+    // plugged into the same telescope, since the CCD would rotate as
+    // seen from the ground when the telescope moves in altitude.
+    return dms(Options::skyRotation() - (
+                   (Options::erectObserverCorrection() && Options::useAltAz()) ? focus()->alt().Degrees() : 0.0));
+}
+
+void SkyMap::slotSetSkyRotation(double angle)
+{
+    angle = dms(angle).reduce().Degrees();
+    Options::setSkyRotation(angle);
+    KStars *kstars = KStars::Instance();
+    if (kstars)
+    {
+        if (angle == 0.)
+        {
+            kstars->actionCollection()->action("up_orientation")->setChecked(true);
+        }
+        else if (angle == 180.)
+        {
+            kstars->actionCollection()->action("down_orientation")->setChecked(true);
+        }
+        else
+        {
+            kstars->actionCollection()->action("arbitrary_orientation")->setChecked(true);
+        }
+    }
+    forceUpdate();
+}
+
 void SkyMap::setupProjector()
 {
     //Update View Parameters for projection
@@ -1177,6 +1245,7 @@ void SkyMap::setupProjector()
     p.useAltAz      = Options::useAltAz();
     p.useRefraction = Options::useRefraction();
     p.zoomFactor    = Options::zoomFactor();
+    p.rotationAngle = determineSkyRotation();
     p.fillGround    = Options::showGround();
     //Check if we need a new projector
     if (m_proj && Options::projection() == m_proj->type())
@@ -1219,6 +1288,15 @@ void SkyMap::setZoomMouseCursor()
     setCursor(QCursor(cursor, mask));
 }
 
+void SkyMap::setRotationMouseCursor()
+{
+    mouseMoveCursor = false;
+    mouseDragCursor = false;
+    QBitmap cursor = rotationCursorBitmap(2);
+    QBitmap mask   = rotationCursorBitmap(4);
+    setCursor(QCursor(cursor, mask));
+}
+
 void SkyMap::setMouseCursorShape(Cursor type)
 {
     // no mousemove cursor
diff --git a/kstars/skymap.h b/kstars/skymap.h
index c7efcbe806..65542107ea 100644
--- a/kstars/skymap.h
+++ b/kstars/skymap.h
@@ -12,6 +12,7 @@
 #include "printing/legend.h"
 #include "skyobjects/skypoint.h"
 #include "skyobjects/skyline.h"
+#include "nan.h"
 
 #include <QGraphicsView>
 #include <QtGlobal>
@@ -397,6 +398,9 @@ class SkyMap : public QGraphicsView
         /** Toggle visibility of all infoboxes */
         void slotToggleInfoboxes(bool);
 
+        /** Sets the base sky rotation (before correction) to the given angle */
+        void slotSetSkyRotation(double angle);
+
         /** Step the Focus point toward the Destination point.  Do this iteratively, redrawing the Sky
              * Map after each step, until the Focus point is within 1 step of the Destination point.
              * For the final step, snap directly to Destination, and redraw the map.
@@ -652,6 +656,9 @@ class SkyMap : public QGraphicsView
         /** @short Sets the shape of the mouse cursor to a magnifying glass. */
         void setZoomMouseCursor();
 
+        /** @short Sets the shape of the mouse cursor to a rotation symbol. */
+        void setRotationMouseCursor();
+
         /** Calculate the zoom factor for the given keyboard modifier
              */
         double zoomFactor(const int modifier);
@@ -689,6 +696,16 @@ class SkyMap : public QGraphicsView
 
         void beginRulerMode(bool starHopRuler); // TODO: Add docs
 
+        /**
+         * Determine the rotation angle of the SkyMap
+         *
+         * This is simply Options::skyRotation() if the erect observer
+         * correction is not applicable, but otherwise it is
+         * determined by adding a correction amount dependent on the
+         * focus of the sky map
+         */
+        dms determineSkyRotation();
+
         /**
          * @short Strart xplanet.
          * @param outputFile Output file path.
@@ -713,6 +730,9 @@ class SkyMap : public QGraphicsView
         bool starHopDefineMode { false };
         double y0;
 
+        QPoint rotationStart;
+        dms rotationStartAngle;
+
         double m_Scale;
 
         KStarsData *data { nullptr };
diff --git a/kstars/skymapdrawabstract.cpp b/kstars/skymapdrawabstract.cpp
index 076a4a6eac..b53687f5ed 100644
--- a/kstars/skymapdrawabstract.cpp
+++ b/kstars/skymapdrawabstract.cpp
@@ -11,6 +11,7 @@
 
 #include <QPainter>
 #include <QPixmap>
+#include <QPainterPath>
 
 #include "skymapdrawabstract.h"
 #include "skymap.h"
@@ -85,6 +86,8 @@ void SkyMapDrawAbstract::drawOverlays(QPainter &p, bool drawFov)
 
     drawZoomBox(p);
 
+    drawOrientationArrows(p);
+
     // FIXME: Maybe we should take care of this differently. Maybe
     // drawOverlays should remain in SkyMap, since it just calls
     // certain drawing functions which are implemented in
@@ -108,6 +111,81 @@ void SkyMapDrawAbstract::drawAngleRuler(QPainter &p)
                                        1))); // FIXME: Again, AngularRuler should be something better -- maybe a class in itself. After all it's used for more than one thing after we integrate the StarHop feature.
 }
 
+void SkyMapDrawAbstract::drawOrientationArrows(QPainter &p)
+{
+    if (m_SkyMap->rotationStart.x() > 0 && m_SkyMap->rotationStart.y() > 0)
+    {
+        auto* data = m_KStarsData;
+        const SkyPoint centerSkyPoint = m_SkyMap->m_proj->fromScreen(
+                                            p.viewport().center(),
+                                            data->lst(), data->geo()->lat());
+
+        QPointF centerScreenPoint = p.viewport().center();
+        double northRotation = m_SkyMap->m_proj->findNorthPA(
+                                   &centerSkyPoint, centerScreenPoint.x(), centerScreenPoint.y());
+        double zenithRotation = m_SkyMap->m_proj->findZenithPA(
+                                    &centerSkyPoint, centerScreenPoint.x(), centerScreenPoint.y());
+
+        QColor overlayColor(data->colorScheme()->colorNamed("CompassColor"));
+        p.setPen(Qt::NoPen);
+        auto drawArrow = [&](double angle, const QString & marker, const float labelRadius, const bool primary)
+        {
+            constexpr float radius = 150.0f; // In pixels
+            const auto fontMetrics = QFontMetricsF(QFont());
+            QTransform transform;
+            QColor color = overlayColor;
+            color.setAlphaF(primary ? 1.0 : 0.75);
+            QPen pen(color, 1.0, primary ? Qt::SolidLine : Qt::DotLine);
+            QBrush brush(color);
+
+            QPainterPath arrowstem;
+            arrowstem.moveTo(0.f, 0.f);
+            arrowstem.lineTo(0.f, -radius + radius / 7.5f);
+            transform.reset();
+            transform.translate(centerScreenPoint.x(), centerScreenPoint.y());
+            transform.rotate(angle);
+            arrowstem = transform.map(arrowstem);
+            p.strokePath(arrowstem, pen);
+
+            QPainterPath arrowhead;
+            arrowhead.moveTo(0.f, 0.f);
+            arrowhead.lineTo(-radius / 30.f, radius / 7.5f);
+            arrowhead.lineTo(radius / 30.f, radius / 7.5f);
+            arrowhead.lineTo(0.f, 0.f);
+            arrowhead.addText(QPointF(-1.1 * fontMetrics.width(marker), radius / 7.5f + 1.2f * fontMetrics.ascent()),
+                              QFont(), marker);
+            transform.translate(0, -radius);
+            arrowhead = transform.map(arrowhead);
+            p.fillPath(arrowhead, brush);
+
+            QRectF angleMarkerRect(centerScreenPoint.x() - labelRadius, centerScreenPoint.y() - labelRadius,
+                                   2.f * labelRadius, 2.f * labelRadius);
+            p.setPen(pen);
+            if (abs(angle) < 0.01)
+            {
+                angle = 0.;
+            }
+            double arcAngle = angle <= 0. ? -angle : 360. - angle;
+            p.drawArc(angleMarkerRect, 90 * 16, int(arcAngle * 16.));
+
+            QPainterPath angleLabel;
+            QString angleLabelText = QString::number(int(round(arcAngle))) + "°";
+            angleLabel.addText(QPointF(-fontMetrics.width(angleLabelText) / 2.f, 1.2f * fontMetrics.ascent()),
+                               QFont(), angleLabelText);
+            transform.reset();
+            transform.translate(centerScreenPoint.x(), centerScreenPoint.y());
+            transform.rotate(angle);
+            transform.translate(0, -labelRadius);
+            transform.rotate(90);
+            angleLabel = transform.map(angleLabel);
+            p.fillPath(angleLabel, brush);
+
+        };
+        drawArrow(northRotation, i18nc("North", "N"), 80.f, !Options::useAltAz());
+        drawArrow(zenithRotation, i18nc("Zenith", "Z"), 40.f, Options::useAltAz());
+    }
+}
+
 void SkyMapDrawAbstract::drawZoomBox(QPainter &p)
 {
     //draw the manual zoom-box, if it exists
diff --git a/kstars/skymapdrawabstract.h b/kstars/skymapdrawabstract.h
index 3627d70c62..ecbd87b31a 100644
--- a/kstars/skymapdrawabstract.h
+++ b/kstars/skymapdrawabstract.h
@@ -68,6 +68,12 @@ class SkyMapDrawAbstract
             */
     void drawSolverFOV(QPainter &psky);
 
+    /**
+     * @short Draw north and zenith arrows to show the orientation while rotating the sky map
+     * @param p reference to the QPainter on which to draw (this should be the sky map)
+     */
+    void drawOrientationArrows(QPainter &p);
+
     /**
         	*@short Draw a dotted-line rectangle which traces the potential new field-of-view in ZoomBox mode.
         	*@param psky reference to the QPainter on which to draw (this should be the Sky pixmap).
diff --git a/kstars/skymapevents.cpp b/kstars/skymapevents.cpp
index 7bab75adf1..ff53f7e348 100644
--- a/kstars/skymapevents.cpp
+++ b/kstars/skymapevents.cpp
@@ -537,6 +537,33 @@ void SkyMap::mouseMoveEvent(QMouseEvent *e)
         }
     }
 
+    // Are we setting the skymap rotation?
+    if (rotationStart.x() > 0 && rotationStart.y() > 0)
+    {
+        // stop the operation if the user let go of SHIFT
+        if (!(e->modifiers() & Qt::ShiftModifier))
+        {
+            rotationStart = QPoint(); // invalidate
+            rotationStartAngle = dms(); // NaN
+            slewing = false;
+            forceUpdate();
+            return;
+        }
+        else
+        {
+            // Compute the rotation
+            const float start_x = rotationStart.x() - width() / 2.0f;
+            const float start_y = height() / 2.0f - rotationStart.y();
+
+            const float curr_x = e->pos().x() - width() / 2.0f;
+            const float curr_y = height() / 2.0f - e->pos().y();
+
+            const dms angle {(std::atan2(curr_y, curr_x) - std::atan2(start_y, start_x)) / dms::DegToRad };
+            slotSetSkyRotation((rotationStartAngle - angle).Degrees());
+            return;
+        }
+    }
+
     if (projector()->unusablePoint(e->pos()))
         return; // break if point is unusable
 
@@ -686,6 +713,16 @@ void SkyMap::mouseReleaseEvent(QMouseEvent *e)
         slotCancelLegendPreviewMode();
     }
 
+    // Are we setting the skymap rotation?
+    if (rotationStart.x() > 0 && rotationStart.y() > 0)
+    {
+        rotationStart = QPoint(); // invalidate
+        rotationStartAngle = dms(); // NaN
+        slewing = false;
+        forceUpdateNow();
+        return;
+    }
+
     //false if double-clicked, because it's unset there.
     if (mouseButtonDown)
     {
@@ -718,6 +755,17 @@ void SkyMap::mousePressEvent(QMouseEvent *e)
         return;
     }
 
+    if ((e->modifiers() & Qt::ShiftModifier) && (e->button() == Qt::LeftButton))
+    {
+        // Skymap rotation mode
+        rotationStart = e->pos();
+        rotationStartAngle = dms(Options::skyRotation());
+        slewing = true;
+        setRotationMouseCursor();
+        forceUpdate();
+        return;
+    }
+
     // if button is down and cursor is not moved set the move cursor after 500 ms
     //QTimer::singleShot(500, this, SLOT(setMouseMoveCursor()));
 
diff --git a/kstars/skymaplite.cpp b/kstars/skymaplite.cpp
index d0930734e6..ac650ea966 100644
--- a/kstars/skymaplite.cpp
+++ b/kstars/skymaplite.cpp
@@ -742,6 +742,7 @@ void SkyMapLite::setupProjector()
     p.useAltAz      = Options::useAltAz();
     p.useRefraction = Options::useRefraction();
     p.zoomFactor    = Options::zoomFactor();
+    p.rotationAngle = Options::skyRotation();
     p.fillGround    = Options::showGround();
 
     //Check if we need a new projector
diff --git a/kstars/skyobjects/skypoint.cpp b/kstars/skyobjects/skypoint.cpp
index b27bf6f2b0..9c1f76858c 100644
--- a/kstars/skyobjects/skypoint.cpp
+++ b/kstars/skyobjects/skypoint.cpp
@@ -214,10 +214,10 @@ void SkyPoint::setFromEcliptic(const CachingDms *Obliquity, const dms &EcLong, c
     // Dec.setUsing_asin(sinDec);
 
     // Use Haversine to set declination
-    Dec.setRadians(dms::PI/2.0 - 2.0 * asin(sqrt(0.5 * (
-                                                     1.0 - sinLat * cosObliq
-                                                     - cosLat * sinObliq * sinLong
-                                                     ))));
+    Dec.setRadians(dms::PI / 2.0 - 2.0 * asin(sqrt(0.5 * (
+                       1.0 - sinLat * cosObliq
+                       - cosLat * sinObliq * sinLong
+                   ))));
 }
 
 void SkyPoint::precess(const KSNumbers *num)
@@ -1149,6 +1149,27 @@ double SkyPoint::minAlt(const dms &lat) const
     return retval;
 }
 
+dms SkyPoint::parallacticAngle(const CachingDms &LST, const CachingDms &lat)
+{
+    // N.B. Technically, we could use the form
+    // cos(angle) = (sin(φ) - sin(h) sin(δ))/(cos(h) cos(δ))
+    // where h = altitude, δ = declination, φ = latitude,
+    // and then disambiguate the sign as
+    //  if (az().reduce() < 180°) angle = -angle;
+    // However, acos(...) is inaccurate when cosine is nearly flat, i.e. near 0° and 180°.
+    // It is therefore better to go through some extra pain to use atan2()
+
+    // Therefore we use the form shown in Jean Meeus' book (14.1)
+    dms HA = LST - ra();
+    double tan_lat = lat.sin() / lat.cos();
+    double angle = atan2( // Measured CW on sky map (See Meeus' Fig on Pg 99)
+        HA.sin(),
+        tan_lat * dec().cos() - HA.cos() * dec().sin()
+        );
+    return dms(angle / dms::DegToRad);
+}
+
+
 #ifndef KSTARS_LITE
 QDBusArgument &operator<<(QDBusArgument &argument, const SkyPoint &source)
 {
diff --git a/kstars/skyobjects/skypoint.h b/kstars/skyobjects/skypoint.h
index 14202a63ee..bb0b6ded29 100644
--- a/kstars/skyobjects/skypoint.h
+++ b/kstars/skyobjects/skypoint.h
@@ -731,6 +731,27 @@ class SkyPoint
          */
         double minAlt(const dms &lat) const;
 
+        /**
+         * @short Return the Parallactic Angle
+         *
+         * The parallactic angle is the angle between "up" and
+         * "north". See Jean Meeus' "Astronomical Algorithms" second
+         * edition, Chapter 14 for more details (especially Fig 4 on Pg
+         * 99). The angle returned in this case, between a vector of
+         * increasing altitude and a vector of increasing declination, is
+         * measured in the clockwise sense as seen on the sky.
+         *
+         * @param LST Local Sidereal Time
+         * @param lat Latitude
+         *
+         * @note EquatorialToHorizontal() need not be called before
+         * invoking this, but it is wise to call updateCoords() to ensure
+         * ra() and dec() refer to the right epoch.
+         *
+         * @return the parallactic angle in the clockwise sense
+         */
+        dms parallacticAngle(const CachingDms &LST, const CachingDms &lat);
+
 #ifdef PROFILE_COORDINATE_CONVERSION
         static double cpuTime_EqToHz;
         static long unsigned eqToHzCalls;
diff --git a/kstars/terrain/terrainrenderer.cpp b/kstars/terrain/terrainrenderer.cpp
index 5709b43f0b..0e425d2286 100644
--- a/kstars/terrain/terrainrenderer.cpp
+++ b/kstars/terrain/terrainrenderer.cpp
@@ -323,7 +323,9 @@ bool TerrainRenderer::sameView(const Projector *proj, bool forceRefresh)
     const double alt = rationalizeAlt(point.alt().Degrees());
 
     bool ok = view.width == savedViewParams.width &&
+              view.height == savedViewParams.height &&
               view.zoomFactor == savedViewParams.zoomFactor &&
+              view.rotationAngle == savedViewParams.rotationAngle &&
               view.useRefraction == savedViewParams.useRefraction &&
               view.useAltAz == savedViewParams.useAltAz &&
               view.fillGround == savedViewParams.fillGround;


More information about the kde-doc-english mailing list