ifc_lite_export/
landxml_terrain.rs

1// SPDX-License-Identifier: MPL-2.0
2//! IFC terrain → LandXML 1.2 surface export (#7600).
3//!
4//! A terrain-TIN producer, not a generic geometry converter: each selected
5//! `IfcGeographicElement` (or `IfcSite`) whose body is one
6//! `IfcTriangulatedFaceSet` becomes one `<Surface>`, its `CoordIndex` copied to
7//! `<Faces>` without re-triangulation. Coordinates are the product placement
8//! composed with the representation context's `IfcMapConversion`, written as
9//! LandXML's `N E Z` in metres. Missing or ambiguous georeferencing refuses
10//! the export instead of guessing a CRS, a unit or a scale; the contract
11//! follows openBIM-surveyor's `IFC_TERRAIN_TO_LANDXML.md`.
12
13#[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/// The length unit the document is written in (#7618). Coordinates are
34/// computed in metres and converted once, at write time.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum LandXmlLinearUnit {
37    #[default]
38    Metre,
39    /// 0.3048 m exactly.
40    InternationalFoot,
41    /// 1200/3937 m exactly. LandXML 1.2's `elevationType` has no US survey
42    /// foot, so elevations are then written in international feet, declared
43    /// as `elevationUnit="feet"`: both units are explicit and exact.
44    UsSurveyFoot,
45}
46
47impl LandXmlLinearUnit {
48    /// Metres per horizontal unit.
49    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    /// Metres per elevation unit (see [`Self::UsSurveyFoot`]).
58    fn elevation_metres(self) -> f64 {
59        match self {
60            Self::Metre => 1.0,
61            Self::InternationalFoot | Self::UsSurveyFoot => 0.3048,
62        }
63    }
64
65    /// The `<Units>` child declaring this unit, with the XSD's other required
66    /// attributes in the same system.
67    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/// How the document is written. `#[non_exhaustive]`: build it with
77/// [`LandXmlTerrainOptions::new`] and set the fields you need, so a later
78/// option is not a breaking change.
79#[derive(Debug, Clone)]
80#[non_exhaustive]
81pub struct LandXmlTerrainOptions {
82    /// `YYYY-MM-DD`, the required `LandXML@date`. The caller owns the clock:
83    /// the wasm build has none, and a fixed stamp keeps tests byte-stable.
84    pub date: String,
85    /// `HH:MM:SS`, the required `LandXML@time`.
86    pub time: String,
87    /// Default metres.
88    pub linear_unit: LandXmlLinearUnit,
89    /// Decimal places in the written unit, 0 to 9. Default 6 (a micrometre in
90    /// metres, far below survey precision); 3 is millimetres in metres. A
91    /// precision that collapses a face onto a line or point is refused.
92    pub decimals: u8,
93    /// `Surface@name` per product express id. A blank or absent entry keeps
94    /// the product's IFC name; duplicates are still made unique.
95    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/// One written `<Surface>`, for the caller to report.
111#[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/// The LandXML document plus what it contains.
122#[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/// The selection is not an authored triangulated terrain, or its
131/// georeferencing (map conversion, CRS, units) is missing or ambiguous. The
132/// reason names the product or attribute, so a caller can show it verbatim.
133///
134/// Its own type rather than an [`crate::ExportError`] variant: that enum is
135/// exhaustive, so a new variant would be a breaking change for every `match`.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct LandXmlTerrainRefusal {
138    pub reason: String,
139}
140
141impl LandXmlTerrainRefusal {
142    /// Stable machine-readable code, the prefix of the `Display` text that
143    /// crosses the wasm boundary (`@ifc-lite/geometry`'s `TERRAIN_REFUSED`).
144    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
155/// Export the selected terrain products of `content` (IFC STEP bytes) as one
156/// LandXML 1.2 document, one `<Surface>` per product in selection order.
157///
158/// Fails closed with a [`LandXmlTerrainRefusal`] naming the first product or
159/// attribute that does not meet the contract; nothing partial is returned.
160pub 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    // The default router keeps placements in project units, which is what
182    // `IfcMapConversion` consumes.
183    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    // Each point as written, and that text read back in metres: the precision
234    // must not collapse a face the source authored (checked again below).
235    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
267/// A triangle with no area: its edge vectors' cross product is exactly zero.
268/// Shared by the source check and the as-written check.
269fn 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
275/// `decimals` places, trailing zeros trimmed. The default six (a micrometre
276/// in metres) is three orders below survey precision: composing placement and
277/// map offsets in `f64` leaves noise such as `5516087.398800001` (#844's
278/// terrain), and a fixed resolution drops it without touching an authored
279/// digit. Never an exponent, never `-0`.
280fn 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
291/// `Surface@name` is unique within `<Surfaces>` (XSD `uSrfName`); a repeated
292/// product name gets a ` (2)`, ` (3)` … suffix.
293fn 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}