Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BUGFIX][FEATURE][NEEDS-DOCS] Disable snapping on invisible features. Second version #6750

Merged
merged 6 commits into from
May 1, 2018
Merged

[BUGFIX][FEATURE][NEEDS-DOCS] Disable snapping on invisible features. Second version #6750

merged 6 commits into from
May 1, 2018

Conversation

lbartoletti
Copy link
Member

@lbartoletti lbartoletti commented Apr 5, 2018

Description

Following the discussions, I open a new PR with modifications for #6657 / Fixes #16838.

If you are ok with the new propositions, I will add tests

A comment, I thought that by activating/deactivating a style, the styleChanged or rendererChanged signal would be emitted. This is not the case, I added the repaintRequested signal. See the video

I tested on a data set with the cadastre and all networks (water, sanitation, road, etc.) on an old computer and for me it is usable.

Checklist

Reviewing is a process done by project maintainers, mostly on a volunteer basis. We try to keep the overhead as small as possible and appreciate if you help us to do so by completing the following items. Feel free to ask in a comment if you have troubles with any of them.

  • Commit messages are descriptive and explain the rationale for changes
  • Commits which fix bugs include fixes #11111 in the commit message next to the description
  • Commits which add new features are tagged with [FEATURE] in the commit message
  • Commits which change the UI or existing user workflows are tagged with [needs-docs] in the commit message and containt sufficient information in the commit message to be documented
  • I have read the QGIS Coding Standards and this PR complies with them
  • This PR passes all existing unit tests (test results will be reported by travis-ci after opening this PR)
  • New unit tests have been added for core changes
  • I have run the scripts/prepare-commit.sh script before each commit

Copy link
Collaborator

@nyalldawson nyalldawson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking much better - this is close now!

Don't forget to add unit tests too.

connect( mLayer, &QgsVectorLayer::styleChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::layerModified, this, &QgsPointLocator::destroyIndex );
// used when a style is (de)activated
connect( mLayer, &QgsVectorLayer::repaintRequested, this, &QgsPointLocator::destroyIndex );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about destroying the index on a repaint and layer modified - these signals are thrown whenever a feature is edited in a layer - so by destroying the index when that occurs it's forcing a full rebuild, instead of the faster update handling done by onFeatureDeleted/onGeometryChanged. (I'd also suggest working on a test data set with 100k+ features while test running this.. that'll be bound to reveal any performance regressions!)

I think you'll need to fix the missing styleChanged signal instead.

@@ -685,7 +694,8 @@ bool QgsPointLocator::rebuildIndex( int maxFeaturesToIndex )
return true; // nothing to index

QgsFeatureRequest request;
request.setSubsetOfAttributes( QgsAttributeList() );
//request.setSubsetOfAttributes( QgsAttributeList() );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetching attributes here isn't required, is it? I think this is leftover from an earlier iteration and will cause a performance drop.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I add this request in an else

@@ -685,7 +694,8 @@ bool QgsPointLocator::rebuildIndex( int maxFeaturesToIndex )
return true; // nothing to index

QgsFeatureRequest request;
request.setSubsetOfAttributes( QgsAttributeList() );
//request.setSubsetOfAttributes( QgsAttributeList() );
request.setFilterFids( mFeatureIds );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be:

if ( !mFeatureIds.empty() )
    request.setFilterFids( mFeatureIds );

(I think ideally QgsFeatureRequest should ignore empty feature lists, but at the moment it still sets the filter type to FilterFids even if an empty list is given -- that would need careful testing though, so go with the easy option here).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done with an else for request.setSubsetOfAttributes

@@ -330,6 +330,57 @@ QgsPointLocator::Match QgsSnappingUtils::snapToMap( const QgsPointXY &pointMap,
}


QgsFeatureIds QgsSnappingUtils::isMatchAVisibleLayer( QgsVectorLayer *layer )
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to findVisibleFeatures

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

renderer->stopRender( context );
}
}
else {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this else branch is needed - if the invisible skipping is disabled, you could return an empty list here. That'll be handled correctly by the request in QgsPointLocator::rebuildIndex and avoid the expensive full layer iteration performed here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -359,7 +411,13 @@ void QgsSnappingUtils::prepareIndex( const QList<LayerAndAreaOfInterest> &layers
QgsVectorLayer *vl = entry.first;
QTime tt;
tt.start();

QgsFeatureIds fids = isMatchAVisibleLayer( vl );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here you may need to check:

if ( mEnableSnappingForInvisibleFeature && fids.empty() )
continue; //no visible features, skip layer

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done with a minor change

if ( !mEnableSnappingForInvisibleFeature && fids.empty() )
continue; //no visible features, skip layer

}


