The file /root/traccar-geocoder/server/src/main.rs has been updated successfully.

mod auth;

use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use memmap2::Mmap;
use s2::cellid::CellID;
use s2::latlng::LatLng;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::fs::File;
use std::sync::{Arc, RwLock};

// --- S2 helpers ---

const DEFAULT_STREET_CELL_LEVEL: u64 = 17;
const DEFAULT_ADMIN_CELL_LEVEL: u64 = 10;
const DEFAULT_SEARCH_DISTANCE: f64 = 75.0;

fn cell_id_at_level(lat: f64, lng: f64, level: u64) -> u64 {
    let ll = LatLng::from_degrees(lat, lng);
    CellID::from(ll).parent(level).0
}

fn cell_neighbors_at_level(cell_id: u64, level: u64) -> Vec<u64> {
    let cell = CellID(cell_id);
    cell.all_neighbors(level).into_iter().map(|c| c.0).collect()
}

// --- Binary format structs (must match C++ build pipeline) ---

#[repr(C)]
#[derive(Clone, Copy)]
struct WayHeader {
    node_offset: u32,
    node_count: u8,
    name_id: u32,
}

#[repr(C)]
#[derive(Clone, Copy)]
struct AddrPoint {
    lat: f32,
    lng: f32,
    housenumber_id: u32,
    street_id: u32,
}

#[repr(C)]
#[derive(Clone, Copy)]
struct InterpWay {
    node_offset: u32,
    node_count: u8,
    street_id: u32,
    start_number: u32,
    end_number: u32,
    interpolation: u8,
}

#[repr(C)]
#[derive(Clone, Copy)]
struct AdminPolygon {
    vertex_offset: u32,
    vertex_count: u16,
    name_id: u32,
    admin_level: u8,
    area: f32,
    country_code: u16,
}

#[repr(C)]
#[derive(Clone, Copy)]
struct NodeCoord {
    lat: f32,
    lng: f32,
}

// --- Index data ---

struct Index {
    geo_cells: Option<Mmap>,
    street_entries: Option<Mmap>,
    street_ways: Option<Mmap>,
    street_nodes: Option<Mmap>,
    addr_entries: Option<Mmap>,
    addr_points: Option<Mmap>,
    interp_entries: Option<Mmap>,
    interp_ways: Option<Mmap>,
    interp_nodes: Option<Mmap>,
    admin_cells: Mmap,
    admin_entries: Mmap,
    admin_polygons: Mmap,
    admin_vertices: Mmap,
    strings: Mmap,
    street_cell_level: u64,
    admin_cell_level: u64,
    max_distance_sq: f64,
}

const NO_DATA: u32 = 0xFFFFFFFF;

struct GeoCellOffsets {
    street: u32,
    addr: u32,
    interp: u32,
}

fn mmap_file(path: &str) -> Result<Mmap, String> {
    let file = File::open(path).map_err(|e| format!("Failed to open {}: {}", path, e))?;
    unsafe { Mmap::map(&file).map_err(|e| format!("Failed to mmap {}: {}", path, e)) }
}

fn mmap_file_optional(path: &str) -> Option<Mmap> {
    let file = File::open(path).ok()?;
    unsafe { Mmap::map(&file).ok() }
}

impl Index {
    fn load(dir: &str, street_cell_level: u64, admin_cell_level: u64, search_distance: f64) -> Result<Self, String> {
        let meters_to_rad = search_distance / 111_320.0;
        let max_distance_sq = meters_to_rad * meters_to_rad;

        let geo_cells = mmap_file_optional(&format!("{}/geo_cells.bin", dir));
        let has_geo = geo_cells.is_some();

        let required_geo = |name: &str| -> Result<Option<Mmap>, String> {
            let path = format!("{}/{}", dir, name);
            let mmap = mmap_file_optional(&path);
            if has_geo && mmap.is_none() {
                return Err(format!("Partial geo index: {} is missing", name));
            }
            Ok(mmap)
        };

        Ok(Index {
            geo_cells,
            street_entries: required_geo("street_entries.bin")?,
            street_ways: required_geo("street_ways.bin")?,
            street_nodes: required_geo("street_nodes.bin")?,
            addr_entries: mmap_file_optional(&format!("{}/addr_entries.bin", dir)),
            addr_points: mmap_file_optional(&format!("{}/addr_points.bin", dir)),
            interp_entries: mmap_file_optional(&format!("{}/interp_entries.bin", dir)),
            interp_ways: mmap_file_optional(&format!("{}/interp_ways.bin", dir)),
            interp_nodes: mmap_file_optional(&format!("{}/interp_nodes.bin", dir)),
            admin_cells: mmap_file(&format!("{}/admin_cells.bin", dir))?,
            admin_entries: mmap_file(&format!("{}/admin_entries.bin", dir))?,
            admin_polygons: mmap_file(&format!("{}/admin_polygons.bin", dir))?,
            admin_vertices: mmap_file(&format!("{}/admin_vertices.bin", dir))?,
            strings: mmap_file(&format!("{}/strings.bin", dir))?,
            street_cell_level,
            admin_cell_level,
            max_distance_sq,
        })
    }

    fn get_string(&self, offset: u32) -> &str {
        let bytes = &self.strings[offset as usize..];
        let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
        std::str::from_utf8(&bytes[..end]).unwrap_or("")
    }

    fn read_u16(data: &[u8], offset: usize) -> u16 {
        u16::from_le_bytes([data[offset], data[offset + 1]])
    }

    fn read_u32(data: &[u8], offset: usize) -> u32 {
        u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap())
    }

    fn read_u64(data: &[u8], offset: usize) -> u64 {
        u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap())
    }

    // Iterate entry IDs inline from entries file at given offset
    fn for_each_entry(entries: &[u8], offset: u32, mut f: impl FnMut(u32)) {
        if offset == NO_DATA { return; }
        let offset = offset as usize;
        if offset + 2 > entries.len() { return; }

        let id_count = Self::read_u16(entries, offset) as usize;
        let data_start = offset + 2;
        if data_start + id_count * 4 > entries.len() { return; }

        for i in 0..id_count {
            f(Self::read_u32(entries, data_start + i * 4));
        }
    }

    // Binary search geo cell index: 20 bytes per entry (u64 cell_id + u32 street + u32 addr + u32 interp)
    fn lookup_geo_cell(cells: &[u8], cell_id: u64) -> GeoCellOffsets {
        let entry_size: usize = 20;
        let count = cells.len() / entry_size;
        let empty = GeoCellOffsets { street: NO_DATA, addr: NO_DATA, interp: NO_DATA };
        if count == 0 { return empty; }

        let mut lo = 0usize;
        let mut hi = count;
        while lo < hi {
            let mid = lo + (hi - lo) / 2;
            let mid_id = Self::read_u64(cells, mid * entry_size);
            if mid_id == cell_id {
                return GeoCellOffsets {
                    street: Self::read_u32(cells, mid * entry_size + 8),
                    addr: Self::read_u32(cells, mid * entry_size + 12),
                    interp: Self::read_u32(cells, mid * entry_size + 16),
                };
            } else if mid_id < cell_id {
                lo = mid + 1;
            } else {
                hi = mid;
            }
        }
        empty
    }

    // Binary search admin cell index: 12 bytes per entry (u64 cell_id + u32 offset)
    fn lookup_admin_cell(cells: &[u8], cell_id: u64) -> u32 {
        let entry_size: usize = 12;
        let count = cells.len() / entry_size;
        if count == 0 { return NO_DATA; }

        let mut lo = 0usize;
        let mut hi = count;
        while lo < hi {
            let mid = lo + (hi - lo) / 2;
            let mid_id = Self::read_u64(cells, mid * entry_size);
            if mid_id == cell_id {
                return Self::read_u32(cells, mid * entry_size + 8);
            } else if mid_id < cell_id {
                lo = mid + 1;
            } else {
                hi = mid;
            }
        }
        NO_DATA
    }

    // --- Geo lookup (streets, addresses, interpolation from merged index) ---

    fn query_geo(&self, lat: f64, lng: f64) -> (Option<(f64, &AddrPoint)>, Option<(f64, &str, u32)>, Option<(f64, &WayHeader)>) {
        let geo_cells = match &self.geo_cells {
            Some(gc) => gc,
            None => return (None, None, None),
        };
        let street_entries = self.street_entries.as_ref().unwrap();
        let street_ways_mmap = self.street_ways.as_ref().unwrap();
        let street_nodes_mmap = self.street_nodes.as_ref().unwrap();
        let cell = cell_id_at_level(lat, lng, self.street_cell_level);
        let neighbors = cell_neighbors_at_level(cell, self.street_cell_level);

        let empty_addr: &[AddrPoint] = &[];
        let all_points: &[AddrPoint] = if let Some(ref m) = self.addr_points {
            unsafe {
                std::slice::from_raw_parts(
                    m.as_ptr() as *const AddrPoint,
                    m.len() / std::mem::size_of::<AddrPoint>(),
                )
            }
        } else {
            empty_addr
        };
        let all_ways: &[WayHeader] = unsafe {
            std::slice::from_raw_parts(
                street_ways_mmap.as_ptr() as *const WayHeader,
                street_ways_mmap.len() / std::mem::size_of::<WayHeader>(),
            )
        };
        let all_street_nodes: &[NodeCoord] = unsafe {
            std::slice::from_raw_parts(
                street_nodes_mmap.as_ptr() as *const NodeCoord,
                street_nodes_mmap.len() / std::mem::size_of::<NodeCoord>(),
            )
        };
        let empty_interp: &[InterpWay] = &[];
        let all_interps: &[InterpWay] = if let Some(ref m) = self.interp_ways {
            unsafe {
                std::slice::from_raw_parts(
                    m.as_ptr() as *const InterpWay,
                    m.len() / std::mem::size_of::<InterpWay>(),
                )
            }
        } else {
            empty_interp
        };
        let empty_interp_nodes: &[NodeCoord] = &[];
        let all_interp_nodes: &[NodeCoord] = if let Some(ref m) = self.interp_nodes {
            unsafe {
                std::slice::from_raw_parts(
                    m.as_ptr() as *const NodeCoord,
                    m.len() / std::mem::size_of::<NodeCoord>(),
                )
            }
        } else {
            empty_interp_nodes
        };

        let cos_lat = lat.to_radians().cos();

        let mut best_addr_dist = f64::MAX;
        let mut best_addr: Option<&AddrPoint> = None;
        let mut best_street_dist = f64::MAX;
        let mut best_street: Option<&WayHeader> = None;
        let mut best_interp_dist = f64::MAX;
        let mut best_interp: Option<&InterpWay> = None;
        let mut best_interp_t: f64 = 0.0;

        // Fixed-size hash set on stack to skip duplicate street IDs across cells
        let mut seen_streets: [u32; 64] = [u32::MAX; 64];

        for c in std::iter::once(cell).chain(neighbors.into_iter()) {
            let offsets = Self::lookup_geo_cell(geo_cells, c);

            // Addresses
            if let Some(ref addr_entries) = self.addr_entries {
                Self::for_each_entry(addr_entries, offsets.addr, |id| {
                    let point = &all_points[id as usize];
                    let dlat = (point.lat as f64 - lat).to_radians();
                    let dlng = (point.lng as f64 - lng).to_radians();
                    let dist = dist_sq(dlat, dlng, cos_lat);
                    if dist < best_addr_dist {
                        best_addr_dist = dist;
                        best_addr = Some(point);
                    }
                });
            }

            // Streets
            Self::for_each_entry(street_entries, offsets.street, |id| {
                let slot = (id as usize) & 0x3F;
                if seen_streets[slot] == id { return; }
                seen_streets[slot] = id;

                let way = &all_ways[id as usize];
                let offset = way.node_offset as usize;
                let count = way.node_count as usize;
                let nodes = &all_street_nodes[offset..offset + count];

                for i in 0..nodes.len() - 1 {
                    let dist = point_to_segment_distance(
                        lat, lng,
                        nodes[i].lat as f64, nodes[i].lng as f64,
                        nodes[i + 1].lat as f64, nodes[i + 1].lng as f64,
                        cos_lat,
                    );
                    if dist < best_street_dist {
                        best_street_dist = dist;
                        best_street = Some(way);
                    }
                }
            });

            // Interpolation
            if let Some(ref interp_entries) = self.interp_entries {
                Self::for_each_entry(interp_entries, offsets.interp, |id| {
                    let iw = &all_interps[id as usize];
                    if iw.start_number == 0 || iw.end_number == 0 { return; }

                    let offset = iw.node_offset as usize;
                    let count = iw.node_count as usize;
                    let nodes = &all_interp_nodes[offset..offset + count];

                    let mut total_len: f64 = 0.0;
                    for i in 0..nodes.len() - 1 {
                        let dlat = (nodes[i + 1].lat as f64 - nodes[i].lat as f64).to_radians();
                        let dlng = (nodes[i + 1].lng as f64 - nodes[i].lng as f64).to_radians();
                        total_len += dist_sq(dlat, dlng, cos_lat);
                    }
                    if total_len == 0.0 { return; }

                    let mut best_seg_dist = f64::MAX;
                    let mut best_seg_t: f64 = 0.0;
                    let mut prev_accumulated: f64 = 0.0;

                    for i in 0..nodes.len() - 1 {
                        let dlat = (nodes[i + 1].lat as f64 - nodes[i].lat as f64).to_radians();
                        let dlng = (nodes[i + 1].lng as f64 - nodes[i].lng as f64).to_radians();
                        let seg_len = dist_sq(dlat, dlng, cos_lat);
                        let (dist, seg_t) = point_to_segment_with_t(
                            lat, lng,
                            nodes[i].lat as f64, nodes[i].lng as f64,
                            nodes[i + 1].lat as f64, nodes[i + 1].lng as f64,
                            cos_lat,
                        );
                        if dist < best_seg_dist {
                            best_seg_dist = dist;
                            best_seg_t = (prev_accumulated + seg_t * seg_len) / total_len;
                        }
                        prev_accumulated += seg_len;
                    }

                    if best_seg_dist < best_interp_dist {
                        best_interp_dist = best_seg_dist;
                        best_interp = Some(iw);
                        best_interp_t = best_seg_t;
                    }
                });
            }
        }

        let addr_result = best_addr.map(|p| (best_addr_dist, p));
        let street_result = best_street.map(|w| (best_street_dist, w));
        let interp_result = best_interp.map(|iw| {
            let start = iw.start_number as f64;
            let end = iw.end_number as f64;
            let raw = start + best_interp_t * (end - start);

            let step: u32 = match iw.interpolation {
                1 | 2 => 2,
                _ => 1,
            };

            let number = if step == 2 {
                let base = iw.start_number;
                let offset = ((raw - base as f64) / step as f64).round() as u32 * step;
                base + offset
            } else {
                raw.round() as u32
            };

            (best_interp_dist, self.get_string(iw.street_id), number)
        });

        (addr_result, interp_result, street_result)
    }

    // --- Admin boundary lookup (point-in-polygon) ---

    fn find_admin(&self, lat: f64, lng: f64) -> AdminResult<'_> {
        let cell = cell_id_at_level(lat, lng, self.admin_cell_level);
        let neighbors = cell_neighbors_at_level(cell, self.admin_cell_level);

        let all_polygons: &[AdminPolygon] = unsafe {
            std::slice::from_raw_parts(
                self.admin_polygons.as_ptr() as *const AdminPolygon,
                self.admin_polygons.len() / std::mem::size_of::<AdminPolygon>(),
            )
        };
        let all_vertices: &[NodeCoord] = unsafe {
            std::slice::from_raw_parts(
                self.admin_vertices.as_ptr() as *const NodeCoord,
                self.admin_vertices.len() / std::mem::size_of::<NodeCoord>(),
            )
        };

        // For each admin level, find the smallest-area polygon containing the point
        let mut best_by_level: [Option<(f32, &AdminPolygon)>; 12] = [None; 12];

        const INTERIOR_FLAG: u32 = 0x80000000;
        const ID_MASK: u32 = 0x7FFFFFFF;

        for c in std::iter::once(cell).chain(neighbors.into_iter()) {
            Self::for_each_entry(&self.admin_entries, Self::lookup_admin_cell(&self.admin_cells, c), |id| {
                let is_interior = (id & INTERIOR_FLAG) != 0;
                let poly_id = (id & ID_MASK) as usize;
                let poly = &all_polygons[poly_id];
                let level = poly.admin_level as usize;
                if level >= 12 { return; }

                // Skip if we already have a smaller polygon at this level
                if let Some((best_area, _)) = best_by_level[level] {
                    if poly.area >= best_area { return; }
                }

                // Interior cells skip point-in-polygon test
                if is_interior || point_in_polygon(lat as f32, lng as f32, {
                    let offset = poly.vertex_offset as usize;
                    let count = poly.vertex_count as usize;
                    &all_vertices[offset..offset + count]
                }) {
                    best_by_level[level] = Some((poly.area, poly));
                }
            });
        }

        let mut result = AdminResult::default();

        for level in 0..12 {
            if let Some((_, poly)) = best_by_level[level] {
                let name = self.get_string(poly.name_id);
                match poly.admin_level {
                    2 => {
                        result.country = Some(name);
                        if poly.country_code != 0 {
                            result.country_code = Some([
                                (poly.country_code >> 8) as u8,
                                (poly.country_code & 0xFF) as u8,
                            ]);
                        }
                    }
                    4 => result.state = Some(name),
                    6 => result.county = Some(name),
                    8 => result.city = Some(name),
                    11 => result.postcode = Some(name),
                    _ => {}
                }
            }
        }

        result
    }

    // --- Combined query ---

    fn query(&self, lat: f64, lng: f64) -> Address<'_> {
        let max_dist = self.max_distance_sq;

        let admin = self.find_admin(lat, lng);
        let (addr, interp, street) = self.query_geo(lat, lng);

        // Determine house_number and road from best geo match (priority: address > interpolation > street)
        let mut house_number: Option<Cow<'_, str>> = None;
        let mut road: Option<&str> = None;

        if let Some((dist, point)) = addr {
            if dist < max_dist {
                house_number = Some(Cow::Borrowed(self.get_string(point.housenumber_id)));
                road = Some(self.get_string(point.street_id));
            }
        }
        if road.is_none() {
            if let Some((dist, street_name, number)) = interp {
                if dist < max_dist {
                    house_number = Some(Cow::Owned(number.to_string()));
                    road = Some(street_name);
                }
            }
        }
        if road.is_none() {
            if let Some((dist, way)) = street {
                if dist < max_dist {
                    road = Some(self.get_string(way.name_id));
                }
            }
        }

        if road.is_none() && admin.country.is_none() && admin.city.is_none() && admin.state.is_none() {
            return Address::default();
        }

        let address = AddressDetails {
            house_number,
            road,
            city: admin.city,
            state: admin.state,
            county: admin.county,
            postcode: admin.postcode,
            country: admin.country,
            country_code: admin.country_code.map(|c| String::from_utf8_lossy(&c).into_owned()),
        };
        let display_name = format_address(&address);
        Address { display_name, address }
    }
}

