use std::io::Write;
use energyml_domain::reproject::Wgs84;
use energyml_geometry::{Placement, SceneObject, Topology};
use crate::error::{Error, Result};
use crate::registry::{Exporter, Options};
pub struct GeoJsonExporter;
impl Exporter for GeoJsonExporter {
fn name(&self) -> &'static str {
"geojson"
}
fn extension(&self) -> &'static str {
"geojson"
}
fn mime(&self) -> &'static str {
"application/geo+json"
}
fn describe(&self) -> &'static str {
"RFC 7946 GeoJSON in WGS 84 - needs a reprojection backend"
}
fn placement(&self) -> Placement {
Placement::Metres
}
fn handles(&self, object: &SceneObject) -> bool {
let has_defined = object.geometry.patches.iter().any(|patch| {
patch
.points
.iter()
.any(|point| point.iter().all(|v| v.is_finite()))
});
has_defined && object.epsg().is_some()
}
fn handles_with(&self, object: &SceneObject, options: &Options<'_>) -> bool {
if !options.can_place_vertical(object) {
return false;
}
if options.native_crs {
return !object.geometry.is_empty();
}
self.handles(object)
}
fn write(&self, object: &SceneObject, out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
write!(out, "{{\"type\":\"FeatureCollection\",")?;
write_crs_member(std::slice::from_ref(object), out, options)?;
writeln!(out, "\"features\":[")?;
self.feature(object, out, options)?;
writeln!(out, "\n]}}")?;
Ok(())
}
fn write_all(&self, objects: &[SceneObject], out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
write!(out, "{{\"type\":\"FeatureCollection\",")?;
write_crs_member(objects, out, options)?;
writeln!(out, "\"features\":[")?;
let mut first = true;
for object in objects {
if !self.handles_with(object, options) {
continue;
}
if !first {
writeln!(out, ",")?;
}
first = false;
self.feature(object, out, options)?;
}
writeln!(out, "\n]}}")?;
Ok(())
}
}
impl GeoJsonExporter {
fn feature(&self, object: &SceneObject, out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
let object = crate::registry::prepare(object, options)?;
let object = object.as_ref();
if options.native_crs {
let placement = options.placement.unwrap_or(Placement::Projected);
let coordinates = object.points_in(placement)?;
return self.feature_body(object, &coordinates, out);
}
let Some(crs) = &object.crs else {
return Err(Error::unsuitable(
"geojson",
format!("`{}`, which names no coordinate system", object.title),
));
};
if options.reprojector.is_none() && crs.projected.epsg() != Some(Wgs84::EPSG) {
return Err(Error::Placement(format!(
"GeoJSON is WGS 84 by definition (RFC 7946) and `{}` is in {}; \
supply a `Reprojector` - `energyml_proj::Proj4` is one - because \
writing projected metres into a longitude field puts the model in \
the Gulf of Guinea",
object.title,
match crs.projected.epsg() {
Some(code) => format!("EPSG:{code}"),
None => format!("{:?}", crs.projected),
}
)));
}
let coordinates = options.points_in_epsg(object, Wgs84::EPSG)?;
self.feature_body(object, &coordinates, out)
}
fn feature_body(
&self,
object: &SceneObject,
coordinates: &[[f64; 3]],
out: &mut dyn Write,
) -> Result<()> {
let geometry = match first_topology(object) {
Some(Topology::Triangles(_)) => triangles(object, coordinates),
Some(Topology::Quads(_)) | Some(Topology::Polygons(_)) => rings(object, coordinates),
Some(Topology::Polylines(_) | Topology::Segments(_)) => polylines(object, coordinates),
_ => multipoint(coordinates),
};
write!(
out,
"{{\"type\":\"Feature\",{}\"geometry\":{geometry},\"properties\":{{",
bbox(coordinates)
)?;
let mut properties: Vec<String> = vec![
pair("uuid", &object.uuid),
pair("uri", &object.uri),
pair("title", &object.title),
pair("type", &object.qualified_type),
pair("crs", &object.uuid_of_crs()),
];
if let Some(interpretation) = &object.interpretation {
properties.push(pair("interpretation_uuid", &interpretation.uuid));
properties.push(pair("interpretation_title", &interpretation.title));
}
if let Some(feature) = &object.feature {
properties.push(pair("feature_uuid", &feature.uuid));
properties.push(pair("feature_title", &feature.title));
}
for (key, value) in object.metadata.entries() {
let key = sanitise_key(key);
if properties.iter().any(|p| p.starts_with(&format!("\"{key}\":"))) {
continue;
}
match value {
Some(value) => properties.push(pair(&key, value)),
None => properties.push(format!("\"{key}\":null")),
}
}
for attribute in &object.attributes {
let name = sanitise_key(&attribute.name);
let finite: Vec<f64> = attribute
.values
.iter()
.copied()
.filter(|v| v.is_finite())
.collect();
properties.push(format!("\"{name}_count\":{}", attribute.values.len()));
if let (Some(min), Some(max)) = (
finite.iter().copied().reduce(f64::min),
finite.iter().copied().reduce(f64::max),
) {
let mean = finite.iter().sum::<f64>() / finite.len() as f64;
properties.push(format!("\"{name}_min\":{min}"));
properties.push(format!("\"{name}_max\":{max}"));
properties.push(format!("\"{name}_mean\":{mean}"));
}
if let Some(uom) = &attribute.uom {
properties.push(pair(&format!("{name}_uom"), uom));
}
if attribute.values.len() == 1 && attribute.values[0].is_finite() {
properties.push(format!("\"{name}\":{}", attribute.values[0]));
}
}
write!(out, "{}", properties.join(","))?;
write!(out, "}}}}")?;
Ok(())
}
}
fn sanitise_key(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}
fn write_crs_member(objects: &[SceneObject], out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
if !options.native_crs {
return Ok(());
}
let mut stated = objects
.iter()
.filter(|o| !o.geometry.is_empty())
.filter_map(|o| o.epsg());
let Some(code) = stated.next() else {
return Ok(());
};
if stated.all(|c| c == code) {
write!(
out,
"\"crs\":{{\"type\":\"name\",\"properties\":{{\"name\":\"urn:ogc:def:crs:EPSG::{code}\"}}}},"
)?;
}
Ok(())
}
trait CrsUuid {
fn uuid_of_crs(&self) -> String;
}
impl CrsUuid for SceneObject {
fn uuid_of_crs(&self) -> String {
self.crs.as_ref().map(|c| c.uuid.clone()).unwrap_or_default()
}
}
fn first_topology(object: &SceneObject) -> Option<&Topology> {
object.geometry.patches.first().map(|patch| &patch.topology)
}
fn triangles(object: &SceneObject, points: &[[f64; 3]]) -> String {
let (_, indices) = object.geometry.merged();
let rings: Vec<String> = indices
.iter()
.filter(|t| t.iter().all(|i| (*i as usize) < points.len()))
.map(|t| {
let ring: Vec<String> = [t[0], t[1], t[2], t[0]]
.iter()
.map(|i| position(points[*i as usize]))
.collect();
format!("[[{}]]", ring.join(","))
})
.collect();
format!(
"{{\"type\":\"MultiPolygon\",\"coordinates\":[{}]}}",
rings.join(",")
)
}
fn rings(object: &SceneObject, points: &[[f64; 3]]) -> String {
let mut out: Vec<String> = Vec::new();
let mut base = 0usize;
for patch in &object.geometry.patches {
let cells: Vec<Vec<usize>> = match &patch.topology {
Topology::Quads(quads) => quads
.iter()
.map(|quad| quad.iter().map(|c| base + *c as usize).collect())
.collect(),
Topology::Polygons(rings) => rings
.iter()
.map(|ring| ring.iter().map(|c| base + *c as usize).collect())
.collect(),
_ => Vec::new(),
};
for corners in cells {
if corners.len() < 3 || corners.iter().any(|c| *c >= points.len()) {
continue;
}
let ring: Vec<String> = corners
.iter()
.chain(std::iter::once(&corners[0]))
.map(|i| position(points[*i]))
.collect();
out.push(format!("[{}]", ring.join(",")));
}
base += patch.points.len();
}
match out.len() {
1 => format!("{{\"type\":\"Polygon\",\"coordinates\":[{}]}}", out[0]),
_ => format!(
"{{\"type\":\"MultiPolygon\",\"coordinates\":[{}]}}",
out.iter().map(|r| format!("[{r}]")).collect::<Vec<_>>().join(",")
),
}
}
fn polylines(object: &SceneObject, points: &[[f64; 3]]) -> String {
let mut lines: Vec<String> = Vec::new();
let mut start = 0usize;
for patch in &object.geometry.patches {
if let Topology::Segments(pairs) = &patch.topology {
for pair in pairs {
let (a, b) = (start + pair[0] as usize, start + pair[1] as usize);
if a < points.len() && b < points.len() {
lines.push(format!("[{},{}]", position(points[a]), position(points[b])));
}
}
}
if let Topology::Polylines(runs) = &patch.topology {
let mut at = start;
for (rank, run) in runs.iter().enumerate() {
let end = (at + *run as usize).min(points.len());
if end > at + 1 {
let mut line: Vec<String> = points[at..end].iter().map(|p| position(*p)).collect();
if patch.closed.get(rank) == Some(&true) {
line.push(position(points[at]));
}
lines.push(format!("[{}]", line.join(",")));
}
at = end;
}
}
start += patch.points.len();
}
format!(
"{{\"type\":\"MultiLineString\",\"coordinates\":[{}]}}",
lines.join(",")
)
}
fn multipoint(points: &[[f64; 3]]) -> String {
if let [only] = points {
return format!("{{\"type\":\"Point\",\"coordinates\":{}}}", position(*only));
}
let positions: Vec<String> = points.iter().map(|p| position(*p)).collect();
format!(
"{{\"type\":\"MultiPoint\",\"coordinates\":[{}]}}",
positions.join(",")
)
}
fn bbox(points: &[[f64; 3]]) -> String {
if points.is_empty() {
return String::new();
}
let mut low = [f64::INFINITY; 3];
let mut high = [f64::NEG_INFINITY; 3];
for point in points {
for axis in 0..3 {
low[axis] = low[axis].min(point[axis]);
high[axis] = high[axis].max(point[axis]);
}
}
if low.iter().chain(high.iter()).any(|v| !v.is_finite()) {
return String::new();
}
format!(
"\"bbox\":[{:.9},{:.9},{:.3},{:.9},{:.9},{:.3}],",
low[0], low[1], low[2], high[0], high[1], high[2]
)
}
fn position(point: [f64; 3]) -> String {
format!("[{:.9},{:.9},{:.3}]", point[0], point[1], point[2])
}
fn pair(key: &str, value: &str) -> String {
format!("\"{key}\":\"{}\"", escape(value))
}
fn escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}