Skip to content

Conversation

@lbartoletti
Copy link
Member

Description

This PR adds support for NURBS curves (Non-Uniform Rational B-Splines) in QGIS.

NURBS are a mathematical model used to represent smooth curves and surfaces using control points, weights, and a knot vector. They generalize Bézier curves and B-splines, providing fine-grained control over shape through control point weights.

The implementation follows the ISO/IEC 13249-3:2016 standard. This geometry type is already supported by SFCGAL, and its integration into PostGIS is currently under development.

Several QGIS plugins already provide Bézier curve tools. This implementation goes further by offering full NURBS support, including Bézier curves, B-splines, and rational NURBS curves.

Development was guided by user feedback and customer requests.

User Documentation

Digitizing Menu

A new NURBS digitizing mode has been added to the digitizing techniques menu, with two sub-modes:

  • Control Points: Direct placement of NURBS control points with a configurable degree (1–5)
  • Poly-Bézier: Bézier curve digitizing similar to Illustrator-style workflows, using anchors and handles
image
Control Points Mode

This mode allows direct placement of NURBS control points.

  • Click to add a control point
  • Curve degree configurable (1–5) in the digitizing options
  • W key: Modify the weight of the selected control point (influences curve attraction)
nurbs_control_points.mp4

Poly-Bézier Mode

This mode provides an intuitive digitizing experience similar to vector drawing software:

  • Click + drag: Add an anchor with symmetric handles
  • Click on a handle: Move the handle independently
  • Click on an anchor: Move the anchor (handles follow)
  • Alt + click on an anchor: Extend handles symmetrically
  • Right-click: Finish the curve

A contextual help banner is displayed to guide the user.

nurbs_bezier_digitize.mp4

Format Compatibility

  • PostgreSQL/PostGIS: Native support for NurbsCurve geometry (hope for 3.7.0)
  • Other formats (Shapefile, GeoPackage, etc.): Curves are automatically segmented on save

Here's an example of how it's segmented in linestring/polygon.

nurbs_linestring.mp4
nurbs_polygon.mp4

Editing Tools

Standard editing tools fully support NURBS geometries:

  • Add part
  • Add ring
  • Fill ring
  • Scale
  • Translate
  • Rotate
nurbs_add_fill.mp4
nurbs_tools.mp4

Vertex Tool Support

The vertex tool supports NURBS geometries:

  • Distinct visualization of anchors (blue squares) and handles (green circles) for poly-Bézier curves
  • Weight (W) column available in the vertex editor
  • Direct editing of NURBS control points and weights
nurbs_vertextool.mp4

3D

NURBS supports Z (and M). So you can view your drawing in 3D. Example with a simple spiral here. But it works for some landscape/urban projects too.

nurbs_spiral_3D.mp4

Development

Some notes on the main parts for developper.

Core

  • Added QgsNurbsCurve class inheriting from QgsCurve
  • Implemented the De Boor algorithm for curve evaluation
  • WKB/WKT serialization compliant with ISO/IEC 13249-3:2016
  • Added Qgis::WkbType::NurbsCurve
  • Utility functions in QgsNurbsUtils for NURBS detection and extraction

GUI

Significant changes in QgsMapToolCapture:

  • New capture mode: Qgis::CaptureTechnique::NurbsCurve
  • QgsBezierData and QgsBezierMarker classes for poly-Bézier handling
  • Extension of QgsMapToolCaptureRubberBand to render NURBS curves

App

  • QgsMapToolsDigitizingTechniqueManager: Added NURBS mode with options (mode, degree)
  • QgsVertexTool: Support for editing NURBS control points and weights
  • QgsVertexEditor: Weight column for NURBS geometries
  • Integration with standard digitizing tools

