plozone 0.1.1

3D spatial zone engine: geofencing, octree hole-scanning, realtime sync (WebSocket + QUIC + io_uring), voxel pathfinding, and AV sensor fusion.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
//! Zone shapes — the [`ZoneShape`] trait, the serializable [`Zone`] enum, and
//! a handful of built-in custom shapes.
//!
//! Two layers cooperate:
//!
//! - [`Zone`] is a serializable, wire-safe enum of built-in shapes, expressed
//!   in geodetic coordinates.
//! - [`ZoneShape`] is the runtime interface every shape implements, operating
//!   purely in ENU metres. [`zone_to_shape`] converts a geodetic `Zone` into a
//!   query-ready `Box<dyn ZoneShape>` once, at insert time.

use std::sync::Arc;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU32, Ordering};

use parry3d::math::Vector;
use parry3d::query::PointQuery;
use parry3d::shape::ConvexPolyhedron;

use crate::coord::CoordSystem;

/// The single interface all zone types implement.
///
/// All coordinates are in ENU metres — geodetic conversion has already
/// happened by the time these methods are called.
pub trait ZoneShape: Send + Sync {
    /// Returns true if the ENU point lies inside this zone.
    fn contains_enu(&self, p: [f64; 3]) -> bool;

    /// Axis-aligned bounding box in ENU metres:
    /// `[min_x, min_y, min_z, max_x, max_y, max_z]`. Used by the R-tree for
    /// fast candidate pruning, so it must fully enclose the shape.
    fn aabb_enu(&self) -> [f64; 6];
}

/// Serializable built-in zone shapes, expressed in geodetic coordinates.
///
/// Safe to send over the wire, store in a DB, or embed in a diff. Becomes a
/// [`ZoneShape`] after conversion via [`zone_to_shape`].
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub enum Zone {
    /// Axis-aligned bounding box. `min`/`max` are `[lat, lon, alt_m]`.
    Aabb { min: [f64; 3], max: [f64; 3] },

    /// Vertical cylinder centred on `[lat, lon]`.
    ///
    /// `z_min` / `z_max` are **ENU metres relative to `EnuConverter::origin_alt`**
    /// (i.e. metres above the local origin, not absolute WGS84 altitude).
    /// To express absolute altitude, set `origin_alt` accordingly in `EnuConverter::new`.
    Cylinder { center: [f64; 2], radius_m: f64, z_min: f64, z_max: f64 },

    /// A 2D lat/lon ring extruded along Z — the common case for floors/buildings.
    ///
    /// Same Z convention as `Cylinder`: ENU metres relative to `EnuConverter::origin_alt`.
    ExtrudedPolygon { ring: Vec<[f64; 2]>, z_min: f64, z_max: f64 },

    /// Arbitrary convex polyhedron over `[lat, lon, alt_m]` vertices.
    ConvexHull { vertices: Vec<[f64; 3]> },
}

#[cfg(feature = "geo")]
impl Zone {
    /// Wrap a [`geo_types`] point as a 1-metre-cubic `Aabb` at that location.
    pub fn from_geo_point(point: &geo_types::Point) -> Self {
        Self::Aabb {
            min: [point.x(), point.y(), 0.0],
            max: [point.x() + 0.00001, point.y() + 0.00001, 1.0],
        }
    }

    /// Convert a [`geo_types`] polygon into an [`ExtrudedPolygon`] zone.
    /// Exterior ring vertices become `[lat, lon]` pairs; the Z range
    /// defaults to `[0, 0]` (set with `.with_z_range()`).
    pub fn from_geo_polygon(poly: &geo_types::Polygon) -> Self {
        let ring: Vec<[f64; 2]> = poly
            .exterior()
            .points()
            .map(|c| [c.y(), c.x()])
            .collect();
        Self::ExtrudedPolygon { ring, z_min: 0.0, z_max: 0.0 }
    }