QgsFeatureIterator fi = layer->getFeatures();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use an optimised request here:

  • use the renderer filter (as explored earlier) to partially limit the requested features
  • set the subset of attributes to match only those required by the renderer
  • maybe limit to mapSettings extent? (not sure)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should limit to map extent.
ping @wonder-sk

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decision whether to limit to mapSettings extent should depend on the indexing strategy of the locator - if the locator indexes only a particular extent, the request should use it, but if the locator indexes everything, no extent should be set.

Looking at the current approach, one thing that worries me is that now we need to iterate over the vector layer twice: first to check whether the features are actually visible and store the fids, second time to build the actual locator index. Another problem is that most of the time all (or nearly all) features will be visible, and constructing feature requests with large number of filtered FIDs is going to be inefficient - for example, for postgres that means a generating and processing a query like this: "SELECT .... WHERE fid IN (1, 2, 3, 4, ..., 10000)"

How about updating QgsPointLocator::init() method to have optional check of feature visibility based on a passed feature renderer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@3nids @wonder-sk @nyalldawson
Thank you for your reviews.
With my last commit, I have good performance for normal case (at least my use case : cadastre, road and networks, land survey, etc.). It hangs only on very big layer (more than 100k) when is filtered.

I wonder, if mapExtent should be the solution for locators created from QgsMapCanvasSnappingUtils?

@wonder-sk

How about updating QgsPointLocator::init() method to have optional check of feature visibility >based on a passed feature renderer?

Can you explain more please, I'm not sure if I understand? Is mEnableSnappingForInvisibleFeature flag not do this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So right now in the first phase you build a set of feature IDs that will be used, then in the second phase you pass it to QgsPointLocator which uses the feature IDs in the rebuildIndex() method. What I am suggesting is to merge these two phases into one: rebuildIndex() would have access to a renderer (if enableSnappingForInvisibleFeature=false) and check its willRenderFeature() and add only visible features to the index. In this way we would avoid creation of the temporary set of feature IDs and we would not need to iterate over the layers twice.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wonder-sk OK I Understand. It was my first idea but I haven't been able to fix all incomplete type and circular include. Maybe I need to take a closer look.

*
* \since QGIS 3.2
*/
void setEnableSnappingForInvisibleFeature( bool enableIt );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/enableIt/enable/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Sorry it was forgotten from precedent version.


