use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::entity::{WorldEntity, WorldTransform};
use crate::world::WorldManifest;
pub const EXT_H3_KEY: &str = "ext-h3";
pub const BODY_EARTH: &str = "earth";
pub const EARTH_RADIUS_M: f64 = 6_371_007.180_918_475;
pub const EDGE_LENGTH_M: [f64; 16] = [
1281256.011,
483056.8391,
182512.9565,
68979.22179,
26071.75968,
9854.09099,
3724.532667,
1406.475763,
531.41401,
200.786148,
75.863783,
28.663897,
10.830188,
4.09201,
1.5461,
0.584169,
];
pub const TRANSLATION_UNITS_PER_EDGE: f64 = 100.0;
pub const ROTATION_UNITS_PER_DEGREE: f64 = 100.0;
pub const SCALE_UNITS_PER_OCTAVE: f64 = 1024.0;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtH3Frame {
pub cell: String,
pub resolution: u8,
#[serde(default = "default_body")]
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtH3Placement {
pub cell: String,
pub resolution: u8,
#[serde(default = "default_body")]
pub body: String,
#[serde(default)]
pub translation: [i64; 3],
#[serde(default)]
pub rotation: [i64; 3],
#[serde(default)]
pub scale: [i64; 3],
}
fn default_body() -> String {
BODY_EARTH.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(from = "[f64; 2]", into = "[f64; 2]")]
pub struct LatLng {
pub lat: f64,
pub lng: f64,
}
impl From<[f64; 2]> for LatLng {
fn from([lat, lng]: [f64; 2]) -> Self {
Self { lat, lng }
}
}
impl From<LatLng> for [f64; 2] {
fn from(ll: LatLng) -> Self {
[ll.lat, ll.lng]
}
}
pub fn edge_length_m(resolution: u8) -> Option<f64> {
EDGE_LENGTH_M.get(usize::from(resolution)).copied()
}
pub fn translation_unit_m(resolution: u8) -> Option<f64> {
edge_length_m(resolution).map(|edge| edge / TRANSLATION_UNITS_PER_EDGE)
}
pub fn quantize_translation(metres: f64, resolution: u8) -> Option<i64> {
translation_unit_m(resolution).map(|unit| (metres / unit).round() as i64)
}
pub fn dequantize_translation(units: i64, resolution: u8) -> Option<f64> {
translation_unit_m(resolution).map(|unit| units as f64 * unit)
}
pub fn quantize_rotation(degrees: f64) -> i64 {
(degrees * ROTATION_UNITS_PER_DEGREE).round() as i64
}
pub fn dequantize_rotation(units: i64) -> f64 {
units as f64 / ROTATION_UNITS_PER_DEGREE
}
pub fn quantize_scale(factor: f64) -> Option<i64> {
(factor.is_finite() && factor > 0.0)
.then(|| (factor.log2() * SCALE_UNITS_PER_OCTAVE).round() as i64)
}
pub fn dequantize_scale(units: i64) -> f64 {
2f64.powf(units as f64 / SCALE_UNITS_PER_OCTAVE)
}
pub fn frame_of(manifest: &WorldManifest) -> Option<ExtH3Frame> {
let block = manifest.environment.as_ref()?.extra.get(EXT_H3_KEY)?;
serde_json::from_value(block.clone()).ok()
}
pub fn placement_of(entity: &WorldEntity) -> Option<ExtH3Placement> {
let block = entity.extra.get(EXT_H3_KEY)?;
serde_json::from_value(block.clone()).ok()
}
struct Tangent {
at: [f64; 3],
east: [f64; 3],
up: [f64; 3],
south: [f64; 3],
}
fn tangent(ll: LatLng) -> Tangent {
let (lat, lng) = (ll.lat.to_radians(), ll.lng.to_radians());
let (sin_lat, cos_lat) = lat.sin_cos();
let (sin_lng, cos_lng) = lng.sin_cos();
let up = [cos_lat * cos_lng, cos_lat * sin_lng, sin_lat];
Tangent {
at: [
up[0] * EARTH_RADIUS_M,
up[1] * EARTH_RADIUS_M,
up[2] * EARTH_RADIUS_M,
],
east: [-sin_lng, cos_lng, 0.0],
up,
south: [sin_lat * cos_lng, sin_lat * sin_lng, -cos_lat],
}
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub fn local_position(
frame_origin: LatLng,
cell_centre: LatLng,
translation_m: [f64; 3],
) -> [f64; 3] {
let o = tangent(frame_origin);
let c = tangent(cell_centre);
let [tx, ty, tz] = translation_m;
let v: [f64; 3] = std::array::from_fn(|axis| {
c.at[axis] - o.at[axis] + tx * c.east[axis] + ty * c.up[axis] + tz * c.south[axis]
});
[dot(v, o.east), dot(v, o.up), dot(v, o.south)]
}
pub fn derive_transform(
frame_origin: Option<LatLng>,
cell_centre: LatLng,
placement: &ExtH3Placement,
) -> Option<WorldTransform> {
if placement.body != BODY_EARTH {
return None;
}
let unit = translation_unit_m(placement.resolution)?;
let translation_m = [
placement.translation[0] as f64 * unit,
placement.translation[1] as f64 * unit,
placement.translation[2] as f64 * unit,
];
let position = local_position(
frame_origin.unwrap_or(cell_centre),
cell_centre,
translation_m,
);
let f = |v: f64| v as f32;
Some(WorldTransform {
position: [f(position[0]), f(position[1]), f(position[2])],
rotation_degrees: [
f(dequantize_rotation(placement.rotation[0])),
f(dequantize_rotation(placement.rotation[1])),
f(dequantize_rotation(placement.rotation[2])),
],
scale: [
f(dequantize_scale(placement.scale[0])),
f(dequantize_scale(placement.scale[1])),
f(dequantize_scale(placement.scale[2])),
],
visible: true,
})
}
#[derive(Debug, Clone, Deserialize)]
pub struct OutcomesDoc {
#[serde(default)]
pub world: String,
#[serde(default)]
pub cell_centres: BTreeMap<String, LatLng>,
pub expect: Vec<Assertion>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Assertion {
Frame {
frame: ExtH3Frame,
},
EdgeLength {
edge_length: EdgeLengthAssertion,
},
Placed {
placed: PlacedAssertion,
},
}
#[derive(Debug, Clone, Deserialize)]
pub struct EdgeLengthAssertion {
pub resolution: u8,
pub metres: f64,
#[serde(default)]
pub tolerance: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlacedAssertion {
pub entity: String,
pub position: [f64; 3],
#[serde(default)]
pub rotation_degrees: Option<[f64; 3]>,
#[serde(default)]
pub scale: Option<[f64; 3]>,
#[serde(default)]
pub tolerance: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OutcomeResult {
pub ok: bool,
pub failures: Vec<String>,
}
fn within(actual: [f32; 3], expected: [f64; 3], tolerance: f64) -> bool {
(0..3).all(|i| (f64::from(actual[i]) - expected[i]).abs() <= tolerance)
}
pub fn run_outcomes(manifest: &WorldManifest, outcomes: &OutcomesDoc) -> OutcomeResult {
let mut failures = Vec::new();
let frame = frame_of(manifest);
let origin = frame
.as_ref()
.and_then(|f| outcomes.cell_centres.get(&f.cell).copied());
for assertion in &outcomes.expect {
match assertion {
Assertion::Frame { frame: expected } => {
if frame.as_ref() != Some(expected) {
failures.push(format!(
"the environment declares {frame:?}, not {expected:?}"
));
}
}
Assertion::EdgeLength { edge_length } => {
let tolerance = edge_length.tolerance.unwrap_or(1e-9);
match edge_length_m(edge_length.resolution) {
Some(m) if (m - edge_length.metres).abs() <= tolerance => {}
found => failures.push(format!(
"edge length at resolution {} is {found:?}, not {}",
edge_length.resolution, edge_length.metres
)),
}
}
Assertion::Placed { placed } => {
let tolerance = placed.tolerance.unwrap_or(0.001);
let Some(entity) = manifest
.entities
.iter()
.find(|e| e.name.as_str() == placed.entity)
else {
failures.push(format!("no entity is named {}", placed.entity));
continue;
};
let Some(placement) = placement_of(entity) else {
failures.push(format!(
"{} carries no readable ext-h3 block",
placed.entity
));
continue;
};
let Some(centre) = outcomes.cell_centres.get(&placement.cell).copied() else {
failures.push(format!(
"{}: the fixture has no centre for cell {}",
placed.entity, placement.cell
));
continue;
};
if frame.is_some() && origin.is_none() {
failures.push("the fixture has no centre for the frame's origin cell".into());
continue;
}
let Some(derived) = derive_transform(origin, centre, &placement) else {
failures.push(format!(
"{}: nothing derives from body {} at resolution {}",
placed.entity, placement.body, placement.resolution
));
continue;
};
let core = &entity.transform;
let mut check = |what: &str, actual: [f32; 3], expected: [f64; 3]| {
if !within(actual, expected, tolerance) {
failures.push(format!(
"{}: {what} is {actual:?}, not within {tolerance} of {expected:?}",
placed.entity
));
}
};
check("derived position", derived.position, placed.position);
check("core position", core.position, placed.position);
if let Some(rotation) = placed.rotation_degrees {
check("derived rotation", derived.rotation_degrees, rotation);
check("core rotation", core.rotation_degrees, rotation);
}
if let Some(scale) = placed.scale {
check("derived scale", derived.scale, scale);
check("core scale", core.scale, scale);
}
}
}
}
OutcomeResult {
ok: failures.is_empty(),
failures,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quantisation_matches_the_producing_format() {
assert_eq!(quantize_scale(2.0), Some(1024));
assert_eq!(quantize_scale(0.5), Some(-1024));
assert_eq!(quantize_scale(1.0), Some(0));
assert_eq!(quantize_scale(0.0), None);
assert!((dequantize_scale(1024) - 2.0).abs() < 1e-12);
assert_eq!(quantize_rotation(30.0), 3000);
assert_eq!(quantize_rotation(-0.005), -1); assert_eq!(dequantize_rotation(-4500), -45.0);
assert_eq!(quantize_translation(0.75, 15), Some(128));
assert!((dequantize_translation(128, 15).unwrap() - 0.74773632).abs() < 1e-9);
assert_eq!(edge_length_m(16), None);
assert_eq!(quantize_translation(1.0, 16), None);
}
#[test]
fn the_origin_cell_places_by_translation_alone() {
let here = LatLng {
lat: 48.85839598760832,
lng: 2.29450022174426,
};
let p = local_position(here, here, [1.0, 2.0, 3.0]);
assert!(
(p[0] - 1.0).abs() < 1e-9 && (p[1] - 2.0).abs() < 1e-9 && (p[2] - 3.0).abs() < 1e-9
);
}
#[test]
fn east_is_plus_x_north_is_minus_z_and_the_tangent_plane_drops() {
let origin = LatLng { lat: 0.0, lng: 0.0 };
let step = 1e-5; let east = local_position(
origin,
LatLng {
lat: 0.0,
lng: step,
},
[0.0; 3],
);
let metres = EARTH_RADIUS_M * step.to_radians();
assert!((east[0] - metres).abs() < 1e-6, "{east:?}");
assert!(east[2].abs() < 1e-9);
assert!(
east[1] < 0.0 && east[1] > -1e-6,
"the drop of the tangent plane: {}",
east[1]
);
let north = local_position(
origin,
LatLng {
lat: step,
lng: 0.0,
},
[0.0; 3],
);
assert!((north[2] + metres).abs() < 1e-6, "{north:?}");
assert!(north[0].abs() < 1e-9);
let up_there = local_position(
origin,
LatLng {
lat: step,
lng: 0.0,
},
[0.0, 5.0, 0.0],
);
assert!((up_there[1] - 5.0).abs() < 1e-6, "{up_there:?}");
}
#[test]
fn derive_reads_the_blocks_and_writes_the_core_transform() {
let json = serde_json::json!({
"cell": "8f1fb46741a12d5", "resolution": 15,
"translation": [0, 128, 0], "rotation": [0, 3000, 0], "scale": [0, 1024, 0]
});
let placement: ExtH3Placement = serde_json::from_value(json.clone()).unwrap();
assert_eq!(placement.body, "earth");
let centre = LatLng {
lat: 48.85839598760832,
lng: 2.29450022174426,
};
let t = derive_transform(None, centre, &placement).unwrap();
assert!((t.position[1] - 0.74773632).abs() < 1e-6);
assert_eq!(t.rotation_degrees, [0.0, 30.0, 0.0]);
assert_eq!(t.scale, [1.0, 2.0, 1.0]);
let moon = ExtH3Placement {
body: "moon".into(),
..placement.clone()
};
assert_eq!(derive_transform(None, centre, &moon), None);
let mut entity = WorldEntity::new(7, "column");
entity.extra.insert(EXT_H3_KEY.into(), json);
assert_eq!(placement_of(&entity), Some(placement));
let mut manifest = WorldManifest::new("terrace");
let mut env = crate::world::EnvironmentDef::default();
env.extra.insert(
EXT_H3_KEY.into(),
serde_json::json!({"cell": "8f1fb46741a12d5", "resolution": 15}),
);
manifest.environment = Some(env);
assert_eq!(
frame_of(&manifest),
Some(ExtH3Frame {
cell: "8f1fb46741a12d5".into(),
resolution: 15,
body: "earth".into()
})
);
}
#[test]
fn the_outcome_runner_checks_the_invariant_from_both_ends() {
let manifest: WorldManifest = serde_json::from_value(serde_json::json!({
"version": 3, "meta": {"name": "t"},
"environment": {"ext-h3": {"cell": "8f1fb46741a12d5", "resolution": 15}},
"entities": [
{"id": 1, "name": "column",
"transform": {"position": [0.0, 0.74773632, 0.0], "rotation_degrees": [0.0, 30.0, 0.0], "scale": [1.0, 2.0, 1.0]},
"ext-h3": {"cell": "8f1fb46741a12d5", "resolution": 15,
"translation": [0, 128, 0], "rotation": [0, 3000, 0], "scale": [0, 1024, 0]}},
{"id": 2, "name": "liar",
"transform": {"position": [5.0, 0.0, 0.0]},
"ext-h3": {"cell": "8f1fb46741a12d5", "resolution": 15}}
]
}))
.unwrap();
let outcomes: OutcomesDoc = serde_json::from_value(serde_json::json!({
"cell_centres": {"8f1fb46741a12d5": [48.85839598760832, 2.29450022174426]},
"expect": [
{"frame": {"cell": "8f1fb46741a12d5", "resolution": 15, "body": "earth"}},
{"edge_length": {"resolution": 15, "metres": 0.584169}},
{"placed": {"entity": "column", "position": [0.0, 0.74773632, 0.0],
"rotation_degrees": [0.0, 30.0, 0.0], "scale": [1.0, 2.0, 1.0]}},
{"placed": {"entity": "liar", "position": [0.0, 0.0, 0.0]}}
]
}))
.unwrap();
let result = run_outcomes(&manifest, &outcomes);
assert_eq!(result.failures.len(), 1, "{:?}", result.failures);
assert!(
result.failures[0].starts_with("liar: core position"),
"{:?}",
result.failures
);
}
}