//! Lossless-in-the-renderer-sense rewrite of decoded Makepad vector tiles. use crate::mkmap::{ content_hash, encode_leaf_directory, write_root_index, BlobRef, LeafEntry, RootIndex, RootRecord, SHARD_HARD_CAP, }; use makepad_mbtile_reader::{ compress_tile, read_pb_len_slice, read_pb_varint, skip_pb_field, MkmapReader, MkmapTileRef, TileCodec, TileCompression, DETAIL_POINT_EXTRA_KEYS, DETAIL_WAY_KEYS, }; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs::{self, File, OpenOptions}; use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use std::sync::{mpsc::sync_channel, Arc, Mutex}; use std::time::{Duration, Instant}; pub const DETAIL_LAYERS: &[&str] = &[ "osm_points", "osm_lines", "osm_polygons", "osm_relation_lines", "osm_relation_polygons", "osm_relation_points", ]; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PolicyAction { Keep, Drop, } #[derive(Clone, Copy, Debug)] pub struct PolicyRow { pub action: PolicyAction, pub item: &'static str, pub reader_or_reason: &'static str, } pub const POLICY_BASE: usize = 0; pub const POLICY_DETAIL: usize = 1; pub const POLICY_REGIONS: usize = 2; pub const POLICY_BUILDINGS: usize = 3; pub const POLICY_FILLS: usize = 4; pub const POLICY_TAGS: usize = 5; pub const POLICY_SYNTHETIC: usize = 6; pub const POLICY_SHADOWS: usize = 7; /// The archive data contract. Keep this table in lock-step with renderer /// reads; reports use the same rows, so every removed byte has a stated case. pub const DATA_POLICY: [PolicyRow; 8] = [ PolicyRow { action: PolicyAction::Keep, item: "shortbread base layers (non-osm_*)", reader_or_reason: "widgets/src/map/tile.rs: LayerParseFilter::BaseNoDetailLayers", }, PolicyRow { action: PolicyAction::Keep, item: "six osm_* detail layers: geometry + whitelisted tags", reader_or_reason: "widgets/src/map/tile.rs: LayerParseFilter::DetailLayers/tag_key_whitelist", }, PolicyRow { action: PolicyAction::Keep, item: "field 101 painter-cascade REGIONS", reader_or_reason: "widgets/src/map/tile.rs: parse_baked_faces/bake.regions cascade hit", }, PolicyRow { action: PolicyAction::Keep, item: "field 101 v4 building groups", reader_or_reason: "widgets/src/map/tile.rs: bake.building_signature/bake.buildings substitution", }, PolicyRow { action: PolicyAction::Keep, item: "field 100 baked fill triangulations", reader_or_reason: "widgets/src/map/tile.rs: parse_baked_fills", }, PolicyRow { action: PolicyAction::Drop, item: "osm_* tags outside DETAIL_WAY_KEYS + DETAIL_POINT_EXTRA_KEYS", reader_or_reason: "discarded by widgets/src/map/tile.rs: tag_key_whitelist", }, PolicyRow { action: PolicyAction::Drop, item: "__makepad_osm_id/type/closed", reader_or_reason: "renderer-tree matches are cfg(test) probes; production only writes them", }, PolicyRow { action: PolicyAction::Drop, item: "field 101 shadow shapes + grounded footprints", reader_or_reason: "widgets/src/map/view.rs: draw_shadow_mask_pass derives live shadows", }, ]; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TileRewriteStats { pub decoded_before: u64, pub decoded_after: u64, pub savings: [u64; DATA_POLICY.len()], } impl TileRewriteStats { pub fn add_assign(&mut self, other: &Self) { self.decoded_before += other.decoded_before; self.decoded_after += other.decoded_after; for (left, right) in self.savings.iter_mut().zip(other.savings) { *left += right; } } } #[derive(Clone, Copy)] struct PbField { start: usize, end: usize, field: u32, wire: u8, payload_start: usize, payload_end: usize, } fn next_field(bytes: &[u8], pos: &mut usize) -> Result { let start = *pos; let key = read_pb_varint(bytes, pos)?; let field = u32::try_from(key >> 3).map_err(|_| "protobuf field number overflow".to_string())?; let wire = (key & 7) as u8; if field == 0 { return Err("protobuf field zero".to_string()); } let payload_start; let payload_end; if wire == 2 { let payload = read_pb_len_slice(bytes, pos)?; payload_start = payload.as_ptr() as usize - bytes.as_ptr() as usize; payload_end = payload_start + payload.len(); } else { payload_start = *pos; skip_pb_field(bytes, pos, wire)?; payload_end = *pos; } Ok(PbField { start, end: *pos, field, wire, payload_start, payload_end, }) } fn write_varint(mut value: u64, out: &mut Vec) { while value >= 0x80 { out.push((value as u8 & 0x7f) | 0x80); value >>= 7; } out.push(value as u8); } fn write_len_field(field: u32, payload: &[u8], out: &mut Vec) { write_varint(u64::from(field) << 3 | 2, out); write_varint(payload.len() as u64, out); out.extend_from_slice(payload); } fn is_detail_layer(name: &str) -> bool { DETAIL_LAYERS.contains(&name) } fn is_synthetic_key(key: &str) -> bool { matches!( key, "__makepad_osm_id" | "__makepad_osm_type" | "__makepad_osm_closed" ) } pub fn detail_key_allowed(key: &str) -> bool { DETAIL_WAY_KEYS.contains(&key) || DETAIL_POINT_EXTRA_KEYS.contains(&key) } fn layer_name(layer: &[u8]) -> Result<&str, String> { let mut pos = 0; while pos < layer.len() { let field = next_field(layer, &mut pos)?; if field.field == 1 && field.wire == 2 { return std::str::from_utf8(&layer[field.payload_start..field.payload_end]) .map_err(|_| "MVT layer name is not UTF-8".to_string()); } } Err("MVT layer has no name".to_string()) } fn feature_tags(feature: &[u8]) -> Result, String> { let mut tags = Vec::new(); let mut pos = 0; while pos < feature.len() { let field = next_field(feature, &mut pos)?; if field.field == 2 { if field.wire != 2 { return Err("MVT feature tags are not packed".to_string()); } let mut packed_pos = field.payload_start; while packed_pos < field.payload_end { let key = usize::try_from(read_pb_varint(feature, &mut packed_pos)?) .map_err(|_| "MVT key index overflow".to_string())?; let value = usize::try_from(read_pb_varint(feature, &mut packed_pos)?) .map_err(|_| "MVT value index overflow".to_string())?; tags.push((key, value)); } if packed_pos != field.payload_end { return Err("MVT packed tags overrun".to_string()); } } } Ok(tags) } struct LayerTables<'a> { fields: Vec, keys: Vec<&'a str>, values: Vec<&'a [u8]>, features: Vec<&'a [u8]>, } fn parse_layer_tables(layer: &[u8]) -> Result, String> { let mut fields = Vec::new(); let mut keys = Vec::new(); let mut values = Vec::new(); let mut features = Vec::new(); let mut pos = 0; while pos < layer.len() { let field = next_field(layer, &mut pos)?; match (field.field, field.wire) { (2, 2) => features.push(&layer[field.payload_start..field.payload_end]), (3, 2) => keys.push( std::str::from_utf8(&layer[field.payload_start..field.payload_end]) .map_err(|_| "MVT key is not UTF-8".to_string())?, ), (4, 2) => values.push(&layer[field.payload_start..field.payload_end]), _ => {} } fields.push(field); } Ok(LayerTables { fields, keys, values, features, }) } fn rewrite_detail_layer_with( layer: &[u8], allowed: impl Fn(&str) -> bool, ) -> Result, String> { let tables = parse_layer_tables(layer)?; let mut kept_tags = Vec::with_capacity(tables.features.len()); let mut used_keys = vec![false; tables.keys.len()]; let mut used_values = vec![false; tables.values.len()]; for feature in &tables.features { let mut tags = Vec::new(); for (key, value) in feature_tags(feature)? { let key_name = tables .keys .get(key) .ok_or_else(|| "MVT feature key index is out of range".to_string())?; if value >= tables.values.len() { return Err("MVT feature value index is out of range".to_string()); } if allowed(key_name) { used_keys[key] = true; used_values[value] = true; tags.push((key, value)); } } kept_tags.push(tags); } let mut key_map = vec![None; tables.keys.len()]; let mut retained_keys = Vec::new(); let mut key_by_name: HashMap<&str, u32> = HashMap::new(); for (old, key) in tables.keys.iter().copied().enumerate() { if !used_keys[old] { continue; } let mapped = if let Some(mapped) = key_by_name.get(key) { *mapped } else { let mapped = retained_keys.len() as u32; retained_keys.push(key); key_by_name.insert(key, mapped); mapped }; key_map[old] = Some(mapped); } let mut value_map = vec![None; tables.values.len()]; let mut retained_values = Vec::new(); let mut value_by_payload: HashMap<&[u8], u32> = HashMap::new(); for (old, value) in tables.values.iter().copied().enumerate() { if !used_values[old] { continue; } let mapped = if let Some(mapped) = value_by_payload.get(value) { *mapped } else { let mapped = retained_values.len() as u32; retained_values.push(value); value_by_payload.insert(value, mapped); mapped }; value_map[old] = Some(mapped); } let mut rewritten_features = Vec::with_capacity(tables.features.len()); for (feature, tags) in tables.features.iter().zip(kept_tags) { let mut packed = Vec::new(); for (key, value) in tags { write_varint(u64::from(key_map[key].unwrap()), &mut packed); write_varint(u64::from(value_map[value].unwrap()), &mut packed); } let rewritten = rewrite_feature_filtered(feature, &packed)?; rewritten_features.push(rewritten); } let mut out = Vec::with_capacity(layer.len()); let mut feature_index = 0; let mut wrote_keys = false; let mut wrote_values = false; for field in tables.fields { match (field.field, field.wire) { (2, 2) => { write_len_field(2, &rewritten_features[feature_index], &mut out); feature_index += 1; } (3, 2) => { if !wrote_keys { for key in &retained_keys { write_len_field(3, key.as_bytes(), &mut out); } wrote_keys = true; } } (4, 2) => { if !wrote_values { for value in &retained_values { write_len_field(4, value, &mut out); } wrote_values = true; } } _ => out.extend_from_slice(&layer[field.start..field.end]), } } Ok(out) } fn rewrite_feature_filtered( feature: &[u8], packed: &[u8], ) -> Result, String> { let mut out = Vec::with_capacity(feature.len()); let mut pos = 0; let mut wrote_tags = false; while pos < feature.len() { let field = next_field(feature, &mut pos)?; if field.field == 2 { if !wrote_tags && !packed.is_empty() { write_len_field(2, packed, &mut out); } wrote_tags = true; } else { out.extend_from_slice(&feature[field.start..field.end]); } } Ok(out) } fn fnv_step(hash: &mut u64, bytes: &[u8]) { for byte in bytes { *hash ^= u64::from(*byte); *hash = hash.wrapping_mul(0x100_0000_01b3); } } fn scan_shapes( bytes: &[u8], pos: &mut usize, first: &mut u64, mut second: Option<&mut u64>, ) -> Result<(), String> { let shapes = usize::try_from(read_pb_varint(bytes, pos)?) .map_err(|_| "field 101 shape count overflow".to_string())?; if shapes > 1_000_000 { return Err("field 101 shape count exceeds limit".to_string()); } for _ in 0..shapes { let rings = usize::try_from(read_pb_varint(bytes, pos)?) .map_err(|_| "field 101 ring count overflow".to_string())?; if rings > 1_000_000 { return Err("field 101 ring count exceeds limit".to_string()); } for _ in 0..rings { let points = usize::try_from(read_pb_varint(bytes, pos)?) .map_err(|_| "field 101 point count overflow".to_string())?; if points > 4_000_000 { return Err("field 101 point count exceeds limit".to_string()); } let (mut x, mut y) = (0_i64, 0_i64); for _ in 0..points { x = x.wrapping_add(zigzag_decode(read_pb_varint(bytes, pos)?)); y = y.wrapping_add(zigzag_decode(read_pb_varint(bytes, pos)?)); fnv_step(first, &x.to_le_bytes()); fnv_step(first, &y.to_le_bytes()); if let Some(hash) = second.as_deref_mut() { fnv_step(hash, &x.to_le_bytes()); fnv_step(hash, &y.to_le_bytes()); } } } } Ok(()) } fn zigzag_decode(value: u64) -> i64 { ((value >> 1) as i64) ^ -((value & 1) as i64) } fn rewrite_baked_faces(blob: &[u8]) -> Result, String> { let version = *blob.first().ok_or_else(|| "empty field 101".to_string())?; if version != 3 && version != 4 { return Err(format!("unsupported field 101 version {version}")); } let mut pos = 1; let bucket_count = usize::try_from(read_pb_varint(blob, &mut pos)?) .map_err(|_| "field 101 bucket count overflow".to_string())?; if bucket_count > 1024 { return Err("field 101 bucket count exceeds limit".to_string()); } let prefix_end = pos; let mut out = Vec::with_capacity(blob.len()); out.extend_from_slice(&blob[..prefix_end]); for _ in 0..bucket_count { let bucket_start = pos; let _ = read_pb_varint(blob, &mut pos)?; let signature_end = pos .checked_add(8) .filter(|end| *end <= blob.len()) .ok_or_else(|| "truncated field 101 signature".to_string())?; pos = signature_end; let checksum_pos = pos; let checksum_end = pos .checked_add(8) .filter(|end| *end <= blob.len()) .ok_or_else(|| "truncated field 101 checksum".to_string())?; let stored_checksum = u64::from_le_bytes(blob[pos..checksum_end].try_into().unwrap()); pos = checksum_end; let body_len = usize::try_from(read_pb_varint(blob, &mut pos)?) .map_err(|_| "field 101 body length overflow".to_string())?; let body_end = pos .checked_add(body_len) .filter(|end| *end <= blob.len()) .ok_or_else(|| "truncated field 101 body".to_string())?; let body = &blob[pos..body_end]; pos = body_end; let mut bpos = 0; let mut old_checksum = 0xcbf2_9ce4_8422_2325; let mut new_checksum = old_checksum; let regions = usize::try_from(read_pb_varint(body, &mut bpos)?) .map_err(|_| "field 101 region count overflow".to_string())?; if regions > 100_000 { return Err("field 101 region count exceeds limit".to_string()); } for _ in 0..regions { let _ = read_pb_varint(body, &mut bpos)?; for _ in 0..3 { scan_shapes( body, &mut bpos, &mut old_checksum, Some(&mut new_checksum), )?; } } let regions_end = bpos; bpos = bpos .checked_add(8) .filter(|end| *end <= body.len()) .ok_or_else(|| "truncated field 101 shadow signature".to_string())?; scan_shapes(body, &mut bpos, &mut old_checksum, None)?; scan_shapes(body, &mut bpos, &mut old_checksum, None)?; let buildings_start = bpos; if version == 4 { bpos = bpos .checked_add(8) .filter(|end| *end <= body.len()) .ok_or_else(|| "truncated field 101 building signature".to_string())?; let groups = usize::try_from(read_pb_varint(body, &mut bpos)?) .map_err(|_| "field 101 building count overflow".to_string())?; if groups > 100_000 { return Err("field 101 building count exceeds limit".to_string()); } for _ in 0..groups { let _ = read_pb_varint(body, &mut bpos)?; let _ = read_pb_varint(body, &mut bpos)?; scan_shapes( body, &mut bpos, &mut old_checksum, Some(&mut new_checksum), )?; } } if bpos != body.len() { return Err("field 101 body has trailing bytes".to_string()); } if old_checksum != stored_checksum { return Err(format!( "field 101 checksum mismatch: stored {stored_checksum:016x}, got {old_checksum:016x}" )); } let mut new_body = Vec::with_capacity(body.len()); new_body.extend_from_slice(&body[..regions_end]); new_body.extend_from_slice(&0_u64.to_le_bytes()); write_varint(0, &mut new_body); write_varint(0, &mut new_body); new_body.extend_from_slice(&body[buildings_start..]); out.extend_from_slice(&blob[bucket_start..checksum_pos]); out.extend_from_slice(&new_checksum.to_le_bytes()); write_varint(new_body.len() as u64, &mut out); out.extend_from_slice(&new_body); } if pos != blob.len() { return Err("field 101 has trailing bytes".to_string()); } Ok(out) } fn varint_len(mut value: u64) -> usize { let mut len = 1; while value >= 0x80 { len += 1; value >>= 7; } len } fn len_field_size(field: u32, payload_len: usize) -> usize { varint_len(u64::from(field) << 3 | 2) + varint_len(payload_len as u64) + payload_len } /// Rewrite one decoded tile protobuf according to [`DATA_POLICY`]. pub fn rewrite_tile(input: &[u8]) -> Result<(Vec, TileRewriteStats), String> { let mut out = Vec::with_capacity(input.len()); let mut stats = TileRewriteStats { decoded_before: input.len() as u64, ..Default::default() }; let mut pos = 0; while pos < input.len() { let field = next_field(input, &mut pos)?; match (field.field, field.wire) { (3, 2) => { let layer = &input[field.payload_start..field.payload_end]; if is_detail_layer(layer_name(layer)?) { let final_layer = rewrite_detail_layer_with(layer, detail_key_allowed)?; let no_synthetic = rewrite_detail_layer_with(layer, |key| !is_synthetic_key(key))?; let old_size = field.end - field.start; let total = old_size.saturating_sub(len_field_size(3, final_layer.len())) as u64; let synthetic = old_size .saturating_sub(len_field_size(3, no_synthetic.len())) as u64; stats.savings[POLICY_SYNTHETIC] += synthetic.min(total); stats.savings[POLICY_TAGS] += total.saturating_sub(synthetic); write_len_field(3, &final_layer, &mut out); } else { out.extend_from_slice(&input[field.start..field.end]); } } (101, 2) => { let rewritten = rewrite_baked_faces(&input[field.payload_start..field.payload_end])?; stats.savings[POLICY_SHADOWS] += (field.end - field.start) .saturating_sub(len_field_size(101, rewritten.len())) as u64; write_len_field(101, &rewritten, &mut out); } _ => out.extend_from_slice(&input[field.start..field.end]), } } stats.decoded_after = out.len() as u64; Ok((out, stats)) } /// Strict verifier used by both unit tests and `makepad-map-repack --verify`. pub fn verify_rewritten_tile(before: &[u8], after: &[u8]) -> Result<(), String> { let expected = rewrite_tile(before)?.0; if expected != after { return Err("repacked tile differs from the deterministic policy rewrite".to_string()); } let again = rewrite_tile(after)?.0; if again != after { return Err("repacked tile is not idempotent".to_string()); } verify_kept_sections(before, after) } #[derive(Default)] struct TileSections { base_layers: Vec<(String, Vec)>, detail_geometry: Vec<(String, Vec>)>, detail_tags: Vec<(String, Vec)>>)>, fills: Vec>, faces: Vec<(u64, Vec, Vec, bool)>, } fn feature_without_tags(feature: &[u8]) -> Result, String> { let mut out = Vec::new(); let mut pos = 0; while pos < feature.len() { let field = next_field(feature, &mut pos)?; if field.field != 2 { out.extend_from_slice(&feature[field.start..field.end]); } } Ok(out) } fn face_sections(blob: &[u8]) -> Result, Vec, bool)>, String> { let version = *blob.first().ok_or_else(|| "empty field 101".to_string())?; if version != 3 && version != 4 { return Err(format!("unsupported field 101 version {version}")); } let mut pos = 1; let count = usize::try_from(read_pb_varint(blob, &mut pos)?) .map_err(|_| "field 101 count overflow".to_string())?; let mut sections = Vec::with_capacity(count); for _ in 0..count { let bucket = read_pb_varint(blob, &mut pos)?; pos = pos .checked_add(16) .filter(|end| *end <= blob.len()) .ok_or_else(|| "truncated field 101 bucket header".to_string())?; let len = usize::try_from(read_pb_varint(blob, &mut pos)?) .map_err(|_| "field 101 body overflow".to_string())?; let body = blob .get(pos..pos + len) .ok_or_else(|| "truncated field 101 body".to_string())?; pos += len; let mut bpos = 0; let regions = read_pb_varint(body, &mut bpos)? as usize; let mut discard = 0xcbf2_9ce4_8422_2325; for _ in 0..regions { let _ = read_pb_varint(body, &mut bpos)?; for _ in 0..3 { scan_shapes(body, &mut bpos, &mut discard, None)?; } } let region_bytes = body[..bpos].to_vec(); bpos += 8; let shadow_shapes_start = bpos; scan_shapes(body, &mut bpos, &mut discard, None)?; let shapes_empty = body[shadow_shapes_start..bpos] == [0]; let footprint_start = bpos; scan_shapes(body, &mut bpos, &mut discard, None)?; let shadows_empty = shapes_empty && body[footprint_start..bpos] == [0]; let buildings = body[bpos..].to_vec(); sections.push((bucket, region_bytes, buildings, shadows_empty)); } Ok(sections) } fn tile_sections(tile: &[u8]) -> Result { let mut sections = TileSections::default(); let mut pos = 0; while pos < tile.len() { let field = next_field(tile, &mut pos)?; match (field.field, field.wire) { (3, 2) => { let layer = &tile[field.payload_start..field.payload_end]; let name = layer_name(layer)?.to_string(); if is_detail_layer(&name) { let tables = parse_layer_tables(layer)?; let geometry = tables .features .iter() .map(|feature| feature_without_tags(feature)) .collect::, _>>()?; let tags = tables .features .iter() .map(|feature| { feature_tags(feature)? .into_iter() .map(|(key, value)| { let key = tables.keys.get(key).ok_or_else(|| { "MVT feature key index is out of range".to_string() })?; let value = tables.values.get(value).ok_or_else(|| { "MVT feature value index is out of range".to_string() })?; Ok(((*key).to_string(), (*value).to_vec())) }) .collect::, String>>() }) .collect::, String>>()?; sections.detail_geometry.push((name.clone(), geometry)); sections.detail_tags.push((name, tags)); } else { sections.base_layers.push((name, layer.to_vec())); } } (100, 2) => sections.fills.push(tile[field.start..field.end].to_vec()), (101, 2) => sections.faces.extend(face_sections( &tile[field.payload_start..field.payload_end], )?), _ => {} } } Ok(sections) } fn verify_detail_tables_compact(tile: &[u8]) -> Result<(), String> { let mut pos = 0; while pos < tile.len() { let field = next_field(tile, &mut pos)?; if (field.field, field.wire) != (3, 2) { continue; } let layer = &tile[field.payload_start..field.payload_end]; if !is_detail_layer(layer_name(layer)?) { continue; } let tables = parse_layer_tables(layer)?; if tables.keys.iter().any(|key| !detail_key_allowed(key)) { return Err("dropped detail tag key remains".to_string()); } let mut used_keys = vec![false; tables.keys.len()]; let mut used_values = vec![false; tables.values.len()]; for feature in &tables.features { for (key, value) in feature_tags(feature)? { let key = used_keys .get_mut(key) .ok_or_else(|| "MVT feature key index is out of range".to_string())?; let value = used_values .get_mut(value) .ok_or_else(|| "MVT feature value index is out of range".to_string())?; *key = true; *value = true; } } if used_keys.iter().any(|used| !used) || used_values.iter().any(|used| !used) { return Err("detail tag table contains an unreferenced entry".to_string()); } if tables.keys.iter().copied().collect::>().len() != tables.keys.len() || tables .values .iter() .copied() .collect::>() .len() != tables.values.len() { return Err("detail tag table contains a duplicate entry".to_string()); } } Ok(()) } fn verify_kept_sections(before: &[u8], after: &[u8]) -> Result<(), String> { verify_detail_tables_compact(after)?; let before = tile_sections(before)?; let after = tile_sections(after)?; if before.base_layers != after.base_layers { return Err("non-osm base layer bytes changed".to_string()); } if before.detail_geometry != after.detail_geometry { return Err("detail feature geometry/non-tag fields changed".to_string()); } let retained_before_tags: Vec<_> = before .detail_tags .iter() .map(|(name, features)| { ( name, features .iter() .map(|tags| { tags.iter() .filter(|(key, _)| detail_key_allowed(key)) .collect::>() }) .collect::>(), ) }) .collect(); let after_tags: Vec<_> = after .detail_tags .iter() .map(|(name, features)| { ( name, features .iter() .map(|tags| tags.iter().collect::>()) .collect::>(), ) }) .collect(); if retained_before_tags != after_tags { return Err("retained detail tag key/value pairs changed".to_string()); } if before.fills != after.fills { return Err("field 100 bytes changed".to_string()); } if before.faces.len() != after.faces.len() { return Err("field 101 bucket count changed".to_string()); } for (left, right) in before.faces.iter().zip(&after.faces) { if left.0 != right.0 || left.1 != right.1 { return Err("field 101 REGIONS bytes changed".to_string()); } if left.2 != right.2 { return Err("field 101 building-group bytes changed".to_string()); } if !right.3 { return Err("field 101 shadow stub is not empty".to_string()); } } Ok(()) } pub const TILE_BROTLI_QUALITY: u32 = 11; const MANIFEST_MAGIC: &[u8; 8] = b"MKRPMF02"; #[derive(Clone, Debug)] pub enum TileSelection { All, HilbertRange { start: u64, end: u64 }, Explicit(BTreeSet), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ShardRange { pub start: usize, pub end: usize, } impl ShardRange { fn contains(self, shard: usize) -> bool { (self.start..self.end).contains(&shard) } } impl TileSelection { fn contains(&self, id: u64) -> bool { match self { Self::All => true, Self::HilbertRange { start, end } => (*start..=*end).contains(&id), Self::Explicit(ids) => ids.contains(&id), } } fn fingerprint(&self) -> u128 { let mut bytes = Vec::new(); match self { Self::All => bytes.push(0), Self::HilbertRange { start, end } => { bytes.push(1); bytes.extend_from_slice(&start.to_le_bytes()); bytes.extend_from_slice(&end.to_le_bytes()); } Self::Explicit(ids) => { bytes.push(2); for id in ids { bytes.extend_from_slice(&id.to_le_bytes()); } } } content_hash(&bytes) } } #[derive(Clone, Debug)] pub struct RepackOptions { pub input: PathBuf, pub output: PathBuf, pub selection: TileSelection, pub dry_run: bool, pub verify: bool, pub resume: bool, pub jobs: usize, pub brotli_quality: u32, pub verify_shards: Option, pub log: Option, } #[derive(Clone, Debug, Default)] pub struct RepackReport { pub shards: u32, pub tiles: u64, pub unique_blobs: u64, pub decoded_before: u64, pub decoded_after: u64, pub compressed_before: u64, pub compressed_after: u64, pub savings: [u64; DATA_POLICY.len()], } impl RepackReport { fn add_manifest(&mut self, manifest: &ShardManifest) { self.shards += 1; self.tiles += manifest.tile_count; self.unique_blobs += manifest.unique_blobs; self.decoded_before += manifest.decoded_before; self.decoded_after += manifest.decoded_after; self.compressed_before += manifest.compressed_before; self.compressed_after += manifest.compressed_after; for (total, shard) in self.savings.iter_mut().zip(manifest.savings) { *total += shard; } } } #[derive(Clone, Debug)] struct ShardManifest { input_root_hash: u128, selection_hash: u128, input_record: u32, output_shard: u32, brotli_quality: u32, file_len: u64, tile_count: u64, unique_blobs: u64, start_tile_id: u64, end_tile_id: u64, dir_offset: u64, dir_len: u64, decoded_before: u64, decoded_after: u64, compressed_before: u64, compressed_after: u64, savings: [u64; DATA_POLICY.len()], min_zoom: u8, max_zoom: u8, } fn shard_path(dir: &Path, shard: u32) -> PathBuf { dir.join(format!("tiles-{shard:03}.mkshard")) } fn manifest_path(dir: &Path, shard: u32) -> PathBuf { dir.join(format!("tiles-{shard:03}.mkrepack")) } fn encode_manifest(manifest: &ShardManifest) -> Vec { let mut out = Vec::with_capacity(256); out.extend_from_slice(MANIFEST_MAGIC); out.extend_from_slice(&manifest.input_root_hash.to_le_bytes()); out.extend_from_slice(&manifest.selection_hash.to_le_bytes()); out.extend_from_slice(&manifest.input_record.to_le_bytes()); out.extend_from_slice(&manifest.output_shard.to_le_bytes()); out.extend_from_slice(&manifest.brotli_quality.to_le_bytes()); for value in [ manifest.file_len, manifest.tile_count, manifest.unique_blobs, manifest.start_tile_id, manifest.end_tile_id, manifest.dir_offset, manifest.dir_len, manifest.decoded_before, manifest.decoded_after, manifest.compressed_before, manifest.compressed_after, ] { out.extend_from_slice(&value.to_le_bytes()); } for value in manifest.savings { out.extend_from_slice(&value.to_le_bytes()); } out.push(manifest.min_zoom); out.push(manifest.max_zoom); out } fn decode_manifest(bytes: &[u8]) -> Result { let expected_len = 8 + 16 + 16 + 4 + 4 + 4 + 11 * 8 + DATA_POLICY.len() * 8 + 2; if bytes.len() != expected_len || bytes.get(..8) != Some(MANIFEST_MAGIC) { return Err("invalid repack shard manifest".to_string()); } let mut pos = 8; let take_u128 = |pos: &mut usize| { let value = u128::from_le_bytes(bytes[*pos..*pos + 16].try_into().unwrap()); *pos += 16; value }; let take_u32 = |pos: &mut usize| { let value = u32::from_le_bytes(bytes[*pos..*pos + 4].try_into().unwrap()); *pos += 4; value }; let take_u64 = |pos: &mut usize| { let value = u64::from_le_bytes(bytes[*pos..*pos + 8].try_into().unwrap()); *pos += 8; value }; let input_root_hash = take_u128(&mut pos); let selection_hash = take_u128(&mut pos); let input_record = take_u32(&mut pos); let output_shard = take_u32(&mut pos); let brotli_quality = take_u32(&mut pos); let file_len = take_u64(&mut pos); let tile_count = take_u64(&mut pos); let unique_blobs = take_u64(&mut pos); let start_tile_id = take_u64(&mut pos); let end_tile_id = take_u64(&mut pos); let dir_offset = take_u64(&mut pos); let dir_len = take_u64(&mut pos); let decoded_before = take_u64(&mut pos); let decoded_after = take_u64(&mut pos); let compressed_before = take_u64(&mut pos); let compressed_after = take_u64(&mut pos); let mut savings = [0; DATA_POLICY.len()]; for value in &mut savings { *value = take_u64(&mut pos); } let min_zoom = bytes[pos]; let max_zoom = bytes[pos + 1]; Ok(ShardManifest { input_root_hash, selection_hash, input_record, output_shard, brotli_quality, file_len, tile_count, unique_blobs, start_tile_id, end_tile_id, dir_offset, dir_len, decoded_before, decoded_after, compressed_before, compressed_after, savings, min_zoom, max_zoom, }) } fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { let partial = path.with_extension("partial"); match fs::remove_file(&partial) { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(format!("remove {}: {err}", partial.display())), } let mut file = File::create(&partial) .map_err(|err| format!("create {}: {err}", partial.display()))?; file.write_all(bytes) .and_then(|_| file.sync_all()) .map_err(|err| format!("write {}: {err}", partial.display()))?; fs::rename(&partial, path) .map_err(|err| format!("rename {} to {}: {err}", partial.display(), path.display())) } fn selected_refs( reader: &mut MkmapReader, record: usize, selection: &TileSelection, ) -> Result, String> { let mut refs = Vec::new(); reader .for_each_root_record_tile_ref(record, |tile| { if selection.contains(tile.tile_id) { refs.push(tile); } }) .map_err(|err| format!("read input root record {record}: {err}"))?; Ok(refs) } fn selection_records( reader: &mut MkmapReader, selection: &TileSelection, ) -> Result, String> { if matches!(selection, TileSelection::All) { return Ok((0..reader.root_record_count()).collect()); } let mut records = Vec::new(); for record in 0..reader.root_record_count() { if !selected_refs(reader, record, selection)?.is_empty() { records.push(record); } } Ok(records) } fn load_completed_manifest( output: &Path, input_root_hash: u128, selection_hash: u128, input_record: u32, output_shard: u32, brotli_quality: u32, ) -> Result, String> { let path = manifest_path(output, output_shard); let shard = shard_path(output, output_shard); if !path.exists() || !shard.exists() { return Ok(None); } let bytes = fs::read(&path).map_err(|err| format!("read {}: {err}", path.display()))?; let Ok(manifest) = decode_manifest(&bytes) else { return Ok(None); }; let actual_len = fs::metadata(&shard) .map_err(|err| format!("stat {}: {err}", shard.display()))? .len(); if manifest.input_root_hash != input_root_hash || manifest.selection_hash != selection_hash || manifest.input_record != input_record || manifest.output_shard != output_shard || manifest.brotli_quality != brotli_quality || manifest.file_len != actual_len || manifest.dir_offset.checked_add(manifest.dir_len) != Some(actual_len) { return Ok(None); } Ok(Some(manifest)) } struct ProgressLog { file: Option>, } impl ProgressLog { fn open(path: Option<&Path>) -> Result { let file = path .map(|path| { OpenOptions::new() .create(true) .append(true) .open(path) .map(BufWriter::new) .map_err(|err| format!("open progress log {}: {err}", path.display())) }) .transpose()?; Ok(Self { file }) } fn line(&mut self, line: &str) -> Result<(), String> { println!("{line}"); if let Some(file) = &mut self.file { writeln!(file, "{line}") .and_then(|_| file.flush()) .map_err(|err| format!("write progress log: {err}"))?; } Ok(()) } } fn display_duration(duration: Duration) -> String { let seconds = duration.as_secs(); format!("{:02}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60) } fn process_shard( reader: &mut MkmapReader, input_record: usize, output_shard: u32, refs: &[MkmapTileRef], codec: &TileCodec, dict: Option<&[u8]>, output: Option<&Path>, input_root_hash: u128, selection_hash: u128, jobs: usize, brotli_quality: u32, ) -> Result { if refs.is_empty() { return Err("cannot write an empty output shard".to_string()); } let final_path = output.map(|dir| shard_path(dir, output_shard)); let partial_path = final_path.as_ref().map(|path| path.with_extension("partial")); if let Some(path) = partial_path.as_ref() { match fs::remove_file(path) { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(format!("remove {}: {err}", path.display())), } } let file = match partial_path.as_ref() { Some(path) => Some( File::create(path).map_err(|err| format!("create {}: {err}", path.display()))?, ), None => None, }; let mut writer = file.map(|file| BufWriter::with_capacity(4 * 1024 * 1024, file)); let mut offset = 0_u64; let mut entries = Vec::with_capacity(refs.len()); let mut dedup: HashMap = HashMap::new(); let mut tile_stats = TileRewriteStats::default(); let mut compressed_before = 0_u64; let mut compressed_after = 0_u64; let mut min_zoom = u8::MAX; let mut max_zoom = 0_u8; struct TileOutput { tile: MkmapTileRef, source_len: u64, compressed: Vec, stats: TileRewriteStats, } let jobs = jobs.max(1); let window = jobs.saturating_mul(2).max(1); let pipeline_result = std::thread::scope(|scope| -> Result<(), String> { let (job_tx, job_rx) = sync_channel::<(usize, MkmapTileRef, Vec)>(jobs); let job_rx = Arc::new(Mutex::new(job_rx)); let (result_tx, result_rx) = sync_channel::<(usize, Result)>(jobs); let (permit_tx, permit_rx) = sync_channel::<()>(window); for _ in 0..window { permit_tx.send(()).unwrap(); } let producer = scope.spawn(move || -> Result<(), String> { for (seq, tile) in refs.iter().copied().enumerate() { permit_rx .recv() .map_err(|_| "tile pipeline stopped before input was read".to_string())?; let source = reader.read_tile_ref(&tile).map_err(|err| { format!("read z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y) })?; job_tx .send((seq, tile, source)) .map_err(|_| "tile pipeline stopped before input was queued".to_string())?; } Ok(()) }); let mut workers = Vec::with_capacity(jobs); for _ in 0..jobs { let job_rx = Arc::clone(&job_rx); let result_tx = result_tx.clone(); workers.push(scope.spawn(move || -> Result<(), String> { loop { let job = job_rx .lock() .map_err(|_| "tile job queue mutex poisoned".to_string())? .recv(); let Ok((seq, tile, source)) = job else { return Ok(()); }; let result = (|| { let decoded = codec.decode(&source).map_err(|err| { format!("decode z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y) })?; let (rewritten, stats) = rewrite_tile(&decoded).map_err(|err| { format!("rewrite z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y) })?; let compressed = compress_tile( &TileCompression::Brotli { quality: brotli_quality, }, dict, &rewritten, ) .map_err(|err| { format!("compress z{}/{}/{}: {err}", tile.zoom, tile.x, tile.y) })?; Ok(TileOutput { tile, source_len: source.len() as u64, compressed, stats, }) })(); result_tx .send((seq, result)) .map_err(|_| "tile result writer stopped".to_string())?; } })); } drop(result_tx); let mut next = 0_usize; let mut pending = BTreeMap::new(); let mut first_error = None; for (seq, result) in result_rx { pending.insert(seq, result); while let Some(result) = pending.remove(&next) { match result { Ok(result) if first_error.is_none() => { let hash = content_hash(&result.compressed); let blob = if let Some(blob) = dedup.get(&hash) { *blob } else { let blob = BlobRef { shard: output_shard, offset, len: result.compressed.len() as u64, }; if let Some(writer) = writer.as_mut() { writer.write_all(&result.compressed).map_err(|err| { format!("write output shard {output_shard}: {err}") })?; } offset += result.compressed.len() as u64; dedup.insert(hash, blob); blob }; entries.push(LeafEntry { tile_id: result.tile.tile_id, blob, }); tile_stats.add_assign(&result.stats); compressed_before += result.source_len; compressed_after += result.compressed.len() as u64; min_zoom = min_zoom.min(result.tile.zoom); max_zoom = max_zoom.max(result.tile.zoom); } Ok(_) => {} Err(error) => { if first_error.is_none() { first_error = Some(error); } } } next += 1; let _ = permit_tx.send(()); } } let producer_result = producer .join() .map_err(|_| "tile input thread panicked".to_string())?; for worker in workers { worker .join() .map_err(|_| "tile worker thread panicked".to_string())??; } producer_result?; if next != refs.len() { return Err(format!( "tile pipeline returned {next} of {} results", refs.len() )); } if let Some(error) = first_error { return Err(error); } Ok(()) }); pipeline_result?; let directory = encode_leaf_directory(&entries)?; let dir_offset = offset; let dir_len = directory.len() as u64; let file_len = dir_offset + dir_len; if file_len >= SHARD_HARD_CAP { return Err(format!( "output shard {output_shard} is {file_len} bytes, cap {SHARD_HARD_CAP}" )); } if let Some(mut writer) = writer { writer .write_all(&directory) .and_then(|_| writer.flush()) .map_err(|err| format!("finish output shard {output_shard}: {err}"))?; writer .get_ref() .sync_all() .map_err(|err| format!("sync output shard {output_shard}: {err}"))?; drop(writer); let partial = partial_path.as_ref().unwrap(); let final_path = final_path.as_ref().unwrap(); fs::rename(partial, final_path).map_err(|err| { format!("rename {} to {}: {err}", partial.display(), final_path.display()) })?; } let manifest = ShardManifest { input_root_hash, selection_hash, input_record: input_record as u32, output_shard, brotli_quality, file_len, tile_count: entries.len() as u64, unique_blobs: dedup.len() as u64, start_tile_id: entries.first().unwrap().tile_id, end_tile_id: entries.last().unwrap().tile_id, dir_offset, dir_len, decoded_before: tile_stats.decoded_before, decoded_after: tile_stats.decoded_after, compressed_before, compressed_after, savings: tile_stats.savings, min_zoom, max_zoom, }; if let Some(output) = output { write_atomic(&manifest_path(output, output_shard), &encode_manifest(&manifest))?; } Ok(manifest) } fn verify_archives( input: &Path, output: &Path, selection: &TileSelection, shard_range: Option, ) -> Result { let mut source = MkmapReader::open(input) .map_err(|err| format!("open input {}: {err}", input.display()))?; let mut repacked = MkmapReader::open(output) .map_err(|err| format!("open output {}: {err}", output.display()))?; let source_codec = TileCodec::from_metadata( &source .get_metadata() .map_err(|err| format!("input metadata: {err}"))?, ) .map_err(|err| format!("input codec: {err}"))?; let output_codec = TileCodec::from_metadata( &repacked .get_metadata() .map_err(|err| format!("output metadata: {err}"))?, ) .map_err(|err| format!("output codec: {err}"))?; let records = selection_records(&mut source, selection)?; if repacked.root_record_count() != records.len() { return Err(format!( "output root record count {} differs from selected input count {}", repacked.root_record_count(), records.len() )); } if let Some(range) = shard_range { if range.start >= range.end || range.end > records.len() { return Err(format!( "--shards range {}..{} is outside 0..{}", range.start, range.end, records.len() )); } } let mut verified_count = 0_u64; for (output_record, input_record) in records.into_iter().enumerate() { if shard_range.is_some_and(|range| !range.contains(output_record)) { continue; } let input_refs = selected_refs(&mut source, input_record, selection)?; let output_refs = selected_refs(&mut repacked, output_record, &TileSelection::All)?; if input_refs.len() != output_refs.len() { return Err(format!( "shard {output_record} tile count differs: input {} output {}", input_refs.len(), output_refs.len() )); } for (tile, output_ref) in input_refs.iter().zip(&output_refs) { if tile.tile_id != output_ref.tile_id { return Err(format!( "shard {output_record} tile order differs: input {} output {}", tile.tile_id, output_ref.tile_id )); } let before_blob = source .read_tile_ref(tile) .map_err(|err| format!("read input tile {}: {err}", tile.tile_id))?; let after_blob = repacked .read_tile_ref(&output_ref) .map_err(|err| format!("read output tile {}: {err}", tile.tile_id))?; let before = source_codec .decode(&before_blob) .map_err(|err| format!("decode input tile {}: {err}", tile.tile_id))?; let after = output_codec .decode(&after_blob) .map_err(|err| format!("decode output tile {}: {err}", tile.tile_id))?; let expected = rewrite_tile(&before) .map_err(|err| format!("verify rewrite tile {}: {err}", tile.tile_id))? .0; if expected != after { return Err(format!( "verify tile {}: repacked tile differs from the deterministic policy rewrite", tile.tile_id )); } verify_kept_sections(&before, &after) .map_err(|err| format!("verify tile {}: {err}", tile.tile_id))?; verified_count += 1; } } Ok(verified_count) } fn write_root_index_atomic(output: &Path, index: &RootIndex<'_>) -> Result { let final_path = output.join("root.mkidx"); let partial_path = output.join("root.partial"); match fs::remove_file(&partial_path) { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(format!("remove {}: {err}", partial_path.display())), } let len = write_root_index(&partial_path, index)?; File::open(&partial_path) .and_then(|file| file.sync_all()) .map_err(|err| format!("sync {}: {err}", partial_path.display()))?; fs::rename(&partial_path, &final_path).map_err(|err| { format!( "rename {} to {}: {err}", partial_path.display(), final_path.display() ) })?; Ok(len) } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RepackStatus { pub completed_shards: usize, pub total_shards: usize, pub compressed_before: u64, pub compressed_after: u64, } pub fn repack_status(options: &RepackOptions) -> Result { if options.brotli_quality > 11 { return Err("--brotli-quality must be in 0..=11".to_string()); } let root_path = options.input.join("root.mkidx"); let root_bytes = fs::read(&root_path) .map_err(|err| format!("read {}: {err}", root_path.display()))?; let input_root_hash = content_hash(&root_bytes); let selection_hash = options.selection.fingerprint(); let mut reader = MkmapReader::open(&options.input) .map_err(|err| format!("open {}: {err}", options.input.display()))?; let records = selection_records(&mut reader, &options.selection)?; let mut status = RepackStatus { total_shards: records.len(), ..Default::default() }; if !options.output.exists() { return Ok(status); } for (output_shard, input_record) in records.into_iter().enumerate() { if let Some(manifest) = load_completed_manifest( &options.output, input_root_hash, selection_hash, input_record as u32, output_shard as u32, options.brotli_quality, )? { status.completed_shards += 1; status.compressed_before += manifest.compressed_before; status.compressed_after += manifest.file_len; } } Ok(status) } pub fn repack_archive(options: &RepackOptions) -> Result { if options.jobs == 0 { return Err("--jobs must be at least 1".to_string()); } if options.brotli_quality > 11 { return Err("--brotli-quality must be in 0..=11".to_string()); } if options.verify_shards.is_some() && !options.verify { return Err("--shards requires --verify".to_string()); } if options.dry_run && options.verify { return Err("--verify requires an output archive and cannot be combined with --dry-run" .to_string()); } let same_path = options.input == options.output || (options.output.exists() && fs::canonicalize(&options.input).ok() == fs::canonicalize(&options.output).ok()); if same_path { return Err("input and output archive directories must differ".to_string()); } let root_path = options.input.join("root.mkidx"); let root_bytes = fs::read(&root_path) .map_err(|err| format!("read {}: {err}", root_path.display()))?; let input_root_hash = content_hash(&root_bytes); let selection_hash = options.selection.fingerprint(); let mut reader = MkmapReader::open(&options.input) .map_err(|err| format!("open {}: {err}", options.input.display()))?; let metadata = reader .get_metadata() .map_err(|err| format!("read input metadata: {err}"))?; let codec = TileCodec::from_metadata(&metadata) .map_err(|err| format!("read input codec: {err}"))?; if !codec.is_brotli() { return Err(format!( "input codec '{}' is unsupported; repack accepts br and br:dict-v1", codec.metadata_value() )); } let dict = codec.dict().map(<[u8]>::to_vec); if reader.shared_dict().unwrap_or(&[]) != dict.as_deref().unwrap_or(&[]) { return Err("root dictionary section differs from codec metadata".to_string()); } let records = selection_records(&mut reader, &options.selection)?; if records.is_empty() { return Err("--tiles selected no input tiles".to_string()); } if !options.dry_run { if options.output.exists() && !options.resume { return Err(format!( "{} already exists; use --resume or choose a new output", options.output.display() )); } fs::create_dir_all(&options.output) .map_err(|err| format!("create {}: {err}", options.output.display()))?; } if let Some(range) = options.verify_shards { if range.start >= range.end || range.end > records.len() { return Err(format!( "--shards range {}..{} is outside 0..{}", range.start, range.end, records.len() )); } } let mut progress = ProgressLog::open(options.log.as_deref())?; let total_shards = records.len(); let run_start = Instant::now(); let mut run_shards = 0_u32; let mut run_tiles = 0_u64; let mut run_output_bytes = 0_u64; let mut manifests = Vec::with_capacity(records.len()); let mut report = RepackReport::default(); for (output_index, input_record) in records.into_iter().enumerate() { let output_shard = output_index as u32; if options.resume && !options.dry_run { if let Some(manifest) = load_completed_manifest( &options.output, input_root_hash, selection_hash, input_record as u32, output_shard, options.brotli_quality, )? { progress.line(&format!( "{}/{total_shards} shard {output_shard:03} {} bytes → {} bytes, {} tiles, resumed, running 0.00 MB/s, ETA --:--:--", output_index + 1, manifest.compressed_before, manifest.file_len, manifest.tile_count, ))?; report.add_manifest(&manifest); manifests.push(manifest); continue; } } let refs = selected_refs(&mut reader, input_record, &options.selection)?; let shard_start = Instant::now(); let manifest = process_shard( &mut reader, input_record, output_shard, &refs, &codec, dict.as_deref(), (!options.dry_run).then_some(options.output.as_path()), input_root_hash, selection_hash, options.jobs, options.brotli_quality, )?; let shard_elapsed = shard_start.elapsed(); run_shards += 1; run_tiles += manifest.tile_count; run_output_bytes += manifest.file_len; report.add_manifest(&manifest); let remaining = total_shards - output_index - 1; let eta = if run_shards == 0 { None } else { Some(run_start.elapsed().mul_f64(remaining as f64 / f64::from(run_shards))) }; let mib_per_second = run_output_bytes as f64 / 1_048_576.0 / run_start.elapsed().as_secs_f64().max(f64::EPSILON); progress.line(&format!( "{}/{total_shards} shard {output_shard:03} {} bytes → {} bytes, {} tiles, {}, running {:.2} MB/s, ETA {}", output_index + 1, manifest.compressed_before, manifest.file_len, manifest.tile_count, display_duration(shard_elapsed), mib_per_second, eta.map(display_duration).unwrap_or_else(|| "--:--:--".to_string()), ))?; manifests.push(manifest); } if !options.dry_run { let roots: Vec<_> = manifests .iter() .map(|manifest| RootRecord { start_tile_id: manifest.start_tile_id, end_tile_id: manifest.end_tile_id, shard: manifest.output_shard, dir_offset: manifest.dir_offset, dir_len: manifest.dir_len, }) .collect(); let min_zoom = manifests.iter().map(|manifest| manifest.min_zoom).min().unwrap(); let max_zoom = manifests.iter().map(|manifest| manifest.max_zoom).max().unwrap(); write_root_index_atomic( &options.output, &RootIndex { metadata: &metadata, dict: dict.as_deref(), shard_cap: SHARD_HARD_CAP, tile_count: report.tiles, unique_blobs: report.unique_blobs, min_zoom, max_zoom, records: &roots, }, )?; if options.verify { let verified = verify_archives( &options.input, &options.output, &options.selection, options.verify_shards, )?; progress.line(&format!( "verify: OK — {verified} tiles decoded once per archive and policy-checked" ))?; } } progress.line(&format!( "total: {} shards, {} tiles, {} bytes in → {} bytes out, elapsed {}, running {:.2} tiles/s, {:.2} MB/s; {}", report.shards, report.tiles, report.compressed_before, manifests.iter().map(|manifest| manifest.file_len).sum::(), display_duration(run_start.elapsed()), run_tiles as f64 / run_start.elapsed().as_secs_f64().max(f64::EPSILON), run_output_bytes as f64 / 1_048_576.0 / run_start.elapsed().as_secs_f64().max(f64::EPSILON), if options.dry_run { "root.mkidx not written (dry run)" } else { "root.mkidx written at end" }, ))?; println!("action | data | decoded bytes saved | reader / reason"); for (row, saved) in DATA_POLICY.iter().zip(report.savings) { println!( "{} | {} | {} | {}", match row.action { PolicyAction::Keep => "KEEP", PolicyAction::Drop => "DROP", }, row.item, saved, row.reader_or_reason ); } Ok(report) } #[cfg(test)] mod tests { use super::*; fn fixture_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../seed-files/amsterdam-tiles") } #[test] fn detail_tables_are_compacted_without_touching_geometry() { let mut feature = Vec::new(); let mut tags = Vec::new(); for value in [0_u64, 0, 1, 1, 2, 2] { write_varint(value, &mut tags); } write_len_field(2, &tags, &mut feature); write_len_field(4, &[9, 2, 2, 0], &mut feature); let mut layer = Vec::new(); write_len_field(1, b"osm_points", &mut layer); write_len_field(2, &feature, &mut layer); for key in ["name", "addr:housenumber", "__makepad_osm_id"] { write_len_field(3, key.as_bytes(), &mut layer); } for value in [b"kept".as_slice(), b"gone", b"42"] { let mut message = Vec::new(); write_len_field(1, value, &mut message); write_len_field(4, &message, &mut layer); } let mut tile = Vec::new(); write_len_field(3, &layer, &mut tile); let (rewritten, stats) = rewrite_tile(&tile).unwrap(); verify_rewritten_tile(&tile, &rewritten).unwrap(); let sections = tile_sections(&rewritten).unwrap(); let retained_keys: Vec<_> = sections .detail_tags .iter() .flat_map(|(_, features)| features.iter()) .flat_map(|tags| tags.iter().map(|(key, _)| key.as_str())) .collect(); assert_eq!(retained_keys, ["name"]); assert!(stats.savings[POLICY_TAGS] > 0); assert!(stats.savings[POLICY_SYNTHETIC] > 0); } fn test_write_shapes(points: &[(i64, i64)], out: &mut Vec, checksum: &mut u64) { write_varint(usize::from(!points.is_empty()) as u64, out); if points.is_empty() { return; } write_varint(1, out); write_varint(points.len() as u64, out); let (mut previous_x, mut previous_y) = (0_i64, 0_i64); for &(x, y) in points { let delta_x = x - previous_x; let delta_y = y - previous_y; write_varint(((delta_x << 1) ^ (delta_x >> 63)) as u64, out); write_varint(((delta_y << 1) ^ (delta_y >> 63)) as u64, out); fnv_step(checksum, &x.to_le_bytes()); fnv_step(checksum, &y.to_le_bytes()); previous_x = x; previous_y = y; } } fn test_faces_tile(version: u8) -> Vec { let mut body = Vec::new(); let mut checksum = 0xcbf2_9ce4_8422_2325; write_varint(1, &mut body); write_varint(7, &mut body); test_write_shapes(&[(2, 3), (5, 7)], &mut body, &mut checksum); test_write_shapes(&[], &mut body, &mut checksum); test_write_shapes(&[], &mut body, &mut checksum); body.extend_from_slice(&123_u64.to_le_bytes()); test_write_shapes(&[(9, 11)], &mut body, &mut checksum); test_write_shapes(&[(1, 2)], &mut body, &mut checksum); if version == 4 { body.extend_from_slice(&456_u64.to_le_bytes()); write_varint(1, &mut body); write_varint(32, &mut body); write_varint(0xff00ff, &mut body); test_write_shapes(&[(13, 17), (19, 23)], &mut body, &mut checksum); } let mut blob = vec![version]; write_varint(1, &mut blob); write_varint(14, &mut blob); blob.extend_from_slice(&99_u64.to_le_bytes()); blob.extend_from_slice(&checksum.to_le_bytes()); write_varint(body.len() as u64, &mut blob); blob.extend_from_slice(&body); let mut tile = Vec::new(); write_len_field(101, &blob, &mut tile); tile } #[test] fn v3_and_v4_faces_keep_regions_and_buildings_but_stub_shadows() { for version in [3, 4] { let input = test_faces_tile(version); let output = rewrite_tile(&input).unwrap().0; verify_rewritten_tile(&input, &output).unwrap(); let before = tile_sections(&input).unwrap(); let after = tile_sections(&output).unwrap(); assert_eq!(before.faces[0].1, after.faces[0].1); assert_eq!(before.faces[0].2, after.faces[0].2); assert!(after.faces[0].3); assert!(output.len() < input.len()); } } #[test] fn dictionary_archive_roundtrip_preserves_codec() { let scratch = Path::new(env!("CARGO_MANIFEST_DIR")) .join(format!("../../target/map-repack-dict-test-{}", std::process::id())); if scratch.exists() { fs::remove_dir_all(&scratch).unwrap(); } let input_dir = scratch.join("input.mkmap"); let output_dir = scratch.join("output.mkmap"); fs::create_dir_all(&input_dir).unwrap(); let mut layer = Vec::new(); write_len_field(1, b"water", &mut layer); let mut tile = Vec::new(); write_len_field(3, &layer, &mut tile); let dict = b"water layer polygons streets"; let compression = TileCompression::Brotli { quality: 5 }; let compressed = compress_tile(&compression, Some(dict), &tile).unwrap(); let entry = LeafEntry { tile_id: makepad_mbtile_reader::mkmap_tile_id(0, 0, 0), blob: BlobRef { shard: 0, offset: 0, len: compressed.len() as u64, }, }; let directory = encode_leaf_directory(&[entry]).unwrap(); let mut shard = compressed; let dir_offset = shard.len() as u64; shard.extend_from_slice(&directory); fs::write(input_dir.join("tiles-000.mkshard"), shard).unwrap(); let metadata = makepad_mbtile_reader::compression_metadata_rows( &compression, Some(dict), ) .into_iter() .collect(); write_root_index( &input_dir.join("root.mkidx"), &RootIndex { metadata: &metadata, dict: Some(dict), shard_cap: SHARD_HARD_CAP, tile_count: 1, unique_blobs: 1, min_zoom: 0, max_zoom: 0, records: &[RootRecord { start_tile_id: entry.tile_id, end_tile_id: entry.tile_id, shard: 0, dir_offset, dir_len: directory.len() as u64, }], }, ) .unwrap(); repack_archive(&RepackOptions { input: input_dir, output: output_dir.clone(), selection: TileSelection::All, dry_run: false, verify: true, resume: false, jobs: 2, brotli_quality: TILE_BROTLI_QUALITY, verify_shards: None, log: None, }) .unwrap(); let mut reader = MkmapReader::open(&output_dir).unwrap(); assert_eq!(reader.shared_dict(), Some(dict.as_slice())); let tile_ref = reader.resolve_tile(0, 0, 0).unwrap().unwrap(); let stored = reader.read_tile_ref(&tile_ref).unwrap(); let codec = TileCodec::from_metadata(&reader.get_metadata().unwrap()).unwrap(); assert_eq!(codec.metadata_value(), "br:dict-v1"); assert_eq!(codec.decode(&stored).unwrap(), tile); fs::remove_dir_all(scratch).unwrap(); } #[test] fn two_shard_partial_resume_finishes_a_readable_archive() { let scratch = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!( "../../target/map-repack-resume-test-{}", std::process::id() )); if scratch.exists() { fs::remove_dir_all(&scratch).unwrap(); } let input_dir = scratch.join("input.mkmap"); let output_dir = scratch.join("output.mkmap"); fs::create_dir_all(&input_dir).unwrap(); let mut tiles = Vec::new(); for name in [b"water".as_slice(), b"landuse"] { let mut layer = Vec::new(); write_len_field(1, name, &mut layer); let mut tile = Vec::new(); write_len_field(3, &layer, &mut tile); tiles.push(tile); } let compression = TileCompression::Brotli { quality: 1 }; let mut records = Vec::new(); for (shard_index, tile) in tiles.iter().enumerate() { let tile_id = shard_index as u64; let compressed = compress_tile(&compression, None, tile).unwrap(); let entry = LeafEntry { tile_id, blob: BlobRef { shard: shard_index as u32, offset: 0, len: compressed.len() as u64, }, }; let directory = encode_leaf_directory(&[entry]).unwrap(); let dir_offset = compressed.len() as u64; let mut shard = compressed; shard.extend_from_slice(&directory); fs::write( input_dir.join(format!("tiles-{shard_index:03}.mkshard")), shard, ) .unwrap(); records.push(RootRecord { start_tile_id: tile_id, end_tile_id: tile_id, shard: shard_index as u32, dir_offset, dir_len: directory.len() as u64, }); } let metadata = [("compression".to_string(), "br".to_string())] .into_iter() .collect(); write_root_index( &input_dir.join("root.mkidx"), &RootIndex { metadata: &metadata, dict: None, shard_cap: SHARD_HARD_CAP, tile_count: 2, unique_blobs: 2, min_zoom: 0, max_zoom: 1, records: &records, }, ) .unwrap(); let options = RepackOptions { input: input_dir, output: output_dir.clone(), selection: TileSelection::All, dry_run: false, verify: true, resume: false, jobs: 2, brotli_quality: 1, verify_shards: None, log: Some(output_dir.join("repack.log")), }; repack_archive(&options).unwrap(); assert_eq!(repack_status(&options).unwrap().completed_shards, 2); let first_shard = fs::read(output_dir.join("tiles-000.mkshard")).unwrap(); fs::remove_file(output_dir.join("tiles-001.mkrepack")).unwrap(); fs::remove_file(output_dir.join("root.mkidx")).unwrap(); fs::write(output_dir.join("tiles-001.partial"), b"interrupted").unwrap(); let mut resumed = options.clone(); resumed.resume = true; assert_eq!(repack_status(&resumed).unwrap().completed_shards, 1); let mut changed_quality = resumed.clone(); changed_quality.brotli_quality = 2; assert_eq!( repack_status(&changed_quality).unwrap().completed_shards, 0 ); repack_archive(&resumed).unwrap(); let status = repack_status(&resumed).unwrap(); assert_eq!((status.completed_shards, status.total_shards), (2, 2)); assert_eq!( verify_archives( &resumed.input, &resumed.output, &resumed.selection, Some(ShardRange { start: 1, end: 2 }), ) .unwrap(), 1 ); assert_eq!( fs::read(output_dir.join("tiles-000.mkshard")).unwrap(), first_shard ); let mut reader = MkmapReader::open(&output_dir).unwrap(); let codec = TileCodec::from_metadata(&reader.get_metadata().unwrap()).unwrap(); let mut decoded = Vec::new(); let mut refs = Vec::new(); reader.for_each_tile_ref(|tile| refs.push(tile)).unwrap(); for tile in refs { decoded.push(codec.decode(&reader.read_tile_ref(&tile).unwrap()).unwrap()); } assert_eq!(decoded, tiles); let log = fs::read_to_string(output_dir.join("repack.log")).unwrap(); assert!(log.contains("1/2 shard 000")); assert!(log.contains("2/2 shard 001")); assert!(log.contains("root.mkidx written at end")); fs::remove_dir_all(scratch).unwrap(); } #[test] #[ignore = "acceptance fixture: deterministic Brotli q11 over 25 large tiles"] fn amsterdam_tiles_rewrite_and_verify() { let fixture_dir = fixture_dir(); let mut paths: Vec<_> = fs::read_dir(fixture_dir) .expect("Amsterdam fixture directory") .map(|entry| entry.unwrap().path()) .filter(|path| path.extension().is_some_and(|ext| ext == "decoded")) .collect(); paths.sort(); assert_eq!(paths.len(), 25); let compression = TileCompression::Brotli { quality: TILE_BROTLI_QUALITY, }; let mut before = 0_u64; let mut after = 0_u64; let mut compressed = 0_u64; let mut shadows = 0_u64; let mut tags = 0_u64; for path in paths { let input = fs::read(&path).unwrap(); let (output, stats) = rewrite_tile(&input) .unwrap_or_else(|error| panic!("{}: {error}", path.display())); verify_rewritten_tile(&input, &output) .unwrap_or_else(|error| panic!("{}: {error}", path.display())); before += input.len() as u64; after += output.len() as u64; compressed += compress_tile(&compression, None, &output).unwrap().len() as u64; shadows += stats.savings[POLICY_SHADOWS]; tags += stats.savings[POLICY_TAGS] + stats.savings[POLICY_SYNTHETIC]; } assert!(after < before); assert!(shadows > 0); assert!(tags > 0); println!( "Amsterdam 25: decoded {before} -> {after} bytes; q{} brotli {} bytes ({:.2} MiB); shadows saved {shadows}; tags saved {tags}", TILE_BROTLI_QUALITY, compressed, compressed as f64 / 1_048_576.0 ); } fn write_repeated_amsterdam_archive(output: &Path, tile_count: usize) { fs::create_dir_all(output).unwrap(); let mut paths: Vec<_> = fs::read_dir(fixture_dir()) .unwrap() .map(|entry| entry.unwrap().path()) .filter(|path| path.extension().is_some_and(|ext| ext == "decoded")) .collect(); paths.sort(); let compression = TileCompression::Brotli { quality: 1 }; let mut shard = Vec::new(); let mut blobs = Vec::new(); for path in paths { let decoded = fs::read(path).unwrap(); let compressed = compress_tile(&compression, None, &decoded).unwrap(); let blob = BlobRef { shard: 0, offset: shard.len() as u64, len: compressed.len() as u64, }; shard.extend_from_slice(&compressed); blobs.push(blob); } let first_id = makepad_mbtile_reader::mkmap_tile_id(14, 0, 0); let entries: Vec<_> = (0..tile_count) .map(|index| LeafEntry { tile_id: first_id + index as u64, blob: blobs[index % blobs.len()], }) .collect(); let directory = encode_leaf_directory(&entries).unwrap(); let dir_offset = shard.len() as u64; shard.extend_from_slice(&directory); fs::write(output.join("tiles-000.mkshard"), shard).unwrap(); let metadata = [("compression".to_string(), "br".to_string())] .into_iter() .collect(); write_root_index( &output.join("root.mkidx"), &RootIndex { metadata: &metadata, dict: None, shard_cap: SHARD_HARD_CAP, tile_count: tile_count as u64, unique_blobs: blobs.len() as u64, min_zoom: 14, max_zoom: 14, records: &[RootRecord { start_tile_id: entries.first().unwrap().tile_id, end_tile_id: entries.last().unwrap().tile_id, shard: 0, dir_offset, dir_len: directory.len() as u64, }], }, ) .unwrap(); } #[test] #[ignore = "acceptance benchmark: six q11 archive repacks"] fn benchmark_parallel_amsterdam_repack() { let scratch = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!( "../../target/map-repack-benchmark-{}", std::process::id() )); if scratch.exists() { fs::remove_dir_all(&scratch).unwrap(); } fs::create_dir_all(&scratch).unwrap(); for tile_count in [25_usize, 500] { let input = scratch.join(format!("input-{tile_count}.mkmap")); write_repeated_amsterdam_archive(&input, tile_count); for jobs in [1_usize, 4, 16] { let output = scratch.join(format!("output-{tile_count}-{jobs}.mkmap")); let start = Instant::now(); let report = repack_archive(&RepackOptions { input: input.clone(), output, selection: TileSelection::All, dry_run: false, verify: false, resume: false, jobs, brotli_quality: TILE_BROTLI_QUALITY, verify_shards: None, log: None, }) .unwrap(); let elapsed = start.elapsed().as_secs_f64(); println!( "BENCH tiles={tile_count} jobs={jobs} elapsed={elapsed:.3}s tiles/s={:.3} output-MiB/s={:.3}", tile_count as f64 / elapsed, report.compressed_after as f64 / 1_048_576.0 / elapsed, ); } } fs::remove_dir_all(scratch).unwrap(); } #[test] fn manifest_roundtrip_is_fixed_and_deterministic() { let manifest = ShardManifest { input_root_hash: 1, selection_hash: 2, input_record: 3, output_shard: 4, brotli_quality: 11, file_len: 5, tile_count: 6, unique_blobs: 7, start_tile_id: 8, end_tile_id: 9, dir_offset: 10, dir_len: 11, decoded_before: 12, decoded_after: 13, compressed_before: 14, compressed_after: 15, savings: [16; DATA_POLICY.len()], min_zoom: 14, max_zoom: 18, }; let bytes = encode_manifest(&manifest); let decoded = decode_manifest(&bytes).unwrap(); assert_eq!(encode_manifest(&decoded), bytes); } #[test] #[ignore = "acceptance fixture: builds and verifies a 25-tile mkmap twice"] fn amsterdam_archive_roundtrip() { let scratch = Path::new(env!("CARGO_MANIFEST_DIR")) .join(format!("../../target/map-repack-test-{}", std::process::id())); if scratch.exists() { fs::remove_dir_all(&scratch).unwrap(); } let input_dir = scratch.join("input.mkmap"); let output_dir = scratch.join("output.mkmap"); fs::create_dir_all(&input_dir).unwrap(); let fixture_dir = fixture_dir(); let mut paths: Vec<_> = fs::read_dir(fixture_dir) .unwrap() .map(|entry| entry.unwrap().path()) .filter(|path| path.extension().is_some_and(|ext| ext == "decoded")) .collect(); paths.sort(); let compression = TileCompression::Brotli { quality: 5 }; let mut tiles = Vec::new(); for path in paths { let stem = path.file_stem().unwrap().to_string_lossy(); let mut parts = stem.split('-'); let zoom = parts.next().unwrap()[1..].parse::().unwrap(); let x = parts.next().unwrap()[1..].parse::().unwrap(); let y = parts.next().unwrap()[1..].parse::().unwrap(); let id = makepad_mbtile_reader::mkmap_tile_id(zoom, x, y); let decoded = fs::read(path).unwrap(); let compressed = compress_tile(&compression, None, &decoded).unwrap(); tiles.push((id, compressed)); } tiles.sort_by_key(|tile| tile.0); let mut shard = Vec::new(); let mut entries = Vec::new(); for (tile_id, compressed) in tiles { let offset = shard.len() as u64; shard.extend_from_slice(&compressed); entries.push(LeafEntry { tile_id, blob: BlobRef { shard: 0, offset, len: compressed.len() as u64, }, }); } let directory = encode_leaf_directory(&entries).unwrap(); let dir_offset = shard.len() as u64; shard.extend_from_slice(&directory); fs::write(input_dir.join("tiles-000.mkshard"), shard).unwrap(); let metadata = [("compression".to_string(), "br".to_string())] .into_iter() .collect(); write_root_index( &input_dir.join("root.mkidx"), &RootIndex { metadata: &metadata, dict: None, shard_cap: SHARD_HARD_CAP, tile_count: entries.len() as u64, unique_blobs: entries.len() as u64, min_zoom: 14, max_zoom: 14, records: &[RootRecord { start_tile_id: entries.first().unwrap().tile_id, end_tile_id: entries.last().unwrap().tile_id, shard: 0, dir_offset, dir_len: directory.len() as u64, }], }, ) .unwrap(); let options = RepackOptions { input: input_dir, output: output_dir.clone(), selection: TileSelection::All, dry_run: false, verify: true, resume: false, jobs: 4, brotli_quality: TILE_BROTLI_QUALITY, verify_shards: None, log: None, }; let report = repack_archive(&options).unwrap(); assert_eq!(report.tiles, 25); let first_root = fs::read(output_dir.join("root.mkidx")).unwrap(); let first_shard = fs::read(output_dir.join("tiles-000.mkshard")).unwrap(); let mut resumed = options.clone(); resumed.resume = true; repack_archive(&resumed).unwrap(); assert_eq!(fs::read(output_dir.join("root.mkidx")).unwrap(), first_root); assert_eq!( fs::read(output_dir.join("tiles-000.mkshard")).unwrap(), first_shard ); // Model a kill after a partial shard write and before its manifest. fs::remove_file(output_dir.join("tiles-000.mkrepack")).unwrap(); fs::write(output_dir.join("tiles-000.partial"), b"truncated").unwrap(); repack_archive(&resumed).unwrap(); assert_eq!(fs::read(output_dir.join("root.mkidx")).unwrap(), first_root); assert_eq!( fs::read(output_dir.join("tiles-000.mkshard")).unwrap(), first_shard ); let mut reader = MkmapReader::open(&output_dir).unwrap(); assert_eq!(reader.tile_count(), 25); assert_eq!(reader.for_each_tile_ref(|_| {}).map(|_| 25).unwrap(), 25); fs::remove_dir_all(scratch).unwrap(); } #[cfg(feature = "faces")] fn assert_float_stream_equal(left: &[f32], right: &[f32], context: &str, stream: &str) { assert_eq!(left.len(), right.len(), "{context} {stream} length"); for (index, (left, right)) in left.iter().zip(right).enumerate() { assert_eq!( left.to_bits(), right.to_bits(), "{context} {stream}[{index}]" ); } } #[cfg(feature = "faces")] fn assert_index_stream_equal(left: &[u32], right: &[u32], context: &str, stream: &str) { assert_eq!(left.len(), right.len(), "{context} {stream} length"); for (index, (left, right)) in left.iter().zip(right).enumerate() { assert_eq!(left, right, "{context} {stream}[{index}]"); } } #[cfg(feature = "faces")] fn assert_tile_buffers_byte_equal( left: &makepad_widgets::map::tile::TileBuffers, right: &makepad_widgets::map::tile::TileBuffers, context: &str, ) { macro_rules! same_indices { ($($field:ident),+ $(,)?) => {$ ( assert_index_stream_equal( &left.$field, &right.$field, context, stringify!($field), ); )+ }; } macro_rules! same_floats { ($($field:ident),+ $(,)?) => {$ ( assert_float_stream_equal( &left.$field, &right.$field, context, stringify!($field), ); )+ }; } macro_rules! same_bytes { ($($field:ident),+ $(,)?) => {$ ( assert_eq!(&left.$field, &right.$field, "{context} {}", stringify!($field)); )+ }; } same_indices!( fill_indices, fill_misc_indices, casing_indices, stroke_indices, icon_indices, icon_high_indices, fringe_indices, fill_3d_indices, fill_3d_misc_indices, wall_indices, tree_indices, tree_cross_indices, tree_template_indices, tree_cross_template_indices, road_icon_indices, ); same_floats!( fill_misc_vertices, icon_vertices, icon_high_vertices, shadow_disc_instances, fill_3d_misc_vertices, wall_vertices, wall_instances, tree_vertices, tree_cross_vertices, tree_template_vertices, tree_cross_template_vertices, tree_instances, road_icon_vertices, ); same_bytes!( fill_vertices, casing_vertices, stroke_vertices, fringe_vertices, fill_3d_vertices, ); for (name, left, right) in [ ("icon_instances", &left.icon_instances, &right.icon_instances), ( "icon_high_instances", &left.icon_high_instances, &right.icon_high_instances, ), ] { assert_eq!(left.len(), right.len(), "{context} {name} length"); for (index, (left, right)) in left.iter().zip(right).enumerate() { assert_eq!(left.mesh_slot, right.mesh_slot, "{context} {name} mesh slot"); assert_float_stream_equal( &left.data, &right.data, context, &format!("{name}[{index}].data"), ); } } assert_eq!(left.labels.len(), right.labels.len(), "{context} labels length"); for (index, (left, right)) in left.labels.iter().zip(&right.labels).enumerate() { assert_eq!(left, right, "{context} labels[{index}]"); } assert_eq!( left.pin_hits.len(), right.pin_hits.len(), "{context} pin_hits length" ); for (index, (left, right)) in left.pin_hits.iter().zip(&right.pin_hits).enumerate() { assert_eq!(left, right, "{context} pin_hits[{index}]"); } assert_eq!(left.mode_overlay_only, right.mode_overlay_only, "{context}"); assert_eq!(left.feature_count, right.feature_count, "{context}"); assert_eq!(left.render_zoom, right.render_zoom, "{context}"); } #[cfg(feature = "faces")] #[test] #[ignore = "acceptance test: 25 tiles x render zooms 14-18 x 2D/3D"] fn amsterdam_bake_parity() { use makepad_widgets::map::geometry::TileKey; use makepad_widgets::map::style::probe_compiled_theme; use makepad_widgets::map::tile::build_tile_buffers_from_mvt; let fixture_dir = fixture_dir(); let mut paths: Vec<_> = fs::read_dir(fixture_dir) .unwrap() .map(|entry| entry.unwrap().path()) .filter(|path| path.extension().is_some_and(|ext| ext == "decoded")) .collect(); paths.sort(); assert_eq!(paths.len(), 25); let theme = probe_compiled_theme(); let mut builds = 0; for path in paths { let stem = path.file_stem().unwrap().to_string_lossy(); let mut parts = stem.split('-'); let zoom = parts.next().unwrap()[1..].parse::().unwrap(); let x = parts.next().unwrap()[1..].parse::().unwrap(); let y = parts.next().unwrap()[1..].parse::().unwrap(); let key = TileKey { z: zoom, x, y }; let original = fs::read(&path).unwrap(); let repacked = rewrite_tile(&original).unwrap().0; for render_zoom in 14..=18 { for buildings_3d in [false, true] { let left = build_tile_buffers_from_mvt( key, &original, Some(&original), None, false, &[], &theme, render_zoom, buildings_3d, true, false, ) .unwrap(); let right = build_tile_buffers_from_mvt( key, &repacked, Some(&repacked), None, false, &[], &theme, render_zoom, buildings_3d, true, false, ) .unwrap(); assert_tile_buffers_byte_equal( &left, &right, &format!("{stem} rz{render_zoom} 3d={buildings_3d}"), ); builds += 2; } } } println!("Amsterdam bake parity: {builds} builds renderer-equivalent"); } }