Fix artifacts when rendering filled line symbol

Use geos to calculate the buffered line instead of Qt's path stroker,
as the later results in artifacts when line segments pass close
to other segments

Fixes #59689
This commit is contained in:
Nyall Dawson 2025-02-12 13:32:38 +10:00
parent a48ec1012f
commit 22d828f2e0
4 changed files with 96 additions and 37 deletions

View File

@ -32,7 +32,8 @@
#include "qgscolorrampimpl.h" #include "qgscolorrampimpl.h"
#include "qgsfillsymbol.h" #include "qgsfillsymbol.h"
#include "qgscolorutils.h" #include "qgscolorutils.h"
#include "qgsgeos.h"
#include "qgspolygon.h"
#include <algorithm> #include <algorithm>
#include <QPainter> #include <QPainter>
#include <QDomDocument> #include <QDomDocument>
@ -3952,25 +3953,12 @@ void QgsFilledLineSymbolLayer::renderPolyline( const QPolygonF &points, QgsSymbo
const bool useSelectedColor = shouldRenderUsingSelectionColor( context ); const bool useSelectedColor = shouldRenderUsingSelectionColor( context );
// stroke out the path using the correct line cap/join style. We'll then use this as the fill polygon if ( points.count() >= 2 )
QPainterPathStroker stroker; {
stroker.setWidth( scaledWidth ); std::unique_ptr< QgsAbstractGeometry > ls = QgsLineString::fromQPolygonF( points );
stroker.setCapStyle( cap ); geos::unique_ptr lineGeom;
stroker.setJoinStyle( join );
QPolygonF polygon; if ( !qgsDoubleNear( offset, 0 ) )
if ( qgsDoubleNear( offset, 0 ) )
{
QPainterPath path;
path.addPolygon( points );
const QPainterPath stroke = stroker.createStroke( path ).simplified();
const QPolygonF polygon = stroke.toFillPolygon();
if ( !polygon.isEmpty() )
{
mFill->renderPolygon( polygon, /* rings */ nullptr, context.feature(), context.renderContext(), -1, useSelectedColor );
}
}
else
{ {
double scaledOffset = context.renderContext().convertToPainterUnits( offset, mOffsetUnit, mOffsetMapUnitScale ); double scaledOffset = context.renderContext().convertToPainterUnits( offset, mOffsetUnit, mOffsetMapUnitScale );
if ( mOffsetUnit == Qgis::RenderUnit::MetersInMapUnits && context.renderContext().flags() & Qgis::RenderContextFlag::RenderSymbolPreview ) if ( mOffsetUnit == Qgis::RenderUnit::MetersInMapUnits && context.renderContext().flags() & Qgis::RenderContextFlag::RenderSymbolPreview )
@ -3980,16 +3968,52 @@ void QgsFilledLineSymbolLayer::renderPolyline( const QPolygonF &points, QgsSymbo
scaledOffset = std::min( std::max( context.renderContext().convertToPainterUnits( offset, Qgis::RenderUnit::Millimeters ), 3.0 ), 100.0 ); scaledOffset = std::min( std::max( context.renderContext().convertToPainterUnits( offset, Qgis::RenderUnit::Millimeters ), 3.0 ), 100.0 );
} }
const QList<QPolygonF> mline = ::offsetLine( points, scaledOffset, context.originalGeometryType() != Qgis::GeometryType::Unknown ? context.originalGeometryType() : Qgis::GeometryType::Line ); const Qgis::GeometryType geometryType = context.originalGeometryType() != Qgis::GeometryType::Unknown ? context.originalGeometryType() : Qgis::GeometryType::Line;
for ( const QPolygonF &part : mline ) if ( geometryType == Qgis::GeometryType::Polygon )
{ {
QPainterPath path; auto inputPoly = std::make_unique< QgsPolygon >( static_cast< QgsLineString * >( ls.release() ) );
path.addPolygon( part ); geos::unique_ptr g( QgsGeos::asGeos( inputPoly.get() ) );
const QPainterPath stroke = stroker.createStroke( path ).simplified(); lineGeom = QgsGeos::buffer( g.get(), -scaledOffset, 0, Qgis::EndCapStyle::Flat, Qgis::JoinStyle::Miter, 2 );
const QPolygonF polygon = stroke.toFillPolygon(); // the result is a polygon => extract line work
if ( !polygon.isEmpty() ) QgsGeometry polygon( QgsGeos::fromGeos( lineGeom.get() ) );
QVector<QgsGeometry> parts = polygon.coerceToType( Qgis::WkbType::MultiLineString );
if ( !parts.empty() )
{ {
mFill->renderPolygon( polygon, /* rings */ nullptr, context.feature(), context.renderContext(), -1, useSelectedColor ); lineGeom = QgsGeos::asGeos( parts.at( 0 ).constGet() );
}
else
{
lineGeom.reset();
}
}
else
{
geos::unique_ptr g( QgsGeos::asGeos( ls.get() ) );
lineGeom = QgsGeos::offsetCurve( g.get(), scaledOffset, 0, Qgis::JoinStyle::Miter, 8.0 );
}
}
else
{
lineGeom = QgsGeos::asGeos( ls.get() );
}
if ( lineGeom )
{
geos::unique_ptr buffered = QgsGeos::buffer( lineGeom.get(), scaledWidth / 2, 8,
QgsSymbolLayerUtils::penCapStyleToEndCapStyle( cap ),
QgsSymbolLayerUtils::penJoinStyleToJoinStyle( join ), 8 );
if ( buffered )
{
// convert to rings
std::unique_ptr< QgsAbstractGeometry > bufferedGeom = QgsGeos::fromGeos( buffered.get() );
const QList< QList< QPolygonF > > parts = QgsSymbolLayerUtils::toQPolygonF( bufferedGeom.get(), Qgis::SymbolType::Fill );
for ( const QList< QPolygonF > &polygon : parts )
{
QVector< QPolygonF > rings;
for ( int i = 1; i < polygon.size(); ++i )
rings << polygon.at( i );
mFill->renderPolygon( polygon.value( 0 ), &rings, context.feature(), context.renderContext(), -1, useSelectedColor );
}
} }
} }
} }