Providers

  • PostgreSQL: Creation of NurbsCurve table (can be removed until we haven't merged nurbs)
  • Oracle: TODO (marked for future implementation)

Tests

  • I've added tests for the C++ NurbsCurve class and Python.
  • GUI for QgsBezierData class
  • And usual maptool tests + vertexeditor

Sponsored by Stadt Frankfurt am Main

@lbartoletti lbartoletti self-assigned this Dec 19, 2025
@github-actions github-actions bot added this to the 4.0.0 milestone Dec 19, 2025
@lbartoletti lbartoletti force-pushed the feature/add_nurbscurve branch from c28f033 to 62c29ef Compare December 19, 2025 10:32
@lbartoletti lbartoletti requested a review from signedav December 19, 2025 10:33
@github-actions
Copy link
Contributor

github-actions bot commented Dec 19, 2025

🪟 Windows Qt6 builds

Download Windows Qt6 builds of this PR for testing.
(Built from commit 7b72dc3)

🍎 MacOS Qt6 builds

Download MacOS Qt6 builds of this PR for testing.
This installer is not signed, control+click > open the app to avoid the warning
(Built from commit 7b72dc3)

@wonder-sk
Copy link
Member

Impressive work.

I believe addition of a major new geometry type deserves a QEP to discuss various design decisions. Can we please have a QEP? The QEP should also provide details about NURBS geometry type - you have referenced the ISO standard, but I am not sure everyone is willing to pay 200+ EUR to download it.

I would also like to suggest to break such a large PR to multiple smaller PRs that can be reviewed one by one. Reviewing 7K+ new lines of code (including a lot math) in one go is certainly going to be a challenge.

Thank you 🙂

Copy link
Contributor

@rouault rouault left a comment

Choose a reason for hiding this comment

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

Impressive work !

I've given the branch a try, and creating 2 NURBSCurve in a scratch layer of type NURBSCurve. When trying to save that layer as GeoPackage, I get:

Export to vector file /tmp/out.gpkg failed.
Error: Feature write errors:
Feature geometry not imported (OGR error: Pointer 'hGeom' is NULL in 'OGR_G_ImportFromWkb'.
)
Feature geometry not imported (OGR error: Pointer 'hGeom' is NULL in 'OGR_G_ImportFromWkb'.
)
Only 0 of 2 features written.

and this message from GDAL in stderr:

Warning 1: Layer new_scratch_layer relies on the 'gpkg_geom_' (http://www.geopackage.org/spec120/#extension_geometry_types) extension that should be implemented in order to read/write it safely, but is not currently. Some data may be missing while reading that layer, and updates are strongly discouraged.

if ( QgsWkbTypes::isCurvedType( type ) && QgsWkbTypes::flatType( type ) != Qgis::WkbType::NurbsCurve )
{
// Check if geometry contains NurbsCurve that needs conversion
bool hasNurbs = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

couldn't you use QgsNurbsUtils::containsNurbsCurve() here ?


/**
* Sets the knot vector of the NURBS curve.
* \param knots knot vector (must have size = control points count + degree + 1)
Copy link
Contributor

Choose a reason for hiding this comment

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

it should be mentioned that the values in knots must be non-decreasing

By the way, do we need all those setters ? It doesn't look this PR use them at all. I would tend to think that a geometry instance must be (mostly) immutable.

return false;
}

if ( mKnots.size() != n + mDegree + 1 )
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't we check that the value in mKnots are non-decreasing ? (otherwise findKnotSpan() could misbehave)

return false;
}

if ( o->mWeights.size() != mWeights.size() || o->mKnots.size() != mKnots.size() )
Copy link
Contributor

Choose a reason for hiding this comment

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

can't you just compare mKnots != o->mKnots and mControlPoints != o-mControlPoints ? Not sure about QVector, but that would definitely work with a std::vector

Copy link
Member Author

Choose a reason for hiding this comment

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

refactored with std

//! structure to keep information about a rubber band used for dragging of a NURBS curve
struct NurbsBand
{
QgsRubberBand *curveBand = nullptr; //!< Rubber band for the evaluated NURBS curve
Copy link
Contributor

Choose a reason for hiding this comment

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

use std::unique_ptr ? Applies to controlBand, mNurbsControlPolygonBand, mBezierTangentBands, mBezierAnchorMarkers, mBezierHandleMarkers

maptools/qgsbezierdata.cpp
maptools/qgsbeziermarker.cpp

maptools/qgsmaptooledit.cpp
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
maptools/qgsmaptooledit.cpp

already present 2 lines below

mHasR = true;
}

if ( qgsgeometry_cast<const QgsNurbsCurve *>( geom ) )
Copy link
Contributor

Choose a reason for hiding this comment

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

all below logic could be simplified by using QgsNurbsUtils::containsNurbsCurve(), and setting mHasR = true as well

if ( mode() == CapturePolygon )
{
// For polygon, we need to close the curve - add first anchor as last anchor
// The NurbsCurve from asNurbsCurve is already properly formed
Copy link
Contributor

Choose a reason for hiding this comment

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

looks like there is missing code here

return;
}

//polygons: bail out if there are not at least two vertices
Copy link
Contributor

Choose a reason for hiding this comment

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

assuming the code is correct

Suggested change
//polygons: bail out if there are not at least two vertices
//polygons: bail out if there are not at least three vertices

@nyalldawson
Copy link
Collaborator

Please don't merge before I've had a chance to review (which won't be till after new years break ends)

@lbartoletti lbartoletti force-pushed the feature/add_nurbscurve branch from 62c29ef to 7b72dc3 Compare December 24, 2025 16:53
@lbartoletti
Copy link
Member Author

Impressive work.

Thanks 😊

I believe addition of a major new geometry type deserves a QEP to discuss various design decisions. Can we please have a QEP? The QEP should also provide details about NURBS geometry type -

I don't remember doing it in the past, and I don't think I've seen it happen.
But if I have to, I'll open it, but you'll be disappointed, because I don't see what else to add to what's already in the description. BTW, NURBS are NURBS and it's explained in the description, I can copy/paste and improve some explanation if you like.

For what it's worth. The PR only adds a widely known geometry, NURBS. The other part is just inserting it into the existing code with some adjustments.
The biggest part is the UI for the poly-bezier mode. So yes, it's big, but it doesn't fundamentally alter QGIS.

But, once again, if it's really necessary, I'll open the QEP.

you have referenced the ISO standard, but I am not sure everyone is willing to pay 200+ EUR to download it.

ISO standard is only for reference about WKB and some requirements.
BTW, If you're interested, there are drafts (pre-versions of the standard) that are freely available (like for C, C++ ISO draft).

I would also like to suggest to break such a large PR to multiple smaller PRs that can be reviewed one by one. Reviewing 7K+ new lines of code (including a lot math) in one go is certainly going to be a challenge.

On this point, I completely agree.
I am always in favor of separating by components. That's what I've always done, but often when we separate, people ask what the UI will look like, and here, the UI is more important than the core. So here, I did the opposite.
Nevertheless, my (first) commits were separated by component. I allow time for reviews, and I am happy to redo several PRs by logical component.

Thank you 🙂

you're welcome 😊

@lbartoletti
Copy link
Member Author

Impressive work !

Thanks 😊

I've given the branch a try, and creating 2 NURBSCurve in a scratch layer of type NURBSCurve. When trying to save that layer as GeoPackage, I get:

Export to vector file /tmp/out.gpkg failed.
Error: Feature write errors:
Feature geometry not imported (OGR error: Pointer 'hGeom' is NULL in 'OGR_G_ImportFromWkb'.
)
Feature geometry not imported (OGR error: Pointer 'hGeom' is NULL in 'OGR_G_ImportFromWkb'.
)
Only 0 of 2 features written.

and this message from GDAL in stderr:

Warning 1: Layer new_scratch_layer relies on the 'gpkg_geom_' (http://www.geopackage.org/spec120/#extension_geometry_types) extension that should be implemented in order to read/write it safely, but is not currently. Some data may be missing while reading that layer, and updates are strongly discouraged.

That's weird, I don't have that in my demos/tests. I can't reproduce it, but I'll take a look after the christmas/newyear break.

Many thanks for the review. 🙏
I think I've corrected most of your comments. I'm putting the PR back into draft mode, as I think I still need to make some changes that I didn't have time to finish/check.

@lbartoletti lbartoletti added Map Tools Related to non-digitizing map tools Digitizing Related to feature digitizing map tools or functionality Needs Documentation When merging a labeled PR, an issue will be created in the Doc repo. NOT FOR MERGE Don't merge! labels Dec 24, 2025
@qgis-bot
Copy link
Collaborator

@lbartoletti
This pull request has been tagged as requiring documentation.

A documentation ticket will be opened at https://github.com/qgis/QGIS-Documentation when this PR is merged.

Please update the description (not the comments) with helpful description and screenshot to help the work from documentors.
Also, any commit having [needs-doc] or [Needs Documentation] in will see its message pushed to the issue, so please be as verbose as you can.

Thank you!

@lbartoletti lbartoletti marked this pull request as draft December 24, 2025 17:16
@github-actions
Copy link
Contributor

Tests failed for Qt 6 (ALL_BUT_PROVIDERS - ubuntu)

One or more tests failed using the build from commit 7b72dc3

layout_export

layout_export

Test failed at test_layout_export at tests/src/python/test_selective_masking.py:1161

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export/layout_export.png (found 8 pixels different)

layout_export_2_sources_masking

layout_export_2_sources_masking

Test failed at test_layout_export_2_sources_masking at tests/src/python/test_selective_masking.py:1533

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_2_sources_masking/layout_export_2_sources_masking.png (found 6 pixels different)

layout_export_w_blend_mode

layout_export_w_blend_mode

Test failed at test_layout_export_w_label_blend_mode at tests/src/python/test_selective_masking.py:1236

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_w_blend_mode/layout_export_w_blend_mode.png (found 287 pixels different)

layout_export_w_raster

layout_export_w_raster

Test failed at test_layout_export_w_raster at tests/src/python/test_selective_masking.py:1327

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_w_raster/layout_export_w_raster.png (found 6 pixels different)

layout_export_markerline_masked

layout_export_markerline_masked

Test failed at test_markerline_masked at tests/src/python/test_selective_masking.py:1817

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_markerline_masked/layout_export_markerline_masked.png (found 8 pixels different)

The full test report (included comparison of rendered vs expected images) can be found here.

Further documentation on the QGIS test infrastructure can be found in the Developer's Guide.

@github-actions
Copy link
Contributor

Tests failed for Qt 6 (ALL_BUT_PROVIDERS - fedora)

One or more tests failed using the build from commit 7b72dc3

layout_export

layout_export

Test failed at test_layout_export at tests/src/python/test_selective_masking.py:1161

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export/layout_export.png (found 8 pixels different)

layout_export_2_sources_masking

layout_export_2_sources_masking

Test failed at test_layout_export_2_sources_masking at tests/src/python/test_selective_masking.py:1533

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_2_sources_masking/layout_export_2_sources_masking.png (found 6 pixels different)

layout_export_w_blend_mode

layout_export_w_blend_mode

Test failed at test_layout_export_w_label_blend_mode at tests/src/python/test_selective_masking.py:1236

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_w_blend_mode/layout_export_w_blend_mode.png (found 287 pixels different)

layout_export_w_raster

layout_export_w_raster

Test failed at test_layout_export_w_raster at tests/src/python/test_selective_masking.py:1327

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_w_raster/layout_export_w_raster.png (found 6 pixels different)

layout_export_markerline_masked

layout_export_markerline_masked

Test failed at test_markerline_masked at tests/src/python/test_selective_masking.py:1817

Rendered image did not match tests/testdata/control_images/selective_masking/layout_export_markerline_masked/layout_export_markerline_masked.png (found 8 pixels different)

The full test report (included comparison of rendered vs expected images) can be found here.

Further documentation on the QGIS test infrastructure can be found in the Developer's Guide.

Constructor for an empty NURBS curve geometry.
%End

QgsNurbsCurve( const QVector<QgsPoint> &ctrlPoints, int degree, const QVector<double> &knots, const QVector<double> &weights );
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
QgsNurbsCurve( const QVector<QgsPoint> &ctrlPoints, int degree, const QVector<double> &knots, const QVector<double> &weights );
QgsNurbsCurve( const QVector<QgsPoint> &controlPoints, int degree, const QVector<double> &knots, const QVector<double> &weights );

points)
%End

virtual QgsCurve *clone() const /Factory/;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
virtual QgsCurve *clone() const /Factory/;
virtual QgsNurbsCurve *clone() const /Factory/;

virtual QgsCurve *clone() const /Factory/;


QgsPoint evaluate( double t ) const;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would be nice to raise a Python ValueError here if t < 0 or > 1

:return: point on the curve at parameter t
%End

bool isBezier() const;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you add HoldGIL annotation to these too?

:param degree: curve degree (typically 1-3)
%End

const QVector<QgsPoint> &controlPoints() const /HoldGIL/;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const QVector<QgsPoint> &controlPoints() const /HoldGIL/;
QVector<QgsPoint> controlPoints() const /HoldGIL/;

* Returns the NURBS curve and sets \a localIndex to the control point index
* within that curve. Returns NULLPTR if the vertex is not part of a NURBS curve.
*/
static QgsNurbsCurve *findMutableNurbsCurveForVertex(
Copy link
Collaborator

Choose a reason for hiding this comment

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

could we just name this findNurbsCurveForVertex and SIP_SKIP the const version? SIP ignores the const qualifier for pointers anyway.

Comment on lines +53 to +54
* within that curve. Returns NULLPTR if the vertex is not part of a NURBS curve.
*/
Copy link
Collaborator

Choose a reason for hiding this comment

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

Needs \param and \returns for sipify to do its magic when SIP_OUT is used

explicit QgsPointLocator_Stream( const QLinkedList<RTree::Data *> &dataList )
: mDataList( dataList )
, mIt( mDataList )
{ }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you revert the unrelated formatting changes?

explicit QgsPointLocator( QgsVectorLayer *layer, const QgsCoordinateReferenceSystem &destinationCrs = QgsCoordinateReferenceSystem(),
const QgsCoordinateTransformContext &transformContext = QgsCoordinateTransformContext(),
const QgsRectangle *extent = nullptr );
explicit QgsPointLocator( QgsVectorLayer *layer, const QgsCoordinateReferenceSystem &destinationCrs = QgsCoordinateReferenceSystem(), const QgsCoordinateTransformContext &transformContext = QgsCoordinateTransformContext(), const QgsRectangle *extent = nullptr );
Copy link
Collaborator

Choose a reason for hiding this comment

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

revert unrelated formatting changes

{ Qgis::WkbType::CircularStringM, WkbEntry( QLatin1String( "CircularStringM" ), false, Qgis::WkbType::MultiCurveM, Qgis::WkbType::CircularStringM, Qgis::WkbType::CircularString, Qgis::GeometryType::Line, false, true ) },
{ Qgis::WkbType::CircularStringZM, WkbEntry( QLatin1String( "CircularStringZM" ), false, Qgis::WkbType::MultiCurveZM, Qgis::WkbType::CircularStringZM, Qgis::WkbType::CircularString, Qgis::GeometryType::Line, true, true ) },
//nurbscurve
{ Qgis::WkbType::NurbsCurve, WkbEntry( QLatin1String( "NurbsCurve" ), false, Qgis::WkbType::MultiCurve, Qgis::WkbType::NurbsCurve, Qgis::WkbType::NurbsCurve, Qgis::GeometryType::Line, false, false ) },
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please extend the tests in test_qgsgeometry.py testWkbTypes accordingly

@nyalldawson
Copy link
Collaborator

@lbartoletti I've reviewed only the core changes above -- in general it's looking great, nice work! Just some minor changes requested for those. Let me know if you are planning on stripping the gui/app parts out to a separate PR or if they need reviewing here too

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Digitizing Related to feature digitizing map tools or functionality Feature Map Tools Related to non-digitizing map tools Needs Documentation When merging a labeled PR, an issue will be created in the Doc repo. NOT FOR MERGE Don't merge!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants