[geos-commits] [SCM] GEOS branch 3.15 updated. 3ee1c785707005880df0899d9ba153093b4e8a85

git at osgeo.org git at osgeo.org
Tue Nov 3 04:10:45 PST 2026


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "GEOS".

The branch, 3.15 has been updated
       via  3ee1c785707005880df0899d9ba153093b4e8a85 (commit)
       via  a2919fc504321a166d11714ad4c8e98629b8e374 (commit)
       via  f05ccef210d32ce0307ed4c540ec9bbf5f299b68 (commit)
       via  2d3157fc739f88f87d652801ff3425da3528a5d6 (commit)
       via  b3d656abfe09307a47d8315f1f1309c540839225 (commit)
       via  24e5696dcea1d5e52c75f05d9b60063351bb48c0 (commit)
       via  0aed98e0728e7386281cce1ead13bdaebcb676cc (commit)
       via  11588117abcf73fe531d3805711070c50a345032 (commit)
       via  9025863856f46c2c436e1ce070bead91d74b7fa1 (commit)
       via  c87e8f2d67d1d347bb81b1d741f426486f10a20e (commit)
       via  771e8a83f8407a56f187433b441cc2e49a2ac4c0 (commit)
       via  68ef040f1cb2eec8573a2048ab343f02e31e5083 (commit)
       via  dc0fe45d2e0fa938b025ed626df157b233546648 (commit)
       via  4f5281976597245f3fb39aa786b928961a131234 (commit)
       via  a3022a63ce3d29e62b3b14861446f641376ff4d1 (commit)
      from  5b3950ef9058283c39e18b7927704054f1cafd7f (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 3ee1c785707005880df0899d9ba153093b4e8a85
Author: Daniel Baston <dbaston at gmail.com>
Date:   Tue Nov 3 06:16:38 2026 -0500

    NEWS updates

diff --git a/NEWS.md b/NEWS.md
index e604cf255..05d6eecaf 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -2,16 +2,24 @@
 2026-xx-xx
 
 - Fixes/Improvements:
-  - Fix arc noding failure with endpoint intersections (GH-1513, Dan Baston)
+  - Arc noding: fix failure with endpoint intersections (GH-1513, Dan Baston)
+  - Arc noding: handle degenerate arcs with repeated points (GH-1584, Alejandro Pacheco and Dan Baston)
   - Make GeometrySplitter able to split MultiSurface inputs (GH-1518, Dan Baston)
   - GEOSGridIntersectionFractions: Set cells outside geometry bounding box to zero (GH-1532, Dan Baston)
   - Skip testing FE_INVALID from unit-geom-Envelope for some Clang builds (GH-1555, Mike Taves)
   - Polygonize / LineMerge: Copy Z/M values at shared endpoints of mixed-dimension inputs (GH-1545, Dan Baston)  
   - CircularArcIntersector: Use Orient2D to check for arc control points on line (GH-1546, Dan Baston)
   - CircularArcIntersector: Avoid incorrectly classifying intersections as cocircular (GH-1549, Dan Baston)
+  - CircularArcIntersector: Avoid missed intersections at shared points (GH-1559, Dan Baston)
   - GEOSProject: Throw IllegalArgumentException on invalid input (GH-1547, Mohd Quamar Tyagi)
-  - CircularString: clear cached arcs when coordinates change (Alejandro Pacheco)
-
+  - CircularString: clear cached arcs when coordinates change (GH-1586, Alejandro Pacheco)
+  - CircularArc: fix copy assignment of arc not starting at position 0 (GH-1586, Alejandro Pacheco)
+  - CircularArcs: fix containsAngle for non-normalized angles (GH-1579, Dan Baston)
+  - CompoundCurve: update component curves in geometryChanged (GH-1586, Alejandro Pacheco)
+  - WKBWriter: preserve includeSRID flag after failed write (GH-1578, Dan Baston)
+  - Envelope: improve robustness of distanceSquared (GH-1576, Jeroen Bloemscheer)
+  - Distance: avoid erroneously large distance between arc and segment (GH-1577, Dan Baston and Jeroen Bloemscheer)
+  - GeometryFactory: fix return type from buildGeometry with collection inputs (GH-1580, Alejandro Pacheco)
 
 ## Changes in 3.15.0
 2026-09-01

commit a2919fc504321a166d11714ad4c8e98629b8e374
Author: Daniel Baston <dbaston at gmail.com>
Date:   Sun Nov 1 21:26:50 2026 -0500

    SpatialIndex.h: fix undocumented parameter

diff --git a/include/geos/index/SpatialIndex.h b/include/geos/index/SpatialIndex.h
index b3842543b..5e82089f0 100644
--- a/include/geos/index/SpatialIndex.h
+++ b/include/geos/index/SpatialIndex.h
@@ -70,9 +70,9 @@ public:
      * intersect the query envelope.
      *
      * @param searchEnv the envelope to query for
+     * @param matches a vector into which found items should be inserted
      */
-    //virtual std::vector<void*>* query(const geom::Envelope *searchEnv)=0;
-    virtual void query(const geom::Envelope* searchEnv, std::vector<void*>&) = 0;
+    virtual void query(const geom::Envelope* searchEnv, std::vector<void*>& matches) = 0;
 
     /** \brief
      * Queries the index for all items whose extents intersect the given search Envelope

commit f05ccef210d32ce0307ed4c540ec9bbf5f299b68
Author: Alejandro Pacheco <ale at alepacheco.dev>
Date:   Thu Oct 29 07:27:45 2026 +0100

    CircularArc: fix copy assignment of arc not starting at position 0
    
    The copy assignment operator copied the three points of the arc into a
    new CoordinateSequence, but kept the position of the arc in the original
    sequence, so an arc assigned from one that did not start at position 0
    referred to points past the end of its copy.
    
    Implement copy assignment with the copy constructor and move assignment.

diff --git a/src/geom/CircularArc.cpp b/src/geom/CircularArc.cpp
index 44414c0ad..1ae2e9e9f 100644
--- a/src/geom/CircularArc.cpp
+++ b/src/geom/CircularArc.cpp
@@ -145,28 +145,10 @@ CircularArc::CircularArc(CircularArc&& other) noexcept {
 CircularArc&
 CircularArc::operator=(const CircularArc& other)
 {
-    if (this == &other) {
-        return *this;
+    if (this != &other) {
+        *this = CircularArc(other);
     }
 
-    if (m_own_coordinates) {
-        delete m_seq;
-    }
-
-    m_seq = new CoordinateSequence(0, other.getCoordinateSequence()->hasZ(), other.getCoordinateSequence()->hasM());
-    m_pos = other.m_pos;
-    m_own_coordinates = true;
-    m_orientation = other.m_orientation;
-    m_orientation_known = other.m_orientation_known;
-    m_center = other.m_center;
-    m_center_known = other.m_center_known;
-    m_radius = other.m_radius;
-    m_radius_known = other.m_radius_known;
-
-    CoordinateSequence* seq = const_cast<CoordinateSequence*>(m_seq);
-    seq->reserve(3);
-    seq->add(*other.getCoordinateSequence(), other.getCoordinatePosition(), other.getCoordinatePosition() + 2);
-
     return *this;
 }
 
diff --git a/tests/unit/geom/CircularArcTest.cpp b/tests/unit/geom/CircularArcTest.cpp
index 47078db1f..3d33a3110 100644
--- a/tests/unit/geom/CircularArcTest.cpp
+++ b/tests/unit/geom/CircularArcTest.cpp
@@ -3,6 +3,7 @@
 #include <geos/algorithm/Orientation.h>
 #include <geos/geom/Coordinate.h>
 #include <geos/geom/CircularArc.h>
+#include <geos/geom/CoordinateSequence.h>
 #include <geos/constants.h>
 
 using geos::geom::CoordinateXY;
@@ -176,4 +177,31 @@ void object::test<7>() {
 }
 
 
+template<>
+template<>
+void object::test<8>() {
+    set_test_name("copy assignment from arc that does not start at the beginning of its CoordinateSequence");
+
+    CoordinateSequence seq{
+        CoordinateXY(0, 0),
+        CoordinateXY(1, 1),
+        CoordinateXY(2, 0),
+        CoordinateXY(3, -1),
+        CoordinateXY(4, 0)
+    };
+
+    CircularArc second(seq, 2);
+
+    CircularArc arc = CircularArc::create(CoordinateXY(0, 0), CoordinateXY(1, 1), CoordinateXY(2, 0));
+    arc = second;
+
+    ensure_equals(arc.p0(), CoordinateXY(2, 0));
+    ensure_equals(arc.p1(), CoordinateXY(3, -1));
+    ensure_equals(arc.p2(), CoordinateXY(4, 0));
+
+    // arc has its own copy of the points
+    seq.setAt(CoordinateXY(5, 5), 2);
+    ensure_equals(arc.p0(), CoordinateXY(2, 0));
+}
+
 }

commit 2d3157fc739f88f87d652801ff3425da3528a5d6
Author: Alejandro Pacheco <ale at alepacheco.dev>
Date:   Thu Oct 29 07:27:45 2026 +0100

    CompoundCurve: update component curves in geometryChanged()
    
    CompoundCurve::geometryChangedAction() recomputed its envelope from the
    envelopes of its component curves, but those curves are not visited by
    apply_rw(GeometryComponentFilter*), so their envelopes (and the cached
    arcs of a CircularString) were not updated. After apply_rw() and
    geometryChanged(), as done by GEOSGeom_transformXY, a CompoundCurve kept
    the envelope of its original coordinates.
    
    Call geometryChanged() on each component curve before recomputing the
    envelope.

diff --git a/include/geos/geom/CompoundCurve.h b/include/geos/geom/CompoundCurve.h
index d090be2af..50146b8bb 100644
--- a/include/geos/geom/CompoundCurve.h
+++ b/include/geos/geom/CompoundCurve.h
@@ -119,6 +119,11 @@ protected:
 
     void geometryChangedAction() override
     {
+        // The component curves are not visited by apply_rw(GeometryComponentFilter*),
+        // so update them before computing the envelope from theirs.
+        for (auto& curve : curves) {
+            curve->geometryChanged();
+        }
         envelope = computeEnvelopeInternal();
     }
 
diff --git a/tests/unit/geom/CompoundCurveTest.cpp b/tests/unit/geom/CompoundCurveTest.cpp
index d6f5cbfde..040f3fd8d 100644
--- a/tests/unit/geom/CompoundCurveTest.cpp
+++ b/tests/unit/geom/CompoundCurveTest.cpp
@@ -454,4 +454,30 @@ void object::test<13>()
     ensure_equals_exact_geometry_xyzm(curved.get(), cc_.get(), 0);
 }
 
+template<>
+template<>
+void object::test<14>()
+{
+    set_test_name("getEnvelopeInternal() after apply_rw(const CoordinateFilter*) and geometryChanged()");
+
+    struct TranslateFilter : public geos::geom::CoordinateFilter {
+        void filter_rw(CoordinateXY* c) const override
+        {
+            c->x += 10;
+        }
+    };
+
+    TranslateFilter filter;
+    cc_->apply_rw(&filter);
+    cc_->geometryChanged();
+
+    auto expected = wktreader_.read<CompoundCurve>("COMPOUNDCURVE(CIRCULARSTRING(10 0, 11 1, 12 0), (12 0, 12 2))");
+
+    ensure_equals(*cc_->getEnvelopeInternal(), *expected->getEnvelopeInternal());
+    for (std::size_t i = 0; i < cc_->getNumCurves(); i++) {
+        ensure_equals(*cc_->getCurveN(i)->getEnvelopeInternal(), *expected->getCurveN(i)->getEnvelopeInternal());
+    }
+    ensure_equals("length", cc_->getLength(), expected->getLength(), 1e-12);
+}
+
 }

commit b3d656abfe09307a47d8315f1f1309c540839225
Author: Alejandro Pacheco <ale at alepacheco.dev>
Date:   Thu Oct 29 07:27:45 2026 +0100

    CircularString: clear cached arcs when coordinates change
    
    CircularString::getArcs() caches CircularArcs that refer to positions in
    the CircularString's CoordinateSequence and cache their center, radius
    and orientation. The cache was cleared when normalize() reversed an open
    CircularString, but not when:
    
    - normalize() of a closed CircularString replaced the CoordinateSequence
      to change the start point, or reversed it
    - apply_rw() modified the coordinates, or first replaced a shared
      CoordinateSequence with a copy
    - geometryChanged() was called after an external change
    
    In these cases the arcs described the old coordinates, or referred to a
    CoordinateSequence that had since been freed. For example, the length of
    a CircularString returned by GEOSGeom_transformXY could be the length of
    its input, and calling GEOSNormalize on a closed CircularString after
    GEOSLength left its arcs referring to freed memory.
    
    Clear the cache in each of these cases. The apply_rw() overloads are
    overridden because SimpleCurve::apply_rw() can replace shared coordinates
    before the caller has a chance to call geometryChanged().

diff --git a/NEWS.md b/NEWS.md
index 1ba0b572e..e604cf255 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -6,10 +6,12 @@
   - Make GeometrySplitter able to split MultiSurface inputs (GH-1518, Dan Baston)
   - GEOSGridIntersectionFractions: Set cells outside geometry bounding box to zero (GH-1532, Dan Baston)
   - Skip testing FE_INVALID from unit-geom-Envelope for some Clang builds (GH-1555, Mike Taves)
-  - Polygonize / LineMerge: Copy Z/M values at shared endpoints of mixed-dimension inputs (GH-1545, Dan Baston)
+  - Polygonize / LineMerge: Copy Z/M values at shared endpoints of mixed-dimension inputs (GH-1545, Dan Baston)  
   - CircularArcIntersector: Use Orient2D to check for arc control points on line (GH-1546, Dan Baston)
   - CircularArcIntersector: Avoid incorrectly classifying intersections as cocircular (GH-1549, Dan Baston)
   - GEOSProject: Throw IllegalArgumentException on invalid input (GH-1547, Mohd Quamar Tyagi)
+  - CircularString: clear cached arcs when coordinates change (Alejandro Pacheco)
+
 
 ## Changes in 3.15.0
 2026-09-01
diff --git a/include/geos/geom/CircularString.h b/include/geos/geom/CircularString.h
index af5fb6555..fd282f180 100644
--- a/include/geos/geom/CircularString.h
+++ b/include/geos/geom/CircularString.h
@@ -25,11 +25,16 @@ class GEOS_DLL CircularString : public SimpleCurve {
 
 public:
     using SimpleCurve::SimpleCurve;
+    using SimpleCurve::apply_rw;
 
     friend class GeometryFactory;
 
     ~CircularString() override;
 
+    void apply_rw(const CoordinateFilter* filter) override;
+
+    void apply_rw(CoordinateSequenceFilter& filter) override;
+
     std::unique_ptr<CircularString> clone() const;
 
     const std::vector<CircularArc>& getArcs() const;
@@ -82,6 +87,7 @@ protected:
     void geometryChangedAction() override
     {
         envelope = computeEnvelopeInternal(false);
+        arcs.clear();
     }
 
     CircularString* getCurvedImpl(const algorithm::LineToCurveParams&) const override {
@@ -105,6 +111,9 @@ private:
 
     void normalizeClosed();
 
+    /// Arcs describing `points`, created on demand by getArcs(). They refer to
+    /// positions in `points` (or, in a copy, to copies of the points of the
+    /// original) and must be cleared whenever `points` is replaced or modified.
     mutable std::vector<CircularArc> arcs;
 
 };
diff --git a/src/geom/CircularString.cpp b/src/geom/CircularString.cpp
index 400094df8..4ec463453 100644
--- a/src/geom/CircularString.cpp
+++ b/src/geom/CircularString.cpp
@@ -40,6 +40,22 @@ CircularString::CircularString(const std::shared_ptr<const CoordinateSequence>&
 
 CircularString::~CircularString() = default;
 
+// SimpleCurve::apply_rw may replace shared coordinates with a copy, and
+// callers are not required to call geometryChanged(), so clear the arcs here.
+void
+CircularString::apply_rw(const CoordinateFilter* filter)
+{
+    arcs.clear();
+    SimpleCurve::apply_rw(filter);
+}
+
+void
+CircularString::apply_rw(CoordinateSequenceFilter& filter)
+{
+    arcs.clear();
+    SimpleCurve::apply_rw(filter);
+}
+
 std::unique_ptr<CircularString>
 CircularString::clone() const
 {
@@ -145,11 +161,13 @@ CircularString::normalizeClosed() {
             coords->reverse();
         }
         points = std::move(coords);
+        arcs.clear();
     } else if (reverse) {
         if (points.use_count() > 1) {
             points = points->clone();
         }
         const_cast<CoordinateSequence*>(points.get())->reverse();
+        arcs.clear();
     }
 }
 
diff --git a/tests/unit/geom/CircularStringTest.cpp b/tests/unit/geom/CircularStringTest.cpp
index 3be6a7b7e..41cf5a77d 100644
--- a/tests/unit/geom/CircularStringTest.cpp
+++ b/tests/unit/geom/CircularStringTest.cpp
@@ -6,7 +6,9 @@
 #include <geos/geom/CircularArc.h>
 #include <geos/geom/CircularString.h>
 #include <geos/geom/GeometryFactory.h>
+#include <geos/geom/CoordinateFilter.h>
 #include <geos/geom/CoordinateSequence.h>
+#include <geos/geom/CoordinateSequenceFilter.h>
 #include <geos/geom/IntersectionMatrix.h>
 #include <geos/io/WKTReader.h>
 #include <geos/util/UnsupportedOperationException.h>
@@ -50,6 +52,29 @@ struct test_circularstring_data {
         ensure_equals_exact_geometry_xyzm(cs.get(), expected.get(), 0);
     }
 
+    // Check that the arcs of a CircularString refer to its current coordinates and
+    // match the arcs of a new CircularString created from the same coordinates.
+    void checkArcs(const CircularString& cs) const {
+        auto expected = factory_->createCircularString(*cs.getCoordinatesRO());
+
+        const auto& arcs = cs.getArcs();
+        const auto& expectedArcs = expected->getArcs();
+
+        ensure_equals("number of arcs", arcs.size(), expectedArcs.size());
+        for (std::size_t i = 0; i < arcs.size(); i++) {
+            ensure("arc refers to coordinates of CircularString", arcs[i].getCoordinateSequence() == cs.getCoordinatesRO());
+            ensure_equals("arc position", arcs[i].getCoordinatePosition(), 2*i);
+            ensure_equals_xy(arcs[i].p0(), expectedArcs[i].p0());
+            ensure_equals_xy(arcs[i].p1(), expectedArcs[i].p1());
+            ensure_equals_xy(arcs[i].p2(), expectedArcs[i].p2());
+            ensure_equals("orientation", arcs[i].getOrientation(), expectedArcs[i].getOrientation());
+            ensure_equals_xy(arcs[i].getCenter(), expectedArcs[i].getCenter());
+            ensure_equals("radius", arcs[i].getRadius(), expectedArcs[i].getRadius());
+        }
+
+        ensure_equals("length", cs.getLength(), expected->getLength());
+    }
+
     void checkLinearizeMaxDeviation(const std::string& wkt_in, const std::string& wkt_expected, double maxDeviation, double tol=1e-12) {
         auto ctl = CurveToLineParams::maxDeviation(maxDeviation);
         checkLinearize(wkt_in, wkt_expected, ctl, tol);
@@ -535,4 +560,149 @@ void object::test<27>()
     ensure_equals_xyzm(seq->getAt<XYZM>(5), XYZM{2.5, -4.33, 6 + (30.0 - 6)*(2.0/3), 8 + (40.0 - 8)*(2.0/3)}, 1e-3);
 }
 
+template<>
+template<>
+void object::test<28>()
+{
+    set_test_name("getArcs() after normalize() of closed CircularString");
+
+    for (const char* wkt : {
+        // circle, start point is OK but need to reverse points
+        "CIRCULARSTRING (-5 0, 0 -5, 5 0, 0 5, -5 0)",
+        // circle, need to change start point but orientation is OK
+        "CIRCULARSTRING (5 0, 0 -5, -5 0, 0 5, 5 0)",
+        // circle, need to change start point and orientation
+        "CIRCULARSTRING (5 0, 0 5, -5 0, 0 -5, 5 0)",
+        // half-moon, start point is OK but need to make CW
+        "CIRCULARSTRING (0 0, -3 5, 0 10, -5 5, 0 0)",
+        // half-moon, need to change start point but orientation is OK
+        "CIRCULARSTRING (0 10, -3 5, 0 0, -5 5, 0 10)"
+    }) {
+        auto cs = wktreader_.read<CircularString>(wkt);
+        cs->getLength(); // create arcs, computing their center, radius and orientation
+        cs->normalize();
+
+        checkArcs(*cs);
+    }
+}
+
+template<>
+template<>
+void object::test<29>()
+{
+    set_test_name("getArcs() after normalize() of closed CircularString with shared coordinates");
+
+    for (const char* wkt : {
+        // start point is OK but need to reverse points
+        "CIRCULARSTRING (-5 0, 0 -5, 5 0, 0 5, -5 0)",
+        // need to change start point but orientation is OK
+        "CIRCULARSTRING (5 0, 0 -5, -5 0, 0 5, 5 0)",
+        // need to change start point and orientation
+        "CIRCULARSTRING (5 0, 0 5, -5 0, 0 -5, 5 0)"
+    }) {
+        auto other = wktreader_.read<CircularString>(wkt);
+        auto cs = factory_->createCircularString(other->getSharedCoordinates());
+        cs->getLength(); // create arcs, computing their center, radius and orientation
+        cs->normalize();
+        other.reset(); // destroy the coordinates used by cs before normalize()
+
+        checkArcs(*cs);
+    }
+}
+
+template<>
+template<>
+void object::test<30>()
+{
+    set_test_name("getArcs() after apply_rw(const CoordinateFilter*)");
+
+    struct ScaleFilter : public geos::geom::CoordinateFilter {
+        void filter_rw(XY* c) const override
+        {
+            c->x *= 2;
+            c->y *= 2;
+        }
+    };
+
+    ScaleFilter filter;
+
+    // coordinates modified in place
+    cs_->getLength(); // create arcs, computing their center, radius and orientation
+    cs_->apply_rw(&filter);
+    checkArcs(*cs_);
+
+    // coordinates copied before modification because they are shared
+    auto cs = factory_->createCircularString(cs_->getSharedCoordinates());
+    cs->getLength(); // create arcs, computing their center, radius and orientation
+    cs->apply_rw(&filter);
+    checkArcs(*cs);
+    cs_.reset(); // destroy the coordinates used by cs before apply_rw()
+    checkArcs(*cs);
+
+    // arcs copied by clone()
+    auto copy = cs->clone();
+    copy->apply_rw(&filter);
+    checkArcs(*copy);
+}
+
+template<>
+template<>
+void object::test<31>()
+{
+    set_test_name("getArcs() after apply_rw(CoordinateSequenceFilter&)");
+
+    struct ScaleFilter : public geos::geom::CoordinateSequenceFilter {
+        explicit ScaleFilter(double p_factor) : factor(p_factor) {}
+
+        void filter_rw(CoordinateSequence& seq, std::size_t i) override
+        {
+            auto& c = seq.getAt<XY>(i);
+            c.x *= factor;
+            c.y *= factor;
+        }
+
+        bool isDone() const override
+        {
+            return false;
+        }
+
+        bool isGeometryChanged() const override
+        {
+            return factor != 1;
+        }
+
+        double factor;
+    };
+
+    // coordinates modified in place
+    ScaleFilter scale(2);
+    cs_->getLength(); // create arcs, computing their center, radius and orientation
+    cs_->apply_rw(scale);
+    checkArcs(*cs_);
+
+    // coordinates unchanged, but copied before the filter is applied because they are shared
+    ScaleFilter identity(1);
+    auto cs = factory_->createCircularString(cs_->getSharedCoordinates());
+    cs->getLength(); // create arcs, computing their center, radius and orientation
+    cs->apply_rw(identity);
+    checkArcs(*cs);
+    cs_.reset(); // destroy the coordinates used by cs before apply_rw()
+    checkArcs(*cs);
+}
+
+template<>
+template<>
+void object::test<32>()
+{
+    set_test_name("getArcs() after geometryChanged()");
+
+    cs_->getLength(); // create arcs, computing their center, radius and orientation
+
+    auto& pts = *const_cast<CoordinateSequence*>(cs_->getCoordinatesRO());
+    pts.setAt(XY(1, 2), 1);
+    cs_->geometryChanged();
+
+    checkArcs(*cs_);
+}
+
 }

commit 24e5696dcea1d5e52c75f05d9b60063351bb48c0
Author: Daniel Baston <dbaston at gmail.com>
Date:   Sat Oct 31 16:00:34 2026 -0400

    NodableArcString: Handle degenerate arcs with repeated points
    
    Co-authored-by: Alejandro Pacheco <ale at alepacheco.dev>

diff --git a/src/noding/NodableArcString.cpp b/src/noding/NodableArcString.cpp
index 5b41a62f8..7c91634ee 100644
--- a/src/noding/NodableArcString.cpp
+++ b/src/noding/NodableArcString.cpp
@@ -268,7 +268,7 @@ public:
             m_coords = std::make_unique<geom::CoordinateSequence>(0, m_constructZ, m_constructM);
         }
 
-        m_coords->add(seq, pos, pos + 2, false);
+        m_coords->add(seq, pos + !m_coords->isEmpty(), pos + 2, true);
         m_arcs.emplace_back(*m_coords, m_coords->getSize() - 3, center, radius, orientation);
     }
 
diff --git a/tests/unit/capi/GEOSNodeTest.cpp b/tests/unit/capi/GEOSNodeTest.cpp
index 10166adbc..97401d714 100644
--- a/tests/unit/capi/GEOSNodeTest.cpp
+++ b/tests/unit/capi/GEOSNodeTest.cpp
@@ -414,5 +414,28 @@ void object::test<19>()
                                       reinterpret_cast<Geometry*>(expected_), 1e-4);
 }
 
+template<>
+template<>
+void object::test<20>()
+{
+    set_test_name("CircularString with repeated points");
+
+    input_ = fromWKT("MULTICURVE (CIRCULARSTRING (0 0, 0 0, 2 0, 3 1, 4 0), LINESTRING (3 0, 3 2))");
+
+    result_ = GEOSNode(input_);
+    ensure(result_);
+
+    ensure_equals(GEOSGetNumGeometries(result_), 4);
+
+    expected_ = fromWKT("MULTICURVE ("
+                        "CIRCULARSTRING (0 0, 0 0, 2 0, 2.2928932188 0.7071067812, 3 1),"
+                        "CIRCULARSTRING (3 1, 3.7071067812 0.7071067812, 4 0),"
+                        "(3 0, 3 1),"
+                        "(3 1, 3 2))");
+
+    ensure_equals_exact_geometry_xyzm(reinterpret_cast<Geometry*>(result_),
+                                      reinterpret_cast<Geometry*>(expected_), 1e-4);
+}
+
 } // namespace tut
 
diff --git a/tests/unit/noding/NodableArcStringTest.cpp b/tests/unit/noding/NodableArcStringTest.cpp
index c1f1c02ce..31b8b9832 100644
--- a/tests/unit/noding/NodableArcStringTest.cpp
+++ b/tests/unit/noding/NodableArcStringTest.cpp
@@ -12,6 +12,7 @@ using geos::geom::CoordinateXY;
 using geos::geom::CoordinateXYZM;
 using geos::noding::ArcString;
 using geos::noding::NodableArcString;
+using namespace std::string_literals;
 
 namespace tut {
 
@@ -223,4 +224,72 @@ void object::test<5>()
                 });
 }
 
-}
\ No newline at end of file
+template<>
+template<>
+void object::test<6>()
+{
+    set_test_name("getNoded keeps all three points of arcs with repeated points");
+
+    for (const char* wkt : {
+            "CIRCULARSTRING (0 0, 0 0, 0 0)", // 0 == 1 == 2
+            "CIRCULARSTRING (0 0, 0 0, 2 0, 3 1, 4 0)", // 0 == 1
+            "CIRCULARSTRING (0 0, 1 1, 1 1, 3 1, 4 0)", // 1 == 2
+            "CIRCULARSTRING (0 0, 1 1, 2 0, 2 0, 4 0)", // 2 == 3
+            "CIRCULARSTRING (0 0, 1 1, 2 0, 3 1, 3 1)" // 3 == 4
+        }) {
+        const auto g = reader_.read<CircularString>(wkt);
+
+        NodableArcString nas(g->getArcs(), g->getSharedCoordinates(), false, false, nullptr);
+
+        std::vector<std::unique_ptr<ArcString>> noded;
+        nas.getNoded(noded);
+
+        ensure_equals("noded.size(): "s + wkt, noded.size(), 1u);
+        ensure_equals("noded[0]->getSize(): "s + wkt, noded[0]->getSize(), g->getArcs().size());
+        ensure("noded[0]->getCoordinates(): "s + wkt, noded[0]->getCoordinates()->equalsIdentical(*g->getCoordinatesRO()));
+        for (std::size_t i = 0; i < noded[0]->getSize(); i++) {
+            ensure_equals(wkt, noded[0]->getArc(i).getCoordinatePosition(), 2*i);
+        }
+    }
+}
+
+template<>
+template<>
+void object::test<7>()
+{
+    set_test_name("getNoded keeps arc with repeated points before a split arc");
+
+    const auto g = reader_.read<CircularString>("CIRCULARSTRING (0 0, 0 0, 2 0, 3 1, 4 0)");
+
+    NodableArcString nas(g->getArcs(), g->getSharedCoordinates(), false, false, nullptr);
+    nas.addIntersection(CoordinateXY{3, 1}, 1);
+
+    std::vector<std::unique_ptr<ArcString>> noded;
+    nas.getNoded(noded);
+
+    ensure_equals(noded.size(), 2u);
+
+    const auto& seq = *noded[0]->getCoordinates();
+    ensure_equals(noded[0]->getSize(), 2u);
+    ensure_equals(seq.size(), 5u);
+
+    const CircularArc& arc0 = noded[0]->getArc(0);
+    ensure_equals(arc0.getCoordinatePosition(), 0u);
+    ensure_equals(arc0.p0(), XY{0, 0});
+    ensure_equals(arc0.p1(), XY{0, 0});
+    ensure_equals(arc0.p2(), XY{2, 0});
+
+    const CircularArc& arc1 = noded[0]->getArc(1);
+    ensure_equals(arc1.getCoordinatePosition(), 2u);
+    ensure_equals(arc1.p0(), XY{2, 0});
+    ensure_equals(arc1.p2(), XY{3, 1});
+
+    ensure_equals(noded[1]->getSize(), 1u);
+    ensure_equals(noded[1]->getCoordinates()->size(), 3u);
+
+    const CircularArc& arc2 = noded[1]->getArc(0);
+    ensure_arc_equals(arc2, CircularArc::create(XY{3, 1}, XY{4, 0}, XY{3, 0}, 1, Orientation::CLOCKWISE), 1e-8);
+    ensure_equals(arc2.p2(), XY{4, 0});
+}
+
+} // namespace tut

commit 0aed98e0728e7386281cce1ead13bdaebcb676cc
Author: Alejandro Pacheco <ale at alepacheco.dev>
Date:   Thu Oct 29 07:16:51 2026 +0100

    Check element types of MultiPoint, MultiLineString and MultiPolygon
    
    The constructors taking a vector of Geometry now throw
    IllegalArgumentException for an element of another type, as the
    MultiCurve and MultiSurface constructors already do. This also covers
    GEOSGeom_createCollection.
    
    buildGeometry(from, toofar) chose its result type by sort index and
    dimension, so it put several CircularStrings or CompoundCurves into a
    MultiLineString and several collections of one type into a Multi*
    geometry. It now calls buildGeometry(const std::vector<const Geometry*>&),
    whose semantics its documentation already refers to. As a result,
    GEOSUnaryUnion of CircularStrings alone no longer fails with "Noder does
    not support curved paths".

diff --git a/include/geos/geom/GeometryFactory.h b/include/geos/geom/GeometryFactory.h
index 73fcb5995..22d663468 100644
--- a/include/geos/geom/GeometryFactory.h
+++ b/include/geos/geom/GeometryFactory.h
@@ -434,54 +434,7 @@ public:
     std::unique_ptr<Geometry>
     buildGeometry(T from, T toofar) const
     {
-        bool isHeterogeneous = false;
-        std::size_t count = 0;
-        int geomClass = -1;
-        for(T i = from; i != toofar; ++i) {
-            ++count;
-            const Geometry* g = *i;
-            if(geomClass < 0) {
-                geomClass = g->getSortIndex();
-            }
-            else if(geomClass != g->getSortIndex()) {
-                isHeterogeneous = true;
-            }
-        }
-
-        // for the empty geometry, return an empty GeometryCollection
-        if(count == 0) {
-            return std::unique_ptr<Geometry>(createGeometryCollection());
-        }
-
-        // for the single geometry, return a clone
-        if(count == 1) {
-            return (*from)->clone();
-        }
-
-        // Now we know it is a collection
-
-        // FIXME:
-        // Until we tweak all the createMulti* interfaces
-        // to support taking iterators we'll have to build
-        // a custom vector here.
-        std::vector<std::unique_ptr<Geometry>> fromGeoms;
-        for(T i = from; i != toofar; ++i) {
-            fromGeoms.push_back((*i)->clone());
-        }
-
-        // for an heterogeneous ...
-        if(isHeterogeneous) {
-            return createGeometryCollection(std::move(fromGeoms));
-        }
-
-        // At this point we know the collection is not hetereogenous.
-        switch((*from)->getDimension()) {
-            case Dimension::A: return createMultiPolygon(std::move(fromGeoms));
-            case Dimension::L: return createMultiLineString(std::move(fromGeoms));
-            case Dimension::P: return createMultiPoint(std::move(fromGeoms));
-            default:
-                throw geos::util::IllegalArgumentException(std::string("Invalid geometry type."));
-        }
+        return buildGeometry(std::vector<const Geometry*>(from, toofar));
     }
 
     /** \brief
diff --git a/src/geom/MultiLineString.cpp b/src/geom/MultiLineString.cpp
index c58615076..f4116f0b2 100644
--- a/src/geom/MultiLineString.cpp
+++ b/src/geom/MultiLineString.cpp
@@ -45,7 +45,14 @@ MultiLineString::MultiLineString(std::vector<std::unique_ptr<LineString>> && new
 MultiLineString::MultiLineString(std::vector<std::unique_ptr<Geometry>> && newLines,
                                  const GeometryFactory& factory)
         : GeometryCollection(std::move(newLines), factory)
-{}
+{
+    for (const auto& geom : geometries) {
+        const auto typeId = geom->getGeometryTypeId();
+        if (typeId != GEOS_LINESTRING && typeId != GEOS_LINEARRING) {
+            throw util::IllegalArgumentException("All elements of MultiLineString must be a LineString");
+        }
+    }
+}
 
 MultiLineString::MultiLineString(std::vector<std::unique_ptr<Curve>> && newLines,
                                  const GeometryFactory& factory)
diff --git a/src/geom/MultiPoint.cpp b/src/geom/MultiPoint.cpp
index aa615bf1a..8513e1bf1 100644
--- a/src/geom/MultiPoint.cpp
+++ b/src/geom/MultiPoint.cpp
@@ -22,6 +22,7 @@
 #include <geos/geom/MultiPoint.h>
 #include <geos/geom/GeometryFactory.h>
 #include <geos/geom/Dimension.h>
+#include <geos/util/IllegalArgumentException.h>
 
 #include <vector>
 
@@ -40,7 +41,11 @@ MultiPoint::MultiPoint(std::vector<std::unique_ptr<Geometry>> && newPoints, cons
         :
         GeometryCollection(std::move(newPoints), factory)
 {
-
+    for (const auto& geom : geometries) {
+        if (geom->getGeometryTypeId() != GEOS_POINT) {
+            throw util::IllegalArgumentException("All elements of MultiPoint must be a Point");
+        }
+    }
 }
 
 Dimension::DimensionType
diff --git a/src/geom/MultiPolygon.cpp b/src/geom/MultiPolygon.cpp
index ae5c253fc..8f7c82b68 100644
--- a/src/geom/MultiPolygon.cpp
+++ b/src/geom/MultiPolygon.cpp
@@ -25,6 +25,7 @@
 #include <geos/geom/MultiSurface.h>
 #include <geos/geom/GeometryFactory.h>
 #include <geos/geom/Dimension.h>
+#include <geos/util/IllegalArgumentException.h>
 
 #include <cassert>
 #include <string>
@@ -42,7 +43,13 @@ MultiPolygon::MultiPolygon(std::vector<std::unique_ptr<Polygon>> && newPolys, co
 
 MultiPolygon::MultiPolygon(std::vector<std::unique_ptr<Geometry>> && newPolys, const GeometryFactory& factory)
         : GeometryCollection(std::move(newPolys), factory)
-{}
+{
+    for (const auto& geom : geometries) {
+        if (geom->getGeometryTypeId() != GEOS_POLYGON) {
+            throw util::IllegalArgumentException("All elements of MultiPolygon must be a Polygon");
+        }
+    }
+}
 
 MultiPolygon::~MultiPolygon() {}
 
diff --git a/tests/unit/capi/GEOSGeom_createCollectionTest.cpp b/tests/unit/capi/GEOSGeom_createCollectionTest.cpp
index c52bce50a..56f40ef39 100644
--- a/tests/unit/capi/GEOSGeom_createCollectionTest.cpp
+++ b/tests/unit/capi/GEOSGeom_createCollectionTest.cpp
@@ -202,5 +202,46 @@ void object::test<9>
 }
 
 
-} // namespace tut
+// Error on an element of another type, ownership is still transferred
+template<>
+template<>
+void object::test<10>
+()
+{
+    GEOSGeometry* points[2];
+    points[0] = fromWKT("POINT (0 0)");
+    points[1] = fromWKT("LINESTRING (0 0, 1 1)");
+    geom1_ = GEOSGeom_createCollection(GEOS_MULTIPOINT, points, 2);
+    ensure(geom1_ == nullptr);
 
+    GEOSGeometry* lines[2];
+    lines[0] = fromWKT("LINESTRING (0 0, 1 1)");
+    lines[1] = fromWKT("MULTILINESTRING ((2 2, 3 3))");
+    geom1_ = GEOSGeom_createCollection(GEOS_MULTILINESTRING, lines, 2);
+    ensure(geom1_ == nullptr);
+
+    GEOSGeometry* polygons[2];
+    polygons[0] = fromWKT("POLYGON ((0 0, 1 0, 1 1, 0 0))");
+    polygons[1] = fromWKT("POINT (5 5)");
+    geom1_ = GEOSGeom_createCollection(GEOS_MULTIPOLYGON, polygons, 2);
+    ensure(geom1_ == nullptr);
+}
+
+// A LinearRing is accepted as an element of a MultiLineString
+template<>
+template<>
+void object::test<11>
+()
+{
+    GEOSGeometry* lines[2];
+    lines[0] = fromWKT("LINEARRING (0 0, 1 0, 1 1, 0 0)");
+    lines[1] = fromWKT("LINESTRING (2 2, 3 3)");
+    geom1_ = GEOSGeom_createCollection(GEOS_MULTILINESTRING, lines, 2);
+    ensure(geom1_ != nullptr);
+
+    ensure_equals(GEOSGeomTypeId(geom1_), GEOS_MULTILINESTRING);
+    ensure_equals(GEOSGetNumGeometries(geom1_), 2);
+    ensure_equals(GEOSGeomTypeId(GEOSGetGeometryN(geom1_, 0)), GEOS_LINEARRING);
+}
+
+} // namespace tut
diff --git a/tests/unit/capi/GEOSUnaryUnionTest.cpp b/tests/unit/capi/GEOSUnaryUnionTest.cpp
index af484b8ef..802078ba9 100644
--- a/tests/unit/capi/GEOSUnaryUnionTest.cpp
+++ b/tests/unit/capi/GEOSUnaryUnionTest.cpp
@@ -333,6 +333,21 @@ void object::test<17>()
     ensure_geometry_equals(result_, input_, 1e-8);
 }
 
+template<>
+template<>
+void object::test<18>()
+{
+    set_test_name("CircularString-only dim=1 inputs");
+
+    input_ = fromWKT("MULTICURVE (CIRCULARSTRING (0 0, 1 1, 2 0), CIRCULARSTRING (2 0, 3 -1, 4 0))");
+
+    result_ = GEOSUnaryUnion(input_);
+    ensure(result_);
+
+    expected_ = fromWKT("CIRCULARSTRING (0 0, 1 1, 2 0, 3 -1, 4 0)");
+    ensure_geometry_equals(result_, expected_, 1e-8);
+}
+
 } // namespace tut
 
 
diff --git a/tests/unit/geom/GeometryFactoryTest.cpp b/tests/unit/geom/GeometryFactoryTest.cpp
index 30a27a924..3d950d03c 100644
--- a/tests/unit/geom/GeometryFactoryTest.cpp
+++ b/tests/unit/geom/GeometryFactoryTest.cpp
@@ -3,6 +3,7 @@
 
 // tut
 #include <tut/tut.hpp>
+#include <tut/tut_macros.hpp>
 #include <utility.h>
 // geos
 #include <geos/geom/GeometryFactory.h>
@@ -1622,4 +1623,87 @@ void object::test<44>()
     }
 }
 
+template<>
+template<>
+void object::test<45>()
+{
+    set_test_name("createMultiPoint, createMultiLineString, createMultiPolygon reject elements of another type");
+
+    using geos::geom::Geometry;
+    using geos::util::IllegalArgumentException;
+
+    auto elements = [this](const std::string& wkt1, const std::string& wkt2) {
+        std::vector<std::unique_ptr<Geometry>> geoms;
+        geoms.push_back(reader_.read(wkt1));
+        geoms.push_back(reader_.read(wkt2));
+        return geoms;
+    };
+
+    ensure_THROW(factory_->createMultiPoint(elements("POINT (0 0)", "MULTIPOINT ((1 1))")), IllegalArgumentException);
+    ensure_THROW(factory_->createMultiPoint(elements("POINT (0 0)", "LINESTRING (0 0, 1 1)")), IllegalArgumentException);
+
+    ensure_THROW(factory_->createMultiLineString(elements("LINESTRING (0 0, 1 1)", "MULTILINESTRING ((2 2, 3 3))")), IllegalArgumentException);
+    ensure_THROW(factory_->createMultiLineString(elements("LINESTRING (0 0, 1 1)", "CIRCULARSTRING (0 0, 1 1, 2 0)")), IllegalArgumentException);
+    ensure_THROW(factory_->createMultiLineString(elements("LINESTRING (0 0, 1 1)", "POINT (0 0)")), IllegalArgumentException);
+
+    ensure_THROW(factory_->createMultiPolygon(elements("POLYGON ((0 0, 1 0, 1 1, 0 0))", "MULTIPOLYGON (((2 2, 3 2, 3 3, 2 2)))")), IllegalArgumentException);
+    ensure_THROW(factory_->createMultiPolygon(elements("POLYGON ((0 0, 1 0, 1 1, 0 0))", "CURVEPOLYGON ((2 2, 3 2, 3 3, 2 2))")), IllegalArgumentException);
+    ensure_THROW(factory_->createMultiPolygon(elements("POLYGON ((0 0, 1 0, 1 1, 0 0))", "LINESTRING (0 0, 1 1)")), IllegalArgumentException);
+
+    // createMultiPolygon(const std::vector<const Geometry*>&) clones into the checked constructor
+    auto poly = reader_.read("POLYGON ((0 0, 1 0, 1 1, 0 0))");
+    auto mpoly = reader_.read("MULTIPOLYGON (((2 2, 3 2, 3 3, 2 2)))");
+    std::vector<const Geometry*> borrowed{ poly.get(), mpoly.get() };
+    ensure_THROW(factory_->createMultiPolygon(borrowed), IllegalArgumentException);
+
+    // A LinearRing is a LineString
+    auto mls = factory_->createMultiLineString(elements("LINESTRING (0 0, 1 1)", "LINEARRING (2 2, 3 2, 3 3, 2 2)"));
+    ensure_equals(mls->getNumGeometries(), 2u);
+}
+
+template<>
+template<>
+void object::test<46>()
+{
+    set_test_name("buildGeometry(from, toofar) and buildGeometry(const std::vector<const Geometry*>&) give the same type");
+
+    using geos::geom::Geometry;
+    using geos::geom::GeometryTypeId;
+
+    struct Case {
+        std::string wkt1;
+        std::string wkt2;
+        GeometryTypeId type;
+    };
+
+    const std::vector<Case> cases = {
+        { "POINT (1 1)", "POINT (2 2)", geos::geom::GEOS_MULTIPOINT },
+        { "LINESTRING (0 0, 1 1)", "LINESTRING (2 2, 3 3)", geos::geom::GEOS_MULTILINESTRING },
+        { "LINESTRING (0 0, 1 1)", "LINEARRING (2 2, 3 2, 3 3, 2 2)", geos::geom::GEOS_MULTILINESTRING },
+        { "POLYGON ((0 0, 1 0, 1 1, 0 0))", "POLYGON ((2 2, 3 2, 3 3, 2 2))", geos::geom::GEOS_MULTIPOLYGON },
+        { "CIRCULARSTRING (0 0, 1 1, 2 0)", "CIRCULARSTRING (2 0, 3 1, 4 0)", geos::geom::GEOS_MULTICURVE },
+        { "COMPOUNDCURVE ((0 0, 1 1))", "COMPOUNDCURVE ((2 2, 3 3))", geos::geom::GEOS_MULTICURVE },
+        { "LINESTRING (0 0, 1 1)", "CIRCULARSTRING (2 0, 3 1, 4 0)", geos::geom::GEOS_MULTICURVE },
+        { "CURVEPOLYGON ((0 0, 1 0, 1 1, 0 0))", "CURVEPOLYGON ((2 2, 3 2, 3 3, 2 2))", geos::geom::GEOS_MULTISURFACE },
+        { "POLYGON ((0 0, 1 0, 1 1, 0 0))", "CURVEPOLYGON ((2 2, 3 2, 3 3, 2 2))", geos::geom::GEOS_MULTISURFACE },
+        { "MULTIPOINT ((0 0))", "MULTIPOINT ((2 2), (3 3))", geos::geom::GEOS_GEOMETRYCOLLECTION },
+        { "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))", "MULTIPOLYGON (((2 2, 3 2, 3 3, 2 2)))", geos::geom::GEOS_GEOMETRYCOLLECTION },
+        { "POINT (1 1)", "LINESTRING (2 2, 3 3)", geos::geom::GEOS_GEOMETRYCOLLECTION },
+    };
+
+    for (const auto& c : cases) {
+        auto g1 = reader_.read(c.wkt1);
+        auto g2 = reader_.read(c.wkt2);
+        std::vector<const Geometry*> geoms{ g1.get(), g2.get() };
+
+        auto fromVector = factory_->buildGeometry(geoms);
+        ensure_equals(c.wkt2, fromVector->getGeometryTypeId(), c.type);
+        ensure_equals(c.wkt2, fromVector->getNumGeometries(), 2u);
+
+        auto fromIterators = factory_->buildGeometry(geoms.begin(), geoms.end());
+        ensure_equals(c.wkt2, fromIterators->getGeometryTypeId(), c.type);
+        ensure_equals(c.wkt2, fromIterators->getNumGeometries(), 2u);
+    }
+}
+
 } // namespace tut

commit 11588117abcf73fe531d3805711070c50a345032
Author: Alejandro Pacheco <ale at alepacheco.dev>
Date:   Thu Oct 29 07:16:51 2026 +0100

    GeometryFactory::buildGeometry: return GeometryCollection when an element is a collection
    
    Since 4e50c4d3c, commonType compares the dimension of the elements
    rather than their type, so a collection among elements of one
    dimension gave a MultiPoint, MultiLineString or MultiPolygon holding
    that collection. Return a GeometryCollection for these, as the
    documentation of buildGeometry says and as 3.14 did.

diff --git a/src/geom/GeometryFactory.cpp b/src/geom/GeometryFactory.cpp
index 1454dc86a..541b90649 100644
--- a/src/geom/GeometryFactory.cpp
+++ b/src/geom/GeometryFactory.cpp
@@ -903,7 +903,9 @@ GeometryTypeId commonType(const T& geoms) {
     for (std::size_t i = 1; i < geoms.size(); i++) {
         curvedType |= geoms[i]->hasCurvedTypes();
 
-        if (geoms[i]->getDimension() != dim) {
+        // A collection can only be an element of a GeometryCollection,
+        // even when its dimension matches the other elements.
+        if (geoms[i]->isCollection() || geoms[i]->getDimension() != dim) {
             return GEOS_GEOMETRYCOLLECTION;
         }
     }
diff --git a/tests/unit/capi/GEOSDensifyTest.cpp b/tests/unit/capi/GEOSDensifyTest.cpp
index 43e87d569..2f1145132 100644
--- a/tests/unit/capi/GEOSDensifyTest.cpp
+++ b/tests/unit/capi/GEOSDensifyTest.cpp
@@ -227,4 +227,20 @@ void object::test<14>()
     );
 }
 
+template<>
+template<>
+void object::test<15>()
+{
+    set_test_name("MultiPolygon with rings collapsed to lines gives a GeometryCollection");
+
+    input_ = fromWKT("MULTIPOLYGON (((0 0, 1 1, 0 0)), ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+
+    result_ = GEOSDensify(input_, 100);
+    ensure(result_);
+
+    // Not a MultiLineString with a MultiLineString element
+    ensure_equals(GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION);
+    ensure_equals(toWKT(result_), "GEOMETRYCOLLECTION (LINESTRING (0 0, 1 1, 0 0), MULTILINESTRING ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+}
+
 } // namespace tut
diff --git a/tests/unit/capi/GEOSGeom_setPrecisionTest.cpp b/tests/unit/capi/GEOSGeom_setPrecisionTest.cpp
index 2c70f84d3..4f1f1609c 100644
--- a/tests/unit/capi/GEOSGeom_setPrecisionTest.cpp
+++ b/tests/unit/capi/GEOSGeom_setPrecisionTest.cpp
@@ -339,5 +339,20 @@ void object::test<24>()
     ensure_geometry_equals_identical(result_, input_);
 }
 
-} // namespace tut
+template<>
+template<>
+void object::test<25>()
+{
+    set_test_name("MultiPolygon with rings collapsed to lines gives a GeometryCollection");
 
+    input_ = fromWKT("MULTIPOLYGON (((0 0, 1 1, 0 0)), ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+
+    result_ = GEOSGeom_setPrecision(input_, 1.0, GEOS_PREC_NO_TOPO);
+    ensure(result_);
+
+    // Not a MultiLineString with a MultiLineString element
+    ensure_equals(GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION);
+    ensure_equals(toWKT(result_), "GEOMETRYCOLLECTION (LINESTRING (0 0, 1 1, 0 0), MULTILINESTRING ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+}
+
+} // namespace tut
diff --git a/tests/unit/capi/GEOSSnapTest.cpp b/tests/unit/capi/GEOSSnapTest.cpp
index 7fedee2e6..f56f0d5d6 100644
--- a/tests/unit/capi/GEOSSnapTest.cpp
+++ b/tests/unit/capi/GEOSSnapTest.cpp
@@ -220,5 +220,20 @@ void object::test<11>()
     ensure("curved geometry not supported", result_ == nullptr);
 }
 
-} // namespace tut
+template<>
+template<>
+void object::test<12>()
+{
+    set_test_name("MultiPolygon with rings collapsed to lines gives a GeometryCollection");
 
+    input_ = fromWKT("MULTIPOLYGON (((0 0, 1 1, 0 0)), ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+
+    result_ = GEOSSnap(input_, input_, 0);
+    ensure(result_);
+
+    // Not a MultiLineString with a MultiLineString element
+    ensure_equals(GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION);
+    ensure_equals(toWKT(result_), "GEOMETRYCOLLECTION (LINESTRING (0 0, 1 1, 0 0), MULTILINESTRING ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+}
+
+} // namespace tut
diff --git a/tests/unit/capi/GEOSTopologyPreserveSimplifyTest.cpp b/tests/unit/capi/GEOSTopologyPreserveSimplifyTest.cpp
index a6ada22d1..11517b2e9 100644
--- a/tests/unit/capi/GEOSTopologyPreserveSimplifyTest.cpp
+++ b/tests/unit/capi/GEOSTopologyPreserveSimplifyTest.cpp
@@ -74,5 +74,20 @@ void object::test<4>()
     ensure_geometry_equals_identical(result_, expected_);
 }
 
-} // namespace tut
+template<>
+template<>
+void object::test<5>()
+{
+    set_test_name("MultiPolygon with rings collapsed to lines gives a GeometryCollection");
 
+    input_ = fromWKT("MULTIPOLYGON (((0 0, 1 1, 0 0)), ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+
+    result_ = GEOSTopologyPreserveSimplify(input_, 0);
+    ensure(result_);
+
+    // Not a MultiLineString with a MultiLineString element
+    ensure_equals(GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION);
+    ensure_equals(toWKT(result_), "GEOMETRYCOLLECTION (LINESTRING (0 0, 1 1, 0 0), MULTILINESTRING ((10 10, 11 11, 10 10), (0 0, 30 0, 30 30, 0 30, 0 0)))");
+}
+
+} // namespace tut
diff --git a/tests/unit/geom/GeometryFactoryTest.cpp b/tests/unit/geom/GeometryFactoryTest.cpp
index 3b10c2015..30a27a924 100644
--- a/tests/unit/geom/GeometryFactoryTest.cpp
+++ b/tests/unit/geom/GeometryFactoryTest.cpp
@@ -22,6 +22,8 @@
 #include <geos/util/IllegalArgumentException.h>
 #include <geos/util.h>
 // std
+#include <string>
+#include <utility>
 #include <vector>
 #include <cstring> // std::size_t
 
@@ -1575,5 +1577,49 @@ void object::test<43>()
         ensure_equals(g->getNumGeometries(), 2u);
     }
 }
-    
+
+template<>
+template<>
+void object::test<44>()
+{
+    set_test_name("buildGeometry returns a GeometryCollection when an element is a collection");
+
+    using geos::geom::Geometry;
+
+    const std::vector<std::pair<std::string, std::string>> cases = {
+        { "POINT (1 1)", "MULTIPOINT ((2 2), (3 3))" },
+        { "LINESTRING (0 0, 1 1)", "MULTILINESTRING ((2 2, 3 3), (4 4, 5 5))" },
+        { "POLYGON ((0 0, 1 0, 1 1, 0 0))", "MULTIPOLYGON (((2 2, 3 2, 3 3, 2 2)))" },
+        { "LINESTRING (0 0, 1 1)", "GEOMETRYCOLLECTION (LINESTRING (2 2, 3 3))" },
+        { "CIRCULARSTRING (0 0, 1 1, 2 0)", "MULTICURVE ((2 2, 3 3))" },
+        { "POLYGON ((0 0, 1 0, 1 1, 0 0))", "MULTISURFACE (((2 2, 3 2, 3 3, 2 2)))" },
+    };
+
+    for (const auto& c : cases) {
+        auto single = reader_.read(c.first);
+        auto multi = reader_.read(c.second);
+
+        for (bool multiFirst : { false, true }) {
+            const Geometry* a = multiFirst ? multi.get() : single.get();
+            const Geometry* b = multiFirst ? single.get() : multi.get();
+
+            std::vector<std::unique_ptr<Geometry>> owned;
+            owned.push_back(a->clone());
+            owned.push_back(b->clone());
+            auto fromOwned = factory_->buildGeometry(std::move(owned));
+            ensure_equals(c.second, fromOwned->getGeometryTypeId(), geos::geom::GEOS_GEOMETRYCOLLECTION);
+            ensure_equals(c.second, fromOwned->getNumGeometries(), 2u);
+
+            std::vector<const Geometry*> borrowed{ a, b };
+            auto fromBorrowed = factory_->buildGeometry(borrowed);
+            ensure_equals(c.second, fromBorrowed->getGeometryTypeId(), geos::geom::GEOS_GEOMETRYCOLLECTION);
+            ensure_equals(c.second, fromBorrowed->getNumGeometries(), 2u);
+
+            auto fromIterators = factory_->buildGeometry(borrowed.begin(), borrowed.end());
+            ensure_equals(c.second, fromIterators->getGeometryTypeId(), geos::geom::GEOS_GEOMETRYCOLLECTION);
+            ensure_equals(c.second, fromIterators->getNumGeometries(), 2u);
+        }
+    }
+}
+
 } // namespace tut

commit 9025863856f46c2c436e1ce070bead91d74b7fa1
Author: Daniel Baston <dbaston at gmail.com>
Date:   Thu Oct 29 10:28:09 2026 -0400

    CircularArcs: Fix missing candidates in closestPointsArcSegment (#1577)
    
    Check for case where closest points are found on segment interior, arc
    endpoint, or segment/arc tangency missed by intersection test.
    
    Co-authored-by: Jeroen Bloemscheer <jbloemscheer at gmail.com>

diff --git a/src/algorithm/CircularArcs.cpp b/src/algorithm/CircularArcs.cpp
index f997f19f0..4c403f942 100644
--- a/src/algorithm/CircularArcs.cpp
+++ b/src/algorithm/CircularArcs.cpp
@@ -502,7 +502,7 @@ CircularArcs::closestPointsArcSegment(const CoordinateXY& c, double r,
     }
 
     // Calculate distance between line and circle center
-    const CoordinateXY d = geom::LineSegment::closestPoint(q0, q1, c);
+    const CoordinateXY d = LineSegment::closestPoint(q0, q1, c);
     const double dist_cd = d.distance(c);
 
     if (dist_cd <= r) {
@@ -516,14 +516,18 @@ CircularArcs::closestPointsArcSegment(const CoordinateXY& c, double r,
             return {isect1, isect1};
         }
 
-        // Closest point is one of the segment endpoints.
-        const auto e0 = closestPointArcPoint(c, r, p0, p2, isCCW, q0);
-        const auto e1 = closestPointArcPoint(c, r, p0, p2, isCCW, q1);
-        if (e0.distance(q0) < e1.distance(q1)) {
-            return {e0, q0};
-        }
+        // Closest point should be a segment or arc endpoint
+        std::array<std::array<CoordinateXY, 2>, 5> candidates;
+        candidates[0] = {closestPointArcPoint(c, r, p0, p2, isCCW, q0), q0};
+        candidates[1] = {closestPointArcPoint(c, r, p0, p2, isCCW, q1), q1};
+        candidates[2] = {p0, LineSegment::closestPoint(q0, q1, p0)};
+        candidates[3] = {p2, LineSegment::closestPoint(q0, q1, p2)};
+        // But we could have also missed a tangency point
+        candidates[4] = {closestPointArcPoint(c, r, p0, p2, isCCW, d), d};
 
-        return {e1, q1};
+        return *std::min_element(candidates.begin(), candidates.end(), [](const auto& a, const auto& b) {
+            return a[0].distance(a[1]) < b[0].distance(b[1]);
+        });
     }
 
     // Find closest point on circle
@@ -541,10 +545,10 @@ CircularArcs::closestPointsArcSegment(const CoordinateXY& c, double r,
     const auto e1 = LineSegment::closestPoint(q0, q1, p2);
 
     if (e0.distance(p0) < e1.distance(p2)) {
-        return {e0, p0};
+        return {p0, e0};
     }
 
-    return {e1, p2};
+    return {p2, e1};
 }
 
 double
diff --git a/tests/unit/algorithm/CircularArcsTest.cpp b/tests/unit/algorithm/CircularArcsTest.cpp
index a19ccb50d..b1de7b939 100644
--- a/tests/unit/algorithm/CircularArcsTest.cpp
+++ b/tests/unit/algorithm/CircularArcsTest.cpp
@@ -1,6 +1,7 @@
 #include <tut/tut.hpp>
 
 #include <geos/geom/Coordinate.h>
+#include <geos/geom/CircularString.h>
 #include <geos/algorithm/CircularArcs.h>
 #include <geos/geom/CircularArc.h>
 #include <geos/geom/Quadrant.h>
@@ -1006,8 +1007,7 @@ set_test_name("getMidpointAngle");
 
 template<>
 template<>
-void object::test<71>()
-{
+void object::test<71>() {
     set_test_name("containsAngle");
 
     using geos::algorithm::Orientation;
@@ -1043,4 +1043,90 @@ void object::test<71>()
     checkAngleContained(MATH_PI, MATH_PI/4, 9*MATH_PI/4, BOTH_);
 }
 
+template<>
+template<>
+void object::test<72>()
+{
+    set_test_name("arc-seg distance, segment inside circle, closest to arc endpoint");
+
+    // The segment lies inside the circle and does not reach the arc, so the
+    // closest pair joins the arc endpoint at 70 degrees to the segment interior.
+    const double r = 10;
+    CircularArc arc = CircularArc::create(XY{r*std::cos(MATH_PI/3), r*std::sin(MATH_PI/3)},
+                                          XY{r*std::cos(13*MATH_PI/36), r*std::sin(13*MATH_PI/36)},
+                                          XY{r*std::cos(7*MATH_PI/18), r*std::sin(7*MATH_PI/18)});
+    XY q0{2, 0};
+    XY q1{2, 9.5};
+
+    ensure_equals("distance", arc.distance(q0, q1), arc.p2().x - 2, 1e-12);
+}
+
+template<>
+template<>
+void object::test<73>()
+{
+    set_test_name("arc-seg distance, segment outside circle, closest to segment endpoint");
+
+    // The closest pair joins a segment endpoint to the arc interior.
+    CircularArc arc = CircularArc::create(XY{-3.48172, 2.91175}, XY{9.45735, -6.02278}, XY{1.44247, -0.448891});
+    XY q0{4.48559, 4.09741};
+    XY q1{-5.45188, -9.20177};
+
+    ensure_equals("distance", arc.distance(q0, q1), 0.2835665009749685, 1e-9);
+
+    const auto pts = CircularArcs::closestPointsArcSegment(arc.getCenter(), arc.getRadius(),
+                     arc.p0(), arc.p2(), arc.isCCW(), q0, q1);
+    ensure("first point is on the arc", std::abs(pts[0].distance(arc.getCenter()) - arc.getRadius()) < 1e-9);
+    ensure_equals("closest points distance", pts[0].distance(pts[1]), 0.2835665009749685, 1e-9);
+}
+
+template<>
+template<>
+void object::test<74>()
+{
+    set_test_name("arc-seg distance, segment tangent to arc");
+
+    // The segment is tangent to the circle at (4, 3), which lies on the arc,
+    // but roundoff hides the intersection from circleIntersectsSegment.
+    CircularArc arc = CircularArc::create(XY{0, -5}, XY{4, -3}, XY{0, 5});
+    XY q0{7, -1};
+    XY q1{1, 7};
+
+    ensure_equals("distance", arc.distance(q0, q1), 0, 1e-12);
+    ensure_equals("distance (reversed segment)", arc.distance(q1, q0), 0, 1e-12);
+}
+
+template<>
+template<>
+void object::test<75>()
+{
+    set_test_name("arc-seg distance, segment nearly tangent to arc");
+
+    // Found by differential testing against NetTopologySuite.Proofs ARC_SEGMENT_DISTANCE.
+    CircularArc arc = CircularArc::create(XY{-81.85405104117687, -125.94712311622965},
+                                          XY{118.46299847713223, -81.33910481556457},
+                                          XY{73.85498017646715, 118.97794470274454});
+    XY q0{176.08930671152348, -164.72607692939968};
+    XY q1{97.98804198508518, 227.31432409657776};
+
+    ensure_equals("distance", arc.distance(q0, q1), 0, 1e-9);
+    ensure_equals("distance (reversed segment)", arc.distance(q1, q0), 0, 1e-9);
+}
+
+template<>
+template<>
+void object::test<76>()
+{
+    set_test_name("arc-seg closest points, segment outside circle, closest to arc endpoint");
+
+    // Closest pair joins the arc endpoint (5, 0) to the segment endpoint (10, -1).
+    CircularArc arc = CircularArc::create(XY{5, 0}, XY{3, 4}, XY{0, 5});
+    XY q0{10, -10};
+    XY q1{10, -1};
+
+    const auto pts = arc.closestPoints(q0, q1);
+    ensure_equals("first point is on the arc", pts[0], XY{5, 0});
+    ensure_equals("second point is on the segment", pts[1], XY{10, -1});
+}
+
 }

commit c87e8f2d67d1d347bb81b1d741f426486f10a20e
Author: Daniel Baston <dbaston at gmail.com>
Date:   Wed Oct 28 19:44:26 2026 -0400

    CircularArcs: fix containsAngle for non-normalized angles (#1579)

diff --git a/include/geos/algorithm/CircularArcs.h b/include/geos/algorithm/CircularArcs.h
index a563e8f12..4b6fab60a 100644
--- a/include/geos/algorithm/CircularArcs.h
+++ b/include/geos/algorithm/CircularArcs.h
@@ -98,10 +98,12 @@ public:
                             const geom::CoordinateXY& p0, const geom::CoordinateXY& p1,
                             geom::CoordinateXY& isect0, geom::CoordinateXY& isect1);
 
-    /** Returns whether an angle is included between two other angles.
+    /** Returns whether an angle is included between two other angles. The angles
+     *  do not need to be normalized. If the start and end angles are the same,
+     *  the input is assumed to represent a full circle.
      *
      * @param t0 starting angle (radians)
-     * @param t2 ending engle (radians)
+     * @param t2 ending angle (radians)
      * @param isCCW whether the interval from t0 to t2 is counterclockwise
      * @param theta angle to test (radians)
      * @return true if theta is included between t0 and t2
diff --git a/src/algorithm/CircularArcs.cpp b/src/algorithm/CircularArcs.cpp
index 03fc02cda..f997f19f0 100644
--- a/src/algorithm/CircularArcs.cpp
+++ b/src/algorithm/CircularArcs.cpp
@@ -416,10 +416,6 @@ CircularArcs::circleIntersectsSegment(const CoordinateXY& center, double r,
 bool
 CircularArcs::containsAngle(double t0, double t2, bool isCCW, double theta)
 {
-    if (theta == t0 || theta == t2) {
-        return true;
-    }
-
     if (isCCW) {
         std::swap(t0, t2);
     }
@@ -427,15 +423,9 @@ CircularArcs::containsAngle(double t0, double t2, bool isCCW, double theta)
     t2 -= t0;
     theta -= t0;
 
-    if (t2 < 0) {
-        t2 += 2*MATH_PI;
-    }
-    if (theta < 0) {
-        theta += 2*MATH_PI;
-    }
-
-    return theta >= t2;
+    const auto norm = Angle::normalizePositive(theta);
 
+    return norm == 0 || norm >= Angle::normalizePositive(t2);
 }
 
 bool
diff --git a/tests/unit/algorithm/CircularArcsTest.cpp b/tests/unit/algorithm/CircularArcsTest.cpp
index 07eda9cab..a19ccb50d 100644
--- a/tests/unit/algorithm/CircularArcsTest.cpp
+++ b/tests/unit/algorithm/CircularArcsTest.cpp
@@ -20,8 +20,41 @@ namespace tut {
 struct test_circulararcs_data {
     const double eps = 1e-8;
 
+    static constexpr int CCW_ = geos::algorithm::Orientation::COUNTERCLOCKWISE;
+    static constexpr int CW_ = geos::algorithm::Orientation::CLOCKWISE;
+    static constexpr int BOTH_ = 0;
+    static constexpr int NONE_ = -2;
+
     using XY = CoordinateXY;
 
+    static void checkAngleContained(double theta, double t0, double t2, int orientation)
+    {
+        std::string angleRange = std::to_string(t0) + " to " + std::to_string(t2);
+
+        if (orientation == CCW_) {
+            ensure(std::to_string(theta) + " should be contained in ccw angle from " + angleRange,
+                   CircularArcs::containsAngle(t0, t2, true, theta));
+            ensure(std::to_string(theta) + " should not be contained in cw angle from " + angleRange,
+                   !CircularArcs::containsAngle(t0, t2, false, theta));
+        }
+        else if (orientation == CW_) {
+            ensure(std::to_string(theta) + " should not be contained in ccw angle from " + angleRange,
+                   !CircularArcs::containsAngle(t0, t2, true, theta));
+            ensure(std::to_string(theta) + " should be contained in cw angle from " + angleRange,
+                   CircularArcs::containsAngle(t0, t2, false, theta));
+        } else if (orientation == BOTH_) {
+            ensure(std::to_string(theta) + " should be contained in ccw angle from " + angleRange,
+                   CircularArcs::containsAngle(t0, t2, true, theta));
+            ensure(std::to_string(theta) + " should be contained in cw angle from " + angleRange,
+                   CircularArcs::containsAngle(t0, t2, false, theta));
+        } else {
+            ensure(std::to_string(theta) + " should not be contained in ccw angle from " + angleRange,
+                   !CircularArcs::containsAngle(t0, t2, true, theta));
+            ensure(std::to_string(theta) + " should not be contained in cw angle from " + angleRange,
+                   !CircularArcs::containsAngle(t0, t2, false, theta));
+        }
+    }
+
     void checkEnvelope(const CoordinateXY& p0, const CoordinateXY& p1, const CoordinateXY& p2,
                        double xmin, double ymin, double xmax, double ymax) const
     {
@@ -970,4 +1003,44 @@ set_test_name("getMidpointAngle");
     ensure_equals(CircularArcs::getMidpointAngle(0, 0, false), MATH_PI);
 }
 
+
+template<>
+template<>
+void object::test<71>()
+{
+    set_test_name("containsAngle");
+
+    using geos::algorithm::Orientation;
+
+    checkAngleContained(MATH_PI / 2, 0, MATH_PI, CCW_);
+    checkAngleContained(MATH_PI / 2, 2*MATH_PI, 3*MATH_PI, CCW_);
+    checkAngleContained(5*MATH_PI / 2, 2*MATH_PI, 3*MATH_PI, CCW_);
+    checkAngleContained(-MATH_PI / 2, 0, MATH_PI, CW_);
+    checkAngleContained(2.5 + MATH_PI, -2.5, 0, CCW_);
+
+    checkAngleContained(0, -MATH_PI / 2, MATH_PI / 2, CCW_);
+    checkAngleContained(2*MATH_PI, -MATH_PI / 2, MATH_PI / 2, CCW_);
+    checkAngleContained(4*MATH_PI, -MATH_PI / 2, MATH_PI / 2, CCW_);
+    checkAngleContained(-2*MATH_PI, -MATH_PI / 2, MATH_PI / 2, CCW_);
+    checkAngleContained(-4*MATH_PI, -MATH_PI / 2, MATH_PI / 2, CCW_);
+
+    checkAngleContained(MATH_PI, 3*MATH_PI/4, -3*MATH_PI/4, CCW_);
+    checkAngleContained(-MATH_PI, 3*MATH_PI/4, -3*MATH_PI/4, CCW_);
+    checkAngleContained(0, 3*MATH_PI/4, -3*MATH_PI/4, CW_);
+    checkAngleContained(MATH_PI/2, 3*MATH_PI/4, -3*MATH_PI/4, CW_);
+
+    // theta == t0
+    checkAngleContained(0, 0, 0, BOTH_);
+    checkAngleContained(0, 0, MATH_PI, BOTH_);
+    checkAngleContained(2*MATH_PI, 0, MATH_PI, BOTH_);
+
+    // t0 == t2, t0 == 0, full circle
+    checkAngleContained(MATH_PI, 0, 0, BOTH_);
+    checkAngleContained(MATH_PI, 0, 2*MATH_PI, BOTH_);
+
+    // t0 == t2, t0 != 0, full circle
+    checkAngleContained(MATH_PI, MATH_PI/4, MATH_PI/4, BOTH_);
+    checkAngleContained(MATH_PI, MATH_PI/4, 9*MATH_PI/4, BOTH_);
+}
+
 }

commit 771e8a83f8407a56f187433b441cc2e49a2ac4c0
Author: Daniel Baston <dbaston at gmail.com>
Date:   Wed Oct 28 11:39:25 2026 -0400

    WKBWriter: preserve includeSRID setting after failed write (#1578)
    
    Resolves https://github.com/libgeos/geos/issues/1567

diff --git a/src/io/WKBWriter.cpp b/src/io/WKBWriter.cpp
index 95c64b044..c71fd1031 100644
--- a/src/io/WKBWriter.cpp
+++ b/src/io/WKBWriter.cpp
@@ -186,9 +186,14 @@ WKBWriter::writeCompoundCurve(const CompoundCurve& g)
     auto orig_includeSRID = includeSRID;
     includeSRID = false;
 
-    for (std::size_t i = 0; i < g.getNumCurves(); i++) {
-        const SimpleCurve& section = *g.getCurveN(i);
-        writeSimpleCurve(section);
+    try {
+        for (std::size_t i = 0; i < g.getNumCurves(); i++) {
+            const SimpleCurve& section = *g.getCurveN(i);
+            writeSimpleCurve(section);
+        }
+    } catch (...) {
+        includeSRID = orig_includeSRID;
+        throw;
     }
 
     includeSRID = orig_includeSRID;
@@ -273,12 +278,19 @@ WKBWriter::writeGeometryCollection(const GeometryCollection& g)
     includeSRID = false;
 
     assert(outStream);
-    for(std::size_t i = 0; i < ngeoms; i++) {
-        const Geometry* elem = g.getGeometryN(i);
-        assert(elem);
 
-        write(*elem, *outStream);
+    try {
+        for(std::size_t i = 0; i < ngeoms; i++) {
+            const Geometry* elem = g.getGeometryN(i);
+            assert(elem);
+
+            write(*elem, *outStream);
+        }
+    } catch (...) {
+        includeSRID = orig_includeSRID;
+        throw;
     }
+
     includeSRID = orig_includeSRID;
 }
 
diff --git a/tests/unit/io/WKBWriterTest.cpp b/tests/unit/io/WKBWriterTest.cpp
index 21387fe6c..71ef08ac3 100644
--- a/tests/unit/io/WKBWriterTest.cpp
+++ b/tests/unit/io/WKBWriterTest.cpp
@@ -3,6 +3,7 @@
 
 // tut
 #include <tut/tut.hpp>
+#include <tut/tut_macros.hpp>
 // geos
 #include <geos/io/WKBReader.h>
 #include <geos/io/WKBWriter.h>
@@ -478,6 +479,39 @@ void object::test<19>
                            "010A0000200E1600000200000001090000000200000001080000000500000000000000000000000000000000000000000000000000004000000000000000000000000000000040000000000000F03F00000000000000400000000000000840000000000000104000000000000008400102000000040000000000000000001040000000000000084000000000000010400000000000001440000000000000F03F000000000000104000000000000000000000000000000000010800000005000000333333333333FB3F000000000000F03F666666666666F63F9A9999999999D93F9A9999999999F93F9A9999999999D93F9A9999999999F93F000000000000E03F333333333333FB3F000000000000F03F");
 }
 
+// includeSRID is kept when writing a collection or a compound curve fails
+// because the output stream throws
+template<>
+template<>
+void object::test<20>
+() {
+    // accepts 20 bytes, then fails
+    struct FixedBuf : std::streambuf {
+        char data[20]{};
+        FixedBuf() { setp(data, data + sizeof data); }
+    };
+
+    geos::io::WKBWriter writer(2, geos::io::ByteOrderValues::ENDIAN_LITTLE, true);
+    auto point = wktreader.read("POINT (1 2)");
+    point->setSRID(4326);
+
+    for (const char *wkt: {
+             "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))",
+             "COMPOUNDCURVE (CIRCULARSTRING (0 0, 1 1, 2 0), (2 0, 3 0))"
+         }) {
+        auto geom = wktreader.read(wkt);
+        geom->setSRID(3857);
+
+        FixedBuf buf;
+        std::ostream os(&buf);
+        os.exceptions(std::ios::badbit | std::ios::failbit);
+        ensure_THROW(writer.write(*geom, os), std::ios_base::failure);
+        ensure("getIncludeSRID() should be set after writing " + std::string(wkt), writer.getIncludeSRID());
+        std::stringstream out;
+        writer.writeHEX(*point, out);
+        ensure_equals(wkt, out.str(), "0101000020E6100000000000000000F03F0000000000000040");
+    }
+}
 
 } // namespace tut
 

commit 68ef040f1cb2eec8573a2048ab343f02e31e5083
Author: Jeroen Bloemscheer <jbloemscheer at gmail.com>
Date:   Mon Oct 26 21:07:40 2026 +0100

    Envelope: Return zero distance between touching envelopes (#1576)
    
    distanceSquared computed each gap as a combined span minus two widths,
    which cancels: envelopes touching along y = -48 came out 7e-15 apart, so
    GEOSDistanceWithin(a, b, 0) was false for lines sharing an endpoint.
    Compute each gap as a single difference between facing sides.

diff --git a/include/geos/geom/Envelope.h b/include/geos/geom/Envelope.h
index 15d360030..654edbc2f 100644
--- a/include/geos/geom/Envelope.h
+++ b/include/geos/geom/Envelope.h
@@ -742,12 +742,8 @@ public:
      */
     double distanceSquared(const Envelope& env) const
     {
-        double dx = std::max(0.0,
-            std::max(maxx, env.maxx) - std::min(minx, env.minx) - (maxx - minx) -
-            (env.maxx - env.minx));
-        double dy = std::max(0.0,
-            std::max(maxy, env.maxy) - std::min(miny, env.miny) - (maxy - miny) -
-            (env.maxy - env.miny));
+        double dx = std::max(0.0, std::max(minx - env.maxx, env.minx - maxx));
+        double dy = std::max(0.0, std::max(miny - env.maxy, env.miny - maxy));
 
         return dx * dx + dy * dy;
     };
diff --git a/tests/unit/capi/GEOSDistanceWithinTest.cpp b/tests/unit/capi/GEOSDistanceWithinTest.cpp
index 0b164d199..044598918 100644
--- a/tests/unit/capi/GEOSDistanceWithinTest.cpp
+++ b/tests/unit/capi/GEOSDistanceWithinTest.cpp
@@ -437,4 +437,18 @@ void object::test<33>()
 }
 
 
+
+template<>
+template<>
+void object::test<34>() {
+    set_test_name("lines touching at endpoint");
+
+    testGEOSDistanceWithin(
+        "LINESTRING (0 -80, 64 -48)",
+        "LINESTRING (64 -48, 0 -0.1)",
+        0.0,
+        1
+    );
+}
+
 } // namespace tut
diff --git a/tests/unit/geom/EnvelopeTest.cpp b/tests/unit/geom/EnvelopeTest.cpp
index a289438bd..f4f95ff84 100644
--- a/tests/unit/geom/EnvelopeTest.cpp
+++ b/tests/unit/geom/EnvelopeTest.cpp
@@ -692,4 +692,18 @@ void object::test<21>()
     ensure(env.containsProperly({3.0000001, 8}));
 }
 
+
+template<>
+template<>
+void object::test<22>()
+{
+    set_test_name("distance between touching envelopes is zero");
+
+    Envelope a(0, 64, -80, -48);
+    Envelope b(0, 64, -48, -0.1);
+
+    ensure_equals(a.distance(b), 0.0);
+    ensure_equals(b.distance(a), 0.0);
+}
+
 }

commit dc0fe45d2e0fa938b025ed626df157b233546648
Author: Daniel Baston <dbaston at gmail.com>
Date:   Tue Oct 20 10:31:30 2026 -0400

    CircularArcIntesector: check for common points before excluding based on distance (#1559)

diff --git a/src/algorithm/CircularArcIntersector.cpp b/src/algorithm/CircularArcIntersector.cpp
index d503fb384..a41265367 100644
--- a/src/algorithm/CircularArcIntersector.cpp
+++ b/src/algorithm/CircularArcIntersector.cpp
@@ -182,6 +182,30 @@ CircularArcIntersector::intersects(const CircularArc& arc, const CoordinateSeque
     }
 }
 