    /// Convert a [`geo_types`] `MultiPolygon` into a union of extruded sub-zones.
    /// **Note:** produces a flat list, not a recursive [`Zone`]. For meaningful
    /// containment, collect these into separate `ZoneEntry` records.
    pub fn from_geo_multi_polygon(multipoly: &geo_types::MultiPolygon) -> Vec<Self> {
        multipoly.0.iter().map(Self::from_geo_polygon).collect()
    }
}

impl std::fmt::Display for Zone {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Aabb { min, max } => write!(
                f,
                "AABB([{:.4}, {:.4}, {:.1}], [{:.4}, {:.4}, {:.1}])",
                min[0], min[1], min[2], max[0], max[1], max[2]
            ),
            Self::Cylinder { center, radius_m, z_min, z_max } => write!(
                f,
                "CYLINDER(({:.6}, {:.6}), {:.2}, [{:.1}, {:.1}])",
                center[0], center[1], radius_m, z_min, z_max
            ),
            Self::ExtrudedPolygon { ring, z_min, z_max } => {
                write!(f, "POLYGON([")?;
                for (i, v) in ring.iter().take(8).enumerate() {
                    if i > 0 {
                        write!(f, ", ")?;
                    }
                    write!(f, "({:.6}, {:.6})", v[0], v[1])?;
                }
                if ring.len() > 8 {
                    write!(f, ", ...")?;
                }
                write!(f, "], {:.1}, {:.1})", z_min, z_max)
            }
            Self::ConvexHull { vertices } => {
                write!(f, "CONVEXHULL([")?;
                for (i, v) in vertices.iter().take(6).enumerate() {
                    if i > 0 {
                        write!(f, ", ")?;
                    }
                    write!(f, "({:.4}, {:.4}, {:.2})", v[0], v[1], v[2])?;
                }
                if vertices.len() > 6 {
                    write!(f, ", ...")?;
                }
                write!(f, "])")
            }
        }
    }
}

/// A serializable zone with its stable id — the unit of wire/DB storage.
///
/// Use [`ZoneEntry::new`] for the minimal constructor (defaults priority and
/// layer to 0), or use the struct literal syntax to set them explicitly.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ZoneEntry {
    /// Stable, caller-assigned zone id.
    pub id: u32,
    /// The serializable zone geometry.
    pub zone: Zone,
    /// Query precedence; higher wins in [`ZoneStore::query_enu_top_priority`](crate::store::ZoneStore::query_enu_top_priority).
    #[serde(default)]
    pub priority: u8,
    /// Grouping layer for filtered queries (e.g. terrain vs. gameplay).
    #[serde(default)]
    pub layer: u8,
}

impl ZoneEntry {
    /// Convenience constructor with default priority (0) and layer (0).
    pub fn new(id: u32, zone: Zone) -> Self {
        Self { id, zone, priority: 0, layer: 0 }
    }

    /// Set the priority for query ordering (0 = lowest, 255 = highest).
    pub fn with_priority(mut self, priority: u8) -> Self {
        self.priority = priority;
        self
    }

    /// Set the layer for grouped queries (e.g. 0 = terrain, 1 = gameplay, 2 = traffic).
    pub fn with_layer(mut self, layer: u8) -> Self {
        self.layer = layer;
        self
    }
}

impl Zone {
    /// Builder: a cylindrical zone centred on `[lat, lon]` with the given radius.
    /// Use the returned builders to set Z range, priority, and layer.
    pub fn cylinder(lat: f64, lon: f64, radius_m: f64) -> ZoneBuilder {
        ZoneBuilder(Zone::Cylinder { center: [lat, lon], radius_m, z_min: 0.0, z_max: 0.0 })
    }

    /// Builder: an axis-aligned bounding box in `[lat, lon, alt_m]`.
    pub fn aabb(min: [f64; 3], max: [f64; 3]) -> ZoneBuilder {
        ZoneBuilder(Zone::Aabb { min, max })
    }

    /// Builder: a polygon ring extruded along Z.
    pub fn extruded_polygon(ring: Vec<[f64; 2]>) -> ZoneBuilder {
        ZoneBuilder(Zone::ExtrudedPolygon { ring, z_min: 0.0, z_max: 0.0 })
    }

