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 00000000..3d785010 --- /dev/null +++ b/changelog.d/fixed/1247-light-source-filters.md @@ -0,0 +1,8 @@ +- **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 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,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 615201b3..424bf062 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: 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) 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 new file mode 100644 index 00000000..8347634e --- /dev/null +++ b/probes/examples/probe_re300_graphics_imposters.rs @@ -0,0 +1,253 @@ +//! RE-300: what a saved graphics `GImposter` holds (#1247, RE-224). +//! +//! 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. +//! +//! Output: one `imposter` line per object for the first 8 per file, with +//! 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 +//! ``` + +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(" ") +} + +/// 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"); + }; + 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(); + let mut sibling_sets: 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 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 + .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}"); + } + for (set, n) in &sibling_sets { + println!("census siblings {n} {set}"); + } + Ok(()) +} 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 00000000..abaf89d0 --- /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) (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 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 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. + +## 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` 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 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,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,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,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; 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. + +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 + +- 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=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" +``` + +The probe: `-f probe=probe_re300_graphics_imposters -f scorers=none` on the same models, at this change. diff --git a/src/research/native_graphics_traversal.rs b/src/research/native_graphics_traversal.rs index 57726ecf..e90d9a54 100644 --- a/src/research/native_graphics_traversal.rs +++ b/src/research/native_graphics_traversal.rs @@ -198,6 +198,36 @@ 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). Only a filter of such leaves is one; any + // other child is walked as before. + let classes: Vec<&str> = children + .iter() + .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(()); + } // Curves are deliberately outside this surface-only selector. if children .iter()