// --- Geometry helpers ---

fn dist_sq(dlat: f64, dlng: f64, cos_lat: f64) -> f64 {
    dlat * dlat + dlng * dlng * cos_lat * cos_lat
}

fn point_to_segment_with_t(
    px: f64, py: f64,
    ax: f64, ay: f64,
    bx: f64, by: f64,
    cos_lat: f64,
) -> (f64, f64) {
    let dx = bx - ax;
    let dy = by - ay;
    let len_sq = dx * dx + dy * dy;

    let t = if len_sq == 0.0 {
        0.0
    } else {
        (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0)
    };

    let proj_x = ax + t * dx;
    let proj_y = ay + t * dy;
    let dlat = (px - proj_x).to_radians();
    let dlng = (py - proj_y).to_radians();
    (dist_sq(dlat, dlng, cos_lat), t)
}

fn point_to_segment_distance(
    px: f64, py: f64,
    ax: f64, ay: f64,
    bx: f64, by: f64,
    cos_lat: f64,
) -> f64 {
    point_to_segment_with_t(px, py, ax, ay, bx, by, cos_lat).0
}

// Ray casting point-in-polygon test
fn point_in_polygon(lat: f32, lng: f32, vertices: &[NodeCoord]) -> bool {
    let mut inside = false;
    let n = vertices.len();
    let mut j = n - 1;

    for i in 0..n {
        let vi = &vertices[i];
        let vj = &vertices[j];

        if ((vi.lng > lng) != (vj.lng > lng))
            && (lat < (vj.lat - vi.lat) * (lng - vi.lng) / (vj.lng - vi.lng) + vi.lat)
        {
            inside = !inside;
        }
        j = i;
    }

    inside
}

// --- API types ---

#[derive(Default)]
struct AdminResult<'a> {
    country: Option<&'a str>,
    country_code: Option<[u8; 2]>,
    state: Option<&'a str>,
    county: Option<&'a str>,
    city: Option<&'a str>,
    postcode: Option<&'a str>,
}

#[derive(Serialize, Default)]
struct AddressDetails<'a> {
    #[serde(skip_serializing_if = "Option::is_none")]
    house_number: Option<Cow<'a, str>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    road: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    city: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    state: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    county: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    postcode: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    country: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    country_code: Option<String>,
}

#[derive(Serialize, Default)]
struct Address<'a> {
    #[serde(skip_serializing_if = "Option::is_none")]
    display_name: Option<String>,
    address: AddressDetails<'a>,
}

// Address formatting patterns by country code
// Returns (number_after_street, postcode_before_city, include_state)
fn format_rules(country_code: Option<&str>) -> (bool, bool, bool) {
    match country_code {
        // Number before street, postcode after city, include state
        Some("US") | Some("CA") | Some("AU") | Some("NZ")
        | Some("GB") | Some("IE") | Some("ZA") | Some("IN")
        | Some("NG") | Some("KE") | Some("GH") | Some("PK")
        | Some("PH") | Some("TH") | Some("MY") => (false, false, true),

        // Number before street, postcode before city, include state
        Some("JP") | Some("KR") | Some("CN") | Some("TW") => (false, true, true),

        // Number after street, postcode before city, no state (most of Europe, etc.)
        _ => (true, true, false),
    }
}

fn format_address(addr: &AddressDetails<'_>) -> Option<String> {
    if addr.road.is_none() && addr.city.is_none() && addr.country.is_none() {
        return None;
    }

    let (number_after, postcode_before_city, include_state) = format_rules(addr.country_code.as_deref());
    let mut parts: Vec<String> = Vec::new();

    // Street + house number
    if let Some(road) = addr.road {
        if let Some(ref hn) = addr.house_number {
            if number_after {
                parts.push(format!("{} {}", road, hn));
            } else {
                parts.push(format!("{} {}", hn, road));
            }
        } else {
            parts.push(road.to_string());
        }
    }

    // City + postcode + state
    if postcode_before_city {
        let mut city_part = String::new();
        if let Some(pc) = addr.postcode {
            city_part.push_str(pc);
            city_part.push(' ');
        }
        if let Some(city) = addr.city {
            city_part.push_str(city);
        }
        if include_state {
            if let Some(state) = addr.state {
                if !city_part.is_empty() { city_part.push_str(", "); }
                city_part.push_str(state);
            }
        }
        if !city_part.is_empty() {
            parts.push(city_part.trim().to_string());
        }
    } else {
        let mut city_part = String::new();
        if let Some(city) = addr.city {
            city_part.push_str(city);
        }
        if include_state {
            if let Some(state) = addr.state {
                if !city_part.is_empty() { city_part.push_str(", "); }
                city_part.push_str(state);
            }
        }
        if let Some(pc) = addr.postcode {
            if !city_part.is_empty() { city_part.push(' '); }
            city_part.push_str(pc);
        }
        if !city_part.is_empty() {
            parts.push(city_part);
        }
    }

    // Country
    if let Some(country) = addr.country {
        parts.push(country.to_string());
    }

    if parts.is_empty() { None } else { Some(parts.join(", ")) }
}

#[derive(Deserialize)]
struct QueryParams {
    lat: f64,
    lon: f64,
    key: Option<String>,
}

async fn reverse_geocode(
    Query(params): Query<QueryParams>,
    state: axum::extract::State<Arc<RwLock<auth::Db>>>,
    index: axum::extract::Extension<Arc<Index>>,
    limiter: axum::extract::Extension<Arc<auth::RateLimiter>>,
    connect_info: axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> Response {
    let key = match params.key {
        Some(k) => k,
        None => return (StatusCode::UNAUTHORIZED, "Missing API key").into_response(),
    };

    let (login, rps, rpd, by_ip) = match state.read().unwrap().validate_token(&key) {
        Some(info) => info,
        None => return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response(),
    };

    let rate_key = if by_ip {
        format!("{}:{}", login, connect_info.0.ip())
    } else {
        login
    };

    if let Err(msg) = auth::check_rate(&limiter, &rate_key, rps, rpd) {
        return (StatusCode::TOO_MANY_REQUESTS, msg).into_response();
    }

    let address = index.query(params.lat, params.lon);
    let json = serde_json::to_string(&address).unwrap_or_default();
    ([(axum::http::header::CONTENT_TYPE, "application/json")], json).into_response()
}

#[tokio::main]
async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let data_dir = args.get(1).map(|s| s.as_str()).unwrap_or(".");

    let arg_value = |flag: &str| -> Option<&String> {
        args.iter().position(|a| a == flag).and_then(|p| args.get(p + 1))
    };
    let street_cell_level = arg_value("--street-level").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_STREET_CELL_LEVEL);
    let admin_cell_level = arg_value("--admin-level").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_ADMIN_CELL_LEVEL);
    let search_distance = arg_value("--search-distance").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_SEARCH_DISTANCE);

    let db_path = format!("{}/geocoder.json", data_dir);
    let db = auth::Db::load(&db_path);

    eprintln!("Loading index from {}...", data_dir);
    let index = match Index::load(data_dir, street_cell_level, admin_cell_level, search_distance) {
        Ok(idx) => {
            if idx.geo_cells.is_some() {
                if idx.addr_points.is_some() {
                    eprintln!("Loaded full index (admin + geo + addresses)");
                } else {
                    eprintln!("Loaded streets index (admin + geo, no addresses)");
                }
            } else {
                eprintln!("Loaded admin-only index (geo files not found)");
            }
            Arc::new(idx)
        }
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(1);
        }
    };

    let db = Arc::new(RwLock::new(db));
    let limiter = Arc::new(auth::RateLimiter::default()); // RwLock<HashMap> with atomic counters

    let app = Router::new()
        .route("/reverse", get(reverse_geocode))
        .merge(auth::router())
        .layer(axum::Extension(index))
        .layer(axum::Extension(limiter))
        .with_state(db);

    // ACME mode: --domain <domain> [--cache <dir>]
    let domain_pos = args.iter().position(|a| a == "--domain");
    if let Some(pos) = domain_pos {
        let domain = args.get(pos + 1).expect("--domain requires a value").clone();
        let cache_dir = args.iter().position(|a| a == "--cache")
            .and_then(|p| args.get(p + 1).cloned())
            .unwrap_or_else(|| "acme-cache".to_string());

        use rustls_acme::caches::DirCache;
        use rustls_acme::AcmeConfig;
        use tokio_stream::StreamExt;

        let mut state = AcmeConfig::new([domain.clone()])
            .cache(DirCache::new(cache_dir.clone()))
            .directory_lets_encrypt(true)
            .state();
        let acceptor = state.axum_acceptor(state.default_rustls_config());

        tokio::spawn(async move {
            loop {
                match state.next().await {
                    Some(Ok(ok)) => eprintln!("ACME event: {:?}", ok),
                    Some(Err(err)) => eprintln!("ACME error: {:?}", err),
                    None => break,
                }
            }
        });

        let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 443));
        eprintln!("Starting HTTPS server on :443 for {}...", domain);
        axum_server::bind(addr)
            .acceptor(acceptor)
            .serve(app.into_make_service_with_connect_info::<std::net::SocketAddr>())
            .await
            .unwrap();
    } else {
        let bind_addr = args.get(2).map(|s| s.as_str()).unwrap_or("0.0.0.0:3000");
        eprintln!("Starting HTTP server on {}...", bind_addr);
        let listener = tokio::net::TcpListener::bind(bind_addr).await.unwrap();
        axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await.unwrap();
    }
}

