Skip to main content

mlt_core/encoder/
sort.rs

1//! Feature reordering for the optimizer
2
3#[cfg(feature = "sort-coords-iter")]
4use geo::CoordsIter as _;
5
6#[cfg(feature = "sort-coords-iter")]
7use crate::codecs::hilbert::hilbert_curve_params_from_bounds;
8#[cfg(feature = "sort-coords-iter")]
9use crate::codecs::hilbert::hilbert_sort_key;
10#[cfg(feature = "sort-coords-iter")]
11use crate::codecs::morton::morton_sort_key;
12#[cfg(feature = "sort-coords-iter")]
13use crate::decoder::TileFeature;
14use crate::decoder::TileLayer01;
15#[cfg(feature = "sort-coords-iter")]
16use crate::geojson::Geom32;
17
18/// Controls how features inside a layer are reordered before encoding.
19///
20/// Reordering features changes their position in every parallel column
21/// (geometry, ID, and all properties simultaneously), so the caller must
22/// opt in explicitly.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::EnumIter, strum::EnumCount)]
24pub enum SortStrategy {
25    /// Preserve the original feature order — no reordering is applied.
26    ///
27    /// This is the default.
28    #[default]
29    Unsorted,
30
31    /// Sort features by the Z-order (Morton) curve index of their first vertex.
32    ///
33    /// Fast to compute.  Spatially close features end up adjacent in the
34    /// stream, improving RLE run lengths for location-correlated properties
35    /// and CPU cache locality during client-side decoding.
36    ///
37    /// Requires the `sort-coords-iter` feature.
38    #[cfg(feature = "sort-coords-iter")]
39    SpatialMorton,
40
41    /// Sort features by the Hilbert curve index of their first vertex.
42    ///
43    /// Slower to compute than Morton but achieves superior spatial locality.
44    ///
45    /// Requires the `sort-coords-iter` feature.
46    #[cfg(feature = "sort-coords-iter")]
47    SpatialHilbert,
48
49    /// Sort features by their feature ID in ascending order.
50    Id,
51}
52
53impl TileLayer01 {
54    /// Reorder all features of `layer` according to `strategy`.
55    ///
56    /// [`SortStrategy::Unsorted`] is a no-op.
57    /// Layers with zero or one features are trivially unchanged by any sort.
58    #[hotpath::measure]
59    pub fn sort(&mut self, strategy: SortStrategy) {
60        match strategy {
61            #[cfg(feature = "sort-coords-iter")]
62            SortStrategy::SpatialMorton | SortStrategy::SpatialHilbert => {
63                let params = curve_params_from_features(&self.features);
64                let curve_key = if let SortStrategy::SpatialMorton = strategy {
65                    morton_sort_key
66                } else {
67                    hilbert_sort_key
68                };
69                self.features.sort_by_cached_key(|f| {
70                    first_vertex(&f.geometry).map_or(u64::MAX, |(x, y)| {
71                        u64::from(curve_key(x, y, params.shift, params.num_bits))
72                    })
73                });
74            }
75            SortStrategy::Id => {
76                self.features
77                    .sort_by_cached_key(|f| f.id.map_or(0, |v| v.saturating_add(1)));
78            }
79            SortStrategy::Unsorted => {
80                // do nothing
81            }
82        }
83    }
84}
85
86/// Parameters derived from the vertex set of a feature collection, used to
87/// normalize coordinates before space-filling-curve key computation.
88#[cfg(feature = "sort-coords-iter")]
89struct CurveParams {
90    shift: u32,
91    num_bits: u32,
92}
93
94/// Compute the Hilbert/Morton curve parameters from all vertex coordinates
95/// in `features` without allocating a temporary vertex buffer.
96#[cfg(feature = "sort-coords-iter")]
97fn curve_params_from_features(features: &[TileFeature]) -> CurveParams {
98    let (min_val, max_val) = features
99        .iter()
100        .flat_map(|f| f.geometry.coords_iter())
101        .fold((i32::MAX, i32::MIN), |(min, max), c| {
102            (min.min(c.x).min(c.y), max.max(c.x).max(c.y))
103        });
104    let (shift, num_bits) = hilbert_curve_params_from_bounds(min_val, max_val);
105    CurveParams { shift, num_bits }
106}
107
108/// Extract the `(x, y)` coordinate of the first vertex of a geometry.
109#[cfg(feature = "sort-coords-iter")]
110fn first_vertex(geom: &Geom32) -> Option<(i32, i32)> {
111    match geom {
112        Geom32::Point(p) => Some((p.0.x, p.0.y)),
113        Geom32::Line(l) => Some((l.start.x, l.start.y)),
114        Geom32::LineString(ls) => ls.0.first().map(|c| (c.x, c.y)),
115        Geom32::Polygon(p) => p.exterior().0.first().map(|c| (c.x, c.y)),
116        Geom32::MultiPoint(mp) => mp.0.first().map(|p| (p.0.x, p.0.y)),
117        Geom32::MultiLineString(mls) => mls
118            .0
119            .first()
120            .and_then(|ls| ls.0.first().map(|c| (c.x, c.y))),
121        Geom32::MultiPolygon(mp) => {
122            mp.0.first()
123                .and_then(|p| p.exterior().0.first().map(|c| (c.x, c.y)))
124        }
125        Geom32::Triangle(t) => Some((t.0.x, t.0.y)),
126        Geom32::Rect(r) => Some((r.min().x, r.min().y)),
127        Geom32::GeometryCollection(gc) => gc.0.first().and_then(first_vertex),
128    }
129}
130
131/// Return `true` if a spatial sort is likely to reduce compressed size.
132///
133/// The heuristic: if the vertex bounding box spans more than
134/// `SPATIAL_HELP_COVERAGE` of the layer's tile extent on **both** axes, the
135/// features are too spread-out for locality clustering to help, so spatial
136/// sorting is skipped.
137#[cfg(feature = "sort-coords-iter")]
138pub(crate) fn spatial_sort_likely_to_help(layer: &TileLayer01) -> bool {
139    const SPATIAL_HELP_COVERAGE: f64 = 0.8;
140
141    let extent = f64::from(layer.extent);
142    if extent <= 0.0 || layer.features.is_empty() {
143        return true;
144    }
145
146    let (min_x, max_x, min_y, max_y) = layer
147        .features
148        .iter()
149        .filter_map(|f| first_vertex(&f.geometry))
150        .fold(
151            (i32::MAX, i32::MIN, i32::MAX, i32::MIN),
152            |(min_x, max_x, min_y, max_y), (x, y)| {
153                (min_x.min(x), max_x.max(x), min_y.min(y), max_y.max(y))
154            },
155        );
156
157    if min_x > max_x || min_y > max_y {
158        return true;
159    }
160
161    let range_x = f64::from(max_x - min_x);
162    let range_y = f64::from(max_y - min_y);
163
164    let spread_x = range_x / extent;
165    let spread_y = range_y / extent;
166
167    !(spread_x > SPATIAL_HELP_COVERAGE && spread_y > SPATIAL_HELP_COVERAGE)
168}
169
170#[cfg(test)]
171mod tests {
172    use geo_types::{Coord, Geometry as GeoGeom, LineString, Point, Polygon};
173
174    use crate::decoder::{GeometryType, GeometryValues, RawGeometry, TileFeature, TileLayer01};
175    use crate::encoder::{
176        Encoder, ExplicitEncoder, IdWidth, IntEncoder, SortStrategy, StagedLayer01,
177    };
178    use crate::geojson::Geom32;
179    use crate::test_helpers::{assert_empty, dec, into_layer01, parser};
180    use crate::{Layer, LazyParsed};
181
182    // ── geometry test helpers ──────────────────────────────────────────────────
183
184    fn pt(x: i32, y: i32) -> Geom32 {
185        GeoGeom::Point(Point::new(x, y))
186    }
187
188    fn ls(coords: &[(i32, i32)]) -> Geom32 {
189        GeoGeom::LineString(LineString::new(
190            coords.iter().map(|&(x, y)| Coord { x, y }).collect(),
191        ))
192    }
193
194    fn poly_square(x0: i32, y0: i32, side: i32) -> Geom32 {
195        let ring = LineString::new(vec![
196            Coord { x: x0, y: y0 },
197            Coord {
198                x: x0 + side,
199                y: y0,
200            },
201            Coord {
202                x: x0 + side,
203                y: y0 + side,
204            },
205            Coord {
206                x: x0,
207                y: y0 + side,
208            },
209            Coord { x: x0, y: y0 },
210        ]);
211        GeoGeom::Polygon(Polygon::new(ring, vec![]))
212    }
213
214    /// Encode + serialize + parse + decode a `GeometryValues` (round-trip).
215    fn roundtrip_geom(decoded: &GeometryValues) -> GeometryValues {
216        let mut enc = Encoder::default();
217        decoded.clone().write_to(&mut enc).expect("encode failed");
218        let buf = enc.data;
219
220        let parsed = assert_empty(RawGeometry::from_bytes(&buf, &mut parser()));
221        let mut d = dec();
222        let result = LazyParsed::Raw(parsed)
223            .into_parsed(&mut d)
224            .expect("decode failed");
225        assert!(
226            d.consumed() > 0,
227            "decoder should consume bytes after decode"
228        );
229        result
230    }
231
232    /// Build the canonical (dense, wire-decoded) form of an ordered geometry sequence.
233    fn canonical(geoms: &[Geom32]) -> GeometryValues {
234        let mut decoded = GeometryValues::default();
235        for g in geoms {
236            decoded.push_geom(g);
237        }
238        roundtrip_geom(&decoded)
239    }
240
241    /// Build a `TileLayer01` from `geoms` and `ids`, apply `reorder_features`,
242    /// and return it.
243    fn layer_after_sort(geoms: &[Geom32], ids: &[u64], strategy: SortStrategy) -> TileLayer01 {
244        let features: Vec<TileFeature> = geoms
245            .iter()
246            .zip(ids.iter())
247            .map(|(g, &id)| TileFeature {
248                id: Some(id),
249                geometry: g.clone(),
250                properties: vec![],
251            })
252            .collect();
253
254        let mut layer = TileLayer01 {
255            name: "test".to_string(),
256            extent: 4096,
257            property_names: vec![],
258            features,
259        };
260
261        layer.sort(strategy);
262        layer
263    }
264
265    /// Sort, then encode+decode the result and compare to `canonical(expected)`.
266    fn assert_sort_roundtrip(
267        geoms: &[Geom32],
268        ids: &[u64],
269        strategy: SortStrategy,
270        expected: &[Geom32],
271    ) {
272        let layer = layer_after_sort(geoms, ids, strategy);
273
274        let mut sorted_decoded = GeometryValues::default();
275        for f in &layer.features {
276            sorted_decoded.push_geom(&f.geometry);
277        }
278
279        let after_roundtrip = roundtrip_geom(&sorted_decoded);
280        let expected_canonical = canonical(expected);
281
282        assert_eq!(
283            after_roundtrip, expected_canonical,
284            "\nsorted geometry did not match expected after encode→decode round-trip\
285             \nvector_types after sort: {:?}\
286             \nvector_types expected:   {:?}",
287            sorted_decoded.vector_types, expected_canonical.vector_types,
288        );
289    }
290
291    // ── pure Points ──────────────────────────────────────────────────────────
292
293    #[test]
294    fn pure_points_id_sort_roundtrip() {
295        assert_sort_roundtrip(
296            &[pt(0, 0), pt(1, 1), pt(2, 2)],
297            &[3, 2, 1],
298            SortStrategy::Id,
299            &[pt(2, 2), pt(1, 1), pt(0, 0)],
300        );
301    }
302
303    // ── pure LineStrings ─────────────────────────────────────────────────────
304
305    #[test]
306    fn pure_linestrings_id_sort_roundtrip() {
307        assert_sort_roundtrip(
308            &[ls(&[(0, 0), (0, 10)]), ls(&[(5, 5), (10, 10)])],
309            &[2, 1],
310            SortStrategy::Id,
311            &[ls(&[(5, 5), (10, 10)]), ls(&[(0, 0), (0, 10)])],
312        );
313    }
314
315    // ── [Point, LineString, Point] ────────────────────────────────────────────
316
317    #[test]
318    fn point_line_point_id_sort_to_line_point_point_roundtrip() {
319        assert_sort_roundtrip(
320            &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)],
321            &[3, 1, 2],
322            SortStrategy::Id,
323            &[ls(&[(1, 0), (1, 5)]), pt(5, 5), pt(0, 0)],
324        );
325    }
326
327    #[test]
328    fn point_line_point_id_sort_to_point_point_line_roundtrip() {
329        assert_sort_roundtrip(
330            &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)],
331            &[1, 3, 2],
332            SortStrategy::Id,
333            &[pt(0, 0), pt(5, 5), ls(&[(1, 0), (1, 5)])],
334        );
335    }
336
337    // ── [Point, Polygon, Point] ───────────────────────────────────────────────
338
339    #[test]
340    fn point_polygon_point_id_sort_roundtrip() {
341        assert_sort_roundtrip(
342            &[pt(0, 0), poly_square(10, 10, 5), pt(5, 5)],
343            &[2, 1, 3],
344            SortStrategy::Id,
345            &[poly_square(10, 10, 5), pt(0, 0), pt(5, 5)],
346        );
347    }
348
349    // ── spatial Morton sort ───────────────────────────────────────────────────
350
351    #[cfg(feature = "sort-coords-iter")]
352    #[test]
353    fn point_line_point_morton_sort_roundtrip() {
354        assert_sort_roundtrip(
355            &[pt(2, 0), ls(&[(0, 0), (0, 5)]), pt(1, 0)],
356            &[1, 2, 3],
357            SortStrategy::SpatialMorton,
358            &[ls(&[(0, 0), (0, 5)]), pt(1, 0), pt(2, 0)],
359        );
360    }
361
362    // ── already-sorted is identity ────────────────────────────────────────────
363
364    #[test]
365    fn id_sort_already_sorted_is_identity_roundtrip() {
366        let geoms = &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)];
367        assert_sort_roundtrip(geoms, &[1, 2, 3], SortStrategy::Id, geoms);
368    }
369
370    // ── ID column co-permuted with geometry ───────────────────────────────────
371
372    #[test]
373    fn id_column_co_permuted_with_geometry() {
374        let layer = layer_after_sort(
375            &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)],
376            &[3, 1, 2],
377            SortStrategy::Id,
378        );
379
380        let ids: Vec<Option<u64>> = layer.features.iter().map(|f| f.id).collect();
381        assert_eq!(ids, vec![Some(1u64), Some(2), Some(3)]);
382
383        // Verify geometry types match expected order
384        let geom_types: Vec<&str> = layer
385            .features
386            .iter()
387            .map(|f| GeometryType::try_from(&f.geometry).unwrap().into())
388            .collect();
389        assert_eq!(geom_types, vec!["LineString", "Point", "Point"]);
390    }
391
392    /// Build row-oriented tile layer from geometries and IDs (one feature per geometry).
393    fn build_tile_layer(geoms: &[Geom32], ids: &[Option<u64>]) -> TileLayer01 {
394        assert_eq!(geoms.len(), ids.len());
395        TileLayer01 {
396            name: "test".to_string(),
397            extent: 4096,
398            property_names: vec![],
399            features: geoms
400                .iter()
401                .zip(ids.iter())
402                .map(|(g, &id)| TileFeature {
403                    id,
404                    geometry: g.clone(),
405                    properties: vec![],
406                })
407                .collect(),
408        }
409    }
410
411    /// Encode the layer with a given sort strategy, decode it back, and return the `TileLayer01`.
412    /// This tests the full encode→decode roundtrip, verifying that sorting was applied.
413    fn sort_encode_decode(tile: TileLayer01, sort: SortStrategy) -> TileLayer01 {
414        let enc = Encoder::with_explicit(
415            Encoder::default().cfg,
416            ExplicitEncoder::for_id(IntEncoder::varint(), IdWidth::Id32),
417        );
418        let enc = StagedLayer01::from_tile(tile, sort, &[])
419            .encode_into(enc)
420            .expect("encode failed");
421
422        // Serialize to bytes and reparse to get a `Layer01`.
423        let buf = enc.into_layer_bytes().expect("into_layer_bytes failed");
424
425        let mut p = parser();
426        let layer_back = assert_empty(Layer::from_bytes(&buf, &mut p));
427        assert!(p.reserved() > 0, "parser should reserve bytes after parse");
428
429        let layer01 = into_layer01(layer_back);
430
431        let mut d = dec();
432        let tile = layer01.into_tile(&mut d).expect("decode after sort failed");
433        assert!(
434            d.consumed() > 0,
435            "decoder should consume bytes after decode"
436        );
437        tile
438    }
439
440    /// Rebuild a flat vertex buffer from the feature geometries in source order.
441    fn vertices_from_source(source: &TileLayer01) -> Vec<i32> {
442        let mut geom = GeometryValues::default();
443        for f in &source.features {
444            geom.push_geom(&f.geometry);
445        }
446        geom.vertices().unwrap_or_default().to_vec()
447    }
448
449    #[cfg(feature = "sort-coords-iter")]
450    #[test]
451    fn test_shared_morton_shift() {
452        // P1 at (0, -10), P2 at (-10, 0).
453        // With shared shift = 10:
454        // P1 shifted: (10, 0) -> interleave(10, 0) = 68
455        // P2 shifted: (0, 10) -> interleave(0, 10) = 136
456        // P1 (key 68) < P2 (key 136), so expected order: [P1(0,-10), P2(-10,0)].
457
458        let tile = build_tile_layer(&[pt(0, -10), pt(-10, 0)], &[Some(1), Some(2)]);
459        let source = sort_encode_decode(tile, SortStrategy::SpatialMorton);
460
461        let verts = vertices_from_source(&source);
462        assert_eq!(verts, vec![0, -10, -10, 0]);
463    }
464
465    #[test]
466    fn test_id_sort_nulls_first() {
467        let tile = build_tile_layer(&[pt(2, 2), pt(1, 1), pt(0, 0)], &[Some(10), None, Some(5)]);
468        let source = sort_encode_decode(tile, SortStrategy::Id);
469
470        let ids: Vec<Option<u64>> = source.features.iter().map(|f| f.id).collect();
471        // Expected order: [None, Some(5), Some(10)]
472        assert_eq!(ids, vec![None, Some(5), Some(10)]);
473
474        let verts = vertices_from_source(&source);
475        // Corresponding verts: [pt(1,1), pt(0,0), pt(2,2)] -> [1,1, 0,0, 2,2]
476        assert_eq!(verts, vec![1, 1, 0, 0, 2, 2]);
477    }
478
479    #[cfg(feature = "sort-coords-iter")]
480    #[test]
481    fn test_mixed_geometry_morton_sort() {
482        // [Point(2,0), LineString(0,0 -> 0,5), Point(1,0)]
483        // Morton keys (assuming shift 0):
484        // P1(2,0) -> 4
485        // LS(0,0) -> 0
486        // P2(1,0) -> 1
487        // Expected order: [LS, P2, P1]
488
489        let tile = build_tile_layer(
490            &[pt(2, 0), ls(&[(0, 0), (0, 5)]), pt(1, 0)],
491            &[Some(1), Some(2), Some(3)],
492        );
493        let source = sort_encode_decode(tile, SortStrategy::SpatialMorton);
494
495        let types: Vec<_> = source
496            .features
497            .iter()
498            .map(|f| GeometryType::try_from(&f.geometry).unwrap())
499            .collect();
500
501        assert_eq!(
502            types,
503            vec![
504                GeometryType::LineString,
505                GeometryType::Point,
506                GeometryType::Point
507            ]
508        );
509
510        let verts = vertices_from_source(&source);
511        // Expected vertices: LS(0,0,0,5), P2(1,0), P1(2,0)
512        assert_eq!(verts, vec![0, 0, 0, 5, 1, 0, 2, 0]);
513    }
514}