From cea54e74974f3f4070f455dedde2101864672841 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:05:52 -0500 Subject: [PATCH 1/7] research(saved-graphics): probe what a GImposter holds, the class that keeps most partial saved meshes as boxes (#1247) (RE-300) --- .../probe_re300_graphics_imposters.rs | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 probes/examples/probe_re300_graphics_imposters.rs diff --git a/probes/examples/probe_re300_graphics_imposters.rs b/probes/examples/probe_re300_graphics_imposters.rs new file mode 100644 index 000000000..9074c5e4f --- /dev/null +++ b/probes/examples/probe_re300_graphics_imposters.rs @@ -0,0 +1,171 @@ +//! RE-300: what a saved graphics `GImposter` holds (#1247, RE-224). +//! +//! FACT under test: `native_graphics_traversal` stops at any graphics class +//! it does not know, and `saved_meshes::attach` then keeps the element's +//! box (RE-224). `GImposter` is the class that stops most of them: 1,813 of +//! the 3,020 such elements on the BIM4LCA office, RE1 and Autodesk's 2024 +//! samples, every one of Snowdon Towers electrical's 511 light fixtures. +//! This prints what each `GImposter` is: its fields, the classes that point +//! to it and those it points to, and the classes under it. +//! +//! Output: one `imposter` line per object for the first 8 per file, with +//! its owner ElementId, scalar fields, parents and pointer targets, then +//! `census` lines over all of them: field sets, parents, flags and the +//! classes found under them within 8 pointer steps. +//! +//! ```text +//! cargo run --profile ci -p rvt-probes --example probe_re300_graphics_imposters -- model.rvt +//! ``` + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use rvt::research::native_document::{Options, extract_graphs}; +use serde_json::Value; + +fn scalars(fields: &Value) -> String { + let Some(map) = fields.as_object() else { + return String::new(); + }; + map.iter() + .map(|(key, value)| { + let text = match value { + Value::Object(inner) if inner.contains_key("pointer_token") => "->".to_string(), + Value::Array(items) => format!("[{}]", items.len()), + Value::Object(_) => { + let text = value.to_string(); + text.chars().take(80).collect() + } + other => other.to_string(), + }; + format!("{key}={text}") + }) + .collect::>() + .join(" ") +} + +fn main() -> anyhow::Result<()> { + let Some(path) = std::env::args().nth(1) else { + anyhow::bail!("usage: probe_re300_graphics_imposters FILE.rvt"); + }; + let mut rf = rvt::RevitFile::open(&path)?; + let records = rvt::elem_table::parse_records(&mut rf)?; + let options = Options { + selected_ids: rvt::elem_table::declared_ids(&records) + .into_iter() + .map(u64::from) + .collect(), + channels: BTreeSet::from([103]), + max_graph_values: 1_000_000, + max_graph_objects: 1_000_000, + ..Options::default() + }; + let mut printed = 0; + let mut total = 0usize; + let mut owners = BTreeSet::new(); + let mut field_sets: BTreeMap = BTreeMap::new(); + let mut parents: BTreeMap = BTreeMap::new(); + let mut flags: BTreeMap = BTreeMap::new(); + let mut under: BTreeMap = BTreeMap::new(); + extract_graphs(&mut rf, &options, |record| { + let Some(graph) = record.graph.as_ref() else { + return Ok(()); + }; + let mut out_edges: BTreeMap> = BTreeMap::new(); + let mut in_edges: BTreeMap> = BTreeMap::new(); + for edge in &graph.edges { + out_edges + .entry(edge.source_object_index) + .or_default() + .push((edge.pointer_offset, edge.target_object_index)); + in_edges + .entry(edge.target_object_index) + .or_default() + .push(edge.source_object_index); + } + for (index, object) in graph.objects.iter().enumerate() { + if object.class_name != "GImposter" { + continue; + } + total += 1; + owners.insert(record.identity.element_id); + let keys: Vec<&str> = object + .fields + .as_object() + .map(|map| map.keys().map(String::as_str).collect()) + .unwrap_or_default(); + *field_sets.entry(keys.join(",")).or_default() += 1; + let parent_classes: Vec<&str> = in_edges + .get(&index) + .map(|sources| { + sources + .iter() + .map(|&s| graph.objects[s].class_name.as_str()) + .collect() + }) + .unwrap_or_default(); + for class in &parent_classes { + *parents.entry((*class).to_string()).or_default() += 1; + } + *flags + .entry(object.fields["m_GInfo"]["m_flags"].to_string()) + .or_default() += 1; + let mut seen = BTreeSet::from([index]); + let mut queue = VecDeque::from([(index, 0)]); + let mut classes: BTreeMap<&str, usize> = BTreeMap::new(); + while let Some((at, depth)) = queue.pop_front() { + if depth == 8 { + continue; + } + for &(_, target) in out_edges.get(&at).map(Vec::as_slice).unwrap_or(&[]) { + if seen.insert(target) { + *classes + .entry(graph.objects[target].class_name.as_str()) + .or_default() += 1; + queue.push_back((target, depth + 1)); + } + } + } + for (class, n) in &classes { + *under.entry((*class).to_string()).or_default() += n; + } + if printed < 8 { + printed += 1; + let targets: Vec = out_edges + .get(&index) + .map(|edges| { + edges + .iter() + .map(|(offset, target)| { + format!("+{offset}:{}", graph.objects[*target].class_name) + }) + .collect() + }) + .unwrap_or_default(); + println!( + "imposter owner={} record={} parents={:?} targets={:?} under={:?} fields: {}", + record.identity.element_id, + record.class_name.as_deref().unwrap_or("?"), + parent_classes, + targets, + classes, + scalars(&object.fields) + ); + } + } + Ok(()) + })?; + println!("census imposters={total} owners={}", owners.len()); + for (set, n) in &field_sets { + println!("census fields {n} {set}"); + } + for (class, n) in &parents { + println!("census parent {n} {class}"); + } + for (value, n) in &flags { + println!("census flags {n} {value}"); + } + for (class, n) in &under { + println!("census under {n} {class}"); + } + Ok(()) +} From 1123e476fa68c1d6961ca94e1822111f66abfd51 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:17:00 -0500 Subject: [PATCH 2/7] fix(saved-graphics): a GImposter, a light source with no surface, no longer keeps its element's box, and a polymesh's per-vertex normals are read (#1247) (RE-300) --- src/research/native_graphics_traversal.rs | 5 ++++- src/research/native_saved_mesh.rs | 24 ++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/research/native_graphics_traversal.rs b/src/research/native_graphics_traversal.rs index 57726ecfb..a5b312095 100644 --- a/src/research/native_graphics_traversal.rs +++ b/src/research/native_graphics_traversal.rs @@ -356,6 +356,9 @@ impl Walker<'_, '_> { } "GLine" | "GArc" | "GEllipse" | "GCurve" | "GNurbSpline" | "GPolyLine" | "GPolyline" | "GPoint" => self.result.excluded_non_surface_branches += 1, + // A transform and a rendering asset of scalar properties, a light + // fixture's light source: no surface (RE-300). + "GImposter" => self.result.excluded_non_surface_branches += 1, other => return Err(format!("unsupported reachable graphics class {other}")), } Ok(()) @@ -366,7 +369,7 @@ impl Walker<'_, '_> { } let result = match self.graph.objects[index].class_name.as_str() { "GLine" | "GArc" | "GEllipse" | "GCurve" | "GNurbSpline" | "GPolyLine" - | "GPolyline" | "GPoint" => true, + | "GPolyline" | "GPoint" | "GImposter" => true, "GElement" | "GGroup" | "GFilter" => self .children(index) .is_ok_and(|c| c.iter().all(|&i| self.only_curves(i, seen))), diff --git a/src/research/native_saved_mesh.rs b/src/research/native_saved_mesh.rs index f0500313c..f01bd8447 100644 --- a/src/research/native_saved_mesh.rs +++ b/src/research/native_saved_mesh.rs @@ -1085,10 +1085,7 @@ pub fn graphics_with_resolver_at_detail<'a>( .iter() .map(point::<3>) .collect::>>()?; - ensure!( - f["m_normalsFlag"].as_u64() == Some(0), - "unsupported normal binding" - ); + let binding = f["m_normalsFlag"].as_u64().context("normal binding")?; let triangles = f["m_facetsArr"] .as_array() .context("mesh facets")? @@ -1104,11 +1101,20 @@ pub fn graphics_with_resolver_at_detail<'a>( Ok(t) }) .collect::>>()?; + // Binding 0 gives one normal per facet. Any other binding with + // one normal per point gives them per vertex (#1247); the + // points and facets do not depend on it. + let vertex_normals = + binding != 0 && !normals.is_empty() && normals.len() == vertices.len(); + ensure!( + binding == 0 || normals.is_empty() || vertex_normals, + "unsupported normal binding" + ); ensure!( - normals.is_empty() || normals.len() == triangles.len(), + binding != 0 || normals.is_empty() || normals.len() == triangles.len(), "unsupported normal cardinality" ); - let facet_normals = !normals.is_empty(); + let facet_normals = binding == 0 && !normals.is_empty(); let mut expanded_vertices = Vec::new(); let mut expanded_normals = Vec::new(); @@ -1131,7 +1137,11 @@ pub fn graphics_with_resolver_at_detail<'a>( }; for idx in t { expanded_vertices.push(vertices[*idx as usize]); - expanded_normals.push(normal); + expanded_normals.push(if vertex_normals { + normals[*idx as usize] + } else { + normal + }); } expanded_triangles.push([base, base + 1, base + 2]); } From ef4da4c29baecef1fe3f9a0ad4019c986d80583f Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:31:14 -0500 Subject: [PATCH 3/7] research(saved-graphics): the RE-300 probe prints what sits beside each GImposter, with polymesh normal bindings (#1247) --- .../probe_re300_graphics_imposters.rs | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/probes/examples/probe_re300_graphics_imposters.rs b/probes/examples/probe_re300_graphics_imposters.rs index 9074c5e4f..6adf0e61a 100644 --- a/probes/examples/probe_re300_graphics_imposters.rs +++ b/probes/examples/probe_re300_graphics_imposters.rs @@ -9,9 +9,11 @@ //! to it and those it points to, and the classes under it. //! //! Output: one `imposter` line per object for the first 8 per file, with -//! its owner ElementId, scalar fields, parents and pointer targets, then -//! `census` lines over all of them: field sets, parents, flags and the -//! classes found under them within 8 pointer steps. +//! its owner ElementId, scalar fields, parents and pointer targets, and a +//! `siblings` line with what else its parent points to (a `GPolyMesh`'s +//! normal binding and counts, a condition's fields), then `census` lines +//! over all of them: field sets, parents, flags, the classes found under +//! them within 8 pointer steps, and their parents' sets of children. //! //! ```text //! cargo run --profile ci -p rvt-probes --example probe_re300_graphics_imposters -- model.rvt @@ -43,6 +45,31 @@ fn scalars(fields: &Value) -> String { .join(" ") } +/// A `GPolyMesh`'s normal binding and its points, normals and facets. +fn polymesh( + graph: &rvt::research::native_parameters::ObjectGraph, + out_edges: &BTreeMap>, + index: usize, +) -> String { + let Some(topology) = out_edges.get(&index).and_then(|edges| { + edges + .iter() + .map(|(_, target)| &graph.objects[*target]) + .find(|object| object.class_name.starts_with("FacetedTopology")) + }) else { + return "no topology".into(); + }; + let f = &topology.fields; + let len = |key: &str| f[key].as_array().map_or(0, Vec::len); + format!( + "flag={} points={} normals={} facets={}", + f["m_normalsFlag"], + len("m_pointsArr"), + len("m_normalsArr"), + len("m_facetsArr") + ) +} + fn main() -> anyhow::Result<()> { let Some(path) = std::env::args().nth(1) else { anyhow::bail!("usage: probe_re300_graphics_imposters FILE.rvt"); @@ -66,6 +93,7 @@ fn main() -> anyhow::Result<()> { let mut parents: BTreeMap = BTreeMap::new(); let mut flags: BTreeMap = BTreeMap::new(); let mut under: BTreeMap = BTreeMap::new(); + let mut sibling_sets: BTreeMap = BTreeMap::new(); extract_graphs(&mut rf, &options, |record| { let Some(graph) = record.graph.as_ref() else { return Ok(()); @@ -128,6 +156,51 @@ fn main() -> anyhow::Result<()> { for (class, n) in &classes { *under.entry((*class).to_string()).or_default() += n; } + if let Some(&parent) = in_edges.get(&index).and_then(|p| p.first()) { + let siblings: Vec = out_edges + .get(&parent) + .map(|edges| { + edges + .iter() + .map(|(offset, target)| { + let object = &graph.objects[*target]; + let detail = match object.class_name.as_str() { + "GPolyMesh" => polymesh(graph, &out_edges, *target), + name if name.starts_with("GCondition") => { + scalars(&object.fields) + } + _ => String::new(), + }; + format!("+{offset}:{}{{{detail}}}", object.class_name) + }) + .collect() + }) + .unwrap_or_default(); + *sibling_sets + .entry( + siblings + .iter() + .map(|s| s.split(['{', ':']).nth(1).unwrap_or("").to_string()) + .collect::>() + .join(","), + ) + .or_default() += 1; + if printed < 8 { + println!( + "siblings owner={} filter={} grandparents={:?} {}", + record.identity.element_id, + scalars(&graph.objects[parent].fields), + in_edges + .get(&parent) + .map(|g| g + .iter() + .map(|&i| graph.objects[i].class_name.clone()) + .collect::>()) + .unwrap_or_default(), + siblings.join(" ") + ); + } + } if printed < 8 { printed += 1; let targets: Vec = out_edges @@ -167,5 +240,8 @@ fn main() -> anyhow::Result<()> { for (class, n) in &under { println!("census under {n} {class}"); } + for (set, n) in &sibling_sets { + println!("census siblings {n} {set}"); + } Ok(()) } From 949131cab0272b6a7f1a0c45f4b84bba30c5d88e Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:44:09 -0500 Subject: [PATCH 4/7] fix(saved-graphics): a light source's filter, its GImposter with the shape that draws it, is left out of the body instead of keeping its element's box (#1247) (RE-300) --- src/research/native_graphics_traversal.rs | 16 +++++++++++---- src/research/native_saved_mesh.rs | 24 +++++++---------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/research/native_graphics_traversal.rs b/src/research/native_graphics_traversal.rs index a5b312095..c752ba4a0 100644 --- a/src/research/native_graphics_traversal.rs +++ b/src/research/native_graphics_traversal.rs @@ -198,6 +198,17 @@ impl Walker<'_, '_> { if children.is_empty() { return Ok(()); } + // A light source: a `GImposter` (a transform and a + // rendering asset of scalar properties) with the shape and + // lines that draw it. No part of the fixture's body + // (RE-300). + if children + .iter() + .any(|&i| self.graph.objects[i].class_name == "GImposter") + { + self.result.excluded_non_surface_branches += 1; + return Ok(()); + } // Curves are deliberately outside this surface-only selector. if children .iter() @@ -356,9 +367,6 @@ impl Walker<'_, '_> { } "GLine" | "GArc" | "GEllipse" | "GCurve" | "GNurbSpline" | "GPolyLine" | "GPolyline" | "GPoint" => self.result.excluded_non_surface_branches += 1, - // A transform and a rendering asset of scalar properties, a light - // fixture's light source: no surface (RE-300). - "GImposter" => self.result.excluded_non_surface_branches += 1, other => return Err(format!("unsupported reachable graphics class {other}")), } Ok(()) @@ -369,7 +377,7 @@ impl Walker<'_, '_> { } let result = match self.graph.objects[index].class_name.as_str() { "GLine" | "GArc" | "GEllipse" | "GCurve" | "GNurbSpline" | "GPolyLine" - | "GPolyline" | "GPoint" | "GImposter" => true, + | "GPolyline" | "GPoint" => true, "GElement" | "GGroup" | "GFilter" => self .children(index) .is_ok_and(|c| c.iter().all(|&i| self.only_curves(i, seen))), diff --git a/src/research/native_saved_mesh.rs b/src/research/native_saved_mesh.rs index f01bd8447..f0500313c 100644 --- a/src/research/native_saved_mesh.rs +++ b/src/research/native_saved_mesh.rs @@ -1085,7 +1085,10 @@ pub fn graphics_with_resolver_at_detail<'a>( .iter() .map(point::<3>) .collect::>>()?; - let binding = f["m_normalsFlag"].as_u64().context("normal binding")?; + ensure!( + f["m_normalsFlag"].as_u64() == Some(0), + "unsupported normal binding" + ); let triangles = f["m_facetsArr"] .as_array() .context("mesh facets")? @@ -1101,20 +1104,11 @@ pub fn graphics_with_resolver_at_detail<'a>( Ok(t) }) .collect::>>()?; - // Binding 0 gives one normal per facet. Any other binding with - // one normal per point gives them per vertex (#1247); the - // points and facets do not depend on it. - let vertex_normals = - binding != 0 && !normals.is_empty() && normals.len() == vertices.len(); - ensure!( - binding == 0 || normals.is_empty() || vertex_normals, - "unsupported normal binding" - ); ensure!( - binding != 0 || normals.is_empty() || normals.len() == triangles.len(), + normals.is_empty() || normals.len() == triangles.len(), "unsupported normal cardinality" ); - let facet_normals = binding == 0 && !normals.is_empty(); + let facet_normals = !normals.is_empty(); let mut expanded_vertices = Vec::new(); let mut expanded_normals = Vec::new(); @@ -1137,11 +1131,7 @@ pub fn graphics_with_resolver_at_detail<'a>( }; for idx in t { expanded_vertices.push(vertices[*idx as usize]); - expanded_normals.push(if vertex_normals { - normals[*idx as usize] - } else { - normal - }); + expanded_normals.push(normal); } expanded_triangles.push([base, base + 1, base + 2]); } From 6afb244d6d853456e68db0a39afb79bdddcaf564 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:58:35 -0500 Subject: [PATCH 5/7] docs(saved-graphics): RE-300's report, changelog entry and addendum for light-source filters (#1247) --- .../fixed/1247-light-source-filters.md | 8 +++ docs/rvt-moat-break-reconnaissance.md | 4 ++ .../probe_re300_graphics_imposters.rs | 18 +++-- .../RE-300-light-source-filters.md | 67 +++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixed/1247-light-source-filters.md create mode 100644 reports/element-framing/RE-300-light-source-filters.md diff --git a/changelog.d/fixed/1247-light-source-filters.md b/changelog.d/fixed/1247-light-source-filters.md new file mode 100644 index 000000000..eb753b002 --- /dev/null +++ b/changelog.d/fixed/1247-light-source-filters.md @@ -0,0 +1,8 @@ +- **Saved meshes leave a light fixture's light source out of its body, + instead of keeping the fixture's box.** The light source is saved as a + filter of its own: a `GImposter` (a rendering asset with no surface) + with the shape and lines that draw the light, none of it in Revit's + export. On the BIM4LCA office 39 more bodies are drawn from their saved + meshes, all exact to 0.001 ft against Revit's export, as are RE1 + Electrical's 2. Snowdon Towers' electrical sample draws 1,279 of its + 1,465 bodies from saved meshes instead of 907 (#1247, RE-300). diff --git a/docs/rvt-moat-break-reconnaissance.md b/docs/rvt-moat-break-reconnaissance.md index fb6e663cb..60ba13969 100644 --- a/docs/rvt-moat-break-reconnaissance.md +++ b/docs/rvt-moat-break-reconnaissance.md @@ -2244,6 +2244,10 @@ Revit's export writes a floor's `Qto_SlabBaseQuantities.NetVolume` as the volume Revit's export writes a floor's `Qto_SlabBaseQuantities.GrossArea` as the area of the profile its slab extrudes, its sketch outline less its holes: equal to rvt-rs's profile on all 40 of the BIM4LCA office's slabs drawn from their record box, where the slab's other quantities follow its body less often (a sloped slab's record box is deeper than its layers). Stair landings' slabs get none. rvt-rs writes it on each floor drawn as one extrusion: 31 equal on the office, none different. Report: `reports/element-framing/RE-249-slab-gross-area.md`. +## Addendum — RE-300 a light source's saved graphics (2026-11-02) + +The saved graphics class `GImposter` is a light source: a transform and a rendering `Asset` of scalar properties, with no surface, always in a `GFilter` of its own beside the `Geometry` (and often a `GPolyMesh` of normal binding 2) that draws the light. Revit's export draws none of that filter. Left out, it no longer keeps a fixture's box: 39 more office bodies and 2 on RE1 Electrical drawn from saved meshes, all exact to 0.001 ft, and 372 more of Snowdon Towers electrical's light fixtures. Report: `reports/element-framing/RE-300-light-source-filters.md`. + ## Addendum — RE-301 a saved cylinder with a left-handed basis (2026-11-02) A saved `CylSurf` whose `m_xVec × m_yVec` is `−m_zVec` is the same cylinder; only its parametric normal ∂P/∂u × ∂P/∂v points inward. Read as a plane is (the normal is the parametric one, turned by the orientation), such faces no longer keep their whole element's box: 100 more saved-mesh bodies on the BIM4LCA office, 66 exact to 0.001 ft against Revit's export, and 172 more on Snowdon Towers' architectural sample. Report: `reports/element-framing/RE-301-left-handed-cylinders.md`. diff --git a/probes/examples/probe_re300_graphics_imposters.rs b/probes/examples/probe_re300_graphics_imposters.rs index 6adf0e61a..8347634eb 100644 --- a/probes/examples/probe_re300_graphics_imposters.rs +++ b/probes/examples/probe_re300_graphics_imposters.rs @@ -1,12 +1,18 @@ //! RE-300: what a saved graphics `GImposter` holds (#1247, RE-224). //! -//! FACT under test: `native_graphics_traversal` stops at any graphics class -//! it does not know, and `saved_meshes::attach` then keeps the element's -//! box (RE-224). `GImposter` is the class that stops most of them: 1,813 of -//! the 3,020 such elements on the BIM4LCA office, RE1 and Autodesk's 2024 +//! FACT: a `GImposter` is a light source. It holds a transform (`m_trf`) and +//! a rendering `Asset` of scalar properties, and no surface. Its parent is +//! always a `GFilter` of its own (`m_flags` 557060) that also holds the shape +//! and lines that draw the light: a `Geometry`, and often a `GPolyMesh` +//! whose normal binding is 2. Revit's export draws none of that filter: +//! with it left out, the 39 BIM4LCA office and 2 RE1 Electrical bodies it +//! kept as boxes are exact to 0.001 ft. With only the `GImposter` left out, +//! the office's exit signs were 12 times Revit's volume. +//! +//! `native_graphics_traversal` stopped at the class, and +//! `saved_meshes::attach` then kept the element's box (RE-224): 1,813 of the +//! 3,020 such elements on the BIM4LCA office, RE1 and Autodesk's 2024 //! samples, every one of Snowdon Towers electrical's 511 light fixtures. -//! This prints what each `GImposter` is: its fields, the classes that point -//! to it and those it points to, and the classes under it. //! //! Output: one `imposter` line per object for the first 8 per file, with //! its owner ElementId, scalar fields, parents and pointer targets, and a diff --git a/reports/element-framing/RE-300-light-source-filters.md b/reports/element-framing/RE-300-light-source-filters.md new file mode 100644 index 000000000..cf5baef77 --- /dev/null +++ b/reports/element-framing/RE-300-light-source-filters.md @@ -0,0 +1,67 @@ +# RE-300 — A light source's saved graphics are a filter of their own, left out of the body + +**Date:** 2026-11-02 +**Issues:** #1247 (light fixtures and other saved meshes that v0.6.1 drew and v0.12.0 draws as boxes), #255; follows RE-224 and RE-301 +**Probe:** `probes/examples/probe_re300_graphics_imposters.rs` +**Runs:** Measure [41542687542](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41542687542) and [41546059869](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41546059869) (the probe), [41544207306](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41544207306) (the first reading, withdrawn), [41547683034](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41547683034) (this change at 949131ca against `main`, `--saved-meshes`, `mesh_bodies_vs_ifc`) +**Result:** positive. The saved graphics class `GImposter` that stops 1,813 of the 3,020 saved meshes RE-224 keeps as boxes on the office, RE1 and the 2024 samples is a light source. It sits in a filter of its own, with the shape and lines that draw the light. Leaving that filter out of the body draws the 39 office and 2 RE1 Electrical bodies it kept as boxes, all exact to 0.001 ft against Revit's export, and 372 more of Snowdon Towers electrical's light fixtures. + +## 1. What a `GImposter` holds + +The probe reads every saved graphics record of the office, RE1 Electrical and five 2024 samples: 281 `GImposter` objects in all (100 on Snowdon Towers electrical, on 85 symbols that its 511 light fixtures share). + +- Every one has the same three fields: `m_GInfo` (flags 557060, category −1), `m_trf` (a transform) and `m_oAsset`. +- `m_oAsset` points to an `Asset` of scalar properties and nothing else within 8 pointer steps: `APropertyBoolean`, `APropertyFloat`, `APropertyEnum`, `APropertyDouble3`, `APropertyString`, `APropertyDistance`, `APropertyInteger`. This is a rendering asset; no `Geometry` or `GPolyMesh` lies under it. +- Every one's parent is a `GFilter` with the same flags, 557060. Beside the `GImposter`, that filter holds a `Geometry` on every file, often curves (`GLine`, `GArc`, `GPoint`, `GNurbSpline`), and on 164 of them a `GPolyMesh`; the 35 the probe prints in full all have `m_normalsFlag` 2 and one normal per facet. That `GPolyMesh` is where RE-224's "unsupported normal binding" came from: it appears only beside a `GImposter`. + +So the filter is the light source: its asset, and the shape and lines that draw it in a view. + +## 2. The first reading, withdrawn + +The first change (1123e476) skipped only the `GImposter` and read the binding-2 `GPolyMesh`. Measure 41544207306 drew 39 more office bodies, none of them Revit's. They were 15 "LEX Exit Sign" fixtures (and the rest of the same families) at 11.7 to 12.2 times Revit's volume, with Revit's every vertex on rvt-rs's surface (within 2.5e-6 ft) and rvt-rs's furthest 0.44 ft from Revit's. The light's shape had been drawn with the fixture. It is also why RE-224 found the office's 43 `GImposter` meshes all inexact: drawn partly, they still held that shape. + +## 3. What rvt-rs does + +`native_graphics_traversal` leaves out a `GFilter` that holds a `GImposter` among its children, counting it as a branch with no surface, as it counts a filter of curves. A `GImposter` anywhere else still stops the walk. The `GPolyMesh` normal binding is not read: it is only met inside these filters. + +## 4. Measured + +Measure 41547683034, this change against `main` at a2cec9dc, both with `--saved-meshes`: + +| file | bodies drawn from saved meshes, before → after | kept as boxes, before → after | +|---|---|---| +| BIM4LCA office as built (2024) | 10,781 → 10,820 | 357 → 318 | +| BIM4LCA office at permit (2024) | 10,896 → 10,935 | 359 → 320 | +| RE1 Electrical (2025) | 45 → 47 | 4 → 2 | +| Snowdon Towers electrical (2024 sample) | 907 → 1,279 | 513 → 139 | +| Snowdon Towers architectural (2024 sample) | 1,799 → 2,086 | 754 → 465 | +| rme basic and advanced (2024 samples) | 5,074 → 5,484 and 5,078 → 5,488 | 566 → 156 | +| rac advanced and basic (2024 samples) | 4,935 → 4,971 and 270 → 276 | 69 → 33 and 49 → 43 | +| Core Interior, RE1 Architecture, Mechanical, Plumbing, the other samples | unchanged | unchanged | + +Against Revit's own export (`mesh_bodies_vs_ifc`): + +| file | scored, before → after | exact (0.001 ft) | within 0.01 ft | volume within 1% | +|---|---|---|---|---| +| office as built | 1,352 → 1,391 | 1,294 → 1,333 | 1,345 → 1,384 | 1,340 → 1,379 | +| office at permit | 1,438 → 1,477 | 1,376 → 1,415 | 1,427 → 1,466 | 1,425 → 1,464 | +| RE1 Electrical | 45 → 47 | 29 → 31 | 45 → 47 | 45 → 47 | + +Every new body scored is exact. The IFC4 schema check is unchanged but for fewer swept solids (the boxes). The meshes cost size: the office as built goes from 331,302 to 354,775 triangles, Snowdon Towers electrical from 41,376 to 604,812. + +Snowdon Towers has no Revit export in the corpus measured here, so its 372 electrical and 287 architectural bodies are not scored; they are the same families' light sources left out. + +## 5. Not claimed + +- The `GPolyMesh` normal binding 2 elsewhere than in a light source's filter: not met on these files, and not read. +- The 139 Snowdon Towers electrical bodies still kept as boxes, and the other kinds of RE-301 §1 (non-rectangular cylinder trims, `GEllipse` profiles, `GBiFlipControl`). + +## 6. Reproduce + +```bash +gh workflow run measure.yml --ref main -f ref=949131ca -f base=main -f ifc_flags=--saved-meshes \ + -f nordic=true -f samples=true -f sample_years="2024 2024" \ + -f models="core-interior re1-* nordic-office-* sample-2024-*" -f scorers="mesh_bodies_vs_ifc schema" +``` + +The probe: `-f probe=probe_re300_graphics_imposters -f scorers=none` on the same models, at this change. From 6689533d8c133217f54ccb4375eac636ac97f3ef Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:08:22 -0500 Subject: [PATCH 6/7] fix(saved-graphics): a filter is a light source only where every other child is a leaf, else walked as before (#1247) (RE-300) --- src/research/native_graphics_traversal.rs | 27 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/research/native_graphics_traversal.rs b/src/research/native_graphics_traversal.rs index c752ba4a0..e90d9a541 100644 --- a/src/research/native_graphics_traversal.rs +++ b/src/research/native_graphics_traversal.rs @@ -200,11 +200,30 @@ impl Walker<'_, '_> { } // A light source: a `GImposter` (a transform and a // rendering asset of scalar properties) with the shape and - // lines that draw it. No part of the fixture's body - // (RE-300). - if children + // lines that draw it, no part of the fixture's body + // (RE-300). Only a filter of such leaves is one; any + // other child is walked as before. + let classes: Vec<&str> = children .iter() - .any(|&i| self.graph.objects[i].class_name == "GImposter") + .map(|&i| self.graph.objects[i].class_name.as_str()) + .collect(); + if classes.contains(&"GImposter") + && classes.iter().all(|class| { + matches!( + *class, + "GImposter" + | "Geometry" + | "GPolyMesh" + | "GLine" + | "GArc" + | "GEllipse" + | "GCurve" + | "GNurbSpline" + | "GPolyLine" + | "GPolyline" + | "GPoint" + ) + }) { self.result.excluded_non_surface_branches += 1; return Ok(()); From 1f9bb08e080e5ab447f526833aa98530634f8e81 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:16:57 -0500 Subject: [PATCH 7/7] docs(saved-graphics): RE-300's figures re-measured on main with RE-301, and the review's wording notes (#1247) --- .../fixed/1247-light-source-filters.md | 8 ++--- docs/rvt-moat-break-reconnaissance.md | 2 +- .../RE-300-light-source-filters.md | 36 +++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/changelog.d/fixed/1247-light-source-filters.md b/changelog.d/fixed/1247-light-source-filters.md index eb753b002..3d7850102 100644 --- a/changelog.d/fixed/1247-light-source-filters.md +++ b/changelog.d/fixed/1247-light-source-filters.md @@ -1,8 +1,8 @@ -- **Saved meshes leave a light fixture's light source out of its body, - instead of keeping the fixture's box.** The light source is saved as a +- **With `--saved-meshes`, a light fixture's light source is left out of + its body, instead of keeping the fixture's box.** The light source is saved as a filter of its own: a `GImposter` (a rendering asset with no surface) with the shape and lines that draw the light, none of it in Revit's - export. On the BIM4LCA office 39 more bodies are drawn from their saved + export. On the BIM4LCA office 43 more bodies are drawn from their saved meshes, all exact to 0.001 ft against Revit's export, as are RE1 - Electrical's 2. Snowdon Towers' electrical sample draws 1,279 of its + Electrical's 2. Snowdon Towers' electrical sample draws 1,324 of its 1,465 bodies from saved meshes instead of 907 (#1247, RE-300). diff --git a/docs/rvt-moat-break-reconnaissance.md b/docs/rvt-moat-break-reconnaissance.md index 60ba13969..4692df902 100644 --- a/docs/rvt-moat-break-reconnaissance.md +++ b/docs/rvt-moat-break-reconnaissance.md @@ -2246,7 +2246,7 @@ Revit's export writes a floor's `Qto_SlabBaseQuantities.GrossArea` as the area o ## Addendum — RE-300 a light source's saved graphics (2026-11-02) -The saved graphics class `GImposter` is a light source: a transform and a rendering `Asset` of scalar properties, with no surface, always in a `GFilter` of its own beside the `Geometry` (and often a `GPolyMesh` of normal binding 2) that draws the light. Revit's export draws none of that filter. Left out, it no longer keeps a fixture's box: 39 more office bodies and 2 on RE1 Electrical drawn from saved meshes, all exact to 0.001 ft, and 372 more of Snowdon Towers electrical's light fixtures. Report: `reports/element-framing/RE-300-light-source-filters.md`. +The saved graphics class `GImposter` is a light source: a transform and a rendering `Asset` of scalar properties, with no surface, always in a `GFilter` of its own beside the `Geometry` (and often a `GPolyMesh` of normal binding 2) that draws the light. Revit's export draws none of that filter. Left out, it no longer keeps a fixture's box: 43 more office bodies and 2 on RE1 Electrical drawn from saved meshes, all exact to 0.001 ft, and 417 more Snowdon Towers electrical bodies. Report: `reports/element-framing/RE-300-light-source-filters.md`. ## Addendum — RE-301 a saved cylinder with a left-handed basis (2026-11-02) diff --git a/reports/element-framing/RE-300-light-source-filters.md b/reports/element-framing/RE-300-light-source-filters.md index cf5baef77..abaf89d0a 100644 --- a/reports/element-framing/RE-300-light-source-filters.md +++ b/reports/element-framing/RE-300-light-source-filters.md @@ -3,15 +3,15 @@ **Date:** 2026-11-02 **Issues:** #1247 (light fixtures and other saved meshes that v0.6.1 drew and v0.12.0 draws as boxes), #255; follows RE-224 and RE-301 **Probe:** `probes/examples/probe_re300_graphics_imposters.rs` -**Runs:** Measure [41542687542](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41542687542) and [41546059869](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41546059869) (the probe), [41544207306](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41544207306) (the first reading, withdrawn), [41547683034](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41547683034) (this change at 949131ca against `main`, `--saved-meshes`, `mesh_bodies_vs_ifc`) -**Result:** positive. The saved graphics class `GImposter` that stops 1,813 of the 3,020 saved meshes RE-224 keeps as boxes on the office, RE1 and the 2024 samples is a light source. It sits in a filter of its own, with the shape and lines that draw the light. Leaving that filter out of the body draws the 39 office and 2 RE1 Electrical bodies it kept as boxes, all exact to 0.001 ft against Revit's export, and 372 more of Snowdon Towers electrical's light fixtures. +**Runs:** Measure [41542687542](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41542687542) and [41546059869](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41546059869) (the probe), [41544207306](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41544207306) (the first reading, withdrawn), [41547683034](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41547683034) (the rule at 949131ca against `main` before RE-301), [41550667212](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41550667212) (this change at 6689533d against `main` at a8ad9dd5, with RE-301, `--saved-meshes`, `mesh_bodies_vs_ifc`) +**Result:** positive. The saved graphics class `GImposter` that stops 1,813 of the 3,020 saved meshes RE-224 keeps as boxes on the office, RE1 and the 2024 samples is a light source. It sits in a filter of its own, with the shape and lines that draw the light. Leaving that filter out of the body draws the 43 office and 2 RE1 Electrical bodies it kept as boxes, all exact to 0.001 ft against Revit's export, and 417 more of Snowdon Towers electrical's bodies. ## 1. What a `GImposter` holds -The probe reads every saved graphics record of the office, RE1 Electrical and five 2024 samples: 281 `GImposter` objects in all (100 on Snowdon Towers electrical, on 85 symbols that its 511 light fixtures share). +The probe reads every saved graphics record of the office, RE1 Electrical and the ten 2024 samples: 281 `GImposter` objects in all (3 on the office, 2 on RE1 Electrical, 276 on six of the samples; 100 of them on Snowdon Towers electrical, on 85 symbols that its 511 light fixtures share). -- Every one has the same three fields: `m_GInfo` (flags 557060, category −1), `m_trf` (a transform) and `m_oAsset`. -- `m_oAsset` points to an `Asset` of scalar properties and nothing else within 8 pointer steps: `APropertyBoolean`, `APropertyFloat`, `APropertyEnum`, `APropertyDouble3`, `APropertyString`, `APropertyDistance`, `APropertyInteger`. This is a rendering asset; no `Geometry` or `GPolyMesh` lies under it. +- Every one has the same three fields: `m_GInfo` (flags 557060; category −1 on every one printed in full), `m_trf` (a transform) and `m_oAsset`. +- `m_oAsset` points to an `Asset` of scalar properties and nothing else within 8 pointer steps: `APropertyBoolean`, `APropertyFloat`, `APropertyEnum`, `APropertyDouble1`, `APropertyDouble3`, `APropertyString`, `APropertyDistance`, `APropertyInteger`. This is a rendering asset; no `Geometry` or `GPolyMesh` lies under it. - Every one's parent is a `GFilter` with the same flags, 557060. Beside the `GImposter`, that filter holds a `Geometry` on every file, often curves (`GLine`, `GArc`, `GPoint`, `GNurbSpline`), and on 164 of them a `GPolyMesh`; the 35 the probe prints in full all have `m_normalsFlag` 2 and one normal per facet. That `GPolyMesh` is where RE-224's "unsupported normal binding" came from: it appears only beside a `GImposter`. So the filter is the light source: its asset, and the shape and lines that draw it in a view. @@ -22,34 +22,34 @@ The first change (1123e476) skipped only the `GImposter` and read the binding-2 ## 3. What rvt-rs does -`native_graphics_traversal` leaves out a `GFilter` that holds a `GImposter` among its children, counting it as a branch with no surface, as it counts a filter of curves. A `GImposter` anywhere else still stops the walk. The `GPolyMesh` normal binding is not read: it is only met inside these filters. +`native_graphics_traversal` leaves out a `GFilter` that holds a `GImposter` and otherwise only leaves (`Geometry`, `GPolyMesh`, curves), counting it as a branch with no surface, as it counts a filter of curves; all 281 observed filters are such. A filter with any other child, and a `GImposter` anywhere else, are walked as before: the `GImposter` is recorded as a decode failure, and the element keeps its box. The `GPolyMesh` normal binding is not read: it is only met inside these filters. ## 4. Measured -Measure 41547683034, this change against `main` at a2cec9dc, both with `--saved-meshes`: +Measure 41550667212, this change (6689533d) against `main` at a8ad9dd5, which reads left-handed cylinders (RE-301), both with `--saved-meshes`: | file | bodies drawn from saved meshes, before → after | kept as boxes, before → after | |---|---|---| -| BIM4LCA office as built (2024) | 10,781 → 10,820 | 357 → 318 | -| BIM4LCA office at permit (2024) | 10,896 → 10,935 | 359 → 320 | +| BIM4LCA office as built (2024) | 10,881 → 10,924 | 322 → 279 | +| BIM4LCA office at permit (2024) | 10,998 → 11,041 | 322 → 279 | | RE1 Electrical (2025) | 45 → 47 | 4 → 2 | -| Snowdon Towers electrical (2024 sample) | 907 → 1,279 | 513 → 139 | -| Snowdon Towers architectural (2024 sample) | 1,799 → 2,086 | 754 → 465 | -| rme basic and advanced (2024 samples) | 5,074 → 5,484 and 5,078 → 5,488 | 566 → 156 | -| rac advanced and basic (2024 samples) | 4,935 → 4,971 and 270 → 276 | 69 → 33 and 49 → 43 | +| Snowdon Towers electrical (2024 sample) | 907 → 1,324 | 556 → 139 | +| Snowdon Towers architectural (2024 sample) | 1,971 → 2,303 | 646 → 314 | +| rme basic and advanced (2024 samples) | 5,096 → 5,506 and 5,099 → 5,509 | 551 → 141 | +| rac advanced and basic (2024 samples) | 4,935 → 4,971 and 284 → 294 | 69 → 33 and 49 → 39 | | Core Interior, RE1 Architecture, Mechanical, Plumbing, the other samples | unchanged | unchanged | Against Revit's own export (`mesh_bodies_vs_ifc`): | file | scored, before → after | exact (0.001 ft) | within 0.01 ft | volume within 1% | |---|---|---|---|---| -| office as built | 1,352 → 1,391 | 1,294 → 1,333 | 1,345 → 1,384 | 1,340 → 1,379 | -| office at permit | 1,438 → 1,477 | 1,376 → 1,415 | 1,427 → 1,466 | 1,425 → 1,464 | +| office as built | 1,452 → 1,495 | 1,360 → 1,403 | 1,438 → 1,481 | 1,434 → 1,477 | +| office at permit | 1,540 → 1,583 | 1,444 → 1,487 | 1,522 → 1,565 | 1,521 → 1,564 | | RE1 Electrical | 45 → 47 | 29 → 31 | 45 → 47 | 45 → 47 | -Every new body scored is exact. The IFC4 schema check is unchanged but for fewer swept solids (the boxes). The meshes cost size: the office as built goes from 331,302 to 354,775 triangles, Snowdon Towers electrical from 41,376 to 604,812. +Every new body scored is exact; they are the office's "LEX Exit Sign" fixtures (wall and ceiling mounted) and RE1 Electrical's `265100-LIGHT-LED`. Measured before RE-301 (Measure 41547683034, against a2cec9dc), the same rule drew 39 more office bodies, all exact. The IFC4 schema check changes only in its count of swept solids (the boxes): 2,404 to 2,361 on the office. Snowdon Towers electrical's `--saved-meshes` export now passes 30 MB, so IfcOpenShell's full validation skips it, as it does any export over that size. The meshes cost size: the office as built goes from 447,784 to 473,681 triangles, Snowdon Towers electrical from 41,376 to 815,408. -Snowdon Towers has no Revit export in the corpus measured here, so its 372 electrical and 287 architectural bodies are not scored; they are the same families' light sources left out. +The samples have no Revit export, so their new bodies are not scored. That they are light fixtures whose light source is now left out is consistent with their counts, not measured: on no file do the new bodies outnumber its `IfcLightFixture`s. ## 5. Not claimed @@ -59,7 +59,7 @@ Snowdon Towers has no Revit export in the corpus measured here, so its 372 elect ## 6. Reproduce ```bash -gh workflow run measure.yml --ref main -f ref=949131ca -f base=main -f ifc_flags=--saved-meshes \ +gh workflow run measure.yml --ref main -f ref=6689533d -f base=a8ad9dd5 -f ifc_flags=--saved-meshes \ -f nordic=true -f samples=true -f sample_years="2024 2024" \ -f models="core-interior re1-* nordic-office-* sample-2024-*" -f scorers="mesh_bodies_vs_ifc schema" ```