#[derive(Clone, Copy)]
struct AdminPolygon {
vertex_offset: u32,
-    vertex_count: u16,
+    vertex_count: u32,
name_id: u32,
admin_level: u8,
area: f32,


Now update the comparison tool and its static_assert:


    11→
    12→struct AdminPolygon {
    13→    uint32_t vertex_offset;
    14→    uint16_t vertex_count;
    15→    char _pad1[2];
    16→    uint32_t name_id;
    17→    uint8_t admin_level;
    18→    char _pad2[3];
    19→    float area;
    20→    uint16_t country_code;
    21→    char _pad3[2];
    22→};

struct AdminPolygon {
    uint32_t vertex_offset;
    uint16_t vertex_count;
    char _pad1[2];
    uint32_t name_id;
    uint8_t admin_level;
    char _pad2[3];
    float area;
    uint16_t country_code;
    char _pad3[2];
};


Edit

struct AdminPolygon {
    uint32_t vertex_offset;
    uint16_t vertex_count;
    char _pad1[2];
    uint32_t name_id;
    uint8_t admin_level;
    char _pad2[3];
    float area;
    uint16_t country_count;
    char _pad3[2];
};
static_assert(sizeof(AdminPolygon) == 24);

struct AdminPolygon {
    uint32_t vertex_offset;
    uint32_t vertex_count;
    uint32_t name_id;
    uint8_t admin_level;
    char _pad1[3];
    float area;
    uint16_t country_code;
    char _pad2[2];
};
static_assert(sizeof(AdminPolygon) == 24);


<tool_use_error>String to replace not found in file.
String: struct AdminPolygon {
    uint32_t vertex_offset;
    uint16_t vertex_count;
    char _pad1[2];
    uint32_t name_id;
    uint8_t admin_level;
    char _pad2[3];
    float area;
    uint16_t country_count;
    char _pad3[2];
};
static_assert(sizeof(AdminPolygon) == 24);</tool_use_error>

Error: String to replace not found in file.
String: struct AdminPolygon {
    uint32_t vertex_offset;
    uint16_t vertex_count;
    char _pad1[2];
    uint32_t name_id;
    uint8_t admin_level;
    char _pad2[3];
    float area;
    uint16_t country_count;
    char _pad3[2];
};
static_assert(sizeof(AdminPolygon) == 24);


struct AdminPolygon {
    uint32_t vertex_offset;
    uint16_t vertex_count;
    char _pad1[2];
    uint32_t name_id;
    uint8_t admin_level;
    char _pad2[3];
    float area;
    uint16_t country_code;
    char _pad3[2];
};
static_assert(sizeof(AdminPolygon) == 24);

struct AdminPolygon {
    uint32_t vertex_offset;
    uint32_t vertex_count;
    uint32_t name_id;
    uint8_t admin_level;
    char _pad1[3];
    float area;
    uint16_t country_code;
    char _pad2[2];
};
static_assert(sizeof(AdminPolygon) == 24);


The file /root/traccar-geocoder/builder/tools/simplify_compare.cpp has been updated successfully.

struct AdminPolygon {
    uint32_t vertex_offset;
    uint32_t vertex_count;
    uint32_t name_id;
    uint8_t admin_level;
    char _pad1[3];
    float area;
    uint16_t country_code;
    char _pad2[2];
};
static_assert(sizeof(AdminPolygon) == 24);

#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <thread>
#include <vector>

struct AdminPolygon {
    uint32_t vertex_offset;
    uint16_t vertex_count;
    char _pad1[2];
    uint32_t name_id;
    uint8_t admin_level;
    char _pad2[3];
    float area;
    uint16_t country_code;
    char _pad3[2];
};
static_assert(sizeof(AdminPolygon) == 24);

struct Vert { float lat, lng; };

static double dp_dist(Vert p, Vert a, Vert b) {
    double dx = b.lat - a.lat, dy = b.lng - a.lng;
    double len_sq = dx*dx + dy*dy;
    if (len_sq == 0) {
        double px = p.lat - a.lat, py = p.lng - a.lng;
        return std::sqrt(px*px + py*py);
    }
    double t = std::max(0.0, std::min(1.0, ((p.lat-a.lat)*dx + (p.lng-a.lng)*dy) / len_sq));
    double proj_x = a.lat + t*dx - p.lat;
    double proj_y = a.lng + t*dy - p.lng;
    return std::sqrt(proj_x*proj_x + proj_y*proj_y);
}

static void dp_mark(const std::vector<Vert>& pts, size_t s, size_t e, double eps, std::vector<bool>& keep) {
    if (e <= s + 1) return;
    double max_d = 0; size_t max_i = s;
    for (size_t i = s+1; i < e; i++) {
        double d = dp_dist(pts[i], pts[s], pts[e]);
        if (d > max_d) { max_d = d; max_i = i; }
    }
    if (max_d > eps) {
        keep[max_i] = true;
        dp_mark(pts, s, max_i, eps, keep);
        dp_mark(pts, max_i, e, eps, keep);
    }
}

// Simplify to max N vertices (binary search epsilon)
static std::vector<Vert> simplify_max(const std::vector<Vert>& pts, size_t max_verts) {
    if (pts.size() <= max_verts) return pts;
    double lo = 0, hi = 1.0;
    for (int iter = 0; iter < 20; iter++) {
        double eps = (lo + hi) / 2;
        std::vector<bool> keep(pts.size(), false);
        keep[0] = keep[pts.size()-1] = true;
        dp_mark(pts, 0, pts.size()-1, eps, keep);
        size_t cnt = 0; for (bool k : keep) if (k) cnt++;
        if (cnt > max_verts) lo = eps; else hi = eps;
    }
    std::vector<bool> keep(pts.size(), false);
    keep[0] = keep[pts.size()-1] = true;
    dp_mark(pts, 0, pts.size()-1, hi, keep);
    std::vector<Vert> out;
    for (size_t i = 0; i < pts.size(); i++) if (keep[i]) out.push_back(pts[i]);
    return out;
}

// Simplify with fixed epsilon (degrees)
static std::vector<Vert> simplify_eps(const std::vector<Vert>& pts, double eps_deg) {
    if (pts.size() <= 4) return pts;
    std::vector<bool> keep(pts.size(), false);
    keep[0] = keep[pts.size()-1] = true;
    dp_mark(pts, 0, pts.size()-1, eps_deg, keep);
    std::vector<Vert> out;
    for (size_t i = 0; i < pts.size(); i++) if (keep[i]) out.push_back(pts[i]);
    return out;
}

static double meters_to_deg(double m, double lat) {
    double c = std::cos(lat * M_PI / 180.0);
    if (c < 0.01) c = 0.01;
    return m / (111320.0 * c);
}

static double admin_eps_meters(uint8_t level) {
    switch (level) {
        case 2: return 500; case 3: return 200; case 4: return 100;
        case 5: return 50; case 6: return 30; case 7: return 20;
        default: return 15;
    }
}

int main(int argc, char* argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <index-dir> [--epsilon] [values...]\n", argv[0]);
        fprintf(stderr, "  Default: compare max-vertex modes (500 200 100 50 25)\n");
        fprintf(stderr, "  --epsilon: compare epsilon modes in meters (default per-level, 30, 50, 100)\n");
        return 1;
    }
    std::string dir = argv[1];

    // Load data
    std::vector<char> poly_buf, vert_buf, str_buf;
    auto load = [](const std::string& path, std::vector<char>& buf) {
        std::ifstream f(path, std::ios::binary | std::ios::ate);
        buf.resize(f.tellg()); f.seekg(0); f.read(buf.data(), buf.size());
    };
    load(dir + "/admin_polygons.bin", poly_buf);
    load(dir + "/admin_vertices.bin", vert_buf);
    load(dir + "/strings.bin", str_buf);

    size_t poly_count = poly_buf.size() / sizeof(AdminPolygon);
    auto* polys = reinterpret_cast<AdminPolygon*>(poly_buf.data());
    auto* verts = reinterpret_cast<Vert*>(vert_buf.data());

    std::vector<std::vector<Vert>> poly_verts(poly_count);
    for (size_t i = 0; i < poly_count; i++)
        poly_verts[i].assign(verts + polys[i].vertex_offset,
                             verts + polys[i].vertex_offset + polys[i].vertex_count);

    fprintf(stderr, "Loaded %zu polygons, %zu vertices\n", poly_count, vert_buf.size()/sizeof(Vert));

    // Determine mode
    bool epsilon_mode = false;
    for (int i = 2; i < argc; i++) {
        if (std::string(argv[i]) == "--epsilon") { epsilon_mode = true; break; }
    }

    // Parallel vertex counting helper
    auto count_parallel = [&](auto simplify_fn) -> size_t {
        std::atomic<size_t> total{0}, idx{0};
        unsigned nt = std::thread::hardware_concurrency();
        std::vector<std::thread> threads;
        for (unsigned t = 0; t < nt; t++) {
            threads.emplace_back([&]() {
                size_t local = 0;
                while (true) {
                    size_t i = idx.fetch_add(1);
                    if (i >= poly_count) break;
                    local += simplify_fn(i).size();
                }
                total += local;
            });
        }
        for (auto& t : threads) t.join();
        idx = 0;
        return total.load();
    };

    struct TestResult { std::string label; size_t total_verts; };
    std::vector<TestResult> results;

    if (!epsilon_mode) {
        // Max-vertex modes
        std::vector<size_t> levels = {500, 200, 100, 50, 25};
        printf("%12s  %12s  %10s\n", "Mode", "Total verts", "Vert MiB");
        printf("%12s  %12s  %10s\n", "----", "-----------", "--------");
        for (size_t max_v : levels) {
            auto total = count_parallel([&](size_t i) { return simplify_max(poly_verts[i], max_v); });
            printf("%8zuv max  %12zu  %10.0f\n", max_v, total, total * 8.0/1024/1024);
            char label[64]; snprintf(label, sizeof(label), "max %zu", max_v);
            results.push_back({label, total});
        }
    } else {
        // Epsilon modes
        printf("%16s  %12s  %10s\n", "Mode", "Total verts", "Vert MiB");
        printf("%16s  %12s  %10s\n", "----", "-----------", "--------");

        // Per-level epsilon
        auto total = count_parallel([&](size_t i) {
            double eps_m = admin_eps_meters(polys[i].admin_level);
            double eps_d = meters_to_deg(eps_m, poly_verts[i][0].lat);
            return simplify_eps(poly_verts[i], eps_d);
        });
        printf("%16s  %12zu  %10.0f\n", "per-level", total, total * 8.0/1024/1024);
        results.push_back({"per-level eps", total});

        // Fixed epsilons
        double fixed_eps[] = {10, 20, 30, 50, 100, 200, 500};
        for (double eps_m : fixed_eps) {
            total = count_parallel([&](size_t i) {
                double eps_d = meters_to_deg(eps_m, poly_verts[i][0].lat);
                return simplify_eps(poly_verts[i], eps_d);
            });
            char label[64]; snprintf(label, sizeof(label), "%.0fm fixed", eps_m);
            printf("%16s  %12zu  %10.0f\n", label, total, total * 8.0/1024/1024);
            results.push_back({label, total});
        }

        // Also show max-500 for comparison
        total = count_parallel([&](size_t i) { return simplify_max(poly_verts[i], 500); });
        printf("%16s  %12zu  %10.0f\n", "max 500 (orig)", total, total * 8.0/1024/1024);
    }

    // Export GeoJSON for map comparison
    auto get_name = [&](uint32_t off) -> std::string {
        if (off >= str_buf.size()) return "???";
        return std::string(str_buf.data() + off);
    };

    struct Target { const char* name; double lat, lng, max_dist; };
    Target targets[] = {
        {"Greater London", 51.5, -0.1, 2.0},
        {"Paris", 48.85, 2.35, 0.3},
        {"Berlin", 52.5, 13.4, 1.0},
        {"New York", 40.7, -74.0, 1.0},
        {"Council of the City of Sydney", -33.9, 151.2, 1.0},
        {"Roma Capitale", 41.9, 12.5, 2.0},
        {"Amsterdam", 52.3, 4.9, 0.5},
        {"Madrid", 40.4, -3.7, 0.5},
        {"München", 48.1, 11.5, 0.3},
        {"Île-de-France", 48.5, 2.5, 2.0},
    };

    struct Sample { std::string name; uint8_t level; size_t idx; };
    std::vector<Sample> samples;
    for (auto& t : targets) {
        size_t best_i = 0; size_t best_vc = 0; double best_dist = 999;
        for (size_t i = 0; i < poly_count; i++) {
            if (get_name(polys[i].name_id) != t.name) continue;
            auto& pv = poly_verts[i];
            double clat = 0, clng = 0;
            for (auto& v : pv) { clat += v.lat; clng += v.lng; }
            clat /= pv.size(); clng /= pv.size();
            double dist = std::sqrt((clat-t.lat)*(clat-t.lat) + (clng-t.lng)*(clng-t.lng));
            if (dist < t.max_dist && pv.size() > best_vc) {
                best_vc = pv.size(); best_i = i; best_dist = dist;
            }
        }
        if (best_vc > 0) samples.push_back({t.name, polys[best_i].admin_level, best_i});
    }

    FILE* gf = fopen((dir + "/comparison.geojson").c_str(), "w");
    fprintf(gf, "{\"type\":\"FeatureCollection\",\"features\":[\n");

    // Color palettes
    const char* max_colors[] = {"#e41a1c", "#0066ff", "#00cc44", "#ff00ff", "#ff8800"};
    const char* eps_colors[] = {"#e41a1c", "#0066ff", "#00cc44", "#ff00ff", "#ff8800", "#999999", "#00cccc", "#cc0066"};

    bool first = true;
    for (auto& s : samples) {
        auto& pts = poly_verts[s.idx];
        uint8_t level = s.level;

        if (!epsilon_mode) {
            size_t max_vals[] = {500, 200, 100, 50, 25};
            for (int li = 0; li < 5; li++) {
                auto sp = simplify_max(pts, max_vals[li]);
                if (!first) fprintf(gf, ",\n"); first = false;
                fprintf(gf, "{\"type\":\"Feature\",\"properties\":{\"name\":\"%s\",\"level\":%d,"
                    "\"mode\":\"max %zu\",\"actual_verts\":%zu,\"color\":\"%s\"},\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[",
                    s.name.c_str(), level, max_vals[li], sp.size(), max_colors[li]);
                for (size_t i = 0; i < sp.size(); i++) {
                    if (i) fprintf(gf, ",");
                    fprintf(gf, "[%.6f,%.6f]", sp[i].lng, sp[i].lat);
                }
                if (!sp.empty()) fprintf(gf, ",[%.6f,%.6f]", sp[0].lng, sp[0].lat);
                fprintf(gf, "]]}}");
            }
        } else {
            // Per-level epsilon
            {
                double eps_m = admin_eps_meters(level);
                double eps_d = meters_to_deg(eps_m, pts[0].lat);
                auto sp = simplify_eps(pts, eps_d);
                if (!first) fprintf(gf, ",\n"); first = false;
                char mode[64]; snprintf(mode, sizeof(mode), "per-level (%.0fm)", eps_m);
                fprintf(gf, "{\"type\":\"Feature\",\"properties\":{\"name\":\"%s\",\"level\":%d,"
                    "\"mode\":\"%s\",\"actual_verts\":%zu,\"color\":\"%s\"},\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[",
                    s.name.c_str(), level, mode, sp.size(), eps_colors[0]);
                for (size_t i = 0; i < sp.size(); i++) { if (i) fprintf(gf, ","); fprintf(gf, "[%.6f,%.6f]", sp[i].lng, sp[i].lat); }
                if (!sp.empty()) fprintf(gf, ",[%.6f,%.6f]", sp[0].lng, sp[0].lat);
                fprintf(gf, "]]}}");
            }
            // Fixed epsilons
            double fixed[] = {10, 30, 50, 100, 200};
            for (int li = 0; li < 5; li++) {
                double eps_d = meters_to_deg(fixed[li], pts[0].lat);
                auto sp = simplify_eps(pts, eps_d);
                if (!first) fprintf(gf, ",\n"); first = false;
                char mode[64]; snprintf(mode, sizeof(mode), "%.0fm fixed", fixed[li]);
                fprintf(gf, "{\"type\":\"Feature\",\"properties\":{\"name\":\"%s\",\"level\":%d,"
                    "\"mode\":\"%s\",\"actual_verts\":%zu,\"color\":\"%s\"},\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[",
                    s.name.c_str(), level, mode, sp.size(), eps_colors[li+1]);
                for (size_t i = 0; i < sp.size(); i++) { if (i) fprintf(gf, ","); fprintf(gf, "[%.6f,%.6f]", sp[i].lng, sp[i].lat); }
                if (!sp.empty()) fprintf(gf, ",[%.6f,%.6f]", sp[0].lng, sp[0].lat);
                fprintf(gf, "]]}}");
            }
            // Also max-500 baseline
            {
                auto sp = simplify_max(pts, 500);
                if (!first) fprintf(gf, ",\n"); first = false;
                fprintf(gf, "{\"type\":\"Feature\",\"properties\":{\"name\":\"%s\",\"level\":%d,"
                    "\"mode\":\"max 500 (baseline)\",\"actual_verts\":%zu,\"color\":\"%s\"},\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[",
                    s.name.c_str(), level, sp.size(), "#888888");
                for (size_t i = 0; i < sp.size(); i++) { if (i) fprintf(gf, ","); fprintf(gf, "[%.6f,%.6f]", sp[i].lng, sp[i].lat); }
                if (!sp.empty()) fprintf(gf, ",[%.6f,%.6f]", sp[0].lng, sp[0].lat);
                fprintf(gf, "]]}}");
            }
        }
    }
    fprintf(gf, "\n]}"); fclose(gf);
    fprintf(stderr, "Wrote %s/comparison.geojson (%zu cities)\n", dir.c_str(), samples.size());
    return 0;
}


Now let me verify the new struct size matches. The old layout was: u32(4) + u16(2) + pad(2) + u32(4) + u8(1) + pad(3) + f32(4) + u16(2) + pad(2) = 24. New: u32(4) + u32(4) + u32(4) + u8(1) + pad(3) + f32(4) + u16(2) + pad(2) = 24. Same size!


Bash

cd /root/traccar-geocoder/builder/build && make -j$(nproc) 2>&1 | tail -5

Build with uint32 vertex_count


[ 75%] Building CXX object CMakeFiles/build-index.dir/src/cache.cpp.o
[ 75%] Building CXX object CMakeFiles/build-index.dir/src/cell_index.cpp.o
[ 87%] Building CXX object CMakeFiles/build-index.dir/src/build_index.cpp.o
[100%] Linking CXX executable build-index
[100%] Built target build-index


You're right — the simplify_compare tool (which generates the GeoJSON for the viewer) has its own copy of the AdminPolygon struct. That's where the uint16 truncation happens when reading the uncapped index. The builder and server would only hit this if someone actually deployed uncapped data.

But since we've already changed it, it's a good fix to have regardless — no reason to have an artificial 65K limit. Let me now rebuild the uncapped index with the new binary and regenerate the viewer:


scp /root/traccar-geocoder/builder/build/build-index node3:~/build-index-u32 2>&1 && scp /root/traccar-geocoder/builder/tools/simplify_compare.cpp node3:~ 2>&1 && ssh node3 "
g++ -O3 -std=c++17 -pthread -o simplify_compare simplify_compare.cpp &&
rm -rf /tmp/test-uncapped && mkdir /tmp/test-uncapped &&
time ~/build-index-u32 /tmp/test-uncapped ~/pbf/planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon 0.1 2>&1 | grep -E 'Simplif|admin index|strings|Done'
echo '---'
du -sh /tmp/test-uncapped/
ls -lh /tmp/test-uncapped/admin_vertices.bin
python3 -c \"
import struct
data = open('/tmp/test-uncapped/admin_polygons.bin','rb').read()
fmt = '<II I B3x f H2x'
n = len(data)//24
total_v = 0; max_v = 0
for i in range(n):
    vo,vc,_,_,_,_ = struct.unpack_from(fmt, data, i*24)
    total_v += vc
    if vc > max_v: max_v = vc
print('Polygons: {:,}'.format(n))
print('Total vertices: {:,} ({:.0f} MiB)'.format(total_v, total_v*8/1024/1024))
print('Max vertices per polygon: {:,}'.format(max_v))
\"" 2>&1

Build uncapped with u32 vertex_count


Command running in background with ID: b845k6vvg. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b845k6vvg.output


for i in $(seq 1 30); do sleep 30; out=$(cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b845k6vvg.output 2>/dev/null); if echo "$out" | grep -q "Max vertices"; then echo "$out"; break; fi; echo "$((i*30))s"; done

Monitor


Command running in background with ID: b91lketl3. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b91lketl3.output


No — the struct stays the same 24 bytes due to padding alignment. The `uint16_t vertex_count` had 2 bytes of padding after it to align `name_id` to a 4-byte boundary. Now it's `uint32_t` which fills those 2 bytes naturally. Zero impact on memory or disk size.

Let me check on the build:


sleep 300 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b845k6vvg.output 2>/dev/null

Check after 5 min


Simplification: error-bounded, fixed 0.1m epsilon
Done reading:
strings.bin: 5124669 bytes (compacted from 210563993)
admin index: 2056426 cells, 459641 polygons
Done.
---

real	13m45.423s
user	184m37.685s
sys	17m37.881s
2.1G	/tmp/test-uncapped/
-rw-rw-r-- 1 michtest michtest 2.0G Mar 23 19:18 /tmp/test-uncapped/admin_vertices.bin
Polygons: 459,641
Total vertices: 261,929,463 (1998 MiB)
Max vertices per polygon: 217,729


