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            })
231            .collect();
232
233        let mut layer = TileLayer::from_parts("test", 4096, vec![], features).unwrap();
234
235        let params = layer.curve_params();
236        layer.sort(strategy, params);
237        layer
238    }
239
240    /// Sort, then encode+decode the result and compare to `canonical(expected)`.
241    fn assert_sort_roundtrip(
242        geoms: &[Geometry<i32>],
243        ids: &[u64],
244        strategy: SortStrategy,
245        expected: &[Geometry<i32>],
246    ) {
247        let layer = layer_after_sort(geoms, ids, strategy);
248
249        let mut sorted_decoded = GeometryValues::default();
250        for f in layer.features() {
251            sorted_decoded.push_geom(f.geometry());
252        }
253
254        let after_roundtrip = roundtrip_geom(&sorted_decoded);
255        let expected_canonical = canonical(expected);
256
257        assert_eq!(
258            after_roundtrip, expected_canonical,
259            "\nsorted geometry did not match expected after encode->decode round-trip\
260             \nvector_types after sort: {:?}\
261             \nvector_types expected:   {:?}",
262            sorted_decoded.vector_types, expected_canonical.vector_types,
263        );
264    }
265
266    // ── pure Points ──────────────────────────────────────────────────────────
267
268    #[test]
269    fn pure_points_id_sort_roundtrip() {
270        assert_sort_roundtrip(
271            &[pt(0, 0), pt(1, 1), pt(2, 2)],
272            &[3, 2, 1],
273            SortStrategy::Id,
274            &[pt(2, 2), pt(1, 1), pt(0, 0)],
275        );
276    }
277
278    // ── pure LineStrings ─────────────────────────────────────────────────────
279
280    #[test]
281    fn pure_linestrings_id_sort_roundtrip() {
282        assert_sort_roundtrip(
283            &[ls(&[(0, 0), (0, 10)]), ls(&[(5, 5), (10, 10)])],
284            &[2, 1],
285            SortStrategy::Id,
286            &[ls(&[(5, 5), (10, 10)]), ls(&[(0, 0), (0, 10)])],
287        );
288    }
289
290    // ── [Point, LineString, Point] ────────────────────────────────────────────
291
292    #[test]
293    fn point_line_point_id_sort_to_line_point_point_roundtrip() {
294        assert_sort_roundtrip(
295            &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)],
296            &[3, 1, 2],
297            SortStrategy::Id,
298            &[ls(&[(1, 0), (1, 5)]), pt(5, 5), pt(0, 0)],
299        );
300    }
301
302    #[test]
303    fn point_line_point_id_sort_to_point_point_line_roundtrip() {
304        assert_sort_roundtrip(
305            &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)],
306            &[1, 3, 2],
307            SortStrategy::Id,
308            &[pt(0, 0), pt(5, 5), ls(&[(1, 0), (1, 5)])],
309        );
310    }
311
312    // ── [Point, Polygon, Point] ───────────────────────────────────────────────
313
314    #[test]
315    fn point_polygon_point_id_sort_roundtrip() {
316        assert_sort_roundtrip(
317            &[pt(0, 0), poly_square(10, 10, 5), pt(5, 5)],
318            &[2, 1, 3],
319            SortStrategy::Id,
320            &[poly_square(10, 10, 5), pt(0, 0), pt(5, 5)],
321        );
322    }
323
324    // ── spatial Morton sort ───────────────────────────────────────────────────
325
326    #[test]
327    fn point_line_point_morton_sort_roundtrip() {
328        assert_sort_roundtrip(
329            &[pt(2, 0), ls(&[(0, 0), (0, 5)]), pt(1, 0)],
330            &[1, 2, 3],
331            SortStrategy::SpatialMorton,
332            &[ls(&[(0, 0), (0, 5)]), pt(1, 0), pt(2, 0)],
333        );
334    }
335
336    // ── already-sorted is identity ────────────────────────────────────────────
337
338    #[test]
339    fn id_sort_already_sorted_is_identity_roundtrip() {
340        let geoms = &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)];
341        assert_sort_roundtrip(geoms, &[1, 2, 3], SortStrategy::Id, geoms);
342    }
343
344    // ── ID column co-permuted with geometry ───────────────────────────────────
345
346    #[test]
347    fn id_column_co_permuted_with_geometry() {
348        let layer = layer_after_sort(
349            &[pt(0, 0), ls(&[(1, 0), (1, 5)]), pt(5, 5)],
350            &[3, 1, 2],
351            SortStrategy::Id,
352        );
353
354        let ids: Vec<Option<u64>> = layer.features().iter().map(TileFeature::id).collect();
355        assert_eq!(ids, vec![Some(1u64), Some(2), Some(3)]);
356
357        // Verify geometry types match expected order
358        let geom_types: Vec<&str> = layer
359            .features()
360            .iter()
361            .map(|f| GeometryType::try_from(f.geometry()).unwrap().into())
362            .collect();
363        assert_eq!(geom_types, vec!["LineString", "Point", "Point"]);
364    }
365
366    /// Build row-oriented tile layer from geometries and IDs (one feature per geometry).
367    fn build_tile_layer(geoms: &[Geometry<i32>], ids: &[Option<u64>]) -> TileLayer {
368        assert_eq!(geoms.len(), ids.len());
369        TileLayer::from_parts(
370            "test",
371            4096,
372            vec![],
373            geoms
374                .iter()
375                .zip(ids.iter())
376                .map(|(g, &id)| TileFeature {
377                    id,
378                    geometry: g.clone(),
379                    properties: vec![],
380                })
381                .collect(),
382        )
383        .unwrap()
384    }
385
386    /// Encode the layer with a given sort strategy, decode it back, and return the `TileLayer`.
387    /// This tests the full encode->decode roundtrip, verifying that sorting was applied.
388    fn sort_encode_decode(tile: TileLayer, sort: SortStrategy) -> TileLayer {
389        let enc_cfg = EncoderConfig::default();
390        let enc = Encoder::with_explicit(enc_cfg, ExplicitEncoder::for_id(IntEncoder::varint()));
391        let mut codecs = Codecs::default();
392        let enc = stage_tile(tile, sort, false, enc_cfg.tessellate())
393            .encode_into(enc, &mut codecs)
394            .expect("encode failed");
395
396        // Serialize to bytes and reparse to get a `Layer01`.
397        let buf = enc.into_layer_bytes().expect("into_layer_bytes failed");
398
399        let mut p = parser();
400        let layer_back = assert_empty(Layer::from_bytes(&buf, &mut p));
401        assert!(p.reserved() > 0, "parser should reserve bytes after parse");
402
403        let layer01 = into_layer01(layer_back);
404
405        let mut d = dec();
406        let tile = layer01.into_tile(&mut d).expect("decode after sort failed");
407        assert!(
408            d.consumed() > 0,
409            "decoder should consume bytes after decode"
410        );
411        tile
412    }
413
414    /// Rebuild a flat vertex buffer from the feature geometries in source order.
415    fn vertices_from_source(source: &TileLayer) -> Vec<i32> {
416        let mut geom = GeometryValues::default();
417        for f in source.features() {
418            geom.push_geom(f.geometry());
419        }
420        geom.vertices().unwrap_or_default().to_vec()
421    }
422
423    #[test]
424    fn test_shared_morton_shift() {
425        // P1 at (0, -10), P2 at (-10, 0).
426        // With shared shift = 10:
427        // P1 shifted: (10, 0) -> interleave(10, 0) = 68
428        // P2 shifted: (0, 10) -> interleave(0, 10) = 136
429        // P1 (key 68) < P2 (key 136), so expected order: [P1(0,-10), P2(-10,0)].
430
431        let tile = build_tile_layer(&[pt(0, -10), pt(-10, 0)], &[Some(1), Some(2)]);
432        let source = sort_encode_decode(tile, SortStrategy::SpatialMorton);
433
434        let verts = vertices_from_source(&source);
435        assert_eq!(verts, vec![0, -10, -10, 0]);
436    }
437
438    #[test]
439    fn test_id_sort_nulls_first() {
440        let tile = build_tile_layer(&[pt(2, 2), pt(1, 1), pt(0, 0)], &[Some(10), None, Some(5)]);
441        let source = sort_encode_decode(tile, SortStrategy::Id);
442
443        let ids: Vec<Option<u64>> = source.features().iter().map(TileFeature::id).collect();
444        // Expected order: [None, Some(5), Some(10)]
445        assert_eq!(ids, vec![None, Some(5), Some(10)]);
446
447        let verts = vertices_from_source(&source);
448        // Corresponding verts: [pt(1,1), pt(0,0), pt(2,2)] -> [1,1, 0,0, 2,2]
449        assert_eq!(verts, vec![1, 1, 0, 0, 2, 2]);
450    }
451
452    #[test]
453    fn test_mixed_geometry_morton_sort() {
454        // [Point(2,0), LineString(0,0 -> 0,5), Point(1,0)]
455        // Morton keys (assuming shift 0):
456        // P1(2,0) -> 4
457        // LS(0,0) -> 0
458        // P2(1,0) -> 1
459        // Expected order: [LS, P2, P1]
460
461        let tile = build_tile_layer(
462            &[pt(2, 0), ls(&[(0, 0), (0, 5)]), pt(1, 0)],
463            &[Some(1), Some(2), Some(3)],
464        );
465        let source = sort_encode_decode(tile, SortStrategy::SpatialMorton);
466
467        let types: Vec<_> = source
468            .features()
469            .iter()
470            .map(|f| GeometryType::try_from(f.geometry()).unwrap())
471            .collect();
472
473        assert_eq!(
474            types,
475            vec![
476                GeometryType::LineString,
477                GeometryType::Point,
478                GeometryType::Point
479            ]
480        );
481
482        let verts = vertices_from_source(&source);
483        // Expected vertices: LS(0,0,0,5), P2(1,0), P1(2,0)
484        assert_eq!(verts, vec![0, 0, 0, 5, 1, 0, 2, 0]);
485    }
486}