    /// Builder: a convex polyhedron from geodetic vertices.
    pub fn convex_hull(vertices: Vec<[f64; 3]>) -> ZoneBuilder {
        ZoneBuilder(Zone::ConvexHull { vertices })
    }

    /// Builder: a spherical zone with a given radius.
    pub fn sphere(center: [f64; 3], radius_m: f64) -> Zone {
        Zone::Aabb {
            min: [center[0] - radius_m, center[1] - radius_m, center[2] - radius_m],
            max: [center[0] + radius_m, center[1] + radius_m, center[2] + radius_m],
        }
    }
}

/// Builder returned by [`Zone`] constructors, allowing optional Z range,
/// priority, and layer to be chained before converting to a [`ZoneEntry`].
pub struct ZoneBuilder(pub Zone);

impl ZoneBuilder {
    /// Set the Z range in ENU metres relative to origin.
    pub fn with_z_range(mut self, z_min: f64, z_max: f64) -> Self {
        match &mut self.0 {
            Zone::Cylinder { z_min: z1, z_max: z2, .. } => { *z1 = z_min; *z2 = z_max; }
            Zone::ExtrudedPolygon { z_min: z1, z_max: z2, .. } => { *z1 = z_min; *z2 = z_max; }
            _ => {}
        }
        self
    }

    /// Consume the builder into a `ZoneEntry` with the given id.
    pub fn entry(self, id: u32) -> ZoneEntry {
        ZoneEntry { id, zone: self.0, priority: 0, layer: 0 }
    }

    /// Consume the builder into a `ZoneEntry` with custom priority and layer.
    pub fn entry_with_meta(self, id: u32, priority: u8, layer: u8) -> ZoneEntry {
        ZoneEntry { id, zone: self.0, priority, layer }
    }

    /// Consume the builder to get the raw `Zone`.
    pub fn build(self) -> Zone {
        self.0
    }
}

// ── Internal ENU shapes (converted once at insert) ──────────────────────────

struct AabbEnu {
    min: [f64; 3],
    max: [f64; 3],
}

struct CylinderEnu {
    cx: f64,
    cy: f64,
    r2: f64,
    z_min: f64,
    z_max: f64,
}

struct PolygonEnu {
    ring: Vec<[f64; 2]>,
    z_min: f64,
    z_max: f64,
}

/// Convex hull, with the parry3d shape built once and cached. The AABB is kept
/// alongside so an empty/degenerate hull still yields a sensible (empty) box.
struct ConvexEnu {
    hull: Option<ConvexPolyhedron>,
    aabb: [f64; 6],
}

impl ZoneShape for AabbEnu {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        (0..3).all(|i| p[i] >= self.min[i] && p[i] <= self.max[i])
    }
    fn aabb_enu(&self) -> [f64; 6] {
        [self.min[0], self.min[1], self.min[2], self.max[0], self.max[1], self.max[2]]
    }
}

impl ZoneShape for CylinderEnu {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        let dx = p[0] - self.cx;
        let dy = p[1] - self.cy;
        dx * dx + dy * dy <= self.r2 && p[2] >= self.z_min && p[2] <= self.z_max
    }
    fn aabb_enu(&self) -> [f64; 6] {
        let r = self.r2.sqrt();
        [self.cx - r, self.cy - r, self.z_min, self.cx + r, self.cy + r, self.z_max]
    }
}

impl ZoneShape for PolygonEnu {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        // Z check first — cheapest gate.
        if p[2] < self.z_min || p[2] > self.z_max {
            return false;
        }
        point_in_ring_enu(p[0], p[1], &self.ring)
    }
    fn aabb_enu(&self) -> [f64; 6] {
        let min_x = self.ring.iter().map(|v| v[0]).fold(f64::MAX, f64::min);
        let min_y = self.ring.iter().map(|v| v[1]).fold(f64::MAX, f64::min);
        let max_x = self.ring.iter().map(|v| v[0]).fold(f64::MIN, f64::max);
        let max_y = self.ring.iter().map(|v| v[1]).fold(f64::MIN, f64::max);
        [min_x, min_y, self.z_min, max_x, max_y, self.z_max]
    }
}

