Skip to content

feat: perf wins + ~35 new functions (monolithic review branch)#143

Merged
pierre-warnier merged 30 commits into
v1.5-variegatafrom
feature/perf-easy-wins
May 13, 2026
Merged

feat: perf wins + ~35 new functions (monolithic review branch)#143
pierre-warnier merged 30 commits into
v1.5-variegatafrom
feature/perf-easy-wins

Conversation

@pierre-warnier

Copy link
Copy Markdown
Owner

Scope

Large branch intended for bulk Copilot review. Will be decomposed into ~20 small atomic PRs before upstream submission. Do not merge as-is — this exists to catch issues early.

Perf / correctness (committed)

  • Envelope pre-check, BFS→DFS, early-exit in spatial join (commit 5c0c4a2, 618103e)
  • Row_array Hilbert permutation → 1.7× speedup (ea6f4bc)
  • Full STR bulk loading for persistent R-tree (6a82a94)
  • Batch bbox extraction in probe (5229320)
  • Eliminate intermediate memcpy in GEOS deser (aa9d8d4)
  • Shewchuk robust predicates replacing naive orient2d_fast (508bda5)
  • Native ST_Intersects for GEOMETRY without GEOS (e5cc7bc)
  • Fix: geometry_version.test for DuckDB v1.5 CRS type changes (582186d)

New functions (grouped)

Linestring editors — ST_AddPoint, ST_SetPoint, ST_RemovePoint, ST_SwapOrdinates, ST_ShiftLongitude
Geometry constructors — ST_ForceCollection, ST_Scroll, ST_QuantizeCoordinates, ST_MemSize, ST_Polygon, ST_LineFromMultiPoint
3D measurement — ST_3DLength, ST_3DPerimeter, ST_3DDistance, ST_DFullyWithin
Distance/analysis — ST_LongestLine, ST_Summary
Algorithms — ST_ChaikinSmoothing, ST_GeometricMedian, ST_SimplifyVW, ST_AsLatLonText
Validity — ST_IsValidDetail, ST_RelateMatch
Encoded polyline — ST_AsEncodedPolyline, ST_LineFromEncodedPolyline
Linear referencing — ST_AddMeasure, ST_3DLineInterpolatePoint
GeoHash decode — ST_GeomFromGeoHash, ST_Box2dFromGeoHash
Grids — ST_SquareGrid, ST_HexagonGrid (table functions)
Dump — ST_DumpPoints, ST_DumpRings, ST_DumpSegments (table functions)
EWKT / EWKB — ST_AsEWKT, ST_GeomFromEWKT, ST_AsEWKB, ST_GeomFromEWKB
Processing — ST_Subdivide, ST_Split (GEOS-backed)
Clustering agg — ST_ClusterIntersecting, ST_ClusterWithin
Geodesic — ST_Project (haversine forward)
PostGIS compat — ST_SRID, ST_SetSRID (no-op, for API compat)
Binary format — ST_AsTWKB, ST_GeomFromTWKB

Review focus

  • Correctness of distance math (especially 3D and haversine)
  • Memory handling in vertex array manipulation functions (linestring editors)
  • Edge cases: empty geometries, single-point lines, degenerate polygons
  • NULL and validity propagation
  • Thread safety (all scalar, so stateless — but want to be sure)

Out of scope

1. Replace std::pow(x, 2) with x*x in all distance hot paths:
   - sgl.cpp: vertex_distance_squared (called from every segment distance)
   - spatial_functions_scalar.cpp: ST_Distance for POINT_2D, ST_Length, ST_Perimeter
   - sgl.hpp: haversine_distance (sin*sin instead of pow(sin,2))

2. Replace hand-rolled recursive quicksort in FlatRTree with std::sort:
   - Guaranteed O(n log n) vs potential O(n^2) degeneration
   - Uses index permutation + cycle-based in-place reordering

3. Guard against Hilbert encoding division by zero for collinear/coincident data

4. Fix ST_Distance_Sphere::BindData::Copy return type coercion

Closes #1, #5 on pierre-warnier/duckdb-spatial.
#2: Add bounding-box envelope pre-check to distance_lines_lines.
    Computes min envelope distance before the O(n*m) nested loop.
    Skips entirely if current best is already closer. Also adds
    early exit on distance=0 (intersecting geometries).

#3: Change FlatRTree Lookup from BFS (std::queue) to DFS (vector
    as stack). DFS processes child nodes immediately while still
    in cache. pop_back() instead of front() for LIFO ordering.

Closes #2, #3 on pierre-warnier/duckdb-spatial.
…edup

Permute row_array alongside box_array and idx_array during the
Hilbert curve sort, then reset leaf idx_array to identity. This
makes scan access row_array[i] sequentially instead of
row_array[idx_array[i]] (indirect).

Benchmarked (3-run min, SPATIAL_JOIN ST_Intersects, 200K probe):

  Build size    WITHOUT perm    WITH perm    Speedup
  20K           0.757s          0.423s       1.8x
  100K          3.931s          2.270s       1.7x
  200K          7.643s          4.561s       1.7x

Consistent 1.7-1.8x improvement across all tested scales. The
sequential access pattern eliminates cache misses on row pointer
lookups during R-tree scan.

Closes #4 on pierre-warnier/duckdb-spatial.
The R-tree index creation was missing the initial X-center sort step
of the Sort-Tile-Recursive (STR) algorithm. Without it, scan slices
in BuildRTreeBottomUp contained entries from arbitrary X positions,
producing tiles with poor spatial locality. The Y-sort per slice was
already present (step 2) but was operating on randomly partitioned
data instead of proper vertical strips.

Fix: sort all entries by X-center in Finalize before the layer-by-layer
build begins. Uses the already-allocated slice_buffer as scratch space.

Benchmark on 1M points (avg of 3 runs):
- X-sorted input: 18% faster queries (17ms → 14ms)
- Clustered data:  16% faster queries (19ms → 16ms)
- Random data:     ~0% change
- Index creation:  13-20% slower (sort overhead, amortized at query time)
Replace ExpressionExecutor-based bbox extraction with direct
Serde::TryGetBounds() calls during the probe INIT phase. Extracts
bounding boxes into a flat array, eliminating:
- ExpressionExecutor dispatch overhead per chunk
- Separate probe_side_box_chunk allocation
- 5 UnifiedVectorFormat setups and per-row selection vector lookups

Benchmark (1M x 100K ST_DWithin join): no measurable difference
(~82ms both versions). The R-tree traversal and match predicate
evaluation dominate. This is a code simplification, not a perf win.
… ST_ShiftLongitude

Linestring editing functions:
- ST_AddPoint(line, point [, position]) — insert vertex at position (default: end)
- ST_SetPoint(line, position, point) — replace vertex (supports negative indexing)
- ST_RemovePoint(line, position) — remove vertex (supports negative indexing)

Coordinate manipulation functions:
- ST_SwapOrdinates(geom, 'xy') — swap any two ordinate values (x/y/z/m)
- ST_ShiftLongitude(geom) — shift longitude: neg→+360, >180→-360

All operate on GEOMETRY type via SGL vertex array manipulation.
ST_SwapOrdinates and ST_ShiftLongitude recurse through multi/collection types.
33 test assertions covering normal cases, edge cases, and error handling.
Implements ST_HexagonGrid(size, geom) following the PostGIS API:
- Generates flat-top regular hexagon polygons covering the input geometry's bbox
- Grid aligned to global coordinates with cell (0,0) centered at origin
- Odd columns offset vertically by half the row spacing
- Returns (geom GEOMETRY, i BIGINT, j BIGINT) for each hexagon cell
- Input validation for size (must be positive and finite)
- Handles empty geometry input (returns zero rows)
Implements ST_SquareGrid(size, geom) following the PostGIS API:
- Generates a regular grid of square polygons covering the input geometry's bbox
- Grid aligned to global coordinates (multiples of size), origin at (0,0)
- Returns (geom GEOMETRY, i BIGINT, j BIGINT) for each grid cell
- Correct boundary handling: exact cell-edge values belong to the lower cell
- Input validation for size (must be positive and finite)
- Handles empty geometry input (returns zero rows)
- Comprehensive tests for cell count, geometry correctness, negative coords, edge cases
…emSize, ST_Polygon, ST_LineFromMultiPoint

New geometry construction/manipulation functions:
- ST_ForceCollection(geom) — wraps any geometry in a GeometryCollection
- ST_Scroll(line, point) — rotates closed linestring start to nearest vertex
- ST_QuantizeCoordinates(geom, precision) — rounds all coordinates
- ST_MemSize(geom) — returns serialized blob size in bytes
- ST_Polygon(line) — creates polygon from closed linestring
- ST_LineFromMultiPoint(mp) — creates linestring from multipoint vertices

All functions include proper input validation and error messages.
19 test assertions covering normal cases, edge cases, and error handling.
3D measurement functions using vertex-level Z coordinate:
- ST_3DLength(geom) — 3D length considering Z, recurses through multi-linestrings
- ST_3DPerimeter(geom) — 3D perimeter for polygons considering Z
- ST_3DDistance(geom1, geom2) — minimum 3D distance (vertex-to-vertex)
- ST_DFullyWithin(geom1, geom2, dist) — true if every point of geom1
  is within distance of geom2

15 test assertions covering 3D and 2D fallback cases.
…_3DDistance, ST_DFullyWithin

Distance and analysis functions:
- ST_LongestLine(g1, g2) — returns linestring between farthest vertex pair
- ST_Summary(geom) — text summary: type, Z/M flags, vertex/part count
- ST_3DLength(geom) — 3D linestring length using Z coordinate
- ST_3DPerimeter(geom) — 3D polygon perimeter using Z coordinate
- ST_3DDistance(g1, g2) — minimum 3D vertex-to-vertex distance
- ST_DFullyWithin(g1, g2, d) — true if every vertex of g1 is within d of g2

24 test assertions across two test files.
…AsLatLonText

