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.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
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);
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::{Codecs, Encoder, ExplicitEncoder, IntEncoder, SortStrategy, stage_tile};
155    use crate::test_helpers::{assert_empty, dec, into_layer01, parser};
156    use crate::{Layer, LazyParsed};
157
158    fn pt(x: i32, y: i32) -> Geometry<i32> {
159        GeoGeom::Point(Point::new(x, y))
160    }
161
162    fn ls(coords: &[(i32, i32)]) -> Geometry<i32> {
163        GeoGeom::LineString(LineString::new(
164            coords.iter().map(|&(x, y)| Coord { x, y }).collect(),
165        ))
166    }
167
168    fn poly_square(x0: i32, y0: i32, side: i32) -> Geometry<i32> {
169        let ring = LineString::new(vec![
170            Coord { x: x0, y: y0 },
171            Coord {
172                x: x0 + side,
173                y: y0,
174            },
175            Coord {
176                x: x0 + side,
177                y: y0 + side,
178            },
179            Coord {
180                x: x0,
181                y: y0 + side,
182            },
183            Coord { x: x0, y: y0 },
184        ]);
185        GeoGeom::Polygon(Polygon::new(ring, vec![]))
186    }
187
188    /// Encode + serialize + parse + decode a `GeometryValues` (round-trip).
189    fn roundtrip_geom(decoded: &GeometryValues) -> GeometryValues {
190        let mut enc = Encoder::default();
191        let mut codecs = Codecs::default();
192        decoded
193            .clone()
194            .write_to(&mut enc, &mut codecs)
195            .expect("encode failed");
196        let buf = enc.data;
197
198        let parsed = assert_empty(RawGeometry::from_bytes(&buf, &mut parser()));
199        let mut d = dec();
200        let result = LazyParsed::Raw(parsed)
201            .into_parsed(&mut d)
202            .expect("decode failed");
203        assert!(
204            d.consumed() > 0,
205            "decoder should consume bytes after decode"
206        );
207        result
208    }
209
210    /// Build the canonical (dense, wire-decoded) form of an ordered geometry sequence.
211    fn canonical(geoms: &[Geometry<i32>]) -> GeometryValues {
212        let mut decoded = GeometryValues::default();
213        for g in geoms {
214            decoded.push_geom(g);
215        }
216        roundtrip_geom(&decoded)
217    }
218
219    /// Build a `TileLayer` from `geoms` and `ids`, apply `reorder_features`,
220    /// and return it.
221    fn layer_after_sort(geoms: &[Geometry<i32>], ids: &[u64], strategy: SortStrategy) -> TileLayer {
222        let features: Vec<TileFeature> = geoms
223            .iter()
224            .zip(ids.iter())
225            .map(|(g, &id)| TileFeature {
226                id: Some(id),
227                geometry: g.clone(),
228                properties: vec![],
229            })
230            .collect();
231
232        let mut layer = TileLayer {
233            name: "test".to_string(),
234            extent: 4096,
235            property_names: vec![],
236            features,
237        };
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(|f| f.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 {
374            name: "test".to_string(),
375            extent: 4096,
376            property_names: vec![],
377            features: geoms
378                .iter()
379                .zip(ids.iter())
380                .map(|(g, &id)| TileFeature {
381                    id,
382                    geometry: g.clone(),
383                    properties: vec![],
384                })
385                .collect(),
386        }
387    }
388
389    /// Encode the layer with a given sort strategy, decode it back, and return the `TileLayer`.
390    /// This tests the full encode→decode roundtrip, verifying that sorting was applied.
391    fn sort_encode_decode(tile: TileLayer, sort: SortStrategy) -> TileLayer {
392        let enc_cfg = Encoder::default().cfg;
393        let enc = Encoder::with_explicit(enc_cfg, ExplicitEncoder::for_id(IntEncoder::varint()));
394        let mut codecs = Codecs::default();
395        let enc = stage_tile(tile, sort, false, enc_cfg.tessellate)
396            .encode_into(enc, &mut codecs)
397            .expect("encode failed");
398
399        // Serialize to bytes and reparse to get a `Layer01`.
400        let buf = enc.into_layer_bytes().expect("into_layer_bytes failed");
401
402        let mut p = parser();
403        let layer_back = assert_empty(Layer::from_bytes(&buf, &mut p));
404        assert!(p.reserved() > 0, "parser should reserve bytes after parse");
405
406        let layer01 = into_layer01(layer_back);
407
408        let mut d = dec();
409        let tile = layer01.into_tile(&mut d).expect("decode after sort failed");
410        assert!(
411            d.consumed() > 0,
412            "decoder should consume bytes after decode"
413        );
414        tile
415    }
416
417    /// Rebuild a flat vertex buffer from the feature geometries in source order.
418    fn vertices_from_source(source: &TileLayer) -> Vec<i32> {
419        let mut geom = GeometryValues::default();
420        for f in &source.features {
421            geom.push_geom(&f.geometry);
422        }
423        geom.vertices().unwrap_or_default().to_vec()
424    }
425
426    #[test]
427    fn test_shared_morton_shift() {
428        // P1 at (0, -10), P2 at (-10, 0).
429        // With shared shift = 10:
430        // P1 shifted: (10, 0) -> interleave(10, 0) = 68
431        // P2 shifted: (0, 10) -> interleave(0, 10) = 136
432        // P1 (key 68) < P2 (key 136), so expected order: [P1(0,-10), P2(-10,0)].
433
434        let tile = build_tile_layer(&[pt(0, -10), pt(-10, 0)], &[Some(1), Some(2)]);
435        let source = sort_encode_decode(tile, SortStrategy::SpatialMorton);
436
437        let verts = vertices_from_source(&source);
438        assert_eq!(verts, vec![0, -10, -10, 0]);
439    }
440
441    #[test]
442    fn test_id_sort_nulls_first() {
443        let tile = build_tile_layer(&[pt(2, 2), pt(1, 1), pt(0, 0)], &[Some(10), None, Some(5)]);
444        let source = sort_encode_decode(tile, SortStrategy::Id);
445
446        let ids: Vec<Option<u64>> = source.features.iter().map(|f| f.id).collect();
447        // Expected order: [None, Some(5), Some(10)]
448        assert_eq!(ids, vec![None, Some(5), Some(10)]);
449
450        let verts = vertices_from_source(&source);
451        // Corresponding verts: [pt(1,1), pt(0,0), pt(2,2)] -> [1,1, 0,0, 2,2]
452        assert_eq!(verts, vec![1, 1, 0, 0, 2, 2]);
453    }
454
455    #[test]
456    fn test_mixed_geometry_morton_sort() {
457        // [Point(2,0), LineString(0,0 -> 0,5), Point(1,0)]
458        // Morton keys (assuming shift 0):
459        // P1(2,0) -> 4
460        // LS(0,0) -> 0
461        // P2(1,0) -> 1
462        // Expected order: [LS, P2, P1]
463
464        let tile = build_tile_layer(
465            &[pt(2, 0), ls(&[(0, 0), (0, 5)]), pt(1, 0)],
466            &[Some(1), Some(2), Some(3)],
467        );
468        let source = sort_encode_decode(tile, SortStrategy::SpatialMorton);
469
470        let types: Vec<_> = source
471            .features
472            .iter()
473            .map(|f| GeometryType::try_from(&f.geometry).unwrap())
474            .collect();
475
476        assert_eq!(
477            types,
478            vec![
479                GeometryType::LineString,
480                GeometryType::Point,
481                GeometryType::Point
482            ]
483        );
484
485        let verts = vertices_from_source(&source);
486        // Expected vertices: LS(0,0,0,5), P2(1,0), P1(2,0)
487        assert_eq!(verts, vec![0, 0, 0, 5, 1, 0, 2, 0]);
488    }
489}