use crate::diagnostic::{Diagnostic, Tolerance};
use crate::version::BcfVersion;
use crate::xml::Node;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeaderFile {
pub ifc_project: Option<String>,
pub ifc_spatial_structure_element: Option<String>,
pub filename: Option<String>,
pub date: Option<String>,
pub reference: Option<String>,
pub is_external: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewPointRef {
pub guid: Option<String>,
pub viewpoint: Option<String>,
pub snapshot: Option<String>,
pub index: Option<i32>,
pub visualization: Option<Visualization>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Visualization {
pub guid: Option<String>,
pub selection: Vec<Component>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct Component {
pub ifc_guid: Option<String>,
pub originating_system: Option<String>,
pub authoring_tool_id: Option<String>,
}
impl Component {
#[must_use]
pub fn ifc(ifc_guid: impl Into<String>) -> Self {
Self {
ifc_guid: Some(ifc_guid.into()),
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Comment {
pub guid: Option<String>,
pub date: Option<String>,
pub author: Option<String>,
pub comment: Option<String>,
pub viewpoint: Option<String>,
pub modified_date: Option<String>,
pub modified_author: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Topic {
pub guid: Option<String>,
pub topic_type: Option<String>,
pub topic_status: Option<String>,
pub server_assigned_id: Option<String>,
pub title: Option<String>,
pub priority: Option<String>,
pub index: Option<i32>,
pub labels: Vec<String>,
pub creation_date: Option<String>,
pub creation_author: Option<String>,
pub modified_date: Option<String>,
pub modified_author: Option<String>,
pub due_date: Option<String>,
pub assigned_to: Option<String>,
pub description: Option<String>,
pub stage: Option<String>,
pub related_topics: Vec<String>,
pub reference_links: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Markup {
pub entry: String,
pub header_files: Vec<HeaderFile>,
pub topic: Topic,
pub comments: Vec<Comment>,
pub viewpoints: Vec<ViewPointRef>,
}
impl Markup {
#[must_use]
pub fn title(&self) -> &str {
self.topic.title.as_deref().unwrap_or("")
}
#[must_use]
pub fn status(&self) -> Option<&str> {
self.topic.topic_status.as_deref()
}
}
pub(crate) fn observe_version(markup: &Node) -> Option<BcfVersion> {
let topic = markup.child("Topic")?;
let nests_collections = topic.child("Comments").is_some()
|| topic.child("Viewpoints").is_some()
|| topic
.child("Labels")
.is_some_and(|l| l.child("Label").is_some())
|| topic
.child("DocumentReferences")
.is_some_and(|d| d.child("DocumentReference").is_some())
|| topic
.child("ReferenceLinks")
.is_some_and(|r| r.child("ReferenceLink").is_some());
if nests_collections || topic.attr("ServerAssignedId").is_some() {
return Some(BcfVersion::V3_0);
}
if markup
.children_named("Viewpoints")
.any(|v| v.child("ViewPoint").is_some())
{
return Some(BcfVersion::V3_0);
}
let comments_are_2_0 = markup.children_named("Comment").any(|c| {
c.child("Topic").is_some()
|| c.child("Status").is_some()
|| c.child("VerbalStatus").is_some()
|| c.child("ReplyToComment").is_some()
});
if comments_are_2_0 {
return Some(BcfVersion::V2_0);
}
None
}
pub(crate) fn interpret(
entry: &str,
root: &Node,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Markup> {
let Some(topic_node) = root.child("Topic") else {
diagnostics.push(Diagnostic::in_entry(entry, Tolerance::MarkupWithoutTopic));
return None;
};
let topic = read_topic(entry, topic_node, diagnostics);
let header_files = root
.child("Header")
.into_iter()
.flat_map(|h| h.children_named("File"))
.map(read_header_file)
.collect();
let mut viewpoints: Vec<ViewPointRef> = Vec::new();
for wrapper in root
.children_named("Viewpoints")
.chain(topic_node.children_named("Viewpoints"))
{
if wrapper.child("ViewPoint").is_some() {
viewpoints.extend(wrapper.children_named("ViewPoint").map(read_viewpoint));
} else if !wrapper.children.is_empty() || wrapper.attr("Guid").is_some() {
viewpoints.push(read_viewpoint(wrapper));
}
}
let comments = root
.children_named("Comment")
.chain(
topic_node
.child("Comments")
.into_iter()
.flat_map(|c| c.children_named("Comment")),
)
.map(|c| read_comment(entry, c, diagnostics))
.collect();
Some(Markup {
entry: entry.to_string(),
header_files,
topic,
comments,
viewpoints,
})
}
fn owned(v: Option<&str>) -> Option<String> {
v.map(str::to_string)
}
fn read_topic(entry: &str, node: &Node, diagnostics: &mut Vec<Diagnostic>) -> Topic {
let guid = owned(node.attr("Guid").map(str::trim).filter(|g| !g.is_empty()));
if guid.is_none() {
diagnostics.push(Diagnostic::in_entry(entry, Tolerance::TopicWithoutGuid));
}
let title = owned(node.child_text("Title"));
if title.is_none() {
diagnostics.push(Diagnostic::in_entry(entry, Tolerance::TopicWithoutTitle));
}
Topic {
guid,
topic_type: owned(node.attr_or_child_text("TopicType")),
topic_status: owned(node.attr_or_child_text("TopicStatus")),
server_assigned_id: owned(node.attr_or_child_text("ServerAssignedId")),
title,
priority: owned(node.child_text("Priority")),
index: node.child_text("Index").and_then(|i| i.parse().ok()),
labels: node
.children_named("Labels")
.flat_map(|l| {
let nested: Vec<&str> = l
.children_named("Label")
.map(|n| n.text.trim())
.filter(|t| !t.is_empty())
.collect();
if nested.is_empty() {
let own = l.text.trim();
if own.is_empty() {
Vec::new()
} else {
vec![own]
}
} else {
nested
}
})
.map(str::to_string)
.collect(),
creation_date: owned(node.child_text("CreationDate")),
creation_author: owned(node.child_text("CreationAuthor")),
modified_date: owned(node.child_text("ModifiedDate")),
modified_author: owned(node.child_text("ModifiedAuthor")),
due_date: owned(node.child_text("DueDate")),
assigned_to: owned(node.child_text("AssignedTo")),
description: owned(node.child_text("Description")),
stage: owned(node.child_text("Stage")),
related_topics: node
.children_named("RelatedTopic")
.chain(
node.child("RelatedTopics")
.into_iter()
.flat_map(|r| r.children_named("RelatedTopic")),
)
.filter_map(|r| owned(r.attr("Guid")))
.collect(),
reference_links: node
.children_named("ReferenceLink")
.chain(
node.child("ReferenceLinks")
.into_iter()
.flat_map(|r| r.children_named("ReferenceLink")),
)
.filter_map(|r| {
let t = r.text.trim();
(!t.is_empty()).then(|| t.to_string())
})
.collect(),
}
}
fn read_header_file(node: &Node) -> HeaderFile {
HeaderFile {
ifc_project: owned(node.attr("IfcProject")),
ifc_spatial_structure_element: owned(node.attr("IfcSpatialStructureElement")),
filename: owned(node.child_text("Filename")),
date: owned(node.child_text("Date")),
reference: owned(node.child_text("Reference")),
is_external: node.attr("IsExternal").and_then(parse_bool),
}
}
fn read_viewpoint(node: &Node) -> ViewPointRef {
ViewPointRef {
guid: owned(node.attr("Guid")),
viewpoint: owned(node.child_text("Viewpoint")),
snapshot: owned(node.child_text("Snapshot")),
index: node.child_text("Index").and_then(|i| i.parse().ok()),
visualization: None,
}
}
pub(crate) fn interpret_visualization(root: &Node) -> Result<Visualization, String> {
if root.name != "VisualizationInfo" {
return Err(format!(
"root element is <{}>, not <VisualizationInfo>",
root.name
));
}
let components = root.child("Components");
let selection = components
.into_iter()
.flat_map(|c| c.children_named("Selection"))
.flat_map(|s| s.children_named("Component"))
.chain(
components
.into_iter()
.flat_map(|c| c.children_named("Component"))
.filter(|c| c.attr("Selected").and_then(parse_bool) == Some(true)),
)
.map(read_component)
.collect();
Ok(Visualization {
guid: owned(root.attr("Guid").map(str::trim).filter(|g| !g.is_empty())),
selection,
})
}
fn read_component(node: &Node) -> Component {
Component {
ifc_guid: owned(
node.attr("IfcGuid")
.map(str::trim)
.filter(|g| !g.is_empty()),
),
originating_system: owned(node.child_text("OriginatingSystem")),
authoring_tool_id: owned(node.child_text("AuthoringToolId")),
}
}
fn read_comment(entry: &str, node: &Node, diagnostics: &mut Vec<Diagnostic>) -> Comment {
let guid = owned(node.attr("Guid").map(str::trim).filter(|g| !g.is_empty()));
if guid.is_none() {
diagnostics.push(Diagnostic::in_entry(entry, Tolerance::CommentWithoutGuid));
}
Comment {
guid,
date: owned(node.child_text("Date")),
author: owned(node.child_text("Author")),
comment: owned(node.child_text("Comment")),
viewpoint: node
.child("Viewpoint")
.and_then(|v| owned(v.attr("Guid")))
.or_else(|| owned(node.child_text("Viewpoint"))),
modified_date: owned(node.child_text("ModifiedDate")),
modified_author: owned(node.child_text("ModifiedAuthor")),
}
}
fn parse_bool(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::xml::parse;
fn tree(src: &str) -> Node {
parse(src.as_bytes()).unwrap()
}
#[test]
fn detects_2_0_from_a_comment_back_reference() {
let t = tree(
r#"<Markup><Topic Guid="a"/><Comment Guid="c"><Topic Guid="a"/></Comment></Markup>"#,
);
assert_eq!(observe_version(&t), Some(BcfVersion::V2_0));
}
#[test]
fn detects_2_0_from_comment_only_members() {
for child in [
"<Status>Open</Status>",
"<VerbalStatus>Open</VerbalStatus>",
r#"<ReplyToComment Guid="x"/>"#,
] {
let t = tree(&format!(
r#"<Markup><Topic Guid="a" TopicStatus="Open"/><Comment Guid="c">{child}</Comment></Markup>"#
));
assert_eq!(observe_version(&t), Some(BcfVersion::V2_0), "for {child}");
}
}
#[test]
fn attribute_status_is_not_evidence_against_2_0() {
let t = tree(
r#"<Markup><Topic Guid="a" TopicType="Information" TopicStatus="Open"><Title>T</Title></Topic></Markup>"#,
);
assert_eq!(observe_version(&t), None);
assert_eq!(
BcfVersion::reconcile(Some(BcfVersion::V2_0), observe_version(&t)),
openbim_core::Detected::Declared(BcfVersion::V2_0)
);
}
#[test]
fn a_2_0_document_references_element_is_not_a_3_0_wrapper() {
let t = tree(
r#"<Markup><Topic Guid="a" TopicStatus="Open"><DocumentReferences Guid="d"><ReferencedDocument>a.pdf</ReferencedDocument></DocumentReferences></Topic></Markup>"#,
);
assert_eq!(observe_version(&t), None);
}
#[test]
fn detects_3_0_from_viewpoints_wrapper_or_server_id() {
let wrapped = tree(
r#"<Markup><Topic Guid="a" TopicStatus="Open"/><Viewpoints><ViewPoint Guid="v"/></Viewpoints></Markup>"#,
);
assert_eq!(observe_version(&wrapped), Some(BcfVersion::V3_0));
let server = tree(r#"<Markup><Topic Guid="a" ServerAssignedId="7"/></Markup>"#);
assert_eq!(observe_version(&server), Some(BcfVersion::V3_0));
}
#[test]
fn a_2_x_viewpoints_element_is_not_a_3_0_wrapper() {
let t = tree(
r#"<Markup><Topic Guid="a" TopicStatus="Open"/><Viewpoints Guid="v"><Viewpoint>v.bcfv</Viewpoint><Snapshot>s.png</Snapshot></Viewpoints></Markup>"#,
);
assert_eq!(observe_version(&t), None);
}
#[test]
fn detects_3_0_from_collections_nested_inside_topic() {
for child in [
"<Comments/>",
"<Comments><Comment Guid=\"c\"/></Comments>",
"<Viewpoints/>",
"<Viewpoints><ViewPoint Guid=\"v\"/></Viewpoints>",
"<Labels><Label>x</Label></Labels>",
"<DocumentReferences><DocumentReference Guid=\"d\"/></DocumentReferences>",
"<ReferenceLinks><ReferenceLink>http://x</ReferenceLink></ReferenceLinks>",
] {
let t = tree(&format!(
r#"<Markup><Topic Guid="a" TopicStatus="OPEN" TopicType="ERROR"><Title>T</Title>{child}</Topic></Markup>"#
));
assert_eq!(observe_version(&t), Some(BcfVersion::V3_0), "for {child}");
}
}
#[test]
fn bare_2_x_labels_are_not_evidence_of_3_0() {
let t = tree(
r#"<Markup><Topic Guid="a" TopicStatus="Open"><Labels>Architecture</Labels></Topic></Markup>"#,
);
assert_eq!(observe_version(&t), None);
}
#[test]
fn reads_comments_and_viewpoints_nested_in_a_3_0_topic() {
let mut d = Vec::new();
let m = interpret(
"e",
&tree(
r#"<Markup><Topic Guid="a"><Title>T</Title><Labels><Label>L1</Label><Label>L2</Label></Labels><Comments><Comment Guid="c1"><Comment>hello</Comment></Comment></Comments><Viewpoints><ViewPoint Guid="v1"><Viewpoint>v.bcfv</Viewpoint></ViewPoint></Viewpoints></Topic></Markup>"#,
),
&mut d,
)
.unwrap();
assert_eq!(m.comments.len(), 1);
assert_eq!(m.comments[0].comment.as_deref(), Some("hello"));
assert_eq!(m.viewpoints.len(), 1);
assert_eq!(m.viewpoints[0].viewpoint.as_deref(), Some("v.bcfv"));
assert_eq!(m.topic.labels, ["L1", "L2"]);
assert!(d.is_empty(), "{d:?}");
}
#[test]
fn an_empty_viewpoints_wrapper_yields_no_viewpoints() {
let mut d = Vec::new();
let m = interpret(
"e",
&tree(r#"<Markup><Topic Guid="a"><Title>T</Title><Viewpoints/><Comments/></Topic></Markup>"#),
&mut d,
)
.unwrap();
assert!(m.viewpoints.is_empty(), "{:?}", m.viewpoints);
assert!(m.comments.is_empty());
}
#[test]
fn indistinguishable_markup_observes_nothing() {
let t = tree(r#"<Markup><Topic Guid="a"><Title>x</Title></Topic></Markup>"#);
assert_eq!(observe_version(&t), None);
assert_eq!(observe_version(&tree("<Markup/>")), None);
}
#[test]
fn status_and_type_are_kept_verbatim_not_normalised() {
let mut d = Vec::new();
let t = tree(
r#"<Markup><Topic Guid="a" TopicStatus="Offen" TopicType="formale Prüfung"><Title>T</Title></Topic></Markup>"#,
);
let m = interpret("x/markup.bcf", &t, &mut d).unwrap();
assert_eq!(m.status(), Some("Offen"));
assert_eq!(m.topic.topic_type.as_deref(), Some("formale Prüfung"));
assert!(d.is_empty(), "{d:?}");
}
#[test]
fn missing_guid_and_title_are_diagnosed_but_not_fatal() {
let mut d = Vec::new();
let m = interpret("x/markup.bcf", &tree("<Markup><Topic/></Markup>"), &mut d).unwrap();
assert_eq!(m.topic.guid, None);
assert_eq!(m.title(), "");
let kinds: Vec<_> = d.iter().map(|x| x.tolerance.clone()).collect();
assert!(kinds.contains(&Tolerance::TopicWithoutGuid), "{kinds:?}");
assert!(kinds.contains(&Tolerance::TopicWithoutTitle), "{kinds:?}");
}
#[test]
fn markup_without_a_topic_yields_nothing_and_says_so() {
let mut d = Vec::new();
assert!(interpret("x", &tree("<Markup/>"), &mut d).is_none());
assert_eq!(d[0].tolerance, Tolerance::MarkupWithoutTopic);
}
#[test]
fn reads_both_wrapped_and_unwrapped_viewpoint_references() {
let mut d = Vec::new();
let v3 = interpret(
"e",
&tree(
r#"<Markup><Topic Guid="a"/><Viewpoints><ViewPoint Guid="v1"><Viewpoint>v1.bcfv</Viewpoint><Snapshot>s1.png</Snapshot><Index>2</Index></ViewPoint></Viewpoints></Markup>"#,
),
&mut d,
)
.unwrap();
assert_eq!(v3.viewpoints.len(), 1);
assert_eq!(v3.viewpoints[0].guid.as_deref(), Some("v1"));
assert_eq!(v3.viewpoints[0].snapshot.as_deref(), Some("s1.png"));
assert_eq!(v3.viewpoints[0].index, Some(2));
let v2 = interpret(
"e",
&tree(
r#"<Markup><Topic Guid="a"/><Viewpoints Guid="v1"><Viewpoint>v1.bcfv</Viewpoint></Viewpoints><Viewpoints Guid="v2"><Viewpoint>v2.bcfv</Viewpoint></Viewpoints></Markup>"#,
),
&mut d,
)
.unwrap();
assert_eq!(v2.viewpoints.len(), 2);
assert_eq!(v2.viewpoints[1].viewpoint.as_deref(), Some("v2.bcfv"));
}
#[test]
fn comments_keep_order_and_anchor_guid() {
let mut d = Vec::new();
let m = interpret(
"e",
&tree(
r#"<Markup><Topic Guid="a"/><Comment Guid="c1"><Comment>first</Comment><Viewpoint Guid="v1"/></Comment><Comment Guid="c2"><Comment>second</Comment></Comment></Markup>"#,
),
&mut d,
)
.unwrap();
assert_eq!(m.comments.len(), 2);
assert_eq!(m.comments[0].comment.as_deref(), Some("first"));
assert_eq!(m.comments[0].viewpoint.as_deref(), Some("v1"));
assert_eq!(m.comments[1].comment.as_deref(), Some("second"));
}
#[test]
fn header_files_read_attributes_and_boolean_spellings() {
let mut d = Vec::new();
let m = interpret(
"e",
&tree(
r#"<Markup><Header><File IfcProject="p1" IsExternal="False"><Filename>a.ifc</Filename><Date>2015-04-14T15:51:25Z</Date></File><File IsExternal="1"><Filename>b.ifc</Filename></File></Header><Topic Guid="a"/></Markup>"#,
),
&mut d,
)
.unwrap();
assert_eq!(m.header_files.len(), 2);
assert_eq!(m.header_files[0].ifc_project.as_deref(), Some("p1"));
assert_eq!(m.header_files[0].is_external, Some(false));
assert_eq!(
m.header_files[0].date.as_deref(),
Some("2015-04-14T15:51:25Z")
);
assert_eq!(m.header_files[1].is_external, Some(true));
}
#[test]
fn related_topics_and_labels_read_in_both_2_x_and_3_0_shapes() {
let mut d = Vec::new();
let m = interpret(
"e",
&tree(
r#"<Markup><Topic Guid="a"><Labels>L1</Labels><Labels>L2</Labels><RelatedTopic Guid="r1"/><RelatedTopics><RelatedTopic Guid="r2"/></RelatedTopics><ReferenceLinks><ReferenceLink>http://x/1</ReferenceLink></ReferenceLinks></Topic></Markup>"#,
),
&mut d,
)
.unwrap();
assert_eq!(m.topic.labels, ["L1", "L2"]);
assert_eq!(m.topic.related_topics, ["r1", "r2"]);
assert_eq!(m.topic.reference_links, ["http://x/1"]);
}
#[test]
fn reads_a_2_1_and_3_0_component_selection() {
let v = interpret_visualization(&tree(
r#"<VisualizationInfo Guid="v"><Components><Selection><Component IfcGuid="0fXw$sQh19ixbI4tZgfkXu"><OriginatingSystem>Revit</OriginatingSystem><AuthoringToolId>42</AuthoringToolId></Component><Component IfcGuid="1Qj3z6Wtb5WwCLdW5ctWxe"/></Selection><Visibility DefaultVisibility="true"><Exceptions><Component IfcGuid="3cUkl32yn9qRSPvBJVyWYp"/></Exceptions></Visibility></Components></VisualizationInfo>"#,
))
.unwrap();
assert_eq!(v.guid.as_deref(), Some("v"));
assert_eq!(
v.selection.len(),
2,
"visibility exceptions are not a selection"
);
assert_eq!(
v.selection[0],
Component {
ifc_guid: Some("0fXw$sQh19ixbI4tZgfkXu".into()),
originating_system: Some("Revit".into()),
authoring_tool_id: Some("42".into()),
}
);
assert_eq!(v.selection[1], Component::ifc("1Qj3z6Wtb5WwCLdW5ctWxe"));
}
#[test]
fn reads_a_2_0_selection_from_the_selected_flag() {
let v = interpret_visualization(&tree(
r#"<VisualizationInfo><Components><Component IfcGuid="0fXw$sQh19ixbI4tZgfkXu" Selected="true"/><Component IfcGuid="1Qj3z6Wtb5WwCLdW5ctWxe" Visible="false"/><Component IfcGuid="3cUkl32yn9qRSPvBJVyWYp" Selected="false"/></Components></VisualizationInfo>"#,
))
.unwrap();
assert_eq!(v.selection, [Component::ifc("0fXw$sQh19ixbI4tZgfkXu")]);
}
#[test]
fn a_non_viewpoint_document_is_refused() {
assert!(interpret_visualization(&tree("<Markup/>")).is_err());
}
#[test]
fn boolean_parsing_rejects_nonsense_rather_than_defaulting() {
assert_eq!(parse_bool("TRUE"), Some(true));
assert_eq!(parse_bool(" 0 "), Some(false));
assert_eq!(parse_bool("yes"), None);
assert_eq!(parse_bool(""), None);
}
}