1#[cfg(test)]
14#[path = "landxml_terrain_tests.rs"]
15mod tests;
16
17#[path = "landxml_terrain_georef.rs"]
18mod georef;
19#[path = "landxml_terrain_tin.rs"]
20mod tin;
21
22use std::collections::{BTreeMap, HashSet};
23use std::fmt::{self, Write as _};
24
25use ifc_lite_core::EntityDecoder;
26use ifc_lite_geometry::GeometryRouter;
27use serde::Serialize;
28
29use crate::xml_text::escape;
30
31const LANDXML_NAMESPACE: &str = "http://www.landxml.org/schema/LandXML-1.2";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum LandXmlLinearUnit {
37 #[default]
38 Metre,
39 InternationalFoot,
41 UsSurveyFoot,
45}
46
47impl LandXmlLinearUnit {
48 fn horizontal_metres(self) -> f64 {
50 match self {
51 Self::Metre => 1.0,
52 Self::InternationalFoot => 0.3048,
53 Self::UsSurveyFoot => 1200.0 / 3937.0,
54 }
55 }
56
57 fn elevation_metres(self) -> f64 {
59 match self {
60 Self::Metre => 1.0,
61 Self::InternationalFoot | Self::UsSurveyFoot => 0.3048,
62 }
63 }
64
65 fn units_element(self) -> &'static str {
68 match self {
69 Self::Metre => "<Metric linearUnit=\"meter\" areaUnit=\"squareMeter\" volumeUnit=\"cubicMeter\" temperatureUnit=\"celsius\" pressureUnit=\"milliBars\" angularUnit=\"decimal degrees\" directionUnit=\"decimal degrees\" elevationUnit=\"meter\"/>",
70 Self::InternationalFoot => "<Imperial linearUnit=\"foot\" areaUnit=\"squareFoot\" volumeUnit=\"cubicFeet\" temperatureUnit=\"fahrenheit\" pressureUnit=\"inchHG\" angularUnit=\"decimal degrees\" directionUnit=\"decimal degrees\" elevationUnit=\"feet\"/>",
71 Self::UsSurveyFoot => "<Imperial linearUnit=\"USSurveyFoot\" areaUnit=\"squareFoot\" volumeUnit=\"cubicFeet\" temperatureUnit=\"fahrenheit\" pressureUnit=\"inchHG\" angularUnit=\"decimal degrees\" directionUnit=\"decimal degrees\" elevationUnit=\"feet\"/>",
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
80#[non_exhaustive]
81pub struct LandXmlTerrainOptions {
82 pub date: String,
85 pub time: String,
87 pub linear_unit: LandXmlLinearUnit,
89 pub decimals: u8,
93 pub surface_names: BTreeMap<u32, String>,
96}
97
98impl LandXmlTerrainOptions {
99 pub fn new(date: impl Into<String>, time: impl Into<String>) -> Self {
100 Self {
101 date: date.into(),
102 time: time.into(),
103 linear_unit: LandXmlLinearUnit::default(),
104 decimals: 6,
105 surface_names: BTreeMap::new(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct LandXmlTerrainSurface {
114 pub product_id: u32,
115 pub global_id: Option<String>,
116 pub name: String,
117 pub points: usize,
118 pub faces: usize,
119}
120
121#[derive(Debug, Clone, Serialize)]
123#[serde(rename_all = "camelCase")]
124pub struct LandXmlTerrainExport {
125 pub xml: String,
126 pub crs_name: String,
127 pub surfaces: Vec<LandXmlTerrainSurface>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct LandXmlTerrainRefusal {
138 pub reason: String,
139}
140
141impl LandXmlTerrainRefusal {
142 pub const CODE: &'static str = "TERRAIN_REFUSED";
145}
146
147impl fmt::Display for LandXmlTerrainRefusal {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 write!(f, "{}: {}", Self::CODE, self.reason)
150 }
151}
152
153impl std::error::Error for LandXmlTerrainRefusal {}
154
155pub fn try_export_landxml_terrain(
161 content: &[u8],
162 product_ids: &[u32],
163 options: &LandXmlTerrainOptions,
164) -> Result<LandXmlTerrainExport, LandXmlTerrainRefusal> {
165 export(content, product_ids, options).map_err(|reason| LandXmlTerrainRefusal { reason })
166}
167
168fn export(content: &[u8], product_ids: &[u32], options: &LandXmlTerrainOptions) -> Result<LandXmlTerrainExport, String> {
169 check_stamp(&options.date, "YYYY-MM-DD", b'-')?;
170 check_stamp(&options.time, "HH:MM:SS", b':')?;
171 if options.decimals > 9 {
172 return Err(format!("precision of {} decimals is outside 0 to 9", options.decimals));
173 }
174 let mut seen = HashSet::new();
175 let ids: Vec<u32> = product_ids.iter().copied().filter(|id| seen.insert(*id)).collect();
176 if ids.is_empty() {
177 return Err("no terrain is selected".into());
178 }
179 let mut decoder = EntityDecoder::new(content);
180 let facts = georef::ModelFacts::scan(content, &mut decoder)?;
181 let router = GeometryRouter::new();
184 let mut validated = HashSet::new();
185 let terrains = ids.iter()
186 .map(|id| tin::read_terrain(*id, &facts, &router, &mut validated, &mut decoder))
187 .collect::<Result<Vec<_>, _>>()?;
188 let crs = terrains[0].crs.clone();
189 if let Some(other) = terrains.iter().find(|terrain| terrain.crs != crs) {
190 return Err(format!("#{} is in {} while #{} is in {}; one LandXML file carries one coordinate system", other.product_id, other.crs.name, terrains[0].product_id, crs.name));
191 }
192
193 let names = unique_names(terrains.iter().map(|terrain| {
194 options.surface_names.get(&terrain.product_id).map(|name| name.trim())
195 .filter(|name| !name.is_empty())
196 .unwrap_or(terrain.name.as_str())
197 }));
198 let mut xml = String::new();
199 let _ = write!(xml, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<LandXML xmlns=\"{LANDXML_NAMESPACE}\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"{LANDXML_NAMESPACE} {LANDXML_NAMESPACE}/LandXML-1.2.xsd\" version=\"1.2\" date=\"{}\" time=\"{}\">\n", options.date, options.time);
200 let _ = write!(xml, " <Units>\n {}\n </Units>\n", options.linear_unit.units_element());
201 let _ = write!(xml, " <CoordinateSystem name=\"{}\"", escape(&crs.name));
202 if let Some(code) = crs.name.strip_prefix("EPSG:").filter(|code| !code.is_empty() && code.bytes().all(|b| b.is_ascii_digit())) {
203 let _ = write!(xml, " epsgCode=\"{code}\"");
204 }
205 for (attribute, value) in [("horizontalDatum", &crs.geodetic_datum), ("verticalDatum", &crs.vertical_datum)] {
206 if let Some(value) = value {
207 let _ = write!(xml, " {attribute}=\"{}\"", escape(value));
208 }
209 }
210 xml.push_str("/>\n");
211 let project = facts.project_name.as_deref().unwrap_or("IFC terrain");
212 let _ = writeln!(xml, " <Project name=\"{}\"/>", escape(project));
213 let _ = writeln!(xml, " <Application name=\"ifc-lite\" manufacturer=\"ifc-lite\" version=\"{}\"/>", env!("CARGO_PKG_VERSION"));
214 xml.push_str(" <Surfaces>\n");
215 for (terrain, name) in terrains.iter().zip(&names) {
216 write_surface(&mut xml, terrain, name, options)?;
217 }
218 xml.push_str(" </Surfaces>\n</LandXML>\n");
219
220 let surfaces = terrains.iter().zip(names).map(|(terrain, name)| LandXmlTerrainSurface {
221 product_id: terrain.product_id,
222 global_id: terrain.global_id.clone(),
223 name,
224 points: terrain.points.len(),
225 faces: terrain.faces.len(),
226 }).collect();
227 Ok(LandXmlTerrainExport { xml, crs_name: crs.name, surfaces })
228}
229
230fn write_surface(xml: &mut String, terrain: &tin::Terrain, name: &str, options: &LandXmlTerrainOptions) -> Result<(), String> {
231 let (horizontal, vertical) = (options.linear_unit.horizontal_metres(), options.linear_unit.elevation_metres());
232 let text = |value: f64, unit: f64| number(value / unit, options.decimals);
233 let written: Vec<[String; 3]> = terrain.points.iter()
236 .map(|[n, e, z]| [text(*n, horizontal), text(*e, horizontal), text(*z, vertical)])
237 .collect();
238 let read_back = |point: &[String; 3]| -> [f64; 3] {
239 let value = |text: &String| text.parse::<f64>().unwrap_or(f64::NAN);
240 [value(&point[0]) * horizontal, value(&point[1]) * horizontal, value(&point[2]) * vertical]
241 };
242 for (number, face) in terrain.faces.iter().enumerate() {
243 if degenerate(face.map(|index| read_back(&written[index - 1]))) {
244 return Err(format!(
245 "face {} of #{} collapses onto a line or point at {} decimal places; choose more decimal places",
246 number + 1, terrain.product_id, options.decimals,
247 ));
248 }
249 }
250 let _ = write!(xml, " <Surface name=\"{}\"", escape(name));
251 if let Some(global_id) = &terrain.global_id {
252 let _ = write!(xml, " OID=\"{}\"", escape(global_id));
253 }
254 let (min, max) = terrain.points.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), point| (min.min(point[2]), max.max(point[2])));
255 let _ = write!(xml, ">\n <Definition surfType=\"TIN\" elevMax=\"{}\" elevMin=\"{}\">\n <Pnts>\n", text(max, vertical), text(min, vertical));
256 for (index, [n, e, z]) in written.iter().enumerate() {
257 let _ = writeln!(xml, " <P id=\"{}\">{n} {e} {z}</P>", index + 1);
258 }
259 xml.push_str(" </Pnts>\n <Faces>\n");
260 for [a, b, c] in &terrain.faces {
261 let _ = writeln!(xml, " <F>{a} {b} {c}</F>");
262 }
263 xml.push_str(" </Faces>\n </Definition>\n </Surface>\n");
264 Ok(())
265}
266
267fn degenerate([a, b, c]: [[f64; 3]; 3]) -> bool {
270 let (u, v) = ([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]);
271 let cross = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
272 cross.iter().all(|value| *value == 0.0)
273}
274
275fn number(value: f64, decimals: u8) -> String {
281 let text = format!("{value:.*}", usize::from(decimals));
282 if !text.contains('.') {
283 return if text == "-0" { "0".into() } else { text };
284 }
285 match text.trim_end_matches('0').trim_end_matches('.') {
286 "-0" => "0".into(),
287 trimmed => trimmed.into(),
288 }
289}
290
291fn unique_names<'a>(names: impl Iterator<Item = &'a str>) -> Vec<String> {
294 let mut taken = HashSet::new();
295 let mut out = Vec::new();
296 for name in names {
297 let mut candidate = name.to_string();
298 let mut suffix = 2;
299 while !taken.insert(candidate.clone()) {
300 candidate = format!("{name} ({suffix})");
301 suffix += 1;
302 }
303 out.push(candidate);
304 }
305 out
306}
307
308fn check_stamp(value: &str, shape: &str, separator: u8) -> Result<(), String> {
309 let ok = value.len() == shape.len()
310 && value.bytes().zip(shape.bytes()).all(|(byte, slot)| if slot == separator { byte == separator } else { byte.is_ascii_digit() });
311 if ok { Ok(()) } else { Err(format!("header stamp {value:?} is not {shape}")) }
312}