if ( layer )
{
if ( !mEnableSnappingForInvisibleFeature )
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should not be checked here but rather before the call at line 407 in prepareIndex().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@lbartoletti
Copy link
Member Author

I will add tests later

@lbartoletti lbartoletti reopened this Apr 12, 2018
@lbartoletti lbartoletti reopened this Apr 13, 2018
@lbartoletti lbartoletti changed the title [WIP][BUGFIX][FEATURE][NEEDS-DOCS] Disable snapping on invisible features. Second version [BUGFIX][FEATURE][NEEDS-DOCS] Disable snapping on invisible features. Second version Apr 17, 2018
@lbartoletti
Copy link
Member Author

If it's OK, can you merge it please ?

@nyalldawson
Copy link
Collaborator

From a quick glance there's still some outstanding comments which haven't been addressed yet - e.g. not rebuilding the index on a triggerRepaint, and @wonder-sk's request to avoid the setFilterFids call

@lbartoletti lbartoletti reopened this Apr 21, 2018
@lbartoletti
Copy link
Member Author

Now, it's done :)

@@ -636,6 +637,9 @@ QgsPointLocator::QgsPointLocator( QgsVectorLayer *layer, const QgsCoordinateRefe
connect( mLayer, &QgsVectorLayer::featureDeleted, this, &QgsPointLocator::onFeatureDeleted );
connect( mLayer, &QgsVectorLayer::geometryChanged, this, &QgsPointLocator::onGeometryChanged );
connect( mLayer, &QgsVectorLayer::dataChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::rendererChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::styleChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::layerModified, this, &QgsPointLocator::destroyIndex );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wonder-sk can you think of a way to avoid this? My interpretation is that it will force a full rebuild when a feature is changed (since it could change the renderer result). But looking above, it seems we rebuild the index on a dataChanged already anyway...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, unfortunately, but it's necessary.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a feeling this may be a blocker: this effectively means that even with every single change of geometry we would be destroying the index - that's going to make digitizing slow... Currently if there is e.g. 1-2 seconds initial lag when the index if first built, such lag would happen after every single move of a node when editing geometries...

Why is it necessary to destroy the index? I would expect we just need to modify existing handlers of featureAdded, featureDeleted, geometryChanged signal handlers to accommodate renderer-dependent search.

@@ -685,7 +695,8 @@ bool QgsPointLocator::rebuildIndex( int maxFeaturesToIndex )
return true; // nothing to index

QgsFeatureRequest request;
request.setSubsetOfAttributes( QgsAttributeList() );
// request.setSubsetOfAttributes( QgsAttributeList() );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd leave this, and then in the branch below when a renderer is set you should set the subset of attributes to that required by the renderer.


bool filter = false;
std::unique_ptr< QgsFeatureRenderer > renderer( mLayer->renderer() ? mLayer->renderer()->clone() : nullptr );
QgsRenderContext *ctx = nullptr;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't you just use mContext directly instead of ctx?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No sorry, it doesn't work :/

{
// setup scale for scale dependent visibility (rule based)
renderer->startRender( *ctx, mLayer->fields() );
filter = renderer->capabilities() & QgsFeatureRenderer::Filter;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set request's filter expression here to renderer->filter()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, if I do request.setFilterExpression(renderer->filter()); with a request.setSubsetOfAttributes( QgsAttributeList() ); before. I can't snap on features when the style is RuleBasedRenderer and one of filter is unchecked. For a layer with a categorized renderer, if you uncheck a category, invisible feature can be snapped.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why in this branch you should set the subset of attributes to those required by the renderer (renderer->usedAttributes())

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yes. Thank you!

@@ -832,6 +875,8 @@ void QgsPointLocator::onFeatureDeleted( QgsFeatureId fid )
mRTree->deleteData( rect2region( mGeoms[fid]->boundingBox() ), fid );
delete mGeoms.take( fid );
}

mFeatureIds.remove( fid );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this isn't needed - it's just a leftover from an earlier version

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

*
* \returns the QgsFeatureIds to index
*/
QgsFeatureIds findVisibleFeatures( QgsVectorLayer *layer );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove leftover declaration

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@nyalldawson
Copy link
Collaborator

Great work @lbartoletti - this iteration looks excellent

@lbartoletti
Copy link
Member Author

Are you OK to merge it now ?

Thank you all.

Copy link
Member

@wonder-sk wonder-sk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work @lbartoletti however I think we need to sort out the performance issue when layer gets modified before merging this...

@@ -331,6 +331,7 @@ bool QgsSymbolLegendNode::setData( const QVariant &value, int role )
vlayer->renderer()->checkLegendSymbolItem( mItem.ruleKey(), value == Qt::Checked );

emit dataChanged();
emit vlayer->styleChanged();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to replace this with vlayer->emitStyleChanged() - we should not emit signals on behalf of other classes...

@@ -636,6 +637,9 @@ QgsPointLocator::QgsPointLocator( QgsVectorLayer *layer, const QgsCoordinateRefe
connect( mLayer, &QgsVectorLayer::featureDeleted, this, &QgsPointLocator::onFeatureDeleted );
connect( mLayer, &QgsVectorLayer::geometryChanged, this, &QgsPointLocator::onGeometryChanged );
connect( mLayer, &QgsVectorLayer::dataChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::rendererChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::styleChanged, this, &QgsPointLocator::destroyIndex );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the index should get destroyed only when the point locator is dependent on visible features

@@ -636,6 +637,9 @@ QgsPointLocator::QgsPointLocator( QgsVectorLayer *layer, const QgsCoordinateRefe
connect( mLayer, &QgsVectorLayer::featureDeleted, this, &QgsPointLocator::onFeatureDeleted );
connect( mLayer, &QgsVectorLayer::geometryChanged, this, &QgsPointLocator::onGeometryChanged );
connect( mLayer, &QgsVectorLayer::dataChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::rendererChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::styleChanged, this, &QgsPointLocator::destroyIndex );
connect( mLayer, &QgsVectorLayer::layerModified, this, &QgsPointLocator::destroyIndex );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a feeling this may be a blocker: this effectively means that even with every single change of geometry we would be destroying the index - that's going to make digitizing slow... Currently if there is e.g. 1-2 seconds initial lag when the index if first built, such lag would happen after every single move of a node when editing geometries...

Why is it necessary to destroy the index? I would expect we just need to modify existing handlers of featureAdded, featureDeleted, geometryChanged signal handlers to accommodate renderer-dependent search.

mContext = std::unique_ptr<QgsRenderContext>( new QgsRenderContext( context ) );

destroyIndex();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me there is no way to turn off visibility-dependent behavior of point locator after it has been enabled?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to configure the behavior in the preferences and then rebuild the index

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it necessary to destroy the index?

It's necessary to destroy the index, if you change the style of your layer, since the subject is especially to filter invisible feature.
Initially there was a bug when you checked a "sub style". I added possible cases.
In use, only styleChanged is really needed. What do you think if I do this:

remove:
connect( mLayer, &QgsVectorLayer::rendererChanged, this, &QgsPointLocator::destroyIndex ); connect( mLayer, &QgsVectorLayer::styleChanged, this, &QgsPointLocator::destroyIndex ); connect( mLayer, &QgsVectorLayer::layerModified, this, &QgsPointLocator::destroyIndex );

add connect( mLayer, &QgsVectorLayer::styleChanged, this, &QgsPointLocator::destroyIndex ); in QgsPointLocator::setRenderContext

if ( ctx && renderer )
{
ctx->expressionContext().setFeature( f );
if ( filter && !renderer->willRenderFeature( f, *ctx ) )
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the "filter" variable could be already checked in the "if" statement two lines above...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right. done.

@@ -278,11 +285,15 @@ class CORE_EXPORT QgsPointLocator : public QObject
//! flag whether the layer is currently empty (i.e. mRTree is null but it is not necessary to rebuild it)
bool mIsEmptyLayer;

QgsFeatureIds mFeatureIds;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used anymore?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes a leftover...

QVERIFY( !m.hasVertex() );

u.setEnableSnappingForInvisibleFeature( true );
mVL->styleChanged();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should not be necessary to emit styleChanged() here - the setEnableSnappingForInvisibleFeature() should take care of the invalidation of point locators.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes right,

@lbartoletti lbartoletti reopened this Apr 24, 2018
@wonder-sk
Copy link
Member

It is possible to configure the behavior in the preferences and then rebuild the index

If I have a QgsPointLocator instance with a QgsRenderContext assigned, there is no way to unassign it: I think the setRenderContext() method should have pointer to QgsRenderContext as an argument - if it is nullptr, the internal mContext would be also reset to nullptr.

Moving of connect() to setRenderContext() and avoiding layerModified() signal looks good, but I see two new problems:

  • every call to setRenderContext() will now add another connection to destroyIndex() - over time this can degrade performance because destroyIndex() maybe be called e.g. 1000 times for one signal
  • for a completely correct implementation, we should have support for visible features in onFeatureAdded, onGeometryChanged, and maybe add onAttributesChanged slot as well. If not added, invisible features may be added to the index. When assuming that only visible features will be added/removed/modified, then we could probably skip that for now, to keep the code simpler and finally get this PR merged...

@lbartoletti
Copy link
Member Author

@wonder-sk I have added support onFeatureAdded and onAttributeValueChanged.
For setRenderContext, i'm not sure about me ; if I understand your request.

Copy link
Member

@wonder-sk wonder-sk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great - nearly ready to merge.

Could you please add a test that check that a feature is registered (or not) when it is added to a layer, depending on whether it is visible or not?

@@ -792,6 +842,30 @@ void QgsPointLocator::onFeatureAdded( QgsFeatureId fid )
if ( !f.hasGeometry() )
return;

std::unique_ptr< QgsFeatureRenderer > renderer( mLayer->renderer() ? mLayer->renderer()->clone() : nullptr );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to be cloned inside "if ( mContext )" block, so we do not copy it when snapping also to invisible features.

Q_UNUSED( idx );
Q_UNUSED( value );
onFeatureDeleted( fid );
onFeatureAdded( fid );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two functions should be called only when snapping to visible features...

@lbartoletti lbartoletti reopened this Apr 30, 2018
@lbartoletti
Copy link
Member Author

@wonder-sk is it ok for you? Thanks.

@wonder-sk
Copy link
Member

Looking good!

@wonder-sk wonder-sk merged commit 3b36cdd into qgis:master May 1, 2018
@lbartoletti lbartoletti deleted the visibleSnapPerf branch May 2, 2018 06:26
@lbartoletti
Copy link
Member Author

Thank you all.

lbartoletti added a commit to lbartoletti/QGIS-Documentation that referenced this pull request May 2, 2018
@saberraz
Copy link
Contributor

saberraz commented May 3, 2018

@lbartoletti it should also not snap to layers not visible within that zoom when digitizing. Do you want me to file a bug?

@saberraz
Copy link
Contributor

saberraz commented May 3, 2018

Also, features not available for snapping can be traced. Now we have inconsistent behaviours for snapping and tracing.

@lbartoletti
Copy link
Member Author

@saberraz yes please, create this two issues. Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants