Skip to main content

mlt_core/encoder/
sort.rs

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