QGIS/src/app/gps/qgsappgpsdigitizing.cpp
2022-11-11 11:12:57 +10:00

697 lines
22 KiB
C++

/***************************************************************************
qgsappgpsdigitizing.cpp
-------------------
begin : October 2022
copyright : (C) 2022 Nyall Dawson
email : nyall dot dawson at gmail dot com
***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/
#include "qgsappgpsdigitizing.h"
#include "qgsrubberband.h"
#include "qgslinesymbol.h"
#include "qgssymbollayerutils.h"
#include "qgsgui.h"
#include "qgisapp.h"
#include "qgsmessagebar.h"
#include "gmath.h"
#include "info.h"
#include "qgspolygon.h"
#include "qgsmapcanvas.h"
#include "qgsfeatureaction.h"
#include "qgsgpsconnection.h"
#include "qgsappgpsconnection.h"
#include "qgsprojectgpssettings.h"
#include <QTimeZone>
QgsAppGpsDigitizing::QgsAppGpsDigitizing( QgsAppGpsConnection *connection, QgsMapCanvas *canvas, QObject *parent )
: QObject( parent )
, mConnection( connection )
, mCanvas( canvas )
{
mWgs84CRS = QgsCoordinateReferenceSystem::fromOgcWmsCrs( QStringLiteral( "EPSG:4326" ) );
mCanvasToWgs84Transform = QgsCoordinateTransform( mCanvas->mapSettings().destinationCrs(), mWgs84CRS, QgsProject::instance() );
connect( mCanvas, &QgsMapCanvas::destinationCrsChanged, this, [ = ]
{
mCanvasToWgs84Transform = QgsCoordinateTransform( mCanvas->mapSettings().destinationCrs(), mWgs84CRS, QgsProject::instance() );
} );
connect( QgsProject::instance(), &QgsProject::transformContextChanged, this, [ = ]
{
mCanvasToWgs84Transform = QgsCoordinateTransform( mCanvas->mapSettings().destinationCrs(), mWgs84CRS, QgsProject::instance() );
} );
mDistanceCalculator.setEllipsoid( QgsProject::instance()->ellipsoid() );
mDistanceCalculator.setSourceCrs( mWgs84CRS, QgsProject::instance()->transformContext() );
connect( QgsProject::instance(), &QgsProject::ellipsoidChanged, this, [ = ]
{
mDistanceCalculator.setEllipsoid( QgsProject::instance()->ellipsoid() );
} );
mLastNmeaPosition.lat = nmea_degree2radian( 0.0 );
mLastNmeaPosition.lon = nmea_degree2radian( 0.0 );
mAcquisitionTimer = std::unique_ptr<QTimer>( new QTimer( this ) );
mAcquisitionTimer->setSingleShot( true );
connect( mAcquisitionTimer.get(), &QTimer::timeout,
this, &QgsAppGpsDigitizing::switchAcquisition );
connect( mConnection, &QgsAppGpsConnection::stateChanged, this, &QgsAppGpsDigitizing::gpsStateChanged );
connect( mConnection, &QgsAppGpsConnection::connected, this, &QgsAppGpsDigitizing::gpsConnected );
connect( mConnection, &QgsAppGpsConnection::disconnected, this, &QgsAppGpsDigitizing::gpsDisconnected );
connect( QgsGui::instance(), &QgsGui::optionsChanged, this, &QgsAppGpsDigitizing::gpsSettingsChanged );
gpsSettingsChanged();
connect( QgisApp::instance(), &QgisApp::activeLayerChanged, this, [ = ]( QgsMapLayer * layer )
{
if ( QgsProject::instance()->gpsSettings()->destinationFollowsActiveLayer() )
{
QgsProject::instance()->gpsSettings()->setDestinationLayer( qobject_cast< QgsVectorLayer *> ( layer ) );
}
} );
connect( QgsProject::instance()->gpsSettings(), &QgsProjectGpsSettings::destinationFollowsActiveLayerChanged, this, [ = ]( bool enabled )
{
if ( enabled )
{
QgsProject::instance()->gpsSettings()->setDestinationLayer( qobject_cast< QgsVectorLayer *> ( QgisApp::instance()->activeLayer() ) );
}
} );
if ( QgsProject::instance()->gpsSettings()->destinationFollowsActiveLayer() )
{
QgsProject::instance()->gpsSettings()->setDestinationLayer( qobject_cast< QgsVectorLayer *> ( QgisApp::instance()->activeLayer() ) );
}
connect( QgsProject::instance(), &QgsProject::transformContextChanged, this, &QgsAppGpsDigitizing::updateDistanceArea );
connect( QgsProject::instance(), &QgsProject::ellipsoidChanged, this, &QgsAppGpsDigitizing::updateDistanceArea );
updateDistanceArea();
}
QgsAppGpsDigitizing::~QgsAppGpsDigitizing()
{
delete mRubberBand;
mRubberBand = nullptr;
}
double QgsAppGpsDigitizing::totalTrackLength() const
{
QVector<QgsPointXY> points;
QgsGeometry::convertPointList( mCaptureListWgs84, points );
return mDa.measureLine( points );
}
double QgsAppGpsDigitizing::trackDistanceFromStart() const
{
if ( mCaptureListWgs84.empty() )
return 0;
return mDa.measureLine( { QgsPointXY( mCaptureListWgs84.constFirst() ), QgsPointXY( mCaptureListWgs84.constLast() )} );
}
const QgsDistanceArea &QgsAppGpsDigitizing::distanceArea() const
{
return mDa;
}
void QgsAppGpsDigitizing::addVertex()
{
if ( !mRubberBand )
{
createRubberBand();
}
// we store the capture list in wgs84 and then transform to layer crs when
// calling close feature
const QgsPoint pointWgs84 = QgsPoint( mLastGpsPositionWgs84.x(), mLastGpsPositionWgs84.y(), mLastElevation );
const bool trackWasEmpty = mCaptureListWgs84.empty();
mCaptureListWgs84.push_back( pointWgs84 );
// we store the rubber band points in map canvas CRS so transform to map crs
// potential problem with transform errors and wrong coordinates if map CRS is changed after points are stored - SLM
// should catch map CRS change and transform the points
QgsPointXY mapPoint;
if ( mCanvas )
{
try
{
mapPoint = mCanvasToWgs84Transform.transform( mLastGpsPositionWgs84, Qgis::TransformDirection::Reverse );
}
catch ( QgsCsException & )
{
QgsDebugMsg( QStringLiteral( "Could not transform GPS location (%1, %2) to map CRS" ).arg( mLastGpsPositionWgs84.x() ).arg( mLastGpsPositionWgs84.y() ) );
return;
}
}
else
{
mapPoint = mLastGpsPositionWgs84;
}
mRubberBand->addPoint( mapPoint );
if ( trackWasEmpty )
emit trackIsEmptyChanged( false );
emit trackChanged();
}
void QgsAppGpsDigitizing::resetTrack()
{
mBlockGpsStateChanged++;
createRubberBand(); //deletes existing rubberband
const bool trackWasEmpty = mCaptureListWgs84.isEmpty();
mCaptureListWgs84.clear();
mBlockGpsStateChanged--;
if ( !trackWasEmpty )
emit trackIsEmptyChanged( true );
emit trackChanged();
}
void QgsAppGpsDigitizing::createFeature()
{
QgsVectorLayer *vlayer = QgsProject::instance()->gpsSettings()->destinationLayer();
if ( !vlayer )
return;
if ( vlayer->geometryType() == QgsWkbTypes::LineGeometry && mCaptureListWgs84.size() < 2 )
{
QgisApp::instance()->messageBar()->pushWarning( tr( "Add Feature" ), tr( "Creating a line feature requires a track with at least two vertices." ) );
return;
}
else if ( vlayer->geometryType() == QgsWkbTypes::PolygonGeometry && mCaptureListWgs84.size() < 3 )
{
QgisApp::instance()->messageBar()->pushWarning( tr( "Add Feature" ),
tr( "Creating a polygon feature requires a track with at least three vertices." ) );
return;
}
if ( !vlayer->isEditable() )
{
if ( vlayer->startEditing() )
{
if ( !QgsProject::instance()->gpsSettings()->automaticallyCommitFeatures() )
{
QgisApp::instance()->messageBar()->pushInfo( tr( "Add Feature" ), tr( "Layer “%1” was made editable" ).arg( vlayer->name() ) );
}
}
else
{
QgisApp::instance()->messageBar()->pushWarning( tr( "Add Feature" ), tr( "Cannot create feature — the layer “%2” could not be made editable" ).arg( vlayer->name() ) );
return;
}
}
// Handle timestamp
QgsAttributeMap attrMap;
const int idx = vlayer->fields().indexOf( QgsProject::instance()->gpsSettings()->destinationTimeStampField() );
if ( idx != -1 )
{
const QVariant ts = timestamp( vlayer, idx );
if ( ts.isValid() )
{
attrMap[ idx ] = ts;
}
}
const QgsCoordinateTransform wgs84ToLayerTransform( mWgs84CRS, vlayer->crs(), QgsProject::instance() );
const bool is3D = QgsWkbTypes::hasZ( vlayer->wkbType() );
switch ( vlayer->geometryType() )
{
case QgsWkbTypes::PointGeometry:
{
QgsFeature f;
try
{
const QgsPointXY pointXYLayerCrs = wgs84ToLayerTransform.transform( mLastGpsPositionWgs84 );
QgsGeometry g;
if ( is3D )
g = QgsGeometry( new QgsPoint( pointXYLayerCrs.x(), pointXYLayerCrs.y(), mLastElevation ) );
else
g = QgsGeometry::fromPointXY( pointXYLayerCrs );
if ( QgsWkbTypes::isMultiType( vlayer->wkbType() ) )
g.convertToMultiType();
f.setGeometry( g );
}
catch ( QgsCsException & )
{
QgisApp::instance()->messageBar()->pushCritical( tr( "Add Feature" ),
tr( "Error reprojecting feature to layer CRS." ) );
return;
}
QgsFeatureAction action( tr( "Feature Added" ), f, vlayer, QUuid(), -1, this );
if ( action.addFeature( attrMap ) )
{
if ( QgsProject::instance()->gpsSettings()->automaticallyCommitFeatures() )
{
// should canvas->isDrawing() be checked?
if ( !vlayer->commitChanges() ) //assumed to be vector layer and is editable and is in editing mode (preconditions have been tested)
{
QgisApp::instance()->messageBar()->pushCritical(
tr( "Save Layer Edits" ),
tr( "Could not commit changes to layer %1\n\nErrors: %2\n" )
.arg( vlayer->name(),
vlayer->commitErrors().join( QLatin1String( "\n " ) ) ) );
}
vlayer->startEditing();
}
}
else
{
QgisApp::instance()->messageBar()->pushCritical( QString(), tr( "Could not create new feature in layer %1" ).arg( vlayer->name() ) );
}
break;
}
case QgsWkbTypes::LineGeometry:
case QgsWkbTypes::PolygonGeometry:
{
mBlockGpsStateChanged++;
QgsFeature f;
QgsGeometry g;
std::unique_ptr<QgsLineString> ringWgs84( new QgsLineString( mCaptureListWgs84 ) );
if ( ! is3D )
ringWgs84->dropZValue();
if ( vlayer->geometryType() == QgsWkbTypes::LineGeometry )
{
g = QgsGeometry( ringWgs84.release() );
try
{
g.transform( wgs84ToLayerTransform );
}
catch ( QgsCsException & )
{
QgisApp::instance()->messageBar()->pushWarning( tr( "Add Feature" ),
tr( "Error reprojecting feature to layer CRS." ) );
return;
}
if ( QgsWkbTypes::isMultiType( vlayer->wkbType() ) )
g.convertToMultiType();
}
else if ( vlayer->geometryType() == QgsWkbTypes::PolygonGeometry )
{
ringWgs84->close();
std::unique_ptr<QgsPolygon> polygon( new QgsPolygon() );
polygon->setExteriorRing( ringWgs84.release() );
g = QgsGeometry( polygon.release() );
try
{
g.transform( wgs84ToLayerTransform );
}
catch ( QgsCsException & )
{
mBlockGpsStateChanged--;
QgisApp::instance()->messageBar()->pushWarning( tr( "Add Feature" ),
tr( "Error reprojecting feature to layer CRS." ) );
return;
}
if ( QgsWkbTypes::isMultiType( vlayer->wkbType() ) )
g.convertToMultiType();
const int avoidIntersectionsReturn = g.avoidIntersections( QgsProject::instance()->avoidIntersectionsLayers() );
if ( avoidIntersectionsReturn == 1 )
{
//not a polygon type. Impossible to get there
}
else if ( avoidIntersectionsReturn == 2 )
{
//bail out...
QgisApp::instance()->messageBar()->pushWarning( tr( "Add Feature" ), tr( "The feature could not be added because removing the polygon intersections would change the geometry type." ) );
mBlockGpsStateChanged--;
return;
}
else if ( avoidIntersectionsReturn == 3 )
{
QgisApp::instance()->messageBar()->pushCritical( tr( "Add Feature" ), tr( "The feature has been added, but at least one geometry intersected is invalid. These geometries must be manually repaired." ) );
mBlockGpsStateChanged--;
return;
}
}
f.setGeometry( g );
QgsFeatureAction action( tr( "Feature added" ), f, vlayer, QUuid(), -1, this );
if ( action.addFeature( attrMap ) )
{
if ( QgsProject::instance()->gpsSettings()->automaticallyCommitFeatures() )
{
if ( !vlayer->commitChanges() )
{
QgisApp::instance()->messageBar()->pushCritical( tr( "Save Layer Edits" ),
tr( "Could not commit changes to layer %1\n\nErrors: %2\n" )
.arg( vlayer->name(),
vlayer->commitErrors().join( QLatin1String( "\n " ) ) ) );
}
vlayer->startEditing();
}
delete mRubberBand;
mRubberBand = nullptr;
// delete the elements of mCaptureList
mCaptureListWgs84.clear();
}
else
{
QgisApp::instance()->messageBar()->pushCritical( QString(), tr( "Could not create new feature in layer %1" ).arg( vlayer->name() ) );
}
mBlockGpsStateChanged--;
if ( mCaptureListWgs84.empty() )
emit trackIsEmptyChanged( true );
emit trackChanged();
break;
}
case QgsWkbTypes::NullGeometry:
case QgsWkbTypes::UnknownGeometry:
return;
}
vlayer->triggerRepaint();
QgisApp::instance()->activateWindow();
}
void QgsAppGpsDigitizing::setNmeaLogFile( const QString &filename )
{
if ( mLogFile )
{
stopLogging();
}
mNmeaLogFile = filename;
if ( mEnableNmeaLogging && !mNmeaLogFile.isEmpty() )
{
startLogging();
}
}
void QgsAppGpsDigitizing::setNmeaLoggingEnabled( bool enabled )
{
if ( enabled == static_cast< bool >( mLogFile ) )
return;
if ( mLogFile && !enabled )
{
stopLogging();
}
mEnableNmeaLogging = enabled;
if ( mEnableNmeaLogging && !mNmeaLogFile.isEmpty() )
{
startLogging();
}
}
void QgsAppGpsDigitizing::gpsSettingsChanged()
{
updateTrackAppearance();
int acquisitionInterval = 0;
if ( QgsGpsConnection::settingsGpsConnectionType.exists() )
{
acquisitionInterval = static_cast< int >( QgsGpsConnection::settingGpsAcquisitionInterval.value() );
mDistanceThreshold = QgsGpsConnection::settingGpsDistanceThreshold.value();
mApplyLeapSettings = QgsGpsConnection::settingGpsApplyLeapSecondsCorrection.value();
mLeapSeconds = static_cast< int >( QgsGpsConnection::settingGpsLeapSeconds.value() );
mTimeStampSpec = QgsGpsConnection::settingsGpsTimeStampSpecification.value();
mTimeZone = QgsGpsConnection::settingsGpsTimeStampTimeZone.value();
mOffsetFromUtc = static_cast< int >( QgsGpsConnection::settingsGpsTimeStampOffsetFromUtc.value() );
}
else
{
// legacy settings
QgsSettings settings;
acquisitionInterval = settings.value( QStringLiteral( "acquisitionInterval" ), 0, QgsSettings::Gps ).toInt();
mDistanceThreshold = settings.value( QStringLiteral( "distanceThreshold" ), 0, QgsSettings::Gps ).toDouble();
mApplyLeapSettings = settings.value( QStringLiteral( "applyLeapSeconds" ), true, QgsSettings::Gps ).toBool();
mLeapSeconds = settings.value( QStringLiteral( "leapSecondsCorrection" ), 18, QgsSettings::Gps ).toInt();
switch ( settings.value( QStringLiteral( "timeStampFormat" ), Qt::LocalTime, QgsSettings::Gps ).toInt() )
{
case 0:
mTimeStampSpec = Qt::TimeSpec::LocalTime;
break;
case 1:
mTimeStampSpec = Qt::TimeSpec::UTC;
break;
case 2:
mTimeStampSpec = Qt::TimeSpec::TimeZone;
break;
}
mTimeZone = settings.value( QStringLiteral( "timestampTimeZone" ), QVariant(), QgsSettings::Gps ).toString();
}
mAcquisitionInterval = acquisitionInterval * 1000;
if ( mAcquisitionTimer->isActive() )
mAcquisitionTimer->stop();
mAcquisitionEnabled = true;
switchAcquisition();
}
void QgsAppGpsDigitizing::updateTrackAppearance()
{
if ( !mRubberBand )
return;
QDomDocument doc;
QDomElement elem;
const QString trackLineSymbolXml = QgsAppGpsDigitizing::settingTrackLineSymbol.value();
if ( !trackLineSymbolXml.isEmpty() )
{
doc.setContent( trackLineSymbolXml );
elem = doc.documentElement();
std::unique_ptr< QgsLineSymbol > trackLineSymbol( QgsSymbolLayerUtils::loadSymbol<QgsLineSymbol>( elem, QgsReadWriteContext() ) );
if ( trackLineSymbol )
{
mRubberBand->setSymbol( trackLineSymbol.release() );
}
mRubberBand->update();
}
}
void QgsAppGpsDigitizing::switchAcquisition()
{
if ( mAcquisitionInterval > 0 )
{
if ( mAcquisitionEnabled )
mAcquisitionTimer->start( mAcquisitionInterval );
else
//wait only acquisitionInterval/10 for new valid data
mAcquisitionTimer->start( mAcquisitionInterval / 10 );
// anyway switch to enabled / disabled acquisition
mAcquisitionEnabled = !mAcquisitionEnabled;
}
}
void QgsAppGpsDigitizing::gpsConnected()
{
if ( !mLogFile && mEnableNmeaLogging && !mNmeaLogFile.isEmpty() )
{
startLogging();
}
}
void QgsAppGpsDigitizing::gpsDisconnected()
{
stopLogging();
}
void QgsAppGpsDigitizing::gpsStateChanged( const QgsGpsInformation &info )
{
if ( mBlockGpsStateChanged )
return;
const bool validFlag = info.isValid();
QgsPointXY newLocationWgs84;
nmeaPOS newNmeaPosition;
nmeaTIME newNmeaTime;
double newAlt = 0.0;
if ( validFlag )
{
newLocationWgs84 = QgsPointXY( info.longitude, info.latitude );
newNmeaPosition.lat = nmea_degree2radian( info.latitude );
newNmeaPosition.lon = nmea_degree2radian( info.longitude );
newAlt = info.elevation;
nmea_time_now( &newNmeaTime );
mLastNmeaTime = newNmeaTime;
}
else
{
newLocationWgs84 = mLastGpsPositionWgs84;
newNmeaPosition = mLastNmeaPosition;
newAlt = mLastElevation;
}
if ( !mAcquisitionEnabled || ( nmea_distance( &newNmeaPosition, &mLastNmeaPosition ) < mDistanceThreshold ) )
{
// do not update position if update is disabled by timer or distance is under threshold
newLocationWgs84 = mLastGpsPositionWgs84;
}
if ( validFlag && mAcquisitionEnabled )
{
// position updated by valid data, reset timer
switchAcquisition();
}
// Avoid refreshing / panning if we haven't moved
if ( mLastGpsPositionWgs84 != newLocationWgs84 )
{
mLastGpsPositionWgs84 = newLocationWgs84;
mLastNmeaPosition = newNmeaPosition;
mLastElevation = newAlt;
if ( QgsProject::instance()->gpsSettings()->automaticallyAddTrackVertices() )
{
addVertex();
}
}
}
void QgsAppGpsDigitizing::logNmeaSentence( const QString &nmeaString )
{
if ( mEnableNmeaLogging && mLogFile && mLogFile->isOpen() )
{
mLogFileTextStream << nmeaString << "\r\n"; // specifically output CR + LF (NMEA requirement)
}
}
void QgsAppGpsDigitizing::startLogging()
{
if ( !mLogFile )
{
mLogFile = std::make_unique< QFile >( mNmeaLogFile );
}
if ( mLogFile->open( QIODevice::Append ) ) // open in binary and explicitly output CR + LF per NMEA
{
mLogFileTextStream.setDevice( mLogFile.get() );
// crude way to separate chunks - use when manually editing file - NMEA parsers should discard
mLogFileTextStream << "====" << "\r\n";
connect( mConnection, &QgsAppGpsConnection::nmeaSentenceReceived, this, &QgsAppGpsDigitizing::logNmeaSentence ); // added to handle raw data
}
else // error opening file
{
mLogFile.reset();
// need to indicate why - this just reports that an error occurred
QgisApp::instance()->messageBar()->pushCritical( QString(), tr( "Error opening log file." ) );
}
}
void QgsAppGpsDigitizing::stopLogging()
{
if ( mLogFile && mLogFile->isOpen() )
{
disconnect( mConnection, &QgsAppGpsConnection::nmeaSentenceReceived, this, &QgsAppGpsDigitizing::logNmeaSentence );
mLogFile->close();
mLogFile.reset();
}
}
void QgsAppGpsDigitizing::updateDistanceArea()
{
mDa.setEllipsoid( QgsProject::instance()->ellipsoid() );
mDa.setSourceCrs( mWgs84CRS, QgsProject::instance()->transformContext() );
emit distanceAreaChanged();
}
void QgsAppGpsDigitizing::createRubberBand()
{
delete mRubberBand;
mRubberBand = new QgsRubberBand( mCanvas, QgsWkbTypes::LineGeometry );
updateTrackAppearance();
mRubberBand->show();
}
QVariant QgsAppGpsDigitizing::timestamp( QgsVectorLayer *vlayer, int idx )
{
QVariant value;
if ( idx != -1 )
{
QDateTime time( QDate( 1900 + mLastNmeaTime.year, mLastNmeaTime.mon + 1, mLastNmeaTime.day ),
QTime( mLastNmeaTime.hour, mLastNmeaTime.min, mLastNmeaTime.sec, mLastNmeaTime.msec ) );
// Time from GPS is UTC time
time.setTimeSpec( Qt::UTC );
// Apply leap seconds correction
if ( mApplyLeapSettings && mLeapSeconds != 0 )
{
time = time.addSecs( mLeapSeconds );
}
// Desired format
if ( mTimeStampSpec != Qt::TimeSpec::OffsetFromUTC )
time = time.toTimeSpec( mTimeStampSpec );
if ( mTimeStampSpec == Qt::TimeSpec::TimeZone )
{
// Get timezone from the combo
const QTimeZone destTz( mTimeZone.toUtf8() );
if ( destTz.isValid() )
{
time = time.toTimeZone( destTz );
}
}
else if ( mTimeStampSpec == Qt::TimeSpec::LocalTime )
{
time = time.toLocalTime();
}
else if ( mTimeStampSpec == Qt::TimeSpec::OffsetFromUTC )
{
time = time.toOffsetFromUtc( mOffsetFromUtc );
}
else if ( mTimeStampSpec == Qt::TimeSpec::UTC )
{
// Do nothing: we are already in UTC
}
// Only string and datetime fields are supported
switch ( vlayer->fields().at( idx ).type() )
{
case QVariant::String:
value = time.toString( Qt::DateFormat::ISODate );
break;
case QVariant::DateTime:
value = time;
break;
default:
break;
}
}
return value;
}