impl ZoneShape for ConvexEnu {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        match &self.hull {
            Some(h) => h.contains_local_point(Vector::new(p[0] as f32, p[1] as f32, p[2] as f32)),
            None => false,
        }
    }
    fn aabb_enu(&self) -> [f64; 6] {
        self.aabb
    }
}

/// Errors returned by polygon validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolygonError {
    TooFewVertices,
    NotClosed,
    SelfIntersecting,
}

/// Validate a polygon ring for `ExtrudedPolygon` construction.
///
/// Checks minimum vertex count, closure (first == last), and winding
/// order. Returns `Ok(())` if the ring is structurally valid for
/// spatial queries.
///
/// Unclosed rings are implicitly closed for validation — the first
/// vertex is appended to the check.
pub fn validate_polygon(ring: &[[f64; 2]]) -> Result<(), PolygonError> {
    if ring.len() < 3 {
        return Err(PolygonError::TooFewVertices);
    }
    // Ring is closed if first == last. Accept either form.
    let closed = ring.len() > 1
        && (ring[0][0] - ring[ring.len() - 1][0]).abs() < 1e-12
        && (ring[0][1] - ring[ring.len() - 1][1]).abs() < 1e-12;
    let n = if closed { ring.len() - 1 } else { ring.len() };
    if n < 3 {
        return Err(PolygonError::TooFewVertices);
    }
    Ok(())
}

/// Point-in-polygon by the Jordan-curve (ray-crossing) test, on already-ENU
/// coordinates. Rings with fewer than three vertices contain nothing.
#[doc(hidden)]
pub fn point_in_ring_enu(px: f64, py: f64, ring: &[[f64; 2]]) -> bool {
    let n = ring.len();
    if n < 3 {
        return false;
    }
    let mut inside = false;
    let mut j = n - 1;
    for i in 0..n {
        let (xi, yi) = (ring[i][0], ring[i][1]);
        let (xj, yj) = (ring[j][0], ring[j][1]);
        if ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
            inside = !inside;
        }
        j = i;
    }
    inside
}

/// Convert a [`Zone`] (expressed in some [`CoordSystem`]'s user space) into a
/// query-ready [`ZoneShape`] in internal metric XYZ.
///
/// Called once at insert time; all subsequent queries are pure arithmetic. The
/// `?Sized` bound lets callers pass either a concrete `&EnuConverter` or a
/// `&dyn CoordSystem`.
pub fn zone_to_shape<C: CoordSystem + ?Sized>(zone: &Zone, conv: &C) -> Box<dyn ZoneShape> {
    match zone {
        Zone::Aabb { min, max } => Box::new(AabbEnu {
            min: conv.to_internal(*min),
            max: conv.to_internal(*max),
        }),

        Zone::Cylinder { center, radius_m, z_min, z_max } => {
            let c = conv.to_internal([center[0], center[1], 0.0]);
            Box::new(CylinderEnu {
                cx: c[0],
                cy: c[1],
                r2: radius_m * radius_m,
                z_min: *z_min,
                z_max: *z_max,
            })
        }

        Zone::ExtrudedPolygon { ring, z_min, z_max } => {
            let enu_ring = ring
                .iter()
                .map(|v| {
                    let e = conv.to_internal([v[0], v[1], 0.0]);
                    [e[0], e[1]]
                })
                .collect();
            Box::new(PolygonEnu { ring: enu_ring, z_min: *z_min, z_max: *z_max })
        }

        Zone::ConvexHull { vertices } => {
            let pts: Vec<Vector> = vertices
                .iter()
                .map(|v| {
                    let e = conv.to_internal(*v);
                    Vector::new(e[0] as f32, e[1] as f32, e[2] as f32)
                })
                .collect();

            // Bounding box from the raw ENU points — independent of whether the
            // hull build succeeds.
            let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);
            for v in &pts {
                let arr = [v.x as f64, v.y as f64, v.z as f64];
                for i in 0..3 {
                    lo[i] = lo[i].min(arr[i]);
                    hi[i] = hi[i].max(arr[i]);
                }
            }
            let aabb = if pts.is_empty() {
                [0.0; 6]
            } else {
                [lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]]
            };

            Box::new(ConvexEnu { hull: ConvexPolyhedron::from_convex_hull(&pts), aabb })
        }
    }
}

// ── Built-in custom shapes ──────────────────────────────────────────────────

/// A cylinder that shrinks over time — e.g. a battle-royale safe zone. The
/// radius is shared and updated externally (millimetre fixed-point).
pub struct ShrinkingZone {
    pub cx: f64,
    pub cy: f64,
    pub z_min: f64,
    pub z_max: f64,
    pub radius: Arc<AtomicU32>, // metres × 1000
}

impl ShrinkingZone {
    pub fn set_radius(&self, r: f64) {
        self.radius.store((r * 1000.0) as u32, Ordering::Relaxed);
    }
    fn r(&self) -> f64 {
        self.radius.load(Ordering::Relaxed) as f64 / 1000.0
    }
}

impl ZoneShape for ShrinkingZone {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        let r = self.r();
        let dx = p[0] - self.cx;
        let dy = p[1] - self.cy;
        dx * dx + dy * dy <= r * r && p[2] >= self.z_min && p[2] <= self.z_max
    }
    fn aabb_enu(&self) -> [f64; 6] {
        let r = self.r();
        [self.cx - r, self.cy - r, self.z_min, self.cx + r, self.cy + r, self.z_max]
    }
}

/// A box/cylinder attached to a moving entity, tracking its ENU position.
pub struct FollowZone {
    pub pos: Arc<RwLock<[f64; 3]>>,
    pub radius_m: f64,
    pub half_h: f64,
}

impl ZoneShape for FollowZone {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        let t = *self.pos.read().unwrap();
        let dx = p[0] - t[0];
        let dy = p[1] - t[1];
        dx * dx + dy * dy <= self.radius_m * self.radius_m && (p[2] - t[2]).abs() <= self.half_h
    }
    fn aabb_enu(&self) -> [f64; 6] {
        let t = *self.pos.read().unwrap();
        let r = self.radius_m;
        let h = self.half_h;
        [t[0] - r, t[1] - r, t[2] - h, t[0] + r, t[1] + r, t[2] + h]
    }
}

/// Union of several shapes — a point is inside if it is inside any part.
pub struct UnionZone {
    pub parts: Vec<Box<dyn ZoneShape>>,
}

impl ZoneShape for UnionZone {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        self.parts.iter().any(|z| z.contains_enu(p))
    }
    fn aabb_enu(&self) -> [f64; 6] {
        self.parts.iter().map(|z| z.aabb_enu()).fold(
            [f64::MAX, f64::MAX, f64::MAX, f64::MIN, f64::MIN, f64::MIN],
            |a, b| {
                [
                    a[0].min(b[0]),
                    a[1].min(b[1]),
                    a[2].min(b[2]),
                    a[3].max(b[3]),
                    a[4].max(b[4]),
                    a[5].max(b[5]),
                ]
            },
        )
    }
}

/// Closure-backed shape — handy for prototyping. Local-only: not serializable.
/// The caller supplies the bounding box manually.
pub struct LambdaZone<F: Fn([f64; 3]) -> bool + Send + Sync> {
    pub f: F,
    pub aabb: [f64; 6],
}

