From 28fb130623e10a7509948150fba5c6aa6c56ae1a Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:43:56 -0500 Subject: [PATCH 1/5] research(elements): a probe and a scorer for what separates the water closet tank Revit's export lacks from the fixtures it holds (#1496) (RE-363) --- .../research/1496-water-closet-tank.md | 7 + .../examples/probe_re363_plumbing_fixtures.rs | 699 ++++++++++++++++++ tools/ci/scrub-holdout-results.sh | 7 +- tools/re/tank_rules_vs_ifc.py | 270 +++++++ 4 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 changelog.d/research/1496-water-closet-tank.md create mode 100644 probes/examples/probe_re363_plumbing_fixtures.rs create mode 100644 tools/re/tank_rules_vs_ifc.py diff --git a/changelog.d/research/1496-water-closet-tank.md b/changelog.d/research/1496-water-closet-tank.md new file mode 100644 index 000000000..7c1baf21c --- /dev/null +++ b/changelog.d/research/1496-water-closet-tank.md @@ -0,0 +1,7 @@ +- **A probe and a scorer for what separates RE1 Plumbing's water closet tank + from the plumbing fixtures Revit's export holds (#1496, RE-363).** + `probe_re363_plumbing_fixtures` prints, for every plumbing fixture a file + holds, the record, native, family-form and saved-graphics readings that + candidate rules would use, and `tools/re/tank_rules_vs_ifc.py` counts, for + each rule, the fixtures in Revit's export it would drop. Not yet measured on + this head. diff --git a/probes/examples/probe_re363_plumbing_fixtures.rs b/probes/examples/probe_re363_plumbing_fixtures.rs new file mode 100644 index 000000000..74d239140 --- /dev/null +++ b/probes/examples/probe_re363_plumbing_fixtures.rs @@ -0,0 +1,699 @@ +//! RE-363 (#1496): what separates RE1 Plumbing's water closet tank 442378, which +//! rvt-rs writes and Revit's export does not hold, from the plumbing fixtures +//! Revit's export does hold. +//! +//! FACT under test (to be measured): Revit's exporter writes a family instance +//! that is not a door or a window only when its 3D body has a solid or a mesh +//! (`FamilyInstanceExporter.ExportFamilyInstanceAsMappedItem` in revit-ifc, +//! CLEANROOM.md source 5, returns with nothing written when it has none, and +//! `ExportFamilyInstanceElement` returns on a null geometry or an invisible +//! instance; `GeometryUtil.GetIFCExportGeometryOptions` asks for the Fine detail +//! level, native level 3). rvt-rs writes a fixture whenever its record box has a +//! volume (RE-21). This probe prints, for every `OST_PlumbingFixtures` element a +//! file holds, the readings that candidate rules would use, so +//! `tools/re/tank_rules_vs_ifc.py` can say how many of Revit's own fixtures each +//! rule would drop: +//! +//! - the record layer: stage (the RE-350 stages, `written` where rvt-rs writes +//! the element), container slot, placement kind, design option, box extents, +//! and the mutual-reference parents of RE-79's nested-component rule (same +//! category and any category); +//! - the native record of each written fixture, of its type symbol and of the +//! symbol's family: every scalar of the root object (so a super-instance, host, +//! work plane or visibility field is read where the schema names it) and the +//! class census of the graph; +//! - the family document's records (class census) and the model forms of the +//! family's own document with the RE-312 verdict +//! (`native_family_forms::instances_without_body`); +//! - the saved graphics at the exporter's detail level, as RE-329 reads them: +//! outcome, primitive counts at levels 1 to 3, how many are hidden, and the +//! render styles and materials of the primitives. +//! +//! Output (one line each; no count is a verdict, the scorer joins them with +//! Revit's Tags): +//! release saved= layout= +//! fixture id= category= stage= frames= container=<..> kind=<..> +//! option=<..> extents= enclosing=<0|1> refs= refs_list= +//! mutual_same= mutual_any= +//! native objects | native no_graph +//! objects +//! field +//! forms id= symbol= family= doc= forms= cutting= +//! invisible= bound= incomplete=<0|1> verdict=<..> +//! famdoc family= records= classes= +//! graphics id= outcome=<..> prim=// hidden= visible= diag= +//! blocking= kinds=<..> vis_branches= filters= nonsurface= +//! unbounded= styles= materials= +//! where `` is `instance`, `symbol` or `family`. +//! +//! ```bash +//! cargo run --profile ci -p rvt-probes --example probe_re363_plumbing_fixtures -- model.rvt +//! ``` + +use rvt::RevitFile; +use rvt::research::native_document::{NATIVE_RELEASES, Options, Record, extract_graphs}; +use rvt::research::native_family_forms::{family_records, family_solids, instances_without_body}; +use rvt::research::native_graphics_traversal::{ + NESTED_DETAIL_FILTER_DIAGNOSTIC, VIEW_COMPONENT_DIAGNOSTIC, +}; +use rvt::research::native_parameters::ObjectGraph; +use rvt::research::native_saved_mesh::{ + graphics_including_hidden, graphics_with_resolver_at_detail, +}; +use rvt::research::partition_element_records::{self as per, PartitionElementRecord}; +use rvt::research::partition_element_records_2023 as per32; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// The most family documents whose records are counted by class: a document is +/// read whole for it. +const MAX_FAMILY_DOCUMENTS: usize = 60; + +/// The most references of a record printed with their categories. +const MAX_REFERENCES: usize = 24; + +fn i64_at(buf: &[u8], at: usize) -> Option { + let end = at.checked_add(8)?; + buf.get(at..end) + .map(|b| i64::from_le_bytes(b.try_into().expect("8 bytes"))) +} + +/// Every frame of the file's marker in the `BuiltInCategory` band, decoded +/// for any category: a 2024 to 2027 file's frames with a declared id or an +/// enclosing record's (as `probe_re350_category_stages`). +fn records_2024(rf: &mut RevitFile, release: u32) -> Vec { + let Some(marker) = per::file_bbox_marker(rf, release) else { + println!("the release gives no record marker"); + return Vec::new(); + }; + let Ok(table) = rvt::elem_table::parse_records(rf) else { + return Vec::new(); + }; + let declared = rvt::elem_table::declared_ids(&table); + let assigned = per::compute_second_prologue_ids(rf); + let none = BTreeMap::new(); + let mut out = Vec::new(); + for stream in rf.partition_stream_names() { + let Ok(inflated) = rf.inflated_partition(&stream) else { + continue; + }; + let buf = inflated.bytes(); + let mut categories: BTreeSet = BTreeSet::new(); + for hit in memchr::memmem::find_iter(buf, &marker) { + let Some(start) = hit.checked_sub(per::BBOX_MARKER_OFFSET) else { + continue; + }; + if let Some(category) = i64_at(buf, start + per::CATEGORY_OFFSET) { + if (per::BUILTIN_CATEGORY_MIN..=per::BUILTIN_CATEGORY_MAX).contains(&category) { + categories.insert(category); + } + } + } + let list: Vec = categories.into_iter().collect(); + for records in per::find_categories_records_assigned( + &stream, + buf, + &list, + &declared, + &marker, + assigned.get(&stream).unwrap_or(&none), + ) { + out.extend(records); + } + } + out +} + +/// Why a placed instance has no 3D body: how many of its three extents are not +/// above the volume epsilon, or that one is not a number. +fn no_body_reason(record: &PartitionElementRecord) -> &'static str { + let (dx, dy, dz) = record.extents_feet(); + let extents = [dx, dy, dz]; + if extents.iter().any(|extent| !extent.is_finite()) { + return "no_body_not_finite"; + } + let flat = extents + .iter() + .filter(|extent| **extent <= per::VOLUME_EPSILON_FEET) + .count(); + match flat { + 1 => "no_body_flat", + 2 => "no_body_line", + _ => "no_body_point", + } +} + +/// `{"m_id":{"m_id64":n}}`, or a bare integer, as `n`. +fn identifier(value: &Value) -> Option { + if let Some(n) = value.as_i64() { + return Some(n); + } + for key in ["m_id", "m_id64"] { + if let Some(inner) = value.get(key) { + return identifier(inner); + } + } + None +} + +/// The element a root field names, where it names one. +fn root_id(graph: &ObjectGraph, field: &str) -> Option { + let root = graph.objects.first()?; + let id = identifier(root.fields.get(field)?)?; + u64::try_from(id).ok().filter(|id| *id > 0) +} + +/// `text` as one word of at most 60 characters. +fn tidy(text: &str) -> String { + text.chars() + .take(60) + .map(|c| if c.is_whitespace() { '_' } else { c }) + .collect() +} + +/// A message with its numbers and digits made `#`, so equal kinds compare equal. +fn message_kind(message: &str) -> String { + let text = match message.strip_prefix("object ") { + Some(rest) => rest.split_once(": ").map_or(rest, |(_, kind)| kind), + None => message, + }; + let words: Vec = text + .split_whitespace() + .map(|word| { + word.chars() + .map(|c| if c.is_ascii_digit() { '#' } else { c }) + .collect() + }) + .collect(); + tidy(&words.join("_")) +} + +fn ignorable(diagnostic: &str) -> bool { + diagnostic.ends_with(NESTED_DETAIL_FILTER_DIAGNOSTIC) + || diagnostic.ends_with(VIEW_COMPONENT_DIAGNOSTIC) +} + +/// Every scalar under `value`, as (path, text): a pointer as `ptrnull` or +/// `ptrset`, an array as its length and, short, its items. +fn flatten(prefix: &str, value: &Value, depth: usize, out: &mut Vec<(String, String)>) { + match value { + Value::Null => {} + Value::Bool(b) => out.push((prefix.to_string(), b.to_string())), + Value::Number(n) => out.push((prefix.to_string(), n.to_string())), + Value::String(text) => out.push((prefix.to_string(), tidy(text))), + Value::Array(items) => { + out.push((format!("{prefix}[]"), items.len().to_string())); + if depth < 3 && items.len() <= 8 { + for (index, item) in items.iter().enumerate() { + flatten(&format!("{prefix}[{index}]"), item, depth + 1, out); + } + } + } + Value::Object(members) => { + if let Some(token) = members.get("pointer_token") { + let state = if token.as_u64() == Some(0) { + "ptrnull" + } else { + "ptrset" + }; + out.push((prefix.to_string(), state.to_string())); + return; + } + if depth >= 4 { + return; + } + for (name, member) in members { + let key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + flatten(&key, member, depth + 1, out); + } + } + } +} + +/// `Class:n` of the objects of a graph, by class name. +fn census(graph: &ObjectGraph) -> String { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for object in &graph.objects { + *counts.entry(object.class_name.as_str()).or_default() += 1; + } + counts + .iter() + .map(|(class, n)| format!("{class}:{n}")) + .collect::>() + .join(",") +} + +fn print_native(scope: &str, record: &Record) { + let id = record.identity.element_id; + let class = record.class_name.as_deref().unwrap_or("-"); + let Some(graph) = record.graph.as_ref() else { + println!( + "native {scope} {id} {class} no_graph {}", + tidy(record.diagnostic.as_deref().unwrap_or("-")) + ); + return; + }; + println!( + "native {scope} {id} {class} objects {}", + graph.objects.len() + ); + println!("objects {scope} {id} {}", census(graph)); + if let Some(root) = graph.objects.first() { + let mut flat = Vec::new(); + flatten("", &root.fields, 0, &mut flat); + for (key, value) in flat { + println!("field {scope} {id} {key} {value}"); + } + } +} + +fn native_options(ids: BTreeSet) -> Options { + Options { + selected_ids: ids, + releases: NATIVE_RELEASES, + ..Options::default() + } +} + +/// The first eight entries of `counts` as `key:count`, joined, or `-`. +fn counted(counts: &BTreeMap) -> String { + if counts.is_empty() { + return "-".to_string(); + } + counts + .iter() + .take(8) + .map(|(key, n)| format!("{key}:{n}")) + .collect::>() + .join(",") +} + +/// The saved graphics of `id` at the Fine detail level, as `probe_re329`. +fn graphics_line(id: u64, records: &BTreeMap) -> String { + let Some(record) = records.get(&id) else { + return format!("graphics id={id} outcome=no_record"); + }; + let Some(graph) = record.graph.as_ref() else { + return format!( + "graphics id={id} outcome=no_graph why={}", + message_kind( + record + .diagnostic + .as_deref() + .unwrap_or("graphics graph unavailable") + ) + ); + }; + let resolver = |rid: u64| records.get(&rid).and_then(|r| r.graph.as_ref()); + let levels: Vec = (1..=3) + .map(|level| { + graphics_with_resolver_at_detail(graph, &resolver, level) + .primitives + .len() + }) + .collect(); + let meshes = graphics_with_resolver_at_detail(graph, &resolver, 3); + let all = graphics_including_hidden(graph, &resolver, 3); + let hidden = all.primitives.iter().filter(|p| p.hidden).count(); + let visible = all.primitives.len() - hidden; + let blocking = meshes.diagnostics.iter().any(|d| !ignorable(d)); + let outcome = match (meshes.primitives.is_empty(), blocking) { + (false, false) => "drawn", + (false, true) => "box_partial", + (true, true) => "box_no_surface", + (true, false) if !meshes.diagnostics.is_empty() => "empty_view_only", + (true, false) => "empty_none", + }; + let kinds: BTreeSet = meshes + .diagnostics + .iter() + .filter(|d| !ignorable(d)) + .map(|d| message_kind(d)) + .collect(); + let mut styles: BTreeMap = BTreeMap::new(); + let mut materials: BTreeMap = BTreeMap::new(); + for primitive in &meshes.primitives { + *styles.entry(primitive.render_style_id).or_default() += 1; + let material = primitive + .material_id + .map_or_else(|| "none".to_string(), |m| m.to_string()); + *materials.entry(material).or_default() += 1; + } + format!( + "graphics id={id} outcome={outcome} prim={}/{}/{} hidden={hidden} visible={visible} diag={} blocking={} kinds={} vis_branches={} filters={} nonsurface={} unbounded={} styles={} materials={}", + levels[0], + levels[1], + levels[2], + meshes.diagnostics.len(), + meshes.diagnostics.iter().filter(|d| !ignorable(d)).count(), + if kinds.is_empty() { + "-".to_string() + } else { + kinds.into_iter().take(4).collect::>().join(";") + }, + meshes.excluded_visibility_branches, + meshes.rejected_filters, + meshes.excluded_non_surface_branches, + meshes.unbounded_faces, + counted(&styles), + counted(&materials), + ) +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args() + .skip(1) + .find(|arg| !arg.starts_with("--")) + .ok_or_else(|| anyhow::anyhow!("usage: probe_re363_plumbing_fixtures "))?; + let mut rf = RevitFile::open_with_limits(&path, rvt::cli::open_limits(None, None, true))?; + let saved = rf.basic_file_info()?.version; + let release = rf.layout_release()?; + println!("release saved={saved} layout={release}"); + let records: Vec = if per32::is_32bit_release(release) { + per32::scan_records(&mut rf, release) + } else if per::supports_revit_version(release) { + records_2024(&mut rf, release) + } else { + println!("a release whose records are not read: nothing to measure"); + return Ok(()); + }; + let filtered = per32::applies_export_filters(release) || per::supports_revit_version(release); + let (phases, design) = if filtered { + (Some(rf.phase_filter()), Some(rf.design_options())) + } else { + (None, None) + }; + let stage_of = |record: &PartitionElementRecord| -> &'static str { + if !record.is_exported_instance() { + if record.is_container_member() { + "container" + } else if record.is_type_symbol() { + "symbol" + } else { + "other_kind" + } + } else if !record.has_volume() { + no_body_reason(record) + } else if design.as_ref().is_some_and(|o| o.excludes(record)) { + "design_option" + } else if phases.as_ref().is_some_and(|p| p.excludes(record)) { + "phase" + } else { + "written" + } + }; + + let mut refs_of: BTreeMap> = BTreeMap::new(); + let mut category_of: BTreeMap = BTreeMap::new(); + for record in &records { + refs_of + .entry(record.element_id) + .or_default() + .extend(record.references.iter().copied()); + category_of + .entry(record.element_id) + .or_insert(record.builtin_category); + } + let mut fixtures: BTreeMap> = BTreeMap::new(); + for record in &records { + if record.builtin_category == per::OST_PLUMBING_FIXTURES { + fixtures.entry(record.element_id).or_default().push(record); + } + } + println!("fixtures {}", fixtures.len()); + + let mut written: BTreeSet = BTreeSet::new(); + for (id, frames) in &fixtures { + let stages: BTreeSet<&'static str> = frames.iter().map(|r| stage_of(r)).collect(); + let is_written = stages.contains("written"); + if is_written { + written.insert(u64::from(*id)); + } + let stage_text = if is_written { + "written".to_string() + } else { + stages.iter().copied().collect::>().join("+") + }; + let shown = frames + .iter() + .find(|r| stage_of(r) == "written") + .or_else(|| frames.first()) + .copied() + .expect("a fixture has a frame"); + let containers: BTreeSet = frames + .iter() + .map(|r| { + if r.is_container_member() { + r.container.to_string() + } else { + "none".to_string() + } + }) + .collect(); + let kinds: BTreeSet = frames + .iter() + .map(|r| format!("{:#x}", r.placement_kind)) + .collect(); + let designs: BTreeSet = frames + .iter() + .map(|r| { + r.design_option + .map_or_else(|| "none".to_string(), |o| o.to_string()) + }) + .collect(); + let (dx, dy, dz) = shown.extents_feet(); + let own_refs = refs_of.get(id).cloned().unwrap_or_default(); + let listed: Vec = own_refs + .iter() + .take(MAX_REFERENCES) + .map(|r| { + let category = u32::try_from(*r) + .ok() + .and_then(|r| category_of.get(&r)) + .map_or_else(|| "?".to_string(), i64::to_string); + format!("{r}:{category}") + }) + .collect(); + let parents: Vec = own_refs + .iter() + .filter_map(|r| u32::try_from(*r).ok()) + .filter(|p| p < id) + .filter(|p| { + refs_of + .get(p) + .is_some_and(|set| set.contains(&u64::from(*id))) + }) + .collect(); + let same: Vec = parents + .iter() + .filter(|p| category_of.get(p) == Some(&per::OST_PLUMBING_FIXTURES)) + .map(u32::to_string) + .collect(); + let any: Vec = parents + .iter() + .map(|p| { + format!( + "{p}:{}", + category_of + .get(p) + .map_or_else(|| "?".to_string(), i64::to_string) + ) + }) + .collect(); + println!( + "fixture id={id} category={} stage={stage_text} frames={} container={} kind={} option={} extents={dx:.3},{dy:.3},{dz:.3} enclosing={} refs={} refs_list={} mutual_same={} mutual_any={}", + per::OST_PLUMBING_FIXTURES, + frames.len(), + containers.into_iter().collect::>().join("+"), + kinds.into_iter().collect::>().join("+"), + designs.into_iter().collect::>().join("+"), + u8::from(shown.id_from_enclosing_record), + own_refs.len(), + if listed.is_empty() { + "-".to_string() + } else { + listed.join(",") + }, + if same.is_empty() { + "-".to_string() + } else { + same.join(",") + }, + if any.is_empty() { + "-".to_string() + } else { + any.join(",") + }, + ); + } + if written.is_empty() { + println!("done: no written fixture"); + return Ok(()); + } + + // The native records of the written fixtures, their type symbols and the + // symbols' families. + let mut symbol_of: BTreeMap = BTreeMap::new(); + let read = extract_graphs(&mut rf, &native_options(written.clone()), |record| { + print_native("instance", &record); + if let Some(symbol) = record + .graph + .as_ref() + .and_then(|graph| root_id(graph, "m_masterSymbolId")) + { + symbol_of.insert(record.identity.element_id, symbol); + } + Ok(()) + }); + if let Err(error) = read { + println!("native refused: {}", tidy(&error.to_string())); + } + let symbols: BTreeSet = symbol_of.values().copied().collect(); + let mut family_of: BTreeMap = BTreeMap::new(); + if !symbols.is_empty() { + let read = extract_graphs(&mut rf, &native_options(symbols.clone()), |record| { + print_native("symbol", &record); + if let Some(family) = record + .graph + .as_ref() + .and_then(|graph| root_id(graph, "m_familyId")) + { + family_of.insert(record.identity.element_id, family); + } + Ok(()) + }); + if let Err(error) = read { + println!("native symbols refused: {}", tidy(&error.to_string())); + } + } + let families: BTreeSet = family_of.values().copied().collect(); + if !families.is_empty() { + let read = extract_graphs(&mut rf, &native_options(families.clone()), |record| { + print_native("family", &record); + Ok(()) + }); + if let Err(error) = read { + println!("native families refused: {}", tidy(&error.to_string())); + } + } + + // The model forms of the families' own documents, and the RE-312 verdict. + let bodiless = instances_without_body(&mut rf, &written); + let solids = family_solids(&mut rf, &families); + for id in &written { + let symbol = symbol_of.get(id).copied(); + let family = symbol.and_then(|s| family_of.get(&s).copied()); + let solid = family.and_then(|f| solids.as_ref().ok().and_then(|all| all.get(&f))); + let (doc, forms, cutting, invisible, bound, incomplete) = match solid { + Some(solid) => ( + "found", + solid.forms.len(), + solid.forms.iter().filter(|f| f.cutting).count(), + solid.forms.iter().filter(|f| f.visible == Some(0)).count(), + solid.forms.iter().filter(|f| f.bound).count(), + u8::from(solid.incomplete), + ), + None => ("missing", 0, 0, 0, 0, 0), + }; + let verdict = match &bodiless { + Err(_) => "refused", + Ok(set) if set.contains(id) => "bodiless", + Ok(_) if solid.is_none() => "undecided_no_document", + Ok(_) if incomplete == 1 => "undecided_incomplete", + Ok(_) if forms == 0 => "undecided_no_forms", + Ok(_) => "drawn_or_unresolved", + }; + println!( + "forms id={id} symbol={} family={} doc={doc} forms={forms} cutting={cutting} invisible={invisible} bound={bound} incomplete={incomplete} verdict={verdict}", + symbol.map_or_else(|| "-".to_string(), |s| s.to_string()), + family.map_or_else(|| "-".to_string(), |f| f.to_string()), + ); + } + if let Err(error) = &bodiless { + println!("forms refused: {}", tidy(&error.to_string())); + } + if let Err(error) = &solids { + println!("family solids refused: {}", tidy(&error.to_string())); + } + if !families.is_empty() && families.len() <= MAX_FAMILY_DOCUMENTS { + match family_records(&mut rf, &families, &BTreeSet::new()) { + Ok(documents) => { + for (family, members) in &documents { + let mut classes: BTreeMap<&str, usize> = BTreeMap::new(); + for (_, graph) in members { + if let Some(root) = graph.objects.first() { + *classes.entry(root.class_name.as_str()).or_default() += 1; + } + } + println!( + "famdoc family={family} records={} classes={}", + members.len(), + counted(&classes) + ); + } + } + Err(error) => println!("family records refused: {}", tidy(&error.to_string())), + } + } + + // The saved graphics of each written fixture: its record on channel 103 and + // the symbols its InstanceInfo objects name, as `probe_re329`. + let mut options = Options { + selected_ids: written.clone(), + channels: BTreeSet::from([103]), + max_graph_values: 1_000_000, + max_graph_objects: 1_000_000, + releases: NATIVE_RELEASES, + ..Options::default() + }; + let mut graphics: BTreeMap = BTreeMap::new(); + let first = extract_graphs(&mut rf, &options, |record| { + graphics.insert(record.identity.element_id, record); + Ok(()) + }); + if let Err(error) = first { + println!("graphics refused: {}", tidy(&error.to_string())); + } + let mut attempted: BTreeSet = graphics.keys().copied().collect(); + for _ in 0..128 { + let mut wanted = BTreeSet::new(); + for record in graphics.values() { + let Some(graph) = &record.graph else { continue }; + for object in &graph.objects { + if object.class_name != "InstanceInfo" { + continue; + } + if let Some(id) = identifier(&object.fields["m_symbolId"]) + .and_then(|id| u64::try_from(id).ok()) + .filter(|id| *id > 0) + { + if !attempted.contains(&id) { + wanted.insert(id); + } + } + } + } + if wanted.is_empty() { + break; + } + attempted.extend(&wanted); + options.selected_ids = wanted; + let more = extract_graphs(&mut rf, &options, |record| { + graphics.insert(record.identity.element_id, record); + Ok(()) + }); + if let Err(error) = more { + println!("graphics symbols refused: {}", tidy(&error.to_string())); + break; + } + } + for id in &written { + println!("{}", graphics_line(*id, &graphics)); + } + println!("done"); + Ok(()) +} diff --git a/tools/ci/scrub-holdout-results.sh b/tools/ci/scrub-holdout-results.sh index 95ddf744d..8fc8db8d9 100755 --- a/tools/ci/scrub-holdout-results.sh +++ b/tools/ci/scrub-holdout-results.sh @@ -33,7 +33,11 @@ # classes they land on and the export's counts by class, beside the category's # built-in name and id and an IFC class name, never an ElementId or a name # from the model; the scorer's table rows, which carry the export's category -# name, and its `not in the export` lines are dropped), the +# name, and its `not in the export` lines are dropped), the `re363` count +# lines of tools/re/tank_rules_vs_ifc.py (RE-363: for each candidate rule the +# counts of plumbing fixtures it would drop, in and not in Revit's export, +# beside the rule's name and the export's schema, never an ElementId or a name +# from the model; its listing lines are dropped), the # `door flips:` count lines of tools/re/door_flips_vs_ifc.py (RE-343: counts of # doors and windows by flag state and by how their placements compare; its # `field ... values` lines only with values true, false, null or under 100; its @@ -96,6 +100,7 @@ for dir in "$RESULTS"/holdout-*/ "$RESULTS"/bimcollab-*/ "$RESULTS"/racadv-*/; d grep -E '^native levels: [A-Za-z0-9 _:.,;()=#<>/+-]+$' "$f" || true grep -E '^native readers: [A-Za-z0-9 _:.,;()=#<>/+-]+$' "$f" || true grep -E '^category census[A-Za-z ]*: [A-Za-z0-9 _:.,;()=#<>/+-]+$' "$f" || true + grep -E '^re363 [A-Za-z0-9 _:.,;()=#<>/+-]+$' "$f" || true grep -E '^door flips: [A-Za-z0-9 _:.,;()=#<>/+-]+$' "$f" | grep -v -E '^door flips: field ' || true grep -E '^door flips: field [A-Za-z0-9_]+ values ((true|false|null|[0-9]{1,2})=[0-9]+(, )?)+$' "$f" || true done diff --git a/tools/re/tank_rules_vs_ifc.py b/tools/re/tank_rules_vs_ifc.py new file mode 100644 index 000000000..9916178b7 --- /dev/null +++ b/tools/re/tank_rules_vs_ifc.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Score rules that would leave out the plumbing fixtures Revit's export lacks (RE-363, #1496). + +Reads what `probes/examples/probe_re363_plumbing_fixtures.rs` prints for a model, joins each +`OST_PlumbingFixtures` element rvt-rs writes (`stage=written`) with the numeric `Tag`s of Revit's own +export, and says for every candidate rule how many of the fixtures Revit's export holds the rule would +drop (a false negative: an element Revit writes would be lost), how many of the fixtures Revit's export +lacks it would drop, and how many of those it leaves written. A rule lands only with no false negative +on every model that has an export, and `-f holdout=true` (the standing bar: never drop an element Revit +writes). A rule drops an element only on positive evidence: an unread or undecided reading keeps it. + +Usage: + + python3 tools/re/tank_rules_vs_ifc.py [--list] + +`--list` is what Measure passes to a probe's scorer; it also lists the ids each rule drops that Revit's +export holds, the written fixtures it lacks with every reading the probe printed for them, and the +features (a scope, a field and its value) that no fixture Revit holds has and some fixture it lacks has. + +The lines that start `re363 ` are counts, rule names and IFC schema names only: they are what a model +with no licence to redistribute it keeps (tools/ci/scrub-holdout-results.sh, RE-363). +""" + +import collections +import sys + +NONE = "none" + + +def tokens(line): + """The `key=value` words of a line, as a dict.""" + return dict(word.split("=", 1) for word in line.split()[1:] if "=" in word) + + +def parse(path): + """Everything the probe printed, by kind.""" + fixtures = {} + fields = collections.defaultdict(dict) + classes = {} + forms = {} + graphics = {} + documents = {} + for raw in open(path): + line = raw.rstrip("\n") + head = line.split(" ", 1)[0] + if head == "fixture": + word = tokens(line) + fixtures[int(word["id"])] = word + elif head == "forms": + word = tokens(line) + forms[int(word["id"])] = word + elif head == "graphics": + word = tokens(line) + graphics[int(word["id"])] = word + elif head == "famdoc": + word = tokens(line) + documents[int(word["family"])] = word + elif head == "native": + parts = line.split(" ") + if len(parts) >= 4: + classes[(parts[1], int(parts[2]))] = parts[3] + elif head == "field": + parts = line.split(" ", 4) + if len(parts) == 5: + fields[(parts[1], int(parts[2]))][parts[3]] = parts[4] + return fixtures, fields, classes, forms, graphics, documents + + +def safe(text): + """`text` in the alphabet tools/ci/scrub-holdout-results.sh keeps for `re363` lines.""" + return "".join( + ch if ch.isascii() and (ch.isalnum() or ch in " _:.,;()=#<>/+-") else "_" for ch in text + ) + + +def export_tags(path): + """(schema, set of numeric Tags) of the elements of Revit's export.""" + import ifcopenshell + + model = ifcopenshell.open(path) + tags = set() + for element in model.by_type("IfcElement"): + if element.is_a("IfcOpeningElement") or element.is_a("IfcVirtualElement"): + continue + tag = getattr(element, "Tag", None) + if tag and tag.isdigit(): + tags.add(int(tag)) + return model.schema, tags + + +def integer(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + +class Reading: + """What the probe printed for one written fixture, as the rules read it.""" + + def __init__(self, ident, fixtures, fields, forms, graphics): + self.ident = ident + self.fixture = fixtures[ident] + self.forms = forms.get(ident, {}) + self.graphics = graphics.get(ident, {}) + symbol = integer(self.forms.get("symbol")) + family = integer(self.forms.get("family")) + self.scopes = { + "instance": fields.get(("instance", ident), {}), + "symbol": fields.get(("symbol", symbol), {}) if symbol is not None else {}, + "family": fields.get(("family", family), {}) if family is not None else {}, + } + + def field(self, scope, key): + return self.scopes[scope].get(key) + + def primitives(self): + """The primitive counts at levels 1, 2 and 3, or None where graphics were not read.""" + text = self.graphics.get("prim") + if not text: + return None + try: + return [int(n) for n in text.split("/")] + except ValueError: + return None + + def features(self): + """Every reading as `scope.key=value`, for the table of what separates the fixtures.""" + out = {} + for scope, values in self.scopes.items(): + for key, value in values.items(): + out[f"{scope}.{key}={value}"] = None + for name in ("stage", "frames", "container", "kind", "option", "enclosing"): + if name in self.fixture: + out[f"record.{name}={self.fixture[name]}"] = None + out[f"record.mutual_same={'yes' if self.fixture.get('mutual_same', '-') != '-' else 'no'}"] = None + out[f"record.mutual_any={'yes' if self.fixture.get('mutual_any', '-') != '-' else 'no'}"] = None + for name in ("verdict", "doc", "forms", "cutting", "invisible", "bound", "incomplete"): + if name in self.forms: + out[f"forms.{name}={self.forms[name]}"] = None + for name in ("outcome", "prim", "hidden", "visible", "diag", "blocking", "kinds", + "vis_branches", "filters", "nonsurface", "unbounded", "styles", "materials"): + if name in self.graphics: + out[f"graphics.{name}={self.graphics[name]}"] = None + return out + + +def rules_for(readings): + """Rule name -> predicate on a Reading, True where the rule drops the fixture.""" + rules = { + "nested_mutual_same_category": lambda r: r.fixture.get("mutual_same", "-") != "-", + "nested_mutual_any_category": lambda r: r.fixture.get("mutual_any", "-") != "-", + "container_slot_set": lambda r: any( + part != NONE for part in r.fixture.get("container", NONE).split("+") + ), + "design_option_set": lambda r: any( + part != NONE for part in r.fixture.get("option", NONE).split("+") + ), + "host_unset": lambda r: r.field("instance", "m_hostId.m_id.m_id64") == "-1", + "work_plane_unset": lambda r: r.field("instance", "m_workPlaneBased") == "false", + "marked_invisible": lambda r: r.field("instance", "m_invisible") == "true", + "forms_bodiless": lambda r: r.forms.get("verdict") == "bodiless", + "forms_family_has_no_form": lambda r: r.forms.get("verdict") == "undecided_no_forms", + "forms_family_nests_instance_or_unread": lambda r: r.forms.get("verdict") + in ("undecided_incomplete", "undecided_no_document"), + "graphics_no_surface_and_no_diagnostic": lambda r: r.graphics.get("outcome") + in ("empty_none", "empty_view_only"), + "graphics_no_surface_at_all": lambda r: r.primitives() is not None + and r.primitives()[2] == 0, + "graphics_all_hidden": lambda r: integer(r.graphics.get("hidden")) is not None + and integer(r.graphics.get("hidden")) > 0 + and integer(r.graphics.get("visible")) == 0, + } + supers = sorted( + { + key + for reading in readings + for key in reading.scopes["instance"] + if "super" in key.lower() + } + ) + for key in supers: + rules[f"instance_{key}_set"] = lambda r, key=key: ( + integer(r.field("instance", key)) is not None and integer(r.field("instance", key)) > 0 + ) + rules["bodiless_by_forms_or_graphics"] = lambda r: rules["forms_bodiless"](r) or rules[ + "graphics_no_surface_and_no_diagnostic" + ](r) + return rules, supers + + +def main(argv): + listing = "--list" in argv + argv = [arg for arg in argv if arg != "--list"] + if len(argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + fixtures, fields, classes, forms, graphics, documents = parse(argv[1]) + schema, tags = export_tags(argv[2]) + written = sorted(i for i, f in fixtures.items() if f.get("stage") == "written") + in_export = [i for i in written if i in tags] + extras = [i for i in written if i not in tags] + stages = collections.Counter( + (f.get("stage"), i in tags) for i, f in fixtures.items() + ) + print( + f"export schema {schema}; elements with a numeric Tag: {len(tags)}; plumbing fixture " + f"elements {len(fixtures)}; written {len(written)} (in export {len(in_export)}, not in " + f"it {len(extras)})" + ) + print( + safe( + f"re363 fixtures: schema {schema}; elements {len(fixtures)}; written {len(written)}; " + f"written in export {len(in_export)}; written not in export {len(extras)}" + ) + ) + for (stage, held), count in sorted(stages.items(), key=lambda item: (str(item[0][0]), item[0][1])): + print(safe(f"re363 stage: {stage}; in export {'yes' if held else 'no'}; {count}")) + readings = {i: Reading(i, fixtures, fields, forms, graphics) for i in written} + for name, count in ( + ("native instance records", sum(1 for i in written if readings[i].scopes["instance"])), + ("forms lines", sum(1 for i in written if readings[i].forms)), + ("graphics lines", sum(1 for i in written if readings[i].graphics)), + ): + print(safe(f"re363 read: {name} {count} of {len(written)}")) + rules, supers = rules_for(list(readings.values())) + if not supers: + print("re363 rule instance_super_field: no instance field is named super") + for name, rule in rules.items(): + dropped = [i for i in written if rule(readings[i])] + wrong = [i for i in dropped if i in tags] + right = [i for i in dropped if i not in tags] + print( + safe( + f"re363 rule {name}: schema {schema}; written {len(written)}; in export " + f"{len(in_export)}; not in export {len(extras)}; rule drops {len(dropped)}; " + f"dropped in export {len(wrong)}; dropped not in export {len(right)}; " + f"not in export and kept {len(extras) - len(right)}" + ) + ) + if listing and wrong: + print(f" {name} would drop, in Revit's export: {','.join(map(str, wrong[:30]))}") + if listing: + for ident in extras: + reading = readings[ident] + print(f"written, not in Revit's export: {ident}") + print(f" record: {reading.fixture}") + print(f" forms: {reading.forms}") + print(f" graphics: {reading.graphics}") + print( + " dropped by: " + + (", ".join(name for name, rule in rules.items() if rule(reading)) or "no rule") + ) + held = collections.defaultdict(int) + lacking = collections.defaultdict(int) + for ident, reading in readings.items(): + for feature in reading.features(): + (held if ident in tags else lacking)[feature] += 1 + print( + "features some fixture Revit's export lacks has and no fixture it holds has " + "(scope.reading=value: lacking count):" + ) + for feature in sorted(lacking): + if held.get(feature, 0) == 0: + print(f" {feature}: {lacking[feature]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 054faf4d6e5fc709fe28bad191af83737c4fd9a7 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:45:35 -0500 Subject: [PATCH 2/5] research(elements): RE-363 probe looks the category up by value, not by reference to a reference (#1496) --- probes/examples/probe_re363_plumbing_fixtures.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/probes/examples/probe_re363_plumbing_fixtures.rs b/probes/examples/probe_re363_plumbing_fixtures.rs index 74d239140..e421b62b5 100644 --- a/probes/examples/probe_re363_plumbing_fixtures.rs +++ b/probes/examples/probe_re363_plumbing_fixtures.rs @@ -490,7 +490,7 @@ fn main() -> anyhow::Result<()> { .collect(); let same: Vec = parents .iter() - .filter(|p| category_of.get(p) == Some(&per::OST_PLUMBING_FIXTURES)) + .filter(|p| category_of.get(*p) == Some(&per::OST_PLUMBING_FIXTURES)) .map(u32::to_string) .collect(); let any: Vec = parents From 0408480241331c46824ebc1135cffb54891f571c Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:55:01 -0500 Subject: [PATCH 3/5] research(elements): RE-363 reads every family instance's saved body for a surface and scores the empty ones against Revit's export (#1496) --- probes/examples/probe_re363_empty_bodies.rs | 368 ++++++++++++++++++++ tools/re/empty_bodies_vs_ifc.py | 143 ++++++++ 2 files changed, 511 insertions(+) create mode 100644 probes/examples/probe_re363_empty_bodies.rs create mode 100644 tools/re/empty_bodies_vs_ifc.py diff --git a/probes/examples/probe_re363_empty_bodies.rs b/probes/examples/probe_re363_empty_bodies.rs new file mode 100644 index 000000000..574deb2ce --- /dev/null +++ b/probes/examples/probe_re363_empty_bodies.rs @@ -0,0 +1,368 @@ +//! RE-363 (#1496): which family instances of any category have a saved body with +//! no visible surface, and are they in Revit's export. +//! +//! FACT under test (to be measured): RE1 Plumbing's water closet tank 442378 is +//! the one plumbing fixture whose saved graphics at the Fine detail level +//! (native level 3, what `GeometryUtil.GetIFCExportGeometryOptions` in revit-ifc +//! asks for, CLEANROOM.md source 5) decode cleanly to no surface at all, every +//! primitive lying under a branch flagged not visible +//! (`probe_re363_plumbing_fixtures`), and revit-ifc's `FamilyInstanceExporter` +//! writes nothing for a family instance that is not a door or a window and has no +//! solid or mesh. A rule that leaves such an instance out is safe only if no +//! family instance Revit's export holds, of any category, has that outcome. This +//! probe reads the saved graphics of every element rvt-rs writes whose native class +//! is `FamilyInstance`, as `probe_re329_saved_graphics_census` does, and prints +//! the outcome of each category, and the ids of the instances whose outcome is +//! `empty_none` (no surface, no diagnostic at all) or `empty_view_only` (no +//! surface, only the view-component or nested-detail-filter diagnostics). +//! `tools/re/empty_bodies_vs_ifc.py` scores those ids against the `Tag`s of +//! Revit's export. +//! +//! Output (one line each): +//! release saved= layout= +//! written family_instances classes +//! progress of +//! analysed +//! outcomes = ... +//! empty +//! emptyinfo id= category= outcome= hidden= visible= vis_branches= +//! filters= nonsurface= +//! truncated of (only where the time budget ran out) +//! done +//! +//! ```bash +//! cargo run --profile ci -p rvt-probes --example probe_re363_empty_bodies -- model.rvt +//! ``` + +use rvt::RevitFile; +use rvt::research::native_document::{NATIVE_RELEASES, Options, Record, extract_graphs}; +use rvt::research::native_graphics_traversal::{ + NESTED_DETAIL_FILTER_DIAGNOSTIC, VIEW_COMPONENT_DIAGNOSTIC, +}; +use rvt::research::native_saved_mesh::{ + graphics_including_hidden, graphics_with_resolver_at_detail, +}; +use rvt::research::partition_element_records::{self as per, PartitionElementRecord}; +use rvt::research::partition_element_records_2023 as per32; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, Instant}; + +/// How many instances are read and analysed at a time. The symbols they name +/// are kept between chunks, the instances' own graphs are not. +const CHUNK: usize = 1000; + +/// The time after which no further chunk is started, so the lines already +/// printed stay a usable partial result under Measure's 20 minute limit. +const BUDGET: Duration = Duration::from_secs(14 * 60); + +type Ids = BTreeMap>; +type EmptyIds = BTreeMap<(i64, &'static str), BTreeSet>; + +fn i64_at(buf: &[u8], at: usize) -> Option { + let end = at.checked_add(8)?; + buf.get(at..end) + .map(|b| i64::from_le_bytes(b.try_into().expect("8 bytes"))) +} + +/// Every frame of the file's marker in the `BuiltInCategory` band, decoded +/// for any category (as `probe_re350_category_stages`). +fn records_2024(rf: &mut RevitFile, release: u32) -> Vec { + let Some(marker) = per::file_bbox_marker(rf, release) else { + println!("the release gives no record marker"); + return Vec::new(); + }; + let Ok(table) = rvt::elem_table::parse_records(rf) else { + return Vec::new(); + }; + let declared = rvt::elem_table::declared_ids(&table); + let assigned = per::compute_second_prologue_ids(rf); + let none = BTreeMap::new(); + let mut out = Vec::new(); + for stream in rf.partition_stream_names() { + let Ok(inflated) = rf.inflated_partition(&stream) else { + continue; + }; + let buf = inflated.bytes(); + let mut categories: BTreeSet = BTreeSet::new(); + for hit in memchr::memmem::find_iter(buf, &marker) { + let Some(start) = hit.checked_sub(per::BBOX_MARKER_OFFSET) else { + continue; + }; + if let Some(category) = i64_at(buf, start + per::CATEGORY_OFFSET) { + if (per::BUILTIN_CATEGORY_MIN..=per::BUILTIN_CATEGORY_MAX).contains(&category) { + categories.insert(category); + } + } + } + let list: Vec = categories.into_iter().collect(); + for records in per::find_categories_records_assigned( + &stream, + buf, + &list, + &declared, + &marker, + assigned.get(&stream).unwrap_or(&none), + ) { + out.extend(records); + } + } + out +} + +/// `{"m_id":{"m_id64":n}}`, or a bare integer, as `n`. +fn identifier(value: &Value) -> Option { + if let Some(n) = value.as_i64() { + return Some(n); + } + for key in ["m_id", "m_id64"] { + if let Some(inner) = value.get(key) { + return identifier(inner); + } + } + None +} + +fn ignorable(diagnostic: &str) -> bool { + diagnostic.ends_with(NESTED_DETAIL_FILTER_DIAGNOSTIC) + || diagnostic.ends_with(VIEW_COMPONENT_DIAGNOSTIC) +} + +fn name_of(category: i64) -> String { + per::builtin_category_name(category) + .map(|name| name.replace(' ', "_")) + .unwrap_or_else(|| "unnamed".to_string()) +} + +fn list(ids: &BTreeSet) -> String { + ids.iter().map(u32::to_string).collect::>().join(",") +} + +/// Read the graphics-channel records of `ids` and, one round at a time, of the +/// symbols their `InstanceInfo` objects name, into `records`. `attempted` holds +/// every id already asked for, so a symbol is read once for the whole run. +fn load( + rf: &mut RevitFile, + records: &mut BTreeMap, + attempted: &mut BTreeSet, + ids: BTreeSet, +) { + let mut options = Options { + channels: BTreeSet::from([103]), + max_graph_values: 1_000_000, + max_graph_objects: 1_000_000, + releases: NATIVE_RELEASES, + ..Options::default() + }; + let mut pending: BTreeSet = ids.difference(attempted).copied().collect(); + for _ in 0..128 { + if pending.is_empty() { + break; + } + attempted.extend(&pending); + options.selected_ids = std::mem::take(&mut pending); + let mut fresh: Vec = Vec::new(); + let read = extract_graphs(rf, &options, |record| { + fresh.push(record); + Ok(()) + }); + if let Err(error) = read { + println!("graphics refused: {error}"); + break; + } + for record in fresh { + if let Some(graph) = &record.graph { + for object in &graph.objects { + if object.class_name != "InstanceInfo" { + continue; + } + if let Some(id) = identifier(&object.fields["m_symbolId"]) + .and_then(|id| u64::try_from(id).ok()) + .filter(|id| *id > 0) + { + if !attempted.contains(&id) { + pending.insert(id); + } + } + } + } + records.insert(record.identity.element_id, record); + } + } +} + +/// The outcome of an instance's saved graphics at the Fine detail level, as +/// `probe_re329` reads it, and for an empty one its hidden and visible +/// primitive counts and the branches the walk excluded. +fn analyse(id: u64, records: &BTreeMap) -> (&'static str, Option) { + let Some(record) = records.get(&id) else { + return ("no_record", None); + }; + let Some(graph) = record.graph.as_ref() else { + return ("no_graph", None); + }; + let resolver = |rid: u64| records.get(&rid).and_then(|r| r.graph.as_ref()); + let meshes = graphics_with_resolver_at_detail(graph, &resolver, 3); + let blocking = meshes.diagnostics.iter().any(|d| !ignorable(d)); + let outcome = match (meshes.primitives.is_empty(), blocking) { + (false, false) => "drawn", + (false, true) => "box_partial", + (true, true) => "box_no_surface", + (true, false) if !meshes.diagnostics.is_empty() => "empty_view_only", + (true, false) => "empty_none", + }; + if !outcome.starts_with("empty") { + return (outcome, None); + } + let all = graphics_including_hidden(graph, &resolver, 3); + let hidden = all.primitives.iter().filter(|p| p.hidden).count(); + let visible = all.primitives.len() - hidden; + let info = format!( + "outcome={outcome} hidden={hidden} visible={visible} vis_branches={} filters={} nonsurface={}", + meshes.excluded_visibility_branches, + meshes.rejected_filters, + meshes.excluded_non_surface_branches + ); + (outcome, Some(info)) +} + +fn main() -> anyhow::Result<()> { + let started = Instant::now(); + let path = std::env::args() + .skip(1) + .find(|arg| !arg.starts_with("--")) + .ok_or_else(|| anyhow::anyhow!("usage: probe_re363_empty_bodies "))?; + let mut rf = RevitFile::open_with_limits(&path, rvt::cli::open_limits(None, None, true))?; + let saved = rf.basic_file_info()?.version; + let release = rf.layout_release()?; + println!("release saved={saved} layout={release}"); + let records: Vec = if per32::is_32bit_release(release) { + per32::scan_records(&mut rf, release) + } else if per::supports_revit_version(release) { + records_2024(&mut rf, release) + } else { + println!("a release whose records are not read: nothing to measure"); + return Ok(()); + }; + let filtered = per32::applies_export_filters(release) || per::supports_revit_version(release); + let (phases, design) = if filtered { + (Some(rf.phase_filter()), Some(rf.design_options())) + } else { + (None, None) + }; + let mut category_of: BTreeMap = BTreeMap::new(); + for record in &records { + if !record.is_exported_instance() || !record.has_volume() { + continue; + } + if design.as_ref().is_some_and(|o| o.excludes(record)) + || phases.as_ref().is_some_and(|p| p.excludes(record)) + { + continue; + } + category_of + .entry(record.element_id) + .or_insert(record.builtin_category); + } + let written: BTreeSet = category_of.keys().map(|id| u64::from(*id)).collect(); + + // The native class of each written element, read without its graph. + let mut instances: BTreeSet = BTreeSet::new(); + let mut classes: BTreeMap = BTreeMap::new(); + let base = Options { + selected_ids: written.clone(), + releases: NATIVE_RELEASES, + graph_classes: Some(BTreeSet::new()), + ..Options::default() + }; + let read = extract_graphs(&mut rf, &base, |record| { + let class = record.class_name.clone().unwrap_or_else(|| "-".to_string()); + if class == "FamilyInstance" { + instances.insert(record.identity.element_id); + } + *classes.entry(class).or_default() += 1; + Ok(()) + }); + if let Err(error) = read { + println!("native refused: {error}"); + } + let shown: Vec = classes + .iter() + .take(12) + .map(|(class, n)| format!("{class}:{n}")) + .collect(); + println!( + "written {} family_instances {} classes {}", + written.len(), + instances.len(), + shown.join(",") + ); + + let ordered: Vec = instances.iter().copied().collect(); + let mut graphics: BTreeMap = BTreeMap::new(); + let mut attempted: BTreeSet = BTreeSet::new(); + let mut analysed = Ids::new(); + let mut outcomes: BTreeMap<(i64, &'static str), usize> = BTreeMap::new(); + let mut empty = EmptyIds::new(); + let mut done = 0usize; + for chunk in ordered.chunks(CHUNK) { + if started.elapsed() > BUDGET { + println!("truncated {done} of {}", ordered.len()); + break; + } + load( + &mut rf, + &mut graphics, + &mut attempted, + chunk.iter().copied().collect(), + ); + for id in chunk { + let Ok(short) = u32::try_from(*id) else { + continue; + }; + let Some(category) = category_of.get(&short).copied() else { + continue; + }; + let (outcome, info) = analyse(*id, &graphics); + analysed.entry(category).or_default().insert(short); + *outcomes.entry((category, outcome)).or_default() += 1; + if let Some(info) = info { + empty.entry((category, outcome)).or_default().insert(short); + println!("emptyinfo id={id} category={category} {info}"); + } + } + for id in chunk { + graphics.remove(id); + } + done += chunk.len(); + println!("progress {done} of {}", ordered.len()); + } + for (category, ids) in &analysed { + println!( + "analysed {category} {} {} {}", + name_of(*category), + ids.len(), + list(ids) + ); + let counts: Vec = outcomes + .iter() + .filter(|((c, _), _)| c == category) + .map(|((_, outcome), n)| format!("{outcome}={n}")) + .collect(); + println!( + "outcomes {category} {} {}", + name_of(*category), + counts.join(" ") + ); + } + for ((category, outcome), ids) in &empty { + println!( + "empty {category} {} {outcome} {} {}", + name_of(*category), + ids.len(), + list(ids) + ); + } + println!("done"); + Ok(()) +} diff --git a/tools/re/empty_bodies_vs_ifc.py b/tools/re/empty_bodies_vs_ifc.py new file mode 100644 index 000000000..637505e64 --- /dev/null +++ b/tools/re/empty_bodies_vs_ifc.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Score a rule that leaves out family instances whose saved body has no surface (RE-363, #1496). + +Reads what `probes/examples/probe_re363_empty_bodies.rs` prints for a model: for each category the +ids of the family instances rvt-rs writes that the probe analysed (`analysed`), and of those the +ids whose saved graphics at the Fine detail level decode to no surface with no diagnostic at all +(`empty ... empty_none`) or with only the view-component or nested-detail-filter diagnostics +(`empty ... empty_view_only`). It joins them with the numeric `Tag`s of Revit's own export and says, +for each category and in total, how many instances in Revit's export the rule would drop (a false +negative: an element Revit writes would be lost) and how many of those Revit's export lacks it +would drop. A door or a window is counted apart: revit-ifc's `FamilyInstanceExporter` writes one +with no geometry, so a rule would not touch it. + +Usage: + + python3 tools/re/empty_bodies_vs_ifc.py [--list] + +`--list` is what Measure passes to a probe's scorer; it also lists the ids of each category that the +rule would drop. + +The lines that start `re363 ` are counts, built-in category names, rule names and the IFC schema +name only: they are what a model with no licence to redistribute it keeps +(tools/ci/scrub-holdout-results.sh, RE-363). +""" + +import collections +import sys + +DOORS_AND_WINDOWS = {-2000023, -2000014} +RULES = { + "empty_none": ("empty_none",), + "empty_none_or_view_only": ("empty_none", "empty_view_only"), +} + + +def parse(path): + """(analysed, empty, notes): category -> (name, ids); (category, outcome) -> ids; other lines.""" + analysed = {} + empty = collections.defaultdict(set) + names = {} + notes = [] + for raw in open(path): + line = raw.rstrip("\n") + head = line.split(" ", 1)[0] + if head == "analysed": + _, category, name, _count, ids = (line.split(" ", 4) + [""])[:5] + analysed[int(category)] = {int(i) for i in ids.split(",") if i} + names[int(category)] = name + elif head == "empty": + _, category, name, outcome, _count, ids = (line.split(" ", 5) + [""])[:6] + empty[(int(category), outcome)] = {int(i) for i in ids.split(",") if i} + names[int(category)] = name + elif head in ("truncated", "written", "done", "release"): + notes.append(line) + return analysed, empty, names, notes + + +def safe(text): + """`text` in the alphabet tools/ci/scrub-holdout-results.sh keeps for `re363` lines.""" + return "".join( + ch if ch.isascii() and (ch.isalnum() or ch in " _:.,;()=#<>/+-") else "_" for ch in text + ) + + +def export_tags(path): + """(schema, {Tag: IFC class}) of the elements of Revit's export.""" + import ifcopenshell + + model = ifcopenshell.open(path) + tags = {} + for element in model.by_type("IfcElement"): + if element.is_a("IfcOpeningElement") or element.is_a("IfcVirtualElement"): + continue + tag = getattr(element, "Tag", None) + if tag and tag.isdigit(): + tags[int(tag)] = element.is_a() + return model.schema, tags + + +def main(argv): + listing = "--list" in argv + argv = [arg for arg in argv if arg != "--list"] + if len(argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + analysed, empty, names, notes = parse(argv[1]) + schema, tags = export_tags(argv[2]) + truncated = [line for line in notes if line.startswith("truncated")] + complete = any(line == "done" for line in notes) and not truncated + total_analysed = sum(len(ids) for ids in analysed.values()) + print( + f"export schema {schema}; elements with a numeric Tag: {len(tags)}; analysed family " + f"instances {total_analysed}; probe {'complete' if complete else 'incomplete'}" + ) + print(safe(f"re363 empty bodies: schema {schema}; analysed {total_analysed}; complete {'yes' if complete else 'no'}")) + for rule, outcomes in RULES.items(): + totals = {"other": collections.Counter(), "door_window": collections.Counter()} + for category in sorted(analysed): + ids = analysed[category] + dropped = set() + for outcome in outcomes: + dropped |= empty.get((category, outcome), set()) + held = ids & set(tags) + wrong = dropped & set(tags) + right = dropped - set(tags) + scope = "door_window" if category in DOORS_AND_WINDOWS else "other" + bucket = totals[scope] + bucket["analysed"] += len(ids) + bucket["in_export"] += len(held) + bucket["not_in_export"] += len(ids - held) + bucket["drops"] += len(dropped) + bucket["wrong"] += len(wrong) + bucket["right"] += len(right) + if dropped or (ids - held): + print( + safe( + f"re363 empty bodies {rule}: category {category} {names.get(category, '-')}; " + f"schema {schema}; analysed {len(ids)}; in export {len(held)}; not in export " + f"{len(ids - held)}; rule drops {len(dropped)}; dropped in export {len(wrong)}; " + f"dropped not in export {len(right)}" + ) + ) + if listing and dropped: + print( + f" {rule} category {category} drops: in export " + f"{','.join(map(str, sorted(wrong)[:30])) or '-'}; not in export " + f"{','.join(map(str, sorted(right)[:30])) or '-'}" + ) + for scope, bucket in totals.items(): + print( + safe( + f"re363 empty bodies {rule} total {'doors and windows' if scope == 'door_window' else 'other categories'}: " + f"schema {schema}; analysed {bucket['analysed']}; in export {bucket['in_export']}; " + f"not in export {bucket['not_in_export']}; rule drops {bucket['drops']}; " + f"dropped in export {bucket['wrong']}; dropped not in export {bucket['right']}; " + f"not in export and kept {bucket['not_in_export'] - bucket['right']}" + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 2aedda0f5694272f67689ec61339a8fc50959b2b Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:13:40 -0500 Subject: [PATCH 4/5] research(elements): RE-363 times the saved-graphics pass against the default export and scores the hidden-branch rule (#1496) --- probes/examples/probe_re363_empty_bodies.rs | 45 +++++++++++++++++++++ tools/re/empty_bodies_vs_ifc.py | 44 ++++++++++++++------ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/probes/examples/probe_re363_empty_bodies.rs b/probes/examples/probe_re363_empty_bodies.rs index 574deb2ce..60e917937 100644 --- a/probes/examples/probe_re363_empty_bodies.rs +++ b/probes/examples/probe_re363_empty_bodies.rs @@ -28,6 +28,11 @@ //! emptyinfo id= category= outcome= hidden= visible= vis_branches= //! filters= nonsurface= //! truncated of (only where the time budget ran out) +//! timing export_ms= entities= the default export, on this handle +//! timing classes_ms= the class of each written element +//! timing pass_ms= read_ms= analyse_ms= instances= +//! the saved-graphics pass, after the export +//! memory vmhwm_kb after_export= after_pass= (Linux only) //! done //! //! ```bash @@ -35,6 +40,7 @@ //! ``` use rvt::RevitFile; +use rvt::ifc::RvtDocExporter; use rvt::research::native_document::{NATIVE_RELEASES, Options, Record, extract_graphs}; use rvt::research::native_graphics_traversal::{ NESTED_DETAIL_FILTER_DIAGNOSTIC, VIEW_COMPONENT_DIAGNOSTIC, @@ -128,6 +134,14 @@ fn ignorable(diagnostic: &str) -> bool { || diagnostic.ends_with(VIEW_COMPONENT_DIAGNOSTIC) } +/// The process's peak resident set in kilobytes, where `/proc` gives it. +fn peak_kb() -> Option { + let text = std::fs::read_to_string("/proc/self/status").ok()?; + text.lines() + .find_map(|line| line.strip_prefix("VmHWM:")) + .and_then(|rest| rest.trim().trim_end_matches("kB").trim().parse().ok()) +} + fn name_of(category: i64) -> String { per::builtin_category_name(category) .map(|name| name.replace(' ', "_")) @@ -266,7 +280,20 @@ fn main() -> anyhow::Result<()> { } let written: BTreeSet = category_of.keys().map(|id| u64::from(*id)).collect(); + // The default export on this handle, for what the pass below adds to it. + let timed = Instant::now(); + match RvtDocExporter.export_with_diagnostics(&mut rf) { + Ok(result) => println!( + "timing export_ms={} entities={}", + timed.elapsed().as_millis(), + result.model.entities.len() + ), + Err(error) => println!("export refused: {error}"), + } + let after_export = peak_kb(); + // The native class of each written element, read without its graph. + let timed = Instant::now(); let mut instances: BTreeSet = BTreeSet::new(); let mut classes: BTreeMap = BTreeMap::new(); let base = Options { @@ -286,6 +313,7 @@ fn main() -> anyhow::Result<()> { if let Err(error) = read { println!("native refused: {error}"); } + println!("timing classes_ms={}", timed.elapsed().as_millis()); let shown: Vec = classes .iter() .take(12) @@ -305,17 +333,22 @@ fn main() -> anyhow::Result<()> { let mut outcomes: BTreeMap<(i64, &'static str), usize> = BTreeMap::new(); let mut empty = EmptyIds::new(); let mut done = 0usize; + let pass = Instant::now(); + let (mut read_time, mut analyse_time) = (Duration::ZERO, Duration::ZERO); for chunk in ordered.chunks(CHUNK) { if started.elapsed() > BUDGET { println!("truncated {done} of {}", ordered.len()); break; } + let timed = Instant::now(); load( &mut rf, &mut graphics, &mut attempted, chunk.iter().copied().collect(), ); + read_time += timed.elapsed(); + let timed = Instant::now(); for id in chunk { let Ok(short) = u32::try_from(*id) else { continue; @@ -331,12 +364,24 @@ fn main() -> anyhow::Result<()> { println!("emptyinfo id={id} category={category} {info}"); } } + analyse_time += timed.elapsed(); for id in chunk { graphics.remove(id); } done += chunk.len(); println!("progress {done} of {}", ordered.len()); } + println!( + "timing pass_ms={} read_ms={} analyse_ms={} instances={done}", + pass.elapsed().as_millis(), + read_time.as_millis(), + analyse_time.as_millis() + ); + println!( + "memory vmhwm_kb after_export={} after_pass={}", + after_export.map_or_else(|| "-".to_string(), |kb| kb.to_string()), + peak_kb().map_or_else(|| "-".to_string(), |kb| kb.to_string()) + ); for (category, ids) in &analysed { println!( "analysed {category} {} {} {}", diff --git a/tools/re/empty_bodies_vs_ifc.py b/tools/re/empty_bodies_vs_ifc.py index 637505e64..f0189cc6d 100644 --- a/tools/re/empty_bodies_vs_ifc.py +++ b/tools/re/empty_bodies_vs_ifc.py @@ -5,7 +5,9 @@ ids of the family instances rvt-rs writes that the probe analysed (`analysed`), and of those the ids whose saved graphics at the Fine detail level decode to no surface with no diagnostic at all (`empty ... empty_none`) or with only the view-component or nested-detail-filter diagnostics -(`empty ... empty_view_only`). It joins them with the numeric `Tag`s of Revit's own export and says, +(`empty ... empty_view_only`), and of the empty ones how many branches the walk left out as +flagged not visible (`emptyinfo`): the third rule drops an `empty_none` instance only where some +branch was, so an instance with no saved graphics at all is kept. It joins them with the numeric `Tag`s of Revit's own export and says, for each category and in total, how many instances in Revit's export the rule would drop (a false negative: an element Revit writes would be lost) and how many of those Revit's export lacks it would drop. A door or a window is counted apart: revit-ifc's `FamilyInstanceExporter` writes one @@ -27,18 +29,18 @@ import sys DOORS_AND_WINDOWS = {-2000023, -2000014} -RULES = { - "empty_none": ("empty_none",), - "empty_none_or_view_only": ("empty_none", "empty_view_only"), -} +RULES = ("empty_none", "empty_none_or_view_only", "empty_none_and_hidden_branches") def parse(path): - """(analysed, empty, notes): category -> (name, ids); (category, outcome) -> ids; other lines.""" + """(analysed, empty, names, notes, hidden): the ids analysed by category, the empty ids by + (category, outcome), the category names, the other lines and, for each empty id, how many + branches of its graphics the walk left out as flagged not visible.""" analysed = {} empty = collections.defaultdict(set) names = {} notes = [] + hidden = {} for raw in open(path): line = raw.rstrip("\n") head = line.split(" ", 1)[0] @@ -50,9 +52,27 @@ def parse(path): _, category, name, outcome, _count, ids = (line.split(" ", 5) + [""])[:6] empty[(int(category), outcome)] = {int(i) for i in ids.split(",") if i} names[int(category)] = name - elif head in ("truncated", "written", "done", "release"): + elif head == "emptyinfo": + word = tokens(line) + hidden[int(word["id"])] = int(word.get("vis_branches", "0")) + elif head in ("truncated", "written", "done", "release", "timing", "memory"): notes.append(line) - return analysed, empty, names, notes + return analysed, empty, names, notes, hidden + + +def tokens(line): + """The `key=value` words of a line, as a dict.""" + return dict(word.split("=", 1) for word in line.split()[1:] if "=" in word) + + +def dropped_by(rule, category, empty, hidden): + """The ids of `category` that `rule` leaves out.""" + none = empty.get((category, "empty_none"), set()) + if rule == "empty_none": + return set(none) + if rule == "empty_none_or_view_only": + return none | empty.get((category, "empty_view_only"), set()) + return {i for i in none if hidden.get(i, 0) > 0} def safe(text): @@ -83,7 +103,7 @@ def main(argv): if len(argv) != 3: print(__doc__, file=sys.stderr) return 2 - analysed, empty, names, notes = parse(argv[1]) + analysed, empty, names, notes, hidden = parse(argv[1]) schema, tags = export_tags(argv[2]) truncated = [line for line in notes if line.startswith("truncated")] complete = any(line == "done" for line in notes) and not truncated @@ -93,13 +113,11 @@ def main(argv): f"instances {total_analysed}; probe {'complete' if complete else 'incomplete'}" ) print(safe(f"re363 empty bodies: schema {schema}; analysed {total_analysed}; complete {'yes' if complete else 'no'}")) - for rule, outcomes in RULES.items(): + for rule in RULES: totals = {"other": collections.Counter(), "door_window": collections.Counter()} for category in sorted(analysed): ids = analysed[category] - dropped = set() - for outcome in outcomes: - dropped |= empty.get((category, outcome), set()) + dropped = dropped_by(rule, category, empty, hidden) held = ids & set(tags) wrong = dropped & set(tags) right = dropped - set(tags) From ec2da9bfd56c80de8e460adb540a784d50b6b39f Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:23:15 -0500 Subject: [PATCH 5/5] research(elements): RE-363 times a pass over only the plumbing fixtures and structural columns beside the full pass (#1496) --- probes/examples/probe_re363_empty_bodies.rs | 174 ++++++++++++++------ 1 file changed, 122 insertions(+), 52 deletions(-) diff --git a/probes/examples/probe_re363_empty_bodies.rs b/probes/examples/probe_re363_empty_bodies.rs index 60e917937..ab63d92d0 100644 --- a/probes/examples/probe_re363_empty_bodies.rs +++ b/probes/examples/probe_re363_empty_bodies.rs @@ -30,9 +30,12 @@ //! truncated of (only where the time budget ran out) //! timing export_ms= entities= the default export, on this handle //! timing classes_ms= the class of each written element -//! timing pass_ms= read_ms= analyse_ms= instances= -//! the saved-graphics pass, after the export -//! memory vmhwm_kb after_export= after_pass= (Linux only) +//! timing scoped_pass_ms= read_ms= analyse_ms= instances= symbols= +//! the pass over the plumbing fixtures and +//! structural columns alone, after the export +//! timing pass_ms= read_ms= analyse_ms= instances= symbols= +//! the pass over every family instance +//! memory vmhwm_kb after_export= after_scoped= after_pass= (Linux only) //! done //! //! ```bash @@ -62,6 +65,10 @@ const CHUNK: usize = 1000; /// printed stay a usable partial result under Measure's 20 minute limit. const BUDGET: Duration = Duration::from_secs(14 * 60); +/// The categories of the scoped pass: where the two extras of #1496 sit (the +/// water closet tank and the permit office's column 1927742). +const SCOPED: [i64; 2] = [per::OST_PLUMBING_FIXTURES, per::OST_STRUCTURAL_COLUMNS]; + type Ids = BTreeMap>; type EmptyIds = BTreeMap<(i64, &'static str), BTreeSet>; @@ -240,6 +247,78 @@ fn analyse(id: u64, records: &BTreeMap) -> (&'static str, Option, + empty: EmptyIds, + read: Duration, + analyse: Duration, + done: usize, + symbols: usize, +} + +/// Read the saved graphics of `ids` a chunk at a time and analyse each instance. +/// `quiet` leaves out the per-instance and progress lines. +fn run_pass( + rf: &mut RevitFile, + ids: &[u64], + category_of: &BTreeMap, + started: Instant, + quiet: bool, +) -> Pass { + let mut pass = Pass::default(); + let mut graphics: BTreeMap = BTreeMap::new(); + let mut attempted: BTreeSet = BTreeSet::new(); + for chunk in ids.chunks(CHUNK) { + if started.elapsed() > BUDGET { + println!("truncated {} of {}", pass.done, ids.len()); + break; + } + let timed = Instant::now(); + load( + rf, + &mut graphics, + &mut attempted, + chunk.iter().copied().collect(), + ); + pass.read += timed.elapsed(); + let timed = Instant::now(); + for id in chunk { + let Ok(short) = u32::try_from(*id) else { + continue; + }; + let Some(category) = category_of.get(&short).copied() else { + continue; + }; + let (outcome, info) = analyse(*id, &graphics); + pass.analysed.entry(category).or_default().insert(short); + *pass.outcomes.entry((category, outcome)).or_default() += 1; + if let Some(info) = info { + pass.empty + .entry((category, outcome)) + .or_default() + .insert(short); + if !quiet { + println!("emptyinfo id={id} category={category} {info}"); + } + } + } + pass.analyse += timed.elapsed(); + for id in chunk { + graphics.remove(id); + } + pass.done += chunk.len(); + if !quiet { + println!("progress {} of {}", pass.done, ids.len()); + } + } + let own: BTreeSet = ids.iter().copied().collect(); + pass.symbols = attempted.difference(&own).count(); + pass +} + fn main() -> anyhow::Result<()> { let started = Instant::now(); let path = std::env::args() @@ -327,61 +406,52 @@ fn main() -> anyhow::Result<()> { ); let ordered: Vec = instances.iter().copied().collect(); - let mut graphics: BTreeMap = BTreeMap::new(); - let mut attempted: BTreeSet = BTreeSet::new(); - let mut analysed = Ids::new(); - let mut outcomes: BTreeMap<(i64, &'static str), usize> = BTreeMap::new(); - let mut empty = EmptyIds::new(); - let mut done = 0usize; - let pass = Instant::now(); - let (mut read_time, mut analyse_time) = (Duration::ZERO, Duration::ZERO); - for chunk in ordered.chunks(CHUNK) { - if started.elapsed() > BUDGET { - println!("truncated {done} of {}", ordered.len()); - break; - } - let timed = Instant::now(); - load( - &mut rf, - &mut graphics, - &mut attempted, - chunk.iter().copied().collect(), - ); - read_time += timed.elapsed(); - let timed = Instant::now(); - for id in chunk { - let Ok(short) = u32::try_from(*id) else { - continue; - }; - let Some(category) = category_of.get(&short).copied() else { - continue; - }; - let (outcome, info) = analyse(*id, &graphics); - analysed.entry(category).or_default().insert(short); - *outcomes.entry((category, outcome)).or_default() += 1; - if let Some(info) = info { - empty.entry((category, outcome)).or_default().insert(short); - println!("emptyinfo id={id} category={category} {info}"); - } - } - analyse_time += timed.elapsed(); - for id in chunk { - graphics.remove(id); - } - done += chunk.len(); - println!("progress {done} of {}", ordered.len()); - } + + // The scoped pass first, so its peak memory is not hidden by the full pass. + let scoped: Vec = ordered + .iter() + .copied() + .filter(|id| { + u32::try_from(*id) + .ok() + .and_then(|short| category_of.get(&short)) + .is_some_and(|category| SCOPED.contains(category)) + }) + .collect(); + let timed = Instant::now(); + let narrow = run_pass(&mut rf, &scoped, &category_of, started, true); + println!( + "timing scoped_pass_ms={} read_ms={} analyse_ms={} instances={} symbols={}", + timed.elapsed().as_millis(), + narrow.read.as_millis(), + narrow.analyse.as_millis(), + narrow.done, + narrow.symbols + ); + let after_scoped = peak_kb(); + let scoped_empty: usize = narrow.empty.values().map(BTreeSet::len).sum(); + println!( + "scoped empty_instances={scoped_empty} of {}", + narrow.analysed.values().map(BTreeSet::len).sum::() + ); + + let timed = Instant::now(); + let full = run_pass(&mut rf, &ordered, &category_of, started, false); println!( - "timing pass_ms={} read_ms={} analyse_ms={} instances={done}", - pass.elapsed().as_millis(), - read_time.as_millis(), - analyse_time.as_millis() + "timing pass_ms={} read_ms={} analyse_ms={} instances={} symbols={}", + timed.elapsed().as_millis(), + full.read.as_millis(), + full.analyse.as_millis(), + full.done, + full.symbols ); println!( - "memory vmhwm_kb after_export={} after_pass={}", + "memory vmhwm_kb after_export={} after_scoped={} after_pass={}", after_export.map_or_else(|| "-".to_string(), |kb| kb.to_string()), + after_scoped.map_or_else(|| "-".to_string(), |kb| kb.to_string()), peak_kb().map_or_else(|| "-".to_string(), |kb| kb.to_string()) ); + let (analysed, outcomes, empty) = (full.analysed, full.outcomes, full.empty); for (category, ids) in &analysed { println!( "analysed {category} {} {} {}",