Now the max is **217,729 vertices** (Deutschland's actual boundary) — no more truncation at 65K. Now regenerate the viewer:


ssh node3 "python3 << 'PYEOF'
import struct, json, math

admin_data = open('/tmp/test-uncapped/admin_polygons.bin', 'rb').read()
verts_data = open('/tmp/test-uncapped/admin_vertices.bin', 'rb').read()
strings_data = open('/tmp/test-uncapped/strings.bin', 'rb').read()

# New struct: u32 vertex_offset, u32 vertex_count, u32 name_id, u8 admin_level, 3pad, f32 area, u16 country_code, 2pad
fmt = '<III B3x f H2x'
poly_count = len(admin_data) // 24

def get_str(off):
    if off >= len(strings_data): return '???'
    end = strings_data.index(b'\\x00', off)
    return strings_data[off:end].decode('utf-8', errors='replace')

def get_verts(vo, vc):
    out = []
    for j in range(vc):
        lat, lng = struct.unpack_from('<ff', verts_data, (vo+j)*8)
        out.append((float(lat), float(lng)))
    return out

targets = [
    ('France', 2, 47.0, 2.0, 5.0),
    ('Deutschland', 2, 51.0, 10.0, 5.0),
    ('Italia', 2, 42.0, 12.0, 5.0),
    ('Bayern', 4, 48.5, 11.5, 3.0),
    ('Berlin', 4, 52.5, 13.4, 1.0),
    ('Greater London', 5, 51.5, -0.1, 2.0),
    ('Roma Capitale', 6, 41.9, 12.5, 2.0),
    ('Paris', 7, 48.85, 2.35, 0.3),
    ('Amsterdam', 8, 52.3, 4.9, 0.5),
    ('Madrid', 8, 40.4, -3.7, 0.5),
]

found = []
for tname, tlevel, tlat, tlng, max_dist in targets:
    best_i = None; best_vc = 0
    for i in range(poly_count):
        vo, vc, name_id, level, area, cc = struct.unpack_from(fmt, admin_data, i*24)
        if level != tlevel or get_str(name_id) != tname: continue
        pts = get_verts(vo, min(vc, 50))  # just sample for centroid
        clat = sum(p[0] for p in pts)/len(pts)
        clng = sum(p[1] for p in pts)/len(pts)
        dist = ((clat-tlat)**2 + (clng-tlng)**2)**0.5
        if dist < max_dist and vc > best_vc:
            best_vc = vc; best_i = i
    if best_i is not None:
        vo, vc, name_id, level, area, cc = struct.unpack_from(fmt, admin_data, best_i*24)
        v = get_verts(vo, vc)
        found.append({'name': get_str(name_id), 'level': level, 'verts': v})
        print('{} L{}: {} verts'.format(get_str(name_id), level, len(v)))

def dp_dist(p, a, b):
    dx, dy = b[0]-a[0], b[1]-a[1]
    lsq = dx*dx+dy*dy
    if lsq == 0: return math.sqrt((p[0]-a[0])**2+(p[1]-a[1])**2)
    t = max(0, min(1, ((p[0]-a[0])*dx+(p[1]-a[1])*dy)/lsq))
    return math.sqrt((a[0]+t*dx-p[0])**2 + (a[1]+t*dy-p[1])**2)

def dp_simplify_eps(pts, eps):
    if len(pts) <= 4: return pts
    keep = [False]*len(pts); keep[0]=keep[-1]=True
    stk = [(0, len(pts)-1)]
    while stk:
        s,e = stk.pop()
        if e<=s+1: continue
        md, mi = 0, s
        for i in range(s+1,e):
            d = dp_dist(pts[i], pts[s], pts[e])
            if d>md: md,mi=d,i
        if md>eps: keep[mi]=True; stk.append((s,mi)); stk.append((mi,e))
    return [p for p,k in zip(pts,keep) if k]

def dp_simplify_max(pts, max_v):
    if len(pts) <= max_v: return pts
    lo, hi = 0.0, 1.0
    for _ in range(20):
        eps = (lo+hi)/2
        keep = [False]*len(pts); keep[0]=keep[-1]=True
        stk = [(0,len(pts)-1)]
        while stk:
            s,e=stk.pop()
            if e<=s+1: continue
            md,mi=0,s
            for i in range(s+1,e):
                d=dp_dist(pts[i],pts[s],pts[e])
                if d>md: md,mi=d,i
            if md>eps: keep[mi]=True;stk.append((s,mi));stk.append((mi,e))
        cnt=sum(keep)
        if cnt>max_v: lo=eps
        else: hi=eps
    keep=[False]*len(pts);keep[0]=keep[-1]=True
    stk=[(0,len(pts)-1)]
    while stk:
        s,e=stk.pop()
        if e<=s+1: continue
        md,mi=0,s
        for i in range(s+1,e):
            d=dp_dist(pts[i],pts[s],pts[e])
            if d>md: md,mi=d,i
        if md>hi: keep[mi]=True;stk.append((s,mi));stk.append((mi,e))
    return [p for p,k in zip(pts,keep) if k]

def meters_to_deg(m, lat):
    c = math.cos(lat * math.pi / 180.0)
    if c < 0.01: c = 0.01
    return m / (111320.0 * c)

defaults = {2:500, 3:200, 4:100, 5:50, 6:30, 7:20, 8:15}

modes = [
    ('Uncapped (1.1GB zst)', 'uncapped', '#000000'),
    ('Max 500 (509MB zst)', 'max500', '#888888'),
    ('1.0x eps (354MB zst)', 1.0, '#e41a1c'),
    ('1.5x eps (294MB zst)', 1.5, '#0066ff'),
    ('2.0x eps (256MB zst)', 2.0, '#00cc44'),
    ('2.5x eps (228MB zst)', 2.5, '#ff8800'),
]

features = []
for city in found:
    pts = city['verts']
    level = city['level']
    label = '{} (L{})'.format(city['name'], level)
    base_eps = defaults.get(level, 15)

    for mname, mval, color in modes:
        print('  {} {} ...'.format(label, mname), end='', flush=True)
        if mval == 'uncapped':
            sp = pts
        elif mval == 'max500':
            sp = dp_simplify_max(pts, 500)
        else:
            eps_m = base_eps * mval
            eps_d = meters_to_deg(eps_m, pts[0][0])
            sp = dp_simplify_eps(pts, eps_d)
        print(' {} verts'.format(len(sp)))

        coords = [[p[1], p[0]] for p in sp]
        if coords[0] != coords[-1]: coords.append(coords[0])

        if mval == 'uncapped':
            detail = 'Uncapped ({} verts)'.format(len(sp))
        elif mval == 'max500':
            detail = 'Max 500 ({} verts)'.format(len(sp))
        else:
            detail = '{}x (L{} = {:.0f}m, {} verts)'.format(mval, level, base_eps*mval, len(sp))

        features.append({
            'type': 'Feature',
            'properties': {
                'name': label, 'level': level,
                'mode': mname, 'mode_detail': detail,
                'actual_verts': len(sp), 'color': color
            },
            'geometry': {'type': 'Polygon', 'coordinates': [coords]}
        })

geojson = {'type': 'FeatureCollection', 'features': features}
with open('/home/michtest/comparison_u32.geojson', 'w') as f:
    json.dump(geojson, f)
print('Wrote {} features'.format(len(features)))
PYEOF" 2>&1

Generate GeoJSON from uncapped u32 data


Command running in background with ID: bw9du3you. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bw9du3you.output


This will take a bit — the DP simplification on Deutschland (217K verts) in Python is slow. Let me check:


sleep 30 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bw9du3you.output 2>/dev/null | tail -5

Check after 30s


  Madrid (L8) 1.0x eps (354MB zst) ... 617 verts
  Madrid (L8) 1.5x eps (294MB zst) ... 479 verts
  Madrid (L8) 2.0x eps (256MB zst) ... 409 verts
  Madrid (L8) 2.5x eps (228MB zst) ... 349 verts
Wrote 60 features


Already done. Let me rebuild the viewer:


ssh node3 "python3 -c \"
html = open('/home/michtest/simplification_map.html').read()
geojson = open('/home/michtest/comparison_u32.geojson').read()
html = html.replace('GEOJSON_PLACEHOLDER', geojson)
open('/home/michtest/simplification_viewer.html', 'w').write(html)
print(len(html))
\"" 2>&1 && scp node3:~/simplification_viewer.html /root/traccar-geocoder/builder/tools/ 2>&1 && echo "Updated"

Rebuild viewer


14822689
Updated


Refresh **http://100.68.107.4:8080/simplification_viewer.html**

Deutschland and Bayern should now render correctly with proper closed rings. Deutschland's uncapped boundary has 217,729 vertices — zoom into the coastline or the Rhine border to see the massive detail difference between uncapped (black) and the simplified versions.


cd /root/traccar-geocoder && git add builder/src/types.h builder/src/s2_helpers.cpp builder/src/build_index.cpp builder/src/continent_filter.cpp builder/tools/simplify_compare.cpp server/src/main.rs && git commit -m "$(cat <<'EOF'
fix: change AdminPolygon vertex_count from uint16 to uint32

The uint16_t vertex_count field truncated polygons at 65,535 vertices,
corrupting boundaries like Deutschland (217K verts) and Bayern (113K)
when simplification is disabled or uses very small epsilon.

Struct stays 24 bytes (the uint16 had 2 bytes padding after it that
the uint32 now fills naturally). No change to disk/memory footprint.

Updated in: types.h, s2_helpers.cpp, build_index.cpp,
continent_filter.cpp, simplify_compare.cpp, server main.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)" && git push origin main 2>&1

Commit and push


ok
To github.com:zp-forks/traccar-geocoder.git
6b271b3..3eff127  main -> main

[main 3eff127] fix: change AdminPolygon vertex_count from uint16 to uint32
 6 files changed, 9 insertions(+), 10 deletions(-)
To github.com:zp-forks/traccar-geocoder.git
   6b271b3..3eff127  main -> main


Fixed and pushed. The viewer should now show correct boundaries for all modes including uncapped.


<task-notification>
<task-id>b845k6vvg</task-id>
<tool-use-id>toolu_01VtHvBAcNSV9c6iAuFKrwYJ</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b845k6vvg.output</output-file>
<status>completed</status>
<summary>Background command "Build uncapped with u32 vertex_count" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b845k6vvg.output


<task-notification>
<task-id>b91lketl3</task-id>
<tool-use-id>toolu_01QRKBLQi7t9JzongNd878bc</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b91lketl3.output</output-file>
<status>completed</status>
<summary>Background command "Monitor" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b91lketl3.output


<task-notification>
<task-id>bw9du3you</task-id>
<tool-use-id>toolu_01K5dr8EpXHva1coiHhnRDAn</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bw9du3you.output</output-file>
<status>completed</status>
<summary>Background command "Generate GeoJSON from uncapped u32 data" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bw9du3you.output


All already handled — uncapped build showed Deutschland at 217K vertices (previously truncated at 65K), viewer updated with correct polygons, uint32 fix committed and pushed.


What is the size difference for this new uncapped file?


Zero difference for all the simplified modes — the struct is the same 24 bytes. The only mode affected is uncapped, where vertices are no longer truncated:

| Mode | Before (uint16) | After (uint32) | Change |
|---|---|---|---|
| Uncapped vertices | 259.9M (truncated) | **261.9M** (correct) | +2M verts |
| Uncapped admin_vertices.bin | 1983 MiB | **1998 MiB** | +15 MiB |
| admin_polygons.bin | Same | Same | 0 (struct is still 24 bytes) |
| All simplified modes | Same | Same | 0 (all under old 65K limit) |

The 2M extra vertices are from Deutschland, Bayern, and any other polygons that were silently truncated at 65,535. With simplification enabled (which is the normal mode), no polygon comes anywhere near 65K vertices so there's no size change.


Could you now generate the entire planet, with all four different levels, different continent splits like we did before, but with unrestricted polygons? Would it be possible to update the code to output different qualities of polygons too, the same way it does with the different continents and detail levels now?


Good idea. Let me add `--multi-quality` that writes different epsilon levels as subdirectories, then do the full planet build. The key is that simplification can happen at write time — only the admin vertex/polygon files change between quality levels, everything else is shared.


     1→#pragma once
     2→
     3→#include <string>
     4→#include <unordered_map>
     5→#include <vector>
     6→
     7→#include "types.h"
     8→#include "parsed_data.h"
     9→
    10→std::vector<uint32_t> write_entries(
    11→    const std::string& path,
    12→    const std::vector<uint64_t>& sorted_cells,
    13→    const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map);
    14→
    15→std::vector<uint32_t> write_entries_from_sorted(
    16→    const std::string& path,
    17→    const std::vector<uint64_t>& sorted_cells,
    18→    const std::vector<CellItemPair>& sorted_pairs);
    19→
    20→void write_cell_index(
    21→    const std::string& cells_path,
    22→    const std::string& entries_path,
    23→    const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map);
    24→
    25→void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode);
    26→

#pragma once

#include <string>
#include <unordered_map>
#include <vector>

#include "types.h"
#include "parsed_data.h"

std::vector<uint32_t> write_entries(
    const std::string& path,
    const std::vector<uint64_t>& sorted_cells,
    const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map);

std::vector<uint32_t> write_entries_from_sorted(
    const std::string& path,
    const std::vector<uint64_t>& sorted_cells,
    const std::vector<CellItemPair>& sorted_pairs);

void write_cell_index(
    const std::string& cells_path,
    const std::string& entries_path,
    const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map);

void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode);