+static std::vector<CoordinateXY>
+getCommonPoints(const CircularArc& arc1, const CircularArc& arc2)
+{
+    std::vector<CoordinateXY> ret;
+    const CoordinateXY& ap0 = arc1.p0();
+    const CoordinateXY& ap1 = arc1.p1();
+    const CoordinateXY& ap2 = arc1.p2();
+    const CoordinateXY& bp0 = arc2.p0();
+    const CoordinateXY& bp1 = arc2.p1();
+    const CoordinateXY& bp2 = arc2.p2();
+
+    if (ap0 == bp0 || ap0 == bp1 || ap0 == bp2) {
+        ret.push_back(ap0);
+    }
+    if (ap1 == bp0 || ap1 == bp1 || ap1 == bp2) {
+        ret.push_back(ap1);
+    }
+    if (ap2 == bp0 || ap2 == bp1 || ap2 == bp2) {
+        ret.push_back(ap2);
+    }
+
+    return ret;
+}
+
 void
 CircularArcIntersector::intersects(const CircularArc& arc1, const CircularArc& arc2)
 {
@@ -217,16 +241,20 @@ CircularArcIntersector::intersects(const CircularArc& arc1, const CircularArc& a
 
     const auto d = c1.distance(c2);
 
-    if (d > r1 + r2) {
-        // Circles are disjoint
-        result = NO_INTERSECTION;
-        return;
-    }
+    auto commonPoints = getCommonPoints(arc1, arc2);
 
-    if (d < std::abs(r1-r2)) {
-        // One circle contained within the other; arcs cannot intersect
-        result = NO_INTERSECTION;
-        return;
+    if (commonPoints.empty()) {
+        if (d > r1 + r2) {
+            // Circles are disjoint
+            result = NO_INTERSECTION;
+            return;
+        }
+
+        if (d < std::abs(r1-r2)) {
+            // One circle contained within the other; arcs cannot intersect
+            result = NO_INTERSECTION;
+            return;
+        }
     }
 
     // TODO because the circle center calculation is inexact we need some kind of tolerance here.
@@ -253,22 +281,8 @@ CircularArcIntersector::intersects(const CircularArc& arc1, const CircularArc& a
         CoordinateXY isect0{p.x + h* dy/d, p.y - h* dx/d };
         CoordinateXY isect1{p.x - h* dy/d, p.y + h* dx/d };
 
-        // Check to see if computed intersection points are inexact versions of an endpoint intersection
-        const CoordinateXY& ap0 = arc1.p0();
-        const CoordinateXY& ap1 = arc1.p1();
-        const CoordinateXY& ap2 = arc1.p2();
-        const CoordinateXY& bp0 = arc2.p0();
-        const CoordinateXY& bp1 = arc2.p1();
-        const CoordinateXY& bp2 = arc2.p2();
-
-        if (ap0 == bp0 || ap0 == bp1 || ap0 == bp2) {
-            closestPoint(isect0, isect1, 2, ap0) = ap0;
-        }
-        if (ap1 == bp0 || ap1 == bp1 || ap1 == bp2) {
-            closestPoint(isect0, isect1, 2, ap1) = ap1;
-        }
-        if (ap2 == bp0 || ap2 == bp1 || ap2 == bp2) {
-            closestPoint(isect0, isect1, 2, ap2) = ap2;
+        for (const auto& pt : commonPoints) {
+            closestPoint(isect0, isect1, 2 , pt) = pt;
         }
 
         if (arc1.containsPointOnCircle(isect0) && arc2.containsPointOnCircle(isect0)) {
diff --git a/tests/unit/algorithm/CircularArcIntersectorTest.cpp b/tests/unit/algorithm/CircularArcIntersectorTest.cpp
index 63695fb9b..518de55e2 100644
--- a/tests/unit/algorithm/CircularArcIntersectorTest.cpp
+++ b/tests/unit/algorithm/CircularArcIntersectorTest.cpp
@@ -1823,6 +1823,18 @@ void object::test<91>()
                       XY{-3, 4});
 }
 
+template<>
+template<>
+void object::test<92>()
+{
+    set_test_name("arcs intersect at shared vertex when inexact distance calculation says they cannot");
+
+    checkIntersection(XY{5, 0}, XY{6.530331962423936, 32.9099081287533}, XY{39.1383458646334, 37.61324718856101},
+                      XY{5, 0}, XY{-13.013189892510923, 476.4218618845866}, XY{426.8482396243887, 660.3353967726679},
+                      CircularArcIntersector::ONE_POINT_INTERSECTION,
+                      XY{5, 0});
+}
+
 // TODO: check Z values of arc result centerpoints
 // TODO: add tests for seg/seg
 

commit 4f5281976597245f3fb39aa786b928961a131234
Author: apocelipes <seve3r at outlook.com>
Date:   Thu Oct 15 08:43:29 2026 +0800

    DiscreteFrechetDistance: Add missing algorithm header file (#1563)

diff --git a/include/geos/algorithm/distance/DiscreteFrechetDistance.h b/include/geos/algorithm/distance/DiscreteFrechetDistance.h
index 819a4b146..3a90e256c 100644
--- a/include/geos/algorithm/distance/DiscreteFrechetDistance.h
+++ b/include/geos/algorithm/distance/DiscreteFrechetDistance.h
@@ -20,6 +20,7 @@
 #include <geos/geom/CoordinateSequenceFilter.h>
 #include <geos/util/math.h>
 
+#include <algorithm> // for lower_bound
 #include <cstdint>
 #include <unordered_map>
 #include <array>

commit a3022a63ce3d29e62b3b14861446f641376ff4d1
Author: apocelipes <seve3r at outlook.com>
Date:   Wed Oct 14 18:49:00 2026 +0800

    CoordinateSequence: Add missing type_traits header file (#1562)

diff --git a/include/geos/geom/CoordinateSequence.h b/include/geos/geom/CoordinateSequence.h
index e52849e7f..c7d5920f2 100644
--- a/include/geos/geom/CoordinateSequence.h
+++ b/include/geos/geom/CoordinateSequence.h
@@ -25,6 +25,7 @@
 #include <iostream>
 #include <iosfwd> // ostream
 #include <memory> // for unique_ptr typedef
+#include <type_traits> // for add_const
 
 // Forward declarations
 namespace geos {

-----------------------------------------------------------------------

Summary of changes:
 NEWS.md                                            |  14 +-
 include/geos/algorithm/CircularArcs.h              |   6 +-
 .../algorithm/distance/DiscreteFrechetDistance.h   |   1 +
 include/geos/geom/CircularString.h                 |   9 ++
 include/geos/geom/CompoundCurve.h                  |   5 +
 include/geos/geom/CoordinateSequence.h             |   1 +
 include/geos/geom/Envelope.h                       |   8 +-
 include/geos/geom/GeometryFactory.h                |  49 +-----
 include/geos/index/SpatialIndex.h                  |   4 +-
 src/algorithm/CircularArcIntersector.cpp           |  64 +++++---
 src/algorithm/CircularArcs.cpp                     |  38 ++---
 src/geom/CircularArc.cpp                           |  22 +--
 src/geom/CircularString.cpp                        |  18 +++
 src/geom/GeometryFactory.cpp                       |   4 +-
 src/geom/MultiLineString.cpp                       |   9 +-
 src/geom/MultiPoint.cpp                            |   7 +-
 src/geom/MultiPolygon.cpp                          |   9 +-
 src/io/WKBWriter.cpp                               |  26 +++-
 src/noding/NodableArcString.cpp                    |   2 +-
 .../unit/algorithm/CircularArcIntersectorTest.cpp  |  12 ++
 tests/unit/algorithm/CircularArcsTest.cpp          | 159 +++++++++++++++++++
 tests/unit/capi/GEOSDensifyTest.cpp                |  16 ++
 tests/unit/capi/GEOSDistanceWithinTest.cpp         |  14 ++
 tests/unit/capi/GEOSGeom_createCollectionTest.cpp  |  43 +++++-
 tests/unit/capi/GEOSGeom_setPrecisionTest.cpp      |  17 ++-
 tests/unit/capi/GEOSNodeTest.cpp                   |  23 +++
 tests/unit/capi/GEOSSnapTest.cpp                   |  17 ++-
 .../unit/capi/GEOSTopologyPreserveSimplifyTest.cpp |  17 ++-
 tests/unit/capi/GEOSUnaryUnionTest.cpp             |  15 ++
 tests/unit/geom/CircularArcTest.cpp                |  28 ++++
 tests/unit/geom/CircularStringTest.cpp             | 170 +++++++++++++++++++++
 tests/unit/geom/CompoundCurveTest.cpp              |  26 ++++
 tests/unit/geom/EnvelopeTest.cpp                   |  14 ++
 tests/unit/geom/GeometryFactoryTest.cpp            | 132 +++++++++++++++-
 tests/unit/io/WKBWriterTest.cpp                    |  34 +++++
 tests/unit/noding/NodableArcStringTest.cpp         |  71 ++++++++-
 36 files changed, 959 insertions(+), 145 deletions(-)


hooks/post-receive
-- 
GEOS


More information about the geos-commits mailing list