impl<F: Fn([f64; 3]) -> bool + Send + Sync> ZoneShape for LambdaZone<F> {
    fn contains_enu(&self, p: [f64; 3]) -> bool {
        (self.f)(p)
    }
    fn aabb_enu(&self) -> [f64; 6] {
        self.aabb
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::coord::EnuConverter;

    fn origin_conv() -> EnuConverter {
        EnuConverter::new(0.0, 0.0, 0.0)
    }

    #[test]
    fn cylinder_contains_and_excludes() {
        let conv = origin_conv();
        let shape = zone_to_shape(
            &Zone::Cylinder { center: [0.0, 0.0], radius_m: 10.0, z_min: 0.0, z_max: 5.0 },
            &conv,
        );
        assert!(shape.contains_enu([0.0, 0.0, 2.5]));
        assert!(shape.contains_enu([9.9, 0.0, 0.0]));
        assert!(!shape.contains_enu([10.1, 0.0, 2.5]), "outside radius");
        assert!(!shape.contains_enu([0.0, 0.0, 6.0]), "above z_max");
    }

    #[test]
    fn polygon_ray_crossing() {
        // A unit square in ENU via a direct ring (origin converter ≈ identity in
        // the small-area limit).
        let poly = PolygonEnu {
            ring: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]],
            z_min: 0.0,
            z_max: 3.0,
        };
        assert!(poly.contains_enu([5.0, 5.0, 1.0]));
        assert!(!poly.contains_enu([11.0, 5.0, 1.0]));
        assert!(!poly.contains_enu([5.0, 5.0, 9.0]), "z out of range");
    }

    #[test]
    fn degenerate_ring_contains_nothing() {
        assert!(!point_in_ring_enu(0.0, 0.0, &[]));
        assert!(!point_in_ring_enu(0.0, 0.0, &[[0.0, 0.0], [1.0, 1.0]]));
    }

    #[test]
    fn convex_hull_tetrahedron() {
        let conv = origin_conv();
        // A tetrahedron expressed directly in ENU-ish small coords. We build it
        // through the AABB variant of the API by using ConvexHull with vertices
        // whose geodetic→ENU mapping near the origin is ≈ metres.
        let shape = zone_to_shape(
            &Zone::ConvexHull {
                vertices: vec![[0.0, 0.0, 0.0], [0.0, 0.001, 0.0], [0.001, 0.0, 0.0], [0.0, 0.0, 50.0]],
            },
            &conv,
        );
        // Centroid-ish interior point should be inside; a far point outside.
        let bb = shape.aabb_enu();
        assert!(bb[3] > bb[0] && bb[4] > bb[1] && bb[5] > bb[2], "non-degenerate aabb: {bb:?}");
        assert!(!shape.contains_enu([1000.0, 1000.0, 1000.0]), "far point outside");
    }

    #[test]
    fn union_zone_is_or() {
        let a: Box<dyn ZoneShape> = Box::new(AabbEnu { min: [0.0; 3], max: [1.0, 1.0, 1.0] });
        let b: Box<dyn ZoneShape> = Box::new(AabbEnu { min: [5.0, 5.0, 5.0], max: [6.0, 6.0, 6.0] });
        let u = UnionZone { parts: vec![a, b] };
        assert!(u.contains_enu([0.5, 0.5, 0.5]));
        assert!(u.contains_enu([5.5, 5.5, 5.5]));
        assert!(!u.contains_enu([3.0, 3.0, 3.0]));
        let bb = u.aabb_enu();
        assert_eq!(bb, [0.0, 0.0, 0.0, 6.0, 6.0, 6.0]);
    }

    #[test]
    fn shrinking_zone_tracks_radius() {
        let z = ShrinkingZone {
            cx: 0.0,
            cy: 0.0,
            z_min: 0.0,
            z_max: 10.0,
            radius: Arc::new(AtomicU32::new(5000)), // 5 m
        };
        assert!(z.contains_enu([4.0, 0.0, 1.0]));
        z.set_radius(2.0);
        assert!(!z.contains_enu([4.0, 0.0, 1.0]));
        assert!(z.contains_enu([1.0, 0.0, 1.0]));
    }

    #[test]
    fn zone_entry_serde_round_trip() {
    let entry = ZoneEntry::new(
        42,
        Zone::Cylinder { center: [10.7, 106.6], radius_m: 50.0, z_min: 0.0, z_max: 20.0 },
    );
        let json = serde_json::to_string(&entry).unwrap();
        let back: ZoneEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(entry, back);
    }
}