I need to add a function that writes a quality-adjusted version of admin data. Let me add a `write_admin_quality` function and a `--multi-quality` flag:


Edit

void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode);

void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode);

// Write admin polygon/vertex files with on-the-fly simplification at a given epsilon scale.
// Scale 0 = uncapped (no simplification). Other files are symlinked/copied from source_dir.
void write_quality_variant(const ParsedData& data, const std::string& source_dir,
                           const std::string& output_dir, double epsilon_scale);


The file /root/traccar-geocoder/builder/src/cell_index.h has been updated successfully.

void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode);

// Write admin polygon/vertex files with on-the-fly simplification at a given epsilon scale.
// Scale 0 = uncapped (no simplification). Other files are symlinked/copied from source_dir.
void write_quality_variant(const ParsedData& data, const std::string& source_dir,
                           const std::string& output_dir, double epsilon_scale);

#pragma once

#include <string>
#include <unordered_map>
#include <vector>

#include "types.h"
#include "parsed_data.h"

std::vector<uint32_t> write_entries(
    const std::string& path,
    const std::vector<uint64_t>& sorted_cells,
    const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map);

std::vector<uint32_t> write_entries_from_sorted(
    const std::string& path,
    const std::vector<uint64_t>& sorted_cells,
    const std::vector<CellItemPair>& sorted_pairs);

void write_cell_index(
    const std::string& cells_path,
    const std::string& entries_path,
    const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map);

void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode);

+// Write admin polygon/vertex files with on-the-fly simplification at a given epsilon scale.

+// Scale 0 = uncapped (no simplification). Other files are symlinked/copied from source_dir.

+void write_quality_variant(const ParsedData& data, const std::string& source_dir,

+                           const std::string& output_dir, double epsilon_scale);


Now implement `write_quality_variant` in cell_index.cpp:


   315→    const bool need_compact = (!write_streets || !write_addresses);
   316→
   317→    if (need_compact) {
   318→        // Collect referenced string offsets
   319→        std::unordered_set<uint32_t> used_offsets;
   320→        if (write_streets) {
   321→            for (const auto& w : data.ways) used_offsets.insert(w.name_id);
   322→            if (write_addresses) {
   323→                for (const auto& a : data.addr_points) {
   324→                    used_offsets.insert(a.housenumber_id);
   325→                    used_offsets.insert(a.street_id);
   326→                }
   327→                for (const auto& iw : data.interp_ways) used_offsets.insert(iw.street_id);
   328→            }
   329→        }

    const bool need_compact = (!write_streets || !write_addresses);

    if (need_compact) {
        // Collect referenced string offsets
        std::unordered_set<uint32_t> used_offsets;
        if (write_streets) {
            for (const auto& w : data.ways) used_offsets.insert(w.name_id);
            if (write_addresses) {
                for (const auto& a : data.addr_points) {
                    used_offsets.insert(a.housenumber_id);
                    used_offsets.insert(a.street_id);
                }
                for (const auto& iw : data.interp_ways) used_offsets.insert(iw.street_id);
            }
        }


   370→            std::ofstream f(output_dir + "/strings.bin", std::ios::binary);
   371→            f.write(data.string_pool.data().data(), data.string_pool.data().size());
   372→            std::cerr << "strings.bin: " << data.string_pool.data().size() << " bytes" << std::endl;
   373→        }
   374→    }));
   375→
   376→    log_phase("  Write: parallel data files launched", _wt);
   377→    admin_future.get();
   378→    for (auto& f : write_futures) f.get();
   379→}
   380→

            std::ofstream f(output_dir + "/strings.bin", std::ios::binary);
            f.write(data.string_pool.data().data(), data.string_pool.data().size());
            std::cerr << "strings.bin: " << data.string_pool.data().size() << " bytes" << std::endl;
        }
    }));

    log_phase("  Write: parallel data files launched", _wt);
    admin_future.get();
    for (auto& f : write_futures) f.get();
}


Now add the quality variant function at the end:


    log_phase("  Write: parallel data files launched", _wt);
    admin_future.get();
    for (auto& f : write_futures) f.get();
}

    log_phase("  Write: parallel data files launched", _wt);
    admin_future.get();
    for (auto& f : write_futures) f.get();
}

void write_quality_variant(const ParsedData& data, const std::string& source_dir,
                           const std::string& output_dir, double epsilon_scale) {
    ensure_dir(output_dir);

    // Re-simplify admin polygons at the given epsilon scale
    std::vector<AdminPolygon> new_polys;
    std::vector<NodeCoord> new_verts;
    new_polys.reserve(data.admin_polygons.size());

    // Parallel simplification
    struct SimplifiedPoly {
        std::vector<std::pair<double,double>> verts;
    };
    std::vector<SimplifiedPoly> simplified(data.admin_polygons.size());

    {
        std::atomic<size_t> idx{0};
        unsigned nthreads = std::thread::hardware_concurrency();
        if (nthreads == 0) nthreads = 4;
        std::vector<std::thread> workers;
        for (unsigned t = 0; t < nthreads; t++) {
            workers.emplace_back([&]() {
                while (true) {
                    size_t i = idx.fetch_add(1);
                    if (i >= data.admin_polygons.size()) break;
                    const auto& ap = data.admin_polygons[i];

                    // Extract original vertices
                    std::vector<std::pair<double,double>> pts;
                    pts.reserve(ap.vertex_count);
                    for (uint32_t j = 0; j < ap.vertex_count; j++) {
                        const auto& v = data.admin_vertices[ap.vertex_offset + j];
                        pts.emplace_back(v.lat, v.lng);
                    }

                    if (epsilon_scale > 0) {
                        double eps_m = admin_epsilon_meters(ap.admin_level) * epsilon_scale;
                        double lat = pts.empty() ? 0.0 : pts[0].first;
                        double eps_deg = meters_to_degrees(eps_m, lat);
                        simplified[i].verts = simplify_polygon_epsilon(pts, eps_deg);
                    } else {
                        simplified[i].verts = std::move(pts);
                    }
                }
            });
        }
        for (auto& w : workers) w.join();
    }

    // Sequential: build new polygon and vertex arrays
    for (size_t i = 0; i < data.admin_polygons.size(); i++) {
        auto& sv = simplified[i].verts;
        if (sv.size() < 3) continue;

        AdminPolygon np = data.admin_polygons[i];
        np.vertex_offset = static_cast<uint32_t>(new_verts.size());
        np.vertex_count = static_cast<uint32_t>(sv.size());
        np.area = polygon_area(sv);

        for (const auto& [lat, lng] : sv) {
            new_verts.push_back({static_cast<float>(lat), static_cast<float>(lng)});
        }
        new_polys.push_back(np);
    }

    std::cerr << "Quality " << epsilon_scale << "x: " << new_polys.size()
              << " polygons, " << new_verts.size() << " vertices ("
              << new_verts.size() * 8 / 1024 / 1024 << " MiB)" << std::endl;

    // Write admin files
    {
        std::ofstream f(output_dir + "/admin_polygons.bin", std::ios::binary);
        f.write(reinterpret_cast<const char*>(new_polys.data()), new_polys.size() * sizeof(AdminPolygon));
    }
    {
        std::ofstream f(output_dir + "/admin_vertices.bin", std::ios::binary);
        f.write(reinterpret_cast<const char*>(new_verts.data()), new_verts.size() * sizeof(NodeCoord));
    }

    // Compact strings for admin-only names
    {
        std::unordered_set<uint32_t> used_offsets;
        for (const auto& ap : new_polys) used_offsets.insert(ap.name_id);

        const auto& old_sp = data.string_pool.data();
        std::vector<uint32_t> sorted_offs(used_offsets.begin(), used_offsets.end());
        std::sort(sorted_offs.begin(), sorted_offs.end());
        std::unordered_map<uint32_t, uint32_t> remap;
        std::vector<char> compact_sp;
        compact_sp.reserve(used_offsets.size() * 16);
        for (uint32_t off : sorted_offs) {
            remap[off] = static_cast<uint32_t>(compact_sp.size());
            size_t len = std::strlen(old_sp.data() + off);
            compact_sp.insert(compact_sp.end(), old_sp.data() + off, old_sp.data() + off + len + 1);
        }

        // Remap and rewrite polygons with new string offsets
        for (auto& np : new_polys) np.name_id = remap[np.name_id];
        {
            std::ofstream f(output_dir + "/admin_polygons.bin", std::ios::binary);
            f.write(reinterpret_cast<const char*>(new_polys.data()), new_polys.size() * sizeof(AdminPolygon));
        }
        {
            std::ofstream f(output_dir + "/strings.bin", std::ios::binary);
            f.write(compact_sp.data(), compact_sp.size());
        }
    }

    // Link/copy the cell index files from source (S2 covering is quality-independent)
    for (const char* fname : {"admin_cells.bin", "admin_entries.bin"}) {
        std::string src = source_dir + "/" + fname;
        std::string dst = output_dir + "/" + fname;
        // Try hardlink first, fall back to copy
        if (link(src.c_str(), dst.c_str()) != 0) {
            std::ifstream in(src, std::ios::binary);
            std::ofstream out(dst, std::ios::binary);
            out << in.rdbuf();
        }
    }
}