ifc_lite_export/
jsonld.rs

1// SPDX-License-Identifier: MPL-2.0
2//! **JSON-LD** exporter for semantic-web interop. Ports
3//! `packages/export/src/jsonld-exporter.ts`: an `@graph` of `ifc:`-prefixed nodes
4//! with `hasPropertySets` / `hasQuantitySets`, against the buildingSMART IFC4 OWL vocab.
5
6use serde_json::{json, Value};
7
8use crate::json::{finite_json_number, typed_value};
9use crate::model::build_export_model;
10
11const DEFAULT_CONTEXT: &str = "https://standards.buildingsmart.org/IFC/DEV/IFC4/ADD2/OWL";
12
13/// Options for JSON-LD export.
14pub struct JsonLdOptions {
15    /// Ontology context IRI (default buildingSMART IFC4 ADD2 OWL).
16    pub context: String,
17    pub include_properties: bool,
18    pub include_quantities: bool,
19    pub pretty: bool,
20    /// Express-id isolation filter. Empty preserves the historical public API
21    /// contract and means all entities; callers that must distinguish an
22    /// active empty filter use [`export_jsonld_with_filter`].
23    pub included: Vec<u32>,
24}
25
26impl Default for JsonLdOptions {
27    fn default() -> Self {
28        Self {
29            context: DEFAULT_CONTEXT.to_string(),
30            include_properties: true,
31            include_quantities: false,
32            pretty: false,
33            included: Vec::new(),
34        }
35    }
36}
37
38/// Export the model as a JSON-LD document string.
39pub fn export_jsonld(content: &[u8], opts: &JsonLdOptions) -> String {
40    let included = (!opts.included.is_empty()).then_some(opts.included.as_slice());
41    export_jsonld_with_filter(content, opts, included)
42}
43
44/// Export JSON-LD with an explicit null-vs-empty isolation filter.
45///
46/// `None` means no filter, `Some(ids)` means an active allowlist, and
47/// `Some(empty)` emits an empty `@graph`. This additive entry point lets wasm
48/// preserve that distinction without changing [`JsonLdOptions`]' stable public
49/// field shape (#4659).
50pub fn export_jsonld_with_filter(
51    content: &[u8],
52    opts: &JsonLdOptions,
53    included: Option<&[u32]>,
54) -> String {
55    let model = build_export_model(content);
56    let filter: Option<std::collections::HashSet<u32>> =
57        included.map(|ids| ids.iter().copied().collect());
58    let mut graph: Vec<Value> = Vec::with_capacity(model.entities.len());
59
60    for e in &model.entities {
61        if let Some(set) = &filter {
62            if !set.contains(&e.express_id) {
63                continue;
64            }
65        }
66        let mut node = serde_json::Map::new();
67        node.insert("@id".into(), json!(format!("ifc:{}", e.express_id)));
68        node.insert("@type".into(), json!(format!("ifc:{}", e.ifc_type)));
69        node.insert("ifc:expressId".into(), json!(e.express_id));
70        if let Some(g) = &e.global_id {
71            node.insert("ifc:globalId".into(), json!(g));
72        }
73        if let Some(n) = &e.name {
74            node.insert("ifc:name".into(), json!(n));
75        }
76
77        if opts.include_properties && !e.property_sets.is_empty() {
78            let psets: Vec<Value> = e
79                .property_sets
80                .iter()
81                .map(|ps| {
82                    let props: Vec<Value> = ps
83                        .properties
84                        .iter()
85                        .map(|p| {
86                            json!({
87                                "@type": "ifc:IfcPropertySingleValue",
88                                "ifc:name": p.name,
89                                "ifc:nominalValue": typed_value(p),
90                            })
91                        })
92                        .collect();
93                    json!({ "@type": "ifc:IfcPropertySet", "ifc:name": ps.name, "ifc:hasProperties": props })
94                })
95                .collect();
96            node.insert("ifc:hasPropertySets".into(), json!(psets));
97        }
98
99        if opts.include_quantities && !e.quantity_sets.is_empty() {
100            let qsets: Vec<Value> = e
101                .quantity_sets
102                .iter()
103                .map(|qs| {
104                    let quants: Vec<Value> = qs
105                        .quantities
106                        .iter()
107                        .map(|q| {
108                            json!({
109                                "@type": format!("ifc:IfcQuantity{}", q.kind),
110                                "ifc:name": q.name,
111                                "ifc:value": finite_json_number(q.value),
112                            })
113                        })
114                        .collect();
115                    json!({ "@type": "ifc:IfcElementQuantity", "ifc:name": qs.name, "ifc:quantities": quants })
116                })
117                .collect();
118            node.insert("ifc:hasQuantitySets".into(), json!(qsets));
119        }
120
121        graph.push(Value::Object(node));
122    }
123
124    let doc = json!({
125        "@context": { "@vocab": format!("{}#", opts.context), "ifc": format!("{}#", opts.context) },
126        "@graph": graph,
127    });
128    if opts.pretty {
129        serde_json::to_string_pretty(&doc).expect("jsonld serializes")
130    } else {
131        serde_json::to_string(&doc).expect("jsonld serializes")
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn duplex_exports_valid_jsonld() {
141        let s = export_jsonld(&fixture_or_skip!("ara3d/duplex.ifc"), &JsonLdOptions::default());
142        let v: Value = serde_json::from_str(&s).expect("valid JSON");
143        assert!(v["@context"]["ifc"].as_str().unwrap().ends_with("OWL#"));
144        let graph = v["@graph"].as_array().expect("graph array");
145        assert!(graph.len() > 50);
146        let first = &graph[0];
147        assert!(first["@id"].as_str().unwrap().starts_with("ifc:"));
148        assert!(first["@type"].as_str().unwrap().starts_with("ifc:Ifc"));
149
150        // At least one node carries property sets in the ifc: namespace.
151        let has_psets = graph.iter().any(|n| n["ifc:hasPropertySets"].is_array());
152        assert!(has_psets, "expected ifc:hasPropertySets somewhere");
153    }
154    /// #4659: `Some(empty)` means "the isolation filter is active and matched
155    /// nothing", not "no filter". The wasm binding previously collapsed its
156    /// empty array into the legacy `JsonLdOptions::included` meaning of
157    /// "export everything", so a zero-match `--type` handed the user the whole
158    /// model. The explicit helper preserves both contracts without breaking
159    /// existing Rust callers.
160    ///
161    /// The `None` sibling pins the other direction: the two must not collapse
162    /// into each other in either direction.
163    #[test]
164    fn an_active_but_empty_included_set_emits_an_empty_graph() {
165        let ifc = "ISO-10303-21;\n\
166HEADER;\n\
167FILE_DESCRIPTION((''),'');\n\
168FILE_NAME('','',(''),(''),'','','');\n\
169FILE_SCHEMA(('IFC4'));\n\
170ENDSEC;\n\
171DATA;\n\
172#4=IFCPROJECT('0PROJECT0000000000000',$,'P',$,$,$,$,$,$);\n\
173#5=IFCWALL('0WALL000000000000000A',$,'W1',$,$,$,$,$,$);\n\
174#6=IFCWALL('0WALL000000000000000B',$,'W2',$,$,$,$,$,$);\n\
175#7=IFCSLAB('0SLAB000000000000000A',$,'S1',$,$,$,$,$,$);\n\
176ENDSEC;\n\
177END-ISO-10303-21;\n";
178        // No filter: every entity. Asserted first so the empty-graph assertion
179        // below cannot pass merely because the fixture exports nothing.
180        let opts = JsonLdOptions::default();
181        let unfiltered: Value = serde_json::from_str(&export_jsonld_with_filter(
182            ifc.as_bytes(),
183            &opts,
184            None,
185        ))
186        .unwrap();
187        let unfiltered = unfiltered["@graph"].as_array().expect("graph array").len();
188        assert_eq!(unfiltered, 3, "unfiltered graph carries every entity in the fixture");
189
190        // A filter that matches something still narrows.
191        let narrowed: Value = serde_json::from_str(&export_jsonld_with_filter(
192            ifc.as_bytes(),
193            &opts,
194            Some(&[5, 6]),
195        ))
196        .unwrap();
197        let narrowed = narrowed["@graph"].as_array().expect("graph array").len();
198        assert_eq!(narrowed, 2, "an explicit two-entity filter emits exactly those entities");
199
200        // A filter that matches nothing emits nothing.
201        let zero_match: Value = serde_json::from_str(&export_jsonld_with_filter(
202            ifc.as_bytes(),
203            &opts,
204            Some(&[]),
205        ))
206        .unwrap();
207        let zero_match = zero_match["@graph"].as_array().expect("graph array").len();
208        assert_eq!(zero_match, 0, "an active-but-empty filter must not export the whole model");
209    }
210
211    #[test]
212    fn included_filter_restricts_the_graph() {
213        let bytes = fixture_or_skip!("ara3d/duplex.ifc");
214        // Full model graph → pick two express ids → re-export isolated to them.
215        let full: Value =
216            serde_json::from_str(&export_jsonld(&bytes, &JsonLdOptions::default())).unwrap();
217        let all = full["@graph"].as_array().unwrap();
218        assert!(all.len() > 2, "fixture should have many entities");
219        let pick: Vec<u32> = all
220            .iter()
221            .take(2)
222            .map(|n| n["ifc:expressId"].as_u64().unwrap() as u32)
223            .collect();
224
225        let opts = JsonLdOptions { included: pick.clone(), ..Default::default() };
226        let filtered: Value = serde_json::from_str(&export_jsonld(&bytes, &opts)).unwrap();
227        let graph = filtered["@graph"].as_array().unwrap();
228        assert_eq!(graph.len(), 2, "isolated export emits only the requested ids");
229        for n in graph {
230            let id = n["ifc:expressId"].as_u64().unwrap() as u32;
231            assert!(pick.contains(&id), "unexpected entity {id} in filtered graph");
232        }
233    }
234
235    /// Mirrors `json::tests::a_non_finite_quantity_survives_instead_of_becoming_null`:
236    /// an out-of-range `REAL` (parses to `f64::INFINITY`) must not collapse the
237    /// `ifc:value` to JSON `null`, which reads as "quantity absent".
238    #[test]
239    fn a_non_finite_quantity_survives_instead_of_becoming_null() {
240        let ifc = "ISO-10303-21;\n\
241HEADER;\n\
242FILE_DESCRIPTION((''),'');\n\
243FILE_NAME('','',(''),(''),'','','');\n\
244FILE_SCHEMA(('IFC4'));\n\
245ENDSEC;\n\
246DATA;\n\
247#1=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
248#3=IFCUNITASSIGNMENT((#1));\n\
249#4=IFCPROJECT('0PROJECT0000000000000',$,'P',$,$,$,$,$,#3);\n\
250#5=IFCWALL('0WALL000000000000000A',$,'W',$,$,$,$,$,$);\n\
251#20=IFCQUANTITYLENGTH('Length',$,$,1.0E400);\n\
252#21=IFCELEMENTQUANTITY('0QTO00000000000000A',$,'Qto_WallBaseQuantities',$,$,(#20));\n\
253#22=IFCRELDEFINESBYPROPERTIES('0REL000000000000000A',$,$,$,(#5),#21);\n\
254ENDSEC;\n\
255END-ISO-10303-21;\n";
256        let opts = JsonLdOptions { include_quantities: true, ..Default::default() };
257        let s = export_jsonld(ifc.as_bytes(), &opts);
258        let v: Value = serde_json::from_str(&s).expect("valid JSON");
259        let graph = v["@graph"].as_array().expect("graph array");
260        let wall = graph.iter().find(|n| n["ifc:globalId"] == "0WALL000000000000000A").expect("wall node");
261        let quants = wall["ifc:hasQuantitySets"][0]["ifc:quantities"].as_array().expect("quantities array");
262        let length = quants.iter().find(|q| q["ifc:name"] == "Length").expect("Length quantity present");
263        assert!(!length["ifc:value"].is_null(), "an out-of-range REAL must not collapse to null");
264        assert_eq!(length["ifc:value"], json!("inf"));
265    }
266}