View File

@ -19,8 +19,6 @@ __author__ = "Nyall Dawson"
__date__ = "November 2023" __date__ = "November 2023"
__copyright__ = "(C) 2023, Nyall Dawson" __copyright__ = "(C) 2023, Nyall Dawson"
from typing import Optional
from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor, QImage, QPainter from qgis.PyQt.QtGui import QColor, QImage, QPainter
from qgis.core import ( from qgis.core import (
@ -30,6 +28,7 @@ from qgis.core import (
QgsMapSettings, QgsMapSettings,
QgsRenderContext, QgsRenderContext,
QgsFilledLineSymbolLayer, QgsFilledLineSymbolLayer,
QgsFillSymbol,
) )
import unittest import unittest
from qgis.testing import start_app, QgisTestCase from qgis.testing import start_app, QgisTestCase
@ -129,6 +128,42 @@ class TestQgsFilledLineSymbolLayer(QgisTestCase):
self.image_check("render_offset", "render_offset", rendered_image) self.image_check("render_offset", "render_offset", rendered_image)
) )
def testLineOffsetPolygon(self):
s = QgsFillSymbol()
s.deleteSymbolLayer(0)
line = QgsFilledLineSymbolLayer()
line.setColor(QColor(255, 0, 0))
line.setWidth(5)
line.setOffset(5)
s.appendSymbolLayer(line.clone())
g = QgsGeometry.fromWkt("Polygon((2 2, 10 10, 10 0, 2 2))")
rendered_image = self.renderGeometry(s, g)
self.assertTrue(
self.image_check(
"render_offset_polygon", "render_offset_polygon", rendered_image
)
)
def testRenderLooped(self):
s = QgsLineSymbol()
s.deleteSymbolLayer(0)
line = QgsFilledLineSymbolLayer()
line.setColor(QColor(255, 0, 0))
line.setWidth(8)
line.setPenCapStyle(Qt.PenCapStyle.FlatCap)
s.appendSymbolLayer(line.clone())
g = QgsGeometry.fromWkt(
"LineString (13.07373737373737299 8.51161616161616053, 0.69292929292929273 5.93585858585858439, 11.32222222222222285 2.15808080808080582, 0 0, 10 10, 10 0)"
)
rendered_image = self.renderGeometry(s, g)
self.assertTrue(self.image_check("loop", "loop", rendered_image))
def renderGeometry(self, symbol, geom, buffer=20): def renderGeometry(self, symbol, geom, buffer=20):
f = QgsFeature() f = QgsFeature()
f.setGeometry(geom) f.setGeometry(geom)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B