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>&amp;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;

Reply via email to