ifc_lite_export/
json.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Structured **JSON** exporter — an array of entity objects with native fields,
3//! property sets and quantity sets. A faithful dump of the shared export model that
4//! preserves value types (numbers stay numbers, booleans stay booleans).
5
6use serde_json::{json, Map, Value};
7
8use crate::model::{build_export_model, EntityRow, PropValue};
9
10/// Options for JSON export.
11pub struct JsonOptions {
12    /// Pretty-print with indentation.
13    pub pretty: bool,
14    /// Include property sets.
15    pub include_properties: bool,
16    /// Include quantity sets.
17    pub include_quantities: bool,
18}
19
20impl Default for JsonOptions {
21    fn default() -> Self {
22        Self { pretty: false, include_properties: true, include_quantities: true }
23    }
24}
25
26/// Render an `f64` as a JSON `Value`, without the silent `null` that `serde_json`
27/// substitutes for a non-finite number (`Number::from_f64` returns `None` for
28/// NaN/±Infinity, and `json!`/`Value::from` map that to `Value::Null` — RFC 8259
29/// has no NaN/Infinity literal, but collapsing a *present* out-of-range measure to
30/// `null` is indistinguishable from the value being absent). A STEP `REAL` literal
31/// with an extreme exponent (e.g. `1.0E400`) parses to `f64::INFINITY` without
32/// erroring, so this is reachable from real (if adversarial) input, not only from
33/// a computed value. Falls back to the `f64::to_string()` form (`"inf"`/`"-inf"`/
34/// `"NaN"`) so the value survives as a string instead of vanishing.
35pub(crate) fn finite_json_number(n: f64) -> Value {
36    if n.is_finite() { json!(n) } else { json!(n.to_string()) }
37}
38
39/// Coerce a rendered property value to a typed JSON value using its IFC type tag.
40pub(crate) fn typed_value(p: &PropValue) -> Value {
41    match p.value_type.as_str() {
42        "IFCREAL" | "IFCINTEGER" | "IFCNUMBER" | "IFCLENGTHMEASURE" | "IFCAREAMEASURE"
43        | "IFCVOLUMEMEASURE" | "IFCPOSITIVELENGTHMEASURE" | "IFCCOUNTMEASURE"
44        | "IFCMASSMEASURE" | "IFCRATIOMEASURE" | "IFCNORMALISEDRATIOMEASURE" => {
45            p.value.parse::<f64>().map(finite_json_number).unwrap_or_else(|_| json!(p.value))
46        }
47        "IFCBOOLEAN" | "IFCLOGICAL" => match p.value.as_str() {
48            "true" => json!(true),
49            "false" => json!(false),
50            other => json!(other),
51        },
52        _ => json!(p.value),
53    }
54}
55
56fn entity_to_json(e: &EntityRow, opts: &JsonOptions) -> Value {
57    let mut obj = Map::new();
58    obj.insert("expressId".into(), json!(e.express_id));
59    obj.insert("type".into(), json!(e.ifc_type));
60    if let Some(g) = &e.global_id {
61        obj.insert("globalId".into(), json!(g));
62    }
63    if let Some(n) = &e.name {
64        obj.insert("name".into(), json!(n));
65    }
66    if let Some(d) = &e.description {
67        obj.insert("description".into(), json!(d));
68    }
69    if let Some(o) = &e.object_type {
70        obj.insert("objectType".into(), json!(o));
71    }
72    obj.insert("hasGeometry".into(), json!(e.has_geometry));
73
74    if opts.include_properties && !e.property_sets.is_empty() {
75        let psets: Vec<Value> = e
76            .property_sets
77            .iter()
78            .map(|ps| {
79                let props: Vec<Value> = ps
80                    .properties
81                    .iter()
82                    .map(|p| json!({ "name": p.name, "value": typed_value(p), "type": p.value_type }))
83                    .collect();
84                json!({ "name": ps.name, "properties": props })
85            })
86            .collect();
87        obj.insert("propertySets".into(), json!(psets));
88    }
89
90    if opts.include_quantities && !e.quantity_sets.is_empty() {
91        let qsets: Vec<Value> = e
92            .quantity_sets
93            .iter()
94            .map(|qs| {
95                let quants: Vec<Value> = qs
96                    .quantities
97                    .iter()
98                    .map(|q| json!({ "name": q.name, "value": finite_json_number(q.value), "type": q.kind }))
99                    .collect();
100                json!({ "name": qs.name, "quantities": quants })
101            })
102            .collect();
103        obj.insert("quantitySets".into(), json!(qsets));
104    }
105
106    Value::Object(obj)
107}
108
109/// Export the model as a JSON array string.
110pub fn export_json(content: &[u8], opts: &JsonOptions) -> String {
111    let model = build_export_model(content);
112    let arr: Vec<Value> = model.entities.iter().map(|e| entity_to_json(e, opts)).collect();
113    let value = Value::Array(arr);
114    if opts.pretty {
115        serde_json::to_string_pretty(&value).expect("json serializes")
116    } else {
117        serde_json::to_string(&value).expect("json serializes")
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn duplex_exports_json_array() {
127        let s = export_json(&fixture_or_skip!("ara3d/duplex.ifc"), &JsonOptions::default());
128        let v: Value = serde_json::from_str(&s).expect("valid JSON");
129        let arr = v.as_array().expect("array");
130        assert!(arr.len() > 50);
131        let first = &arr[0];
132        assert!(first["expressId"].is_number());
133        assert!(first["type"].is_string());
134        assert!(first["hasGeometry"].is_boolean());
135
136        // A property's value preserves its type (some number-valued property is a JSON number).
137        let has_typed_number = arr.iter().any(|e| {
138            e["propertySets"].as_array().is_some_and(|ps| {
139                ps.iter().any(|p| {
140                    p["properties"].as_array().is_some_and(|props| {
141                        props.iter().any(|pr| pr["value"].is_number() || pr["value"].is_boolean())
142                    })
143                })
144            })
145        });
146        assert!(has_typed_number, "expected at least one typed (number/bool) property value");
147    }
148
149    /// A STEP `REAL` literal with an extreme exponent (e.g.
150    /// `1.0E400`) parses to `f64::INFINITY` without erroring in the decoder, so
151    /// it is reachable from real (if adversarial) input, not just a computed
152    /// value. Before the fix, `json!(f64::INFINITY)` silently became JSON
153    /// `null` (`serde_json::Number::from_f64` returns `None` for a non-finite
154    /// float) — indistinguishable in the output from the quantity being
155    /// absent. The value must survive as a string instead of vanishing.
156    #[test]
157    fn a_non_finite_quantity_survives_instead_of_becoming_null() {
158        let ifc = "ISO-10303-21;\n\
159HEADER;\n\
160FILE_DESCRIPTION((''),'');\n\
161FILE_NAME('','',(''),(''),'','','');\n\
162FILE_SCHEMA(('IFC4'));\n\
163ENDSEC;\n\
164DATA;\n\
165#1=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
166#3=IFCUNITASSIGNMENT((#1));\n\
167#4=IFCPROJECT('0PROJECT0000000000000',$,'P',$,$,$,$,$,#3);\n\
168#5=IFCWALL('0WALL000000000000000A',$,'W',$,$,$,$,$,$);\n\
169#20=IFCQUANTITYLENGTH('Length',$,$,1.0E400);\n\
170#21=IFCELEMENTQUANTITY('0QTO00000000000000A',$,'Qto_WallBaseQuantities',$,$,(#20));\n\
171#22=IFCRELDEFINESBYPROPERTIES('0REL000000000000000A',$,$,$,(#5),#21);\n\
172ENDSEC;\n\
173END-ISO-10303-21;\n";
174        let s = export_json(ifc.as_bytes(), &JsonOptions::default());
175        let v: Value = serde_json::from_str(&s).expect("valid JSON");
176        let quantities = v[0]["quantitySets"][0]["quantities"].as_array().expect("quantities array");
177        let length = quantities.iter().find(|q| q["name"] == "Length").expect("Length quantity present");
178        assert!(!length["value"].is_null(), "an out-of-range REAL must not collapse to null");
179        assert_eq!(length["value"], json!("inf"));
180    }
181}