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