1use 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
13pub struct JsonLdOptions {
15 pub context: String,
17 pub include_properties: bool,
18 pub include_quantities: bool,
19 pub pretty: bool,
20 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
38pub 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
44pub 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 let has_psets = graph.iter().any(|n| n["ifc:hasPropertySets"].is_array());
152 assert!(has_psets, "expected ifc:hasPropertySets somewhere");
153 }
154 #[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 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 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 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 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 #[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}