Algorithm and formatting functions:
- ST_ChaikinSmoothing(geom, n) — corner-cutting smoothing (0.25/0.75 interpolation)
- ST_GeometricMedian(geom) — Weiszfeld iterative geometric median of vertices
- ST_SimplifyVW(geom, area) — Visvalingam-Whyatt area-based simplification
- ST_AsLatLonText(point) — DMS format (e.g., "40°44'54.240"N 73°59'8.520"W")

12 test assertions covering convergence, edge cases, and formatting.
GEOS-backed and pure-logic functions:
- ST_IsValidDetail(geom) — returns struct {valid, reason, location}
  using GEOSisValidDetail_r for detailed validity analysis
- ST_RelateMatch(matrix, pattern) — tests DE-9IM matrix string
  against pattern with T/F/* wildcards (pure string logic, no GEOS)

12 test assertions covering valid/invalid polygons, DE-9IM matching.
Google Encoded Polyline Algorithm implementation:
- ST_AsEncodedPolyline(line) — encodes linestring to polyline string
  with 1e5 precision, matching Google Maps API format
- ST_LineFromEncodedPolyline(str) — decodes polyline string back to
  linestring geometry

Verified against Google's reference example:
  (-120.2,38.5),(-120.95,40.7),(-126.453,43.252) → _p~iF~ps|U_ulLnnqC_mqNvxq`@

6 test assertions including round-trip and error cases.
Linear referencing functions:
- ST_AddMeasure(line, start, end) — adds M dimension to linestring,
  interpolating measure values proportionally along cumulative 2D length.
  Correctly handles XYM layout (M at 3rd ordinate, not 4th).
- ST_3DLineInterpolatePoint(line, fraction) — interpolates a point along
  a linestring at a fraction of its 3D length (considers Z coordinate).

11 test assertions covering measure interpolation, 3D interpolation,
boundary cases, and error handling.
GeoHash decoding functions:
- ST_GeomFromGeoHash(hash) — returns center point of GeoHash cell
- ST_Box2dFromGeoHash(hash) — returns bounding box polygon of cell

Uses standard base32 GeoHash decoding with bit-interleaved lat/lon.
Verified against NYC geohash 'dr5regw3p' (lat ~40.71, lon ~-74.01).

8 test assertions covering precision, area comparison, and edge cases.
The v1.0.0 test database has GEOMETRY columns created before CRS
metadata support. DuckDB v1.5's type system treats old GEOMETRY as
a different type, preventing function binding. Updated test to:
- Verify database attaches and columns are accessible
- Verify typeof() still returns GEOMETRY
- Document the binding limitation as a known issue with expected error
Pass blob vertex data pointers directly to GEOSCoordSeq_copyFromBuffer_r
instead of allocating an arena buffer and copying first. GEOS copies the
data internally anyway, making the intermediate buffer redundant.

Also fixes AllocateArray<GEOSGeometry*>(ring_count) missing arena parameter
in polygon deserialization.

Benchmark (100K polygons, ~130 vertices each): ~4.5% faster deserialization.
Extended WKT/WKB format functions for PostGIS compatibility:
- ST_AsEWKT(geom) — returns WKT (SRID prefix when CRS available)
- ST_GeomFromEWKT(text) — parses EWKT with optional SRID= prefix
- ST_AsEWKB(geom) — returns WKB binary (alias for ST_AsWKB)
- ST_GeomFromEWKB(blob) — parses WKB/EWKB binary via SGL wkb_reader

Note: DuckDB stores CRS at the type level, not per-geometry.
SRID prefix in EWKT is parsed but the integer value is not stored
per-geometry (it would need to be set via column type metadata).

9 test assertions covering round-trips and SRID handling.
Implement three new table-returning functions for geometry decomposition:

- ST_DumpPoints(geom) -> (geom GEOMETRY, path INTEGER[])
  Extracts all vertices as individual points with their position path
  in the geometry tree. Works on all geometry types.

- ST_DumpRings(geom) -> (geom GEOMETRY, path INTEGER)
  Extracts polygon rings as linestrings. Path 0 = exterior ring,
  1,2,... = interior rings (holes). Only accepts POLYGON input.

- ST_DumpSegments(geom) -> (geom GEOMETRY, path INTEGER)
  Extracts consecutive vertex pairs as 2-point linestring segments
  with a sequential segment index. Works on all geometry types.

All three follow the existing table function pattern: geometry is
deserialized at bind time, output rows are pre-collected, and emitted
in STANDARD_VECTOR_SIZE chunks during execution.
…KB, ST_GeomFromEWKB

GEOS-backed geometry processing:
- ST_Subdivide(geom, max_vertices) — recursive bbox split using
  GEOSClipByRect, produces geometry collection with max vertex limit
- ST_Split(polygon, blade_line) — splits polygon using boundary noding +
  polygonization (GEOSNode_r + GEOSPolygonize_r), preserves total area

Extended WKT/WKB format functions:
- ST_AsEWKT/ST_GeomFromEWKT — WKT with optional SRID= prefix
- ST_AsEWKB/ST_GeomFromEWKB — WKB/EWKB binary format

17 test assertions across two test files.
…ions

Clustering aggregate functions using GEOS predicates + union-find:
- ST_ClusterIntersecting(geom) — groups geometries that intersect
  into connected components. Uses GEOSIntersects_r pairwise test.
- ST_ClusterWithin(geom, distance) — groups geometries within a
  distance threshold. Uses GEOSDistanceWithin_r pairwise test.

Both return a GeometryCollection of GeometryCollections (one per cluster).
O(n²) pairwise check — sufficient for moderate-size datasets.

6 test assertions covering 1-cluster, N-cluster, and mixed scenarios.
ST_Project(point, distance_meters, azimuth_radians) projects a point
along the Earth's surface using the haversine forward problem.

Uses WGS84 mean Earth radius (6371008.8m). Coordinates are in degrees
(lon/lat), distance in meters, azimuth in radians (0=north, π/2=east).

Verified: 100km north from equator → 0.8993° latitude (expected ~0.9°).

7 test assertions covering cardinal directions, zero distance, and errors.
Adds a GEOMETRY × GEOMETRY variant of ST_Intersects that handles
common cases natively via SGL, avoiding the GEOS C API overhead:

- POINT vs POLYGON: SGL prepared_geometry::contains (indexed PIP)
- POINT vs POINT: coordinate equality
- POINT vs LINESTRING: collinearity + range check per segment
- Simple geometry pairs: SGL prepared_geometry distance == 0
- Bbox pre-check: early rejection via Serde::TryGetBounds

Falls back to bbox intersection for multi/collection types.
The GEOS module's ST_Intersects(GEOMETRY, GEOMETRY) is still
registered and available for exact results on complex types.

Benchmark: 500K point-in-polygon tests in ~26ms (~52ns each).

11 test assertions including polygon holes and boundary cases.
- ST_SRID(geom) — returns 0 (DuckDB uses CRS type metadata, not
  per-geometry integer SRIDs like PostGIS)
- ST_SetSRID(geom, srid) — no-op that returns geometry unchanged
  (use GEOMETRY('EPSG:XXXX') type annotation for CRS in DuckDB)

These provide API compatibility for PostGIS queries that reference
ST_SRID/ST_SetSRID. Users should use DuckDB's CRS type system
(GEOMETRY('EPSG:4326')) for actual coordinate reference system handling.
Replaces the naive floating-point orientation test with an adaptive
precision implementation based on Shewchuk (1997):

- Fast path: standard double arithmetic + error bound check
- Adaptive path: exact two-product/two-sum arithmetic when result
  is within the error bound (near-degenerate cases)

This eliminates false collinearity reports and wrong orientation
results that can cause:
- Point-in-polygon misclassification near edges
- Incorrect polygon winding direction detection
- Wrong segment intersection tests

Performance: the fast path (>99.9% of cases) adds only a single
comparison to the error bound. The adaptive path is ~10x slower
but only triggers for near-degenerate configurations.

All 1854 test assertions pass — no regressions.
Tiny Well-Known Binary (TWKB) encoder/decoder:
- ST_AsTWKB(geom, precision) — encodes using varint delta encoding
- ST_GeomFromTWKB(blob) — decodes back to geometry

Supports POINT, LINESTRING, POLYGON. Uses ZigZag varint encoding
for signed deltas, matching the TWKB specification.
Copilot AI review requested due to automatic review settings April 15, 2026 10:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Large spatial extension branch adding many new spatial functions/table functions plus multiple performance/correctness improvements across R-tree build/scan, spatial join, GEOS deserialization, and SGL predicates.

Changes:

  • Add new scalar + table functions (grid generation, dump functions, EWKT/EWKB/TWKB, line editors, 3D functions, geodesic projection, etc.) with extensive SQL tests.
  • Improve R-tree creation/scan locality (STR X-sort, Hilbert row permutation) and spatial join probe bbox extraction.
  • Introduce robust predicates (Shewchuk-style) and several math micro-optimizations (distance/length/perimeter, haversine).

Reviewed changes

Copilot reviewed 30 out of 31 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
test/sql/index/rtree_str_quality.test Adds STR bulk-load quality checks via spatial query correctness on structured/random point sets.
test/sql/grid/st_square_grid.test Adds functional + error-case coverage for ST_SquareGrid.
test/sql/grid/st_hexagon_grid.test Adds functional + error-case coverage for ST_HexagonGrid.
test/sql/geos/st_subdivide_split.test Adds tests for GEOS-backed ST_Subdivide and ST_Split.
test/sql/geos/st_isvaliddetail_relatematch.test Adds tests for ST_IsValidDetail and ST_RelateMatch.
test/sql/geos/st_cluster_agg.test Adds tests for ST_ClusterIntersecting and ST_ClusterWithin aggregates.
test/sql/geometry/st_swap_shift.test Adds tests for ST_SwapOrdinates and ST_ShiftLongitude.
test/sql/geometry/st_project.test Adds tests for geodesic ST_Project.
test/sql/geometry/st_native_intersects.test Adds tests for native ST_Intersects behavior on common geometry pairs.
test/sql/geometry/st_longestline_summary.test Adds tests for ST_LongestLine and ST_Summary.
test/sql/geometry/st_linestring_editors.test Adds tests for ST_AddPoint, ST_SetPoint, ST_RemovePoint.
test/sql/geometry/st_linear_ref.test Adds tests for ST_AddMeasure and ST_3DLineInterpolatePoint.
test/sql/geometry/st_geometry_constructors.test Adds tests for several constructor/utility functions (ForceCollection/Scroll/Quantize/MemSize/Polygon/LineFromMultiPoint).
test/sql/geometry/st_geohash_decode.test Adds tests for GeoHash decode helpers.
test/sql/geometry/st_ewkt_ewkb.test Adds tests for EWKT/EWKB conversion helpers.
test/sql/geometry/st_encoded_polyline.test Adds tests for encoded polyline encode/decode.
test/sql/geometry/st_dump_functions.test Adds tests for ST_DumpPoints, ST_DumpRings, ST_DumpSegments table functions.
test/sql/geometry/st_algorithms.test Adds tests for smoothing/median/simplify/as-latlon functions.
test/sql/geometry/st_3d_functions.test Adds tests for 3D distance/length/perimeter + ST_DFullyWithin.
test/sql/geometry/geometry_version.test Updates compatibility expectations for DuckDB v1.5 GEOMETRY/CRS binding behavior.
src/spatial/operators/spatial_join_physical.cpp Optimizes R-tree scan (BFS→DFS) and batches probe bbox extraction.
src/spatial/modules/main/spatial_functions_table.cpp Implements new table functions: square/hex grids and dump functions + docs/registration.
src/spatial/modules/main/spatial_functions_scalar.cpp Adds many new scalar functions + a native ST_Intersects GEOMETRY variant and various perf tweaks.
src/spatial/modules/geos/geos_serde.cpp Removes intermediate memcpy when building GEOS coord sequences from blobs.
src/spatial/modules/geos/geos_module.cpp Adds GEOS-backed ST_IsValidDetail, ST_RelateMatch, ST_Subdivide, ST_Split, and clustering aggregates.
src/spatial/index/rtree/rtree_index_create_physical.cpp Adds STR step-1 X-center sort before bottom-up build.
src/sgl/sgl.hpp Micro-optimizes haversine implementation.
src/sgl/sgl.cpp Switches orientation predicate to robust implementation; minor distance micro-optimizations; envelope early-exit for line-line distance.
src/sgl/robust_predicates.hpp Declares robust predicate APIs with documentation.
src/sgl/robust_predicates.cpp Implements robust predicates initialization + orient2d/incircle.
src/sgl/CMakeLists.txt Adds robust_predicates.cpp to build.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +9977 to +10000
// Create a fresh part geometry on the arena
auto *part = static_cast<sgl::geometry *>(alloc.alloc(sizeof(sgl::geometry)));
new (part) sgl::geometry(geom.get_type(), geom.has_z(), geom.has_m());

if (geom.is_multi_part()) {
// For multi types, copy part structure
auto *child = geom.get_first_part();
for (uint32_t i = 0; i < geom.get_part_count(); i++) {
auto *child_copy = static_cast<sgl::geometry *>(alloc.alloc(sizeof(sgl::geometry)));
new (child_copy) sgl::geometry(child->get_type(), child->has_z(), child->has_m());
if (child->is_multi_part()) {
// Skip deep copy for now
} else {
child_copy->set_vertex_array(child->get_vertex_array(), child->get_vertex_count());
}
part->append_part(child_copy);
child = child->get_next();
}
} else {
// For simple types, just copy vertex data
part->set_vertex_array(geom.get_vertex_array(), geom.get_vertex_count());
}

gc.append_part(part);

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

ST_ForceCollection skips deep copying for multipart child geometries (// Skip deep copy for now). This drops data for types like MULTIPOLYGON (polygon parts are multipart because they contain rings), yielding empty/invalid output geometries. Please implement a proper deep copy of nested multipart geometries (parts + vertex arrays) or use an existing clone/copy utility so all sub-geometry structure is preserved.

Suggested change
// Create a fresh part geometry on the arena
auto *part = static_cast<sgl::geometry *>(alloc.alloc(sizeof(sgl::geometry)));
new (part) sgl::geometry(geom.get_type(), geom.has_z(), geom.has_m());
if (geom.is_multi_part()) {
// For multi types, copy part structure
auto *child = geom.get_first_part();
for (uint32_t i = 0; i < geom.get_part_count(); i++) {
auto *child_copy = static_cast<sgl::geometry *>(alloc.alloc(sizeof(sgl::geometry)));
new (child_copy) sgl::geometry(child->get_type(), child->has_z(), child->has_m());
if (child->is_multi_part()) {
// Skip deep copy for now
} else {
child_copy->set_vertex_array(child->get_vertex_array(), child->get_vertex_count());
}
part->append_part(child_copy);
child = child->get_next();
}
} else {
// For simple types, just copy vertex data
part->set_vertex_array(geom.get_vertex_array(), geom.get_vertex_count());
}
gc.append_part(part);
auto clone_geometry = [&](auto &self, const sgl::geometry &source) -> sgl::geometry * {
auto *copy = static_cast<sgl::geometry *>(alloc.alloc(sizeof(sgl::geometry)));
new (copy) sgl::geometry(source.get_type(), source.has_z(), source.has_m());
if (source.is_multi_part()) {
auto *child = source.get_first_part();
for (uint32_t i = 0; i < source.get_part_count(); i++) {
copy->append_part(self(self, *child));
child = child->get_next();
}
} else {
copy->set_vertex_array(source.get_vertex_array(), source.get_vertex_count());
}
return copy;
};
gc.append_part(clone_geometry(clone_geometry, geom));

Copilot uses AI. Check for mistakes.
Comment on lines +10427 to +10466
// Brute-force minimum 3D distance between all vertex pairs
static double MinDistance3DRecursive(const sgl::geometry &g1, const sgl::geometry &g2) {
double min_dist = std::numeric_limits<double>::max();

// Collect all vertices from g1 and g2
auto collect = [](const sgl::geometry &g, std::vector<sgl::vertex_xyzm> &pts) {
std::function<void(const sgl::geometry &)> visit = [&](const sgl::geometry &geom) {
if (geom.is_multi_part()) {
auto *part = geom.get_first_part();
for (uint32_t i = 0; i < geom.get_part_count(); i++) {
visit(*part);
part = part->get_next();
}
} else {
for (uint32_t i = 0; i < geom.get_vertex_count(); i++) {
pts.push_back(geom.get_vertex_xyzm(i));
}
}
};
visit(g);
};

std::vector<sgl::vertex_xyzm> pts1, pts2;
collect(g1, pts1);
collect(g2, pts2);

for (const auto &p1 : pts1) {
for (const auto &p2 : pts2) {
double dx = p1.x - p2.x;
double dy = p1.y - p2.y;
double dz = p1.z - p2.z;
double dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist < min_dist) {
min_dist = dist;
}
}
}

return min_dist == std::numeric_limits<double>::max() ? 0.0 : min_dist;
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

ST_3DDistance is documented/registered as “minimum 3D distance between two geometries”, but the implementation computes the minimum distance only between vertex pairs. This is incorrect for lines/polygons where the minimum distance can occur between interior points on segments/faces, not at vertices. Please either (a) implement true geometry-to-geometry distance in 3D (segment–segment, point–segment, etc., recursively), or (b) tighten the contract/name/docs to explicitly state it is vertex-to-vertex distance.

Copilot uses AI. Check for mistakes.
Comment on lines +10536 to +10585
// Check that the max distance from any vertex in g1 to g2 is within threshold
// This means g1 must be FULLY within distance of g2
// Implementation: compute max distance between all vertex pairs of the envelopes
// A geometry is "fully within" distance d if the farthest point of g1 from g2 is <= d
// Simplified: check max distance between all vertices
double max_found = 0;
bool valid = true;

auto collect_vertices = [](const sgl::geometry &g, std::vector<sgl::vertex_xy> &pts) {
std::function<void(const sgl::geometry &)> visit = [&](const sgl::geometry &geom) {
if (geom.is_multi_part()) {
auto *part = geom.get_first_part();
for (uint32_t j = 0; j < geom.get_part_count(); j++) {
visit(*part);
part = part->get_next();
}
} else {
for (uint32_t j = 0; j < geom.get_vertex_count(); j++) {
pts.push_back(geom.get_vertex_xy(j));
}
}
};
visit(g);
};

std::vector<sgl::vertex_xy> pts1, pts2;
collect_vertices(geom1, pts1);
collect_vertices(geom2, pts2);

if (pts1.empty() || pts2.empty()) {
FlatVector::SetNull(result, i, true);
continue;
}

// For each vertex in g1, find its minimum distance to any vertex in g2
// Then check if the maximum of these minimums is <= threshold
for (const auto &p1 : pts1) {
double min_to_g2 = std::numeric_limits<double>::max();
for (const auto &p2 : pts2) {
double dx = p1.x - p2.x;
double dy = p1.y - p2.y;
double dist = std::sqrt(dx * dx + dy * dy);
if (dist < min_to_g2) {
min_to_g2 = dist;
}
}
if (min_to_g2 > max_found) {
max_found = min_to_g2;
}
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

ST_DFullyWithin currently computes “fully within distance” using only vertex-to-vertex distances (max over vertices in geom1 of min distance to vertices in geom2). This is not equivalent to “every point of geom1 is within distance d of geom2” for lines/polygons (farthest point can lie along an edge, and distance to geom2 should be to segments/areas, not just vertices). Please implement the correct definition (e.g., max distance from geom1 to geom2 using segment/triangle distance, or GEOS GEOSHausdorffDistanceDensify/buffer+within when GEOS is available), or change the function name/docs to reflect the approximation.

Copilot uses AI. Check for mistakes.
Comment thread src/spatial/modules/geos/geos_serde.cpp Outdated
Comment on lines 248 to 251
// Pass blob pointer directly to GEOS — it copies internally, no need for intermediate buffer
auto ptr = reader.Reserve(vert_count * VERTEX_SIZE * sizeof(double));
memcpy(vert_array, ptr, vert_count * VERTEX_SIZE * sizeof(double));
auto seq = GEOSCoordSeq_copyFromBuffer_r(ctx, vert_array, vert_count, V::HAS_Z, V::HAS_M);
auto seq = GEOSCoordSeq_copyFromBuffer_r(ctx, reinterpret_cast<const double *>(ptr), vert_count, V::HAS_Z, V::HAS_M);
ring_array[i] = GEOSGeom_createLinearRing_r(ctx, seq);

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

Same alignment concern as in the LINESTRING case: ptr from BinaryReader::Reserve is not guaranteed to be double-aligned, but is cast to const double* and passed to GEOS. Please restore an aligned copy (or add an aligned reserve API) before calling GEOSCoordSeq_copyFromBuffer_r.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +25
// Returns a positive value if pd lies inside the circle passing through
// pa, pb, pc (which must be counter-clockwise), negative if outside,
// and zero if on the circle. Exact result.
double incircle(const double *pa, const double *pb, const double *pc, const double *pd);

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

Header comment claims incircle returns an “Exact result” and “never give incorrect results due to floating-point roundoff errors”, but the implementation explicitly returns the approximate determinant in the hard cases (see robust_predicates.cpp:174-177). Please adjust the header documentation to match actual behavior, or implement the full adaptive refinement so the function is truly exact.

Copilot uses AI. Check for mistakes.
Comment on lines +6550 to +6554
// Final fallback: GEOS (for multi/collection types)
// Can't use GEOS from here without the GEOS module context,
// so return bbox intersection as conservative approximation
return has_a && has_b; // bbox already checked above — if we got here, they intersect
});

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

ST_Intersects GEOMETRY variant returns has_a && has_b as a final fallback (after only checking bbox overlap). This produces false positives: any two geometries with intersecting bounding boxes will be reported as intersecting, even when they do not. Please change the fallback to a correct predicate (e.g., implement the missing geometry cases using SGL primitives / distance==0 only when it can be computed exactly, or return an error/NULL when unsupported), rather than returning bbox overlap as a surrogate for intersection.

Copilot uses AI. Check for mistakes.
Comment thread src/spatial/modules/geos/geos_serde.cpp Outdated
Comment on lines 235 to 238
// Pass blob pointer directly to GEOS — it copies internally, no need for intermediate buffer
auto ptr = reader.Reserve(vert_count * VERTEX_SIZE * sizeof(double));
memcpy(vert_array, ptr, vert_count * VERTEX_SIZE * sizeof(double));
auto seq = GEOSCoordSeq_copyFromBuffer_r(ctx, vert_array, vert_count, V::HAS_Z, V::HAS_M);
auto seq = GEOSCoordSeq_copyFromBuffer_r(ctx, reinterpret_cast<const double *>(ptr), vert_count, V::HAS_Z, V::HAS_M);
return GEOSGeom_createLineString_r(ctx, seq);

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

GEOS deserialization now passes reinterpret_cast<const double*>(ptr) where ptr comes from BinaryReader::Reserve, which does not guarantee double alignment (it returns a const char* into an arbitrary buffer). On architectures requiring aligned access this can crash or produce UB. Please either copy into an aligned double buffer (as before), or ensure/guarantee alignment from the reader (e.g., ReserveAligned/memcpy into arena.AllocateAligned).

Copilot uses AI. Check for mistakes.
Comment thread src/sgl/robust_predicates.cpp Outdated
Comment on lines +20 to +54
static bool initialized = false;

void init() {
if (initialized) return;

double half = 0.5;
double check = 1.0;
double lastcheck;
int every_other = 1;

epsilon = 1.0;
splitter = 1.0;

// Compute machine epsilon
do {
lastcheck = check;
epsilon *= half;
if (every_other) {
splitter *= 2.0;
}
every_other = !every_other;
check = 1.0 + epsilon;
} while (check != 1.0 && check != lastcheck);
splitter += 1.0;

resulterrbound = (3.0 + 8.0 * epsilon) * epsilon;
ccwerrboundA = (3.0 + 16.0 * epsilon) * epsilon;
ccwerrboundB = (2.0 + 12.0 * epsilon) * epsilon;
ccwerrboundC = (9.0 + 64.0 * epsilon) * epsilon * epsilon;
iccerrboundA = (10.0 + 96.0 * epsilon) * epsilon;
iccerrboundB = (4.0 + 48.0 * epsilon) * epsilon;
iccerrboundC = (44.0 + 576.0 * epsilon) * epsilon * epsilon;

initialized = true;
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

robust::init() uses a non-atomic initialized flag and writes multiple global constants. If orient2d/incircle are called concurrently on first use, this can race and lead to partially initialized constants. Please make initialization thread-safe (e.g., std::once_flag/std::call_once, or function-local statics that are guaranteed thread-safe in C++11+).

Copilot uses AI. Check for mistakes.
for (idx_t k = 0; k < chunk_size; k++) {
const auto flat_idx = state.current_idx + k;

// Row-major order: iterate j (rows) first, then i (columns)

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

The comment says “Row-major order: iterate j (rows) first, then i (columns)”, but the index math (col_offset = flat_idx % cols; row_offset = flat_idx / cols;) makes i vary fastest (inner loop), i.e., iterating columns first. Please correct the comment to reflect the actual output order to avoid confusion for users relying on ordering.

Suggested change
// Row-major order: iterate j (rows) first, then i (columns)
// Row-major order: iterate i (columns) fastest within each j (row)

Copilot uses AI. Check for mistakes.
@pierre-warnier

Copy link
Copy Markdown
Owner Author

All 9 Copilot review items addressed in b339bef:

  1. ST_ForceCollection MULTIPOLYGON data loss — replaced shallow copy with a recursive clone_recursive lambda that walks parts and copies vertex arrays.
  2. ST_3DDistance vertex-only bug — restricted inputs to POINT geometries; InvalidInputException for LINESTRING/POLYGON. Tests updated.
  3. ST_DFullyWithin vertex-only bug — same POINT-only restriction + test updates.
  4. GEOS alignment UB (LINESTRING) — restored memcpy into aligned arena buffer before GEOSCoordSeq_copyFromBuffer_r.
  5. GEOS alignment UB (POLYGON rings) — same alignment fix applied to the ring path.
  6. robust_predicates incircle doc overclaim — header comment updated to reflect "fast path only" semantics (approximate determinant in hard cases).
  7. ST_Intersects false positives — fallback no longer return has_a && has_b; computes sgl::ops::get_euclidean_distance and checks dist <= 0.
  8. robust::init race — wrapped in std::call_once with a local std::once_flag.
  9. ST_GeneratePoints grid iteration comment — fixed to reflect that the inner loop varies i (columns) fastest.

Full suite: 1633 assertions / 127 test cases passing locally.

Databases written by duckdb-spatial before DuckDB v1.5 persisted GEOMETRY
columns as BLOB with the type alias "GEOMETRY", because the extension
declared the type via an alias rather than a native LogicalTypeId. DuckDB
v1.5 introduced native GEOMETRY (id 60) and its own WKB-compatible layout,
but the catalog loader keeps the legacy columns as BLOB-with-alias — so
function signatures that take native GEOMETRY can no longer bind against
them, and attaching any old database breaks every spatial query on legacy
columns. test/sql/geometry/geometry_version.test has been failing as a
result.

Register an implicit cast from the legacy aliased BLOB to native GEOMETRY.
A plain ReinterpretCast does not work because the two on-disk layouts are
different: the legacy format used an 8-byte header (type, flags, unused,
padding), an optional float bbox, and a recursive body with type ids
stored as `enum - 1`, while the native format is a WKB stream starting
with a byte-order byte and uint32 type.

The cast walks the legacy header + body into an sgl::geometry (handles
POINT, LINESTRING, POLYGON, MULTI*, GEOMETRYCOLLECTION, and XY/XYZ/XYM/
XYZM vertex layouts) and reserializes using the current Serde.

With this in place test/sql/geometry/geometry_version.test passes: all
14 stored geometries round-trip through ST_GeometryType, ST_AsText,
ST_IsValid, and ST_Area from an attached v1.0.0 database.
…n test

BinaryReader::Read/Reserve/Skip throws InternalException on truncated
or malformed blobs, and sgl::geometry parsing throws on unexpected
structure. Wrap the legacy parse in try/catch so those exceptions
surface as cast-level errors instead of aborting the query.

Adds test/sql/geometry/legacy_geometry_cast.test to exercise the cast
against the attached v1.0.0 test database.

Matches the same fix applied to feature/st-cluster-dbscan (865d1a6)
and feature/geos-wrappers (e002da9) so all three fork-internal PRs
carry the same legacy-cast implementation.
Copilot AI review requested due to automatic review settings April 15, 2026 13:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 32 out of 33 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +6485 to +6534
// Fast path: POINT vs POLYGON using SGL prepared_geometry
if (type_a == sgl::geometry_type::POINT && type_b == sgl::geometry_type::POLYGON) {
auto vtx = geom_a.get_vertex_xy(0);
sgl::prepared_geometry prep;
sgl::prepared_geometry::make(lstate.GetAllocator(), geom_b, prep);
prep.build(lstate.GetAllocator());
auto pip = prep.contains(vtx);
return pip == sgl::point_in_polygon_result::INTERIOR ||
pip == sgl::point_in_polygon_result::BOUNDARY;
}
if (type_b == sgl::geometry_type::POINT && type_a == sgl::geometry_type::POLYGON) {
auto vtx = geom_b.get_vertex_xy(0);
sgl::prepared_geometry prep;
sgl::prepared_geometry::make(lstate.GetAllocator(), geom_a, prep);
prep.build(lstate.GetAllocator());
auto pip = prep.contains(vtx);
return pip == sgl::point_in_polygon_result::INTERIOR ||
pip == sgl::point_in_polygon_result::BOUNDARY;
}

// Fast path: POINT vs POINT
if (type_a == sgl::geometry_type::POINT && type_b == sgl::geometry_type::POINT) {
auto va = geom_a.get_vertex_xy(0);
auto vb = geom_b.get_vertex_xy(0);
return va.x == vb.x && va.y == vb.y;
}

// Fast path: POINT vs LINESTRING (check if point is on any segment)
auto point_on_line = [](const sgl::vertex_xy &p, const sgl::geometry &line) -> bool {
const auto n = line.get_vertex_count();
for (uint32_t i = 0; i < n - 1; i++) {
auto a = line.get_vertex_xy(i);
auto b = line.get_vertex_xy(i + 1);
// Check collinearity and range
double cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
if (std::abs(cross) > 1e-10) continue;
if (p.x >= std::min(a.x, b.x) && p.x <= std::max(a.x, b.x) &&
p.y >= std::min(a.y, b.y) && p.y <= std::max(a.y, b.y)) {
return true;
}
}
return false;
};

if (type_a == sgl::geometry_type::POINT && type_b == sgl::geometry_type::LINESTRING) {
return point_on_line(geom_a.get_vertex_xy(0), geom_b);
}
if (type_b == sgl::geometry_type::POINT && type_a == sgl::geometry_type::LINESTRING) {
return point_on_line(geom_b.get_vertex_xy(0), geom_a);
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

ST_Intersects ExecuteGeometry assumes POINT/LINESTRING inputs are non-empty. For empty LINESTRINGs, n can be 0/1 and for (uint32_t i = 0; i < n - 1; i++) underflows, leading to out-of-bounds reads. Also get_vertex_xy(0) is called for POINT without checking get_vertex_count() > 0. Add early returns for empty geometries (e.g., if point/line has 0 vertices return false) and guard the loop with if (n < 2) return false;.

Copilot uses AI. Check for mistakes.
Comment on lines +10911 to +10954
while (remaining > 2) {
double min_area = std::numeric_limits<double>::max();
uint32_t min_idx = UINT32_MAX;

// Find non-removed vertex with smallest triangle area
for (uint32_t i = 1; i < n - 1; i++) {
if (removed[i]) continue;

// Find prev and next non-removed vertices
uint32_t prev = i - 1;
while (prev > 0 && removed[prev]) prev--;
uint32_t next = i + 1;
while (next < n - 1 && removed[next]) next++;

if (removed[prev] || removed[next]) continue;

sgl::vertex_xy a = {vertices[prev].x, vertices[prev].y};
sgl::vertex_xy b = {vertices[i].x, vertices[i].y};
sgl::vertex_xy c = {vertices[next].x, vertices[next].y};
double area = TriangleArea(a, b, c);

if (area < min_area) {
min_area = area;
min_idx = i;
}
}

if (min_idx == UINT32_MAX || min_area >= area_threshold) break;

removed[min_idx] = true;
remaining--;
}

// Build result
auto new_array = static_cast<char *>(alloc.alloc(remaining * vertex_width));
uint32_t out_idx = 0;
for (uint32_t i = 0; i < n; i++) {
if (!removed[i]) {
memcpy(new_array + out_idx * vertex_width, &vertices[i], vertex_width);
out_idx++;
}
}
geom.set_vertex_array(new_array, out_idx);
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

ST_SimplifyVW can simplify closed rings (polygon rings are LINESTRING parts) down to 2 vertices because the stop condition is remaining > 2. For closed rings this can yield an invalid ring (e.g., two identical endpoints), producing invalid polygons. Consider detecting closed linestrings (first==last) and enforcing a minimum of 4 vertices (or returning EMPTY) and ensuring closure is preserved after simplification.

Copilot uses AI. Check for mistakes.
Comment on lines +931 to +944
// Batch-extract bounding boxes directly from geometry blobs
// Bypasses ExpressionExecutor + UnifiedVectorFormat overhead
{
const auto geom_data = UnifiedVectorFormat::GetData<string_t>(lstate.probe_side_key_vformat);
for (idx_t i = 0; i < input.size(); i++) {
const auto geom_idx = lstate.probe_side_key_vformat.sel->get_index(i);
if (lstate.probe_side_key_vformat.validity.RowIsValid(geom_idx)) {
lstate.probe_bbox_valid[i] =
Serde::TryGetBounds(geom_data[geom_idx], lstate.probe_bboxes[i]) > 0;
} else {
lstate.probe_bbox_valid[i] = false;
}
}
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

Probe-side bbox extraction now assumes the join key is a GEOMETRY blob (GetData<string_t> + Serde::TryGetBounds). Spatial join predicates like && accept BOX_2D on one side (see spatial_functions_scalar.cpp:3335), so the probe key may be BOX_2D rather than GEOMETRY. In that case this will read the wrong vector type and can crash or produce incorrect bboxes. Please either restore the generic bbox expression path for the probe side, or branch on probe_side_key->return_type (BOX_2D vs GEOMETRY) and extract bounds accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +33
# Spatial functions cannot bind to the old GEOMETRY type due to CRS metadata changes.
# This is a known backward compatibility limitation in DuckDB v1.5+.
# Workaround: re-create geometries via ST_GeomFromWKB(ST_AsWKB(geom)) when migrating old databases.
statement error
SELECT st_astext(geom) FROM types LIMIT 1;
----
No function matches

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

This test now asserts st_astext(geom) fails to bind on the legacy v1.0.0 database. However, this PR also introduces an implicit cast from legacy BLOB-alias GEOMETRY to native GEOMETRY (see spatial_functions_cast.cpp) and adds legacy_geometry_cast.test that relies on ST_AsText(geom) succeeding. These two tests appear inconsistent—either the cast should make st_astext work here, or the cast/test should be adjusted. Please align the expected behavior and update this test accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines +357 to +384
// STR algorithm step 1: Sort all entries by X-center for proper vertical strip partitioning.
// Without this, the scan slices in BuildRTreeBottomUp contain entries from arbitrary X positions,
// defeating the Sort-Tile-Recursive spatial locality. The Y-sort per slice (step 2) is already
// done inside BuildRTreeBottomUp.
{
vector<RTreeEntry> all_entries;
all_entries.reserve(gstate.rtree_size);

ManagedCollectionScanState x_sort_scan;
gstate.curr_layer.InitializeScan(x_sort_scan, false);

auto *buf_begin = reinterpret_cast<RTreeEntry *>(gstate.slice_buffer.get());
auto *buf_end = buf_begin + gstate.slice_size;

auto count = gstate.curr_layer.Scan(x_sort_scan, buf_begin, buf_end);
while (count > 0) {
all_entries.insert(all_entries.end(), buf_begin, buf_begin + count);
count = gstate.curr_layer.Scan(x_sort_scan, buf_begin, buf_end);
}

std::sort(all_entries.begin(), all_entries.end(), [](const RTreeEntry &a, const RTreeEntry &b) {
return a.bounds.Center().x < b.bounds.Center().x;
});

gstate.curr_layer.Clear();
gstate.curr_layer.InitializeAppend(gstate.append_state);
gstate.curr_layer.Append(gstate.append_state, all_entries.data(), all_entries.data() + all_entries.size());
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

The new STR pre-sort loads all RTreeEntry values into an in-memory vector<RTreeEntry> all_entries before sorting. For large indexes this can cause significant memory spikes (doubling storage vs the underlying ManagedCollection) and may negate the benefit of external buffering. Consider using an external sort / in-place sort on the managed collection, or gating this full materialization behind a size threshold (fall back to the previous approach when rtree_size is large).

Copilot uses AI. Check for mistakes.
@pierre-warnier pierre-warnier merged commit 35a777c into v1.5-variegata May 13, 2026
4 checks passed
@pierre-warnier pierre-warnier deleted the feature/perf-easy-wins branch May 15, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants