diff --git a/changelog.d/fixed/1247-native-map-void-only-doors.md b/changelog.d/fixed/1247-native-map-void-only-doors.md new file mode 100644 index 000000000..9fcf8db23 --- /dev/null +++ b/changelog.d/fixed/1247-native-map-void-only-doors.md @@ -0,0 +1,9 @@ +- **A door or window is no longer written as its opening alone when its + type draws geometry in no material of its own.** RE-84 takes a type + whose value block holds no geometry-material map as one that only cuts + its host; the scan of that block missed a map whose values are all unset + or name a material whose name it did not read. The type's native map + now decides: it is empty on every door and window Revit exports as its + opening alone (RE1's CasedOpening, Snowdon Towers' 47), and those keep + their openings; no export of the reference models, the BIM4LCA office, + the R2025 models or Autodesk's samples changes (#1247, RE-299). diff --git a/docs/rvt-moat-break-reconnaissance.md b/docs/rvt-moat-break-reconnaissance.md index fb6e663cb..615201b36 100644 --- a/docs/rvt-moat-break-reconnaissance.md +++ b/docs/rvt-moat-break-reconnaissance.md @@ -2248,6 +2248,10 @@ Revit's export writes a floor's `Qto_SlabBaseQuantities.GrossArea` as the area o 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`. +## Addendum — RE-299 a door or window that only cuts its host (2026-11-02) + +RE-84 writes a door or window as its opening alone where the scan of its type's value block finds no geometry-material map, and the scan rejects a map whose values are all unset or name a material whose name it did not read. The type's native `m_geomTag2MaterialId` is empty on every door and window Revit exports as its opening alone (RE1's CasedOpening, Snowdon Towers' 9 Door-Opening and 38 Schematic Opening Cut, none with saved graphics), while the byte scan's all-unset map also fires on CasedOpening and Door-Opening. A type whose native map holds entries is no longer read as drawing nothing; no corpus export changes, and #1247's 73 doors are the case it is for. Report: `reports/element-framing/RE-299-native-map-void-only-doors.md`. + ## Addendum — RE-298 a flat roof's slab quantities, and a level floor's Depth (2026-10-30) Revit's export writes a slab's `GrossVolume` as its `GrossArea` times its `Depth` on all 42 of the BIM4LCA office's slabs with a volume. A flat roof written as a slab (all 10, `Basic Roof`) has the extrusion's depth, its layers' total, however many there are (3 for `YP-3`, 5 for `YP-2`, 10 for `YP-1`), its `GrossArea` is its sketch's profile's area, and its `NetVolume` is the area times the depth except on the one a hatch cuts, which holds four `WallCutoutGStep`s; rvt-rs writes these. A level floor of several layers (the 10 of types `VP-1`, `VP-2` and `AP-1`) has its last layer's thickness as `Depth` in the office's export (24.0.5), but Revit 26.4's export of the held-out Revit 2026 house writes another value on 2 such slabs, so rvt-rs writes none (the maintainer's standing rule, 2026-10-31). Report: `reports/element-framing/RE-298-slab-depth-roofs.md`. diff --git a/probes/examples/probe_re299_unset_map_doors.rs b/probes/examples/probe_re299_unset_map_doors.rs new file mode 100644 index 000000000..0946bf008 --- /dev/null +++ b/probes/examples/probe_re299_unset_map_doors.rs @@ -0,0 +1,186 @@ +//! RE-299: which doors and windows only cut their host (#1247, RE-84). +//! +//! FACT: RE-84 writes a door or window as its opening alone where RE-82's +//! scan of its type's value block finds no geometry-material map. That scan +//! takes a map only where every value is a material whose name it read, so +//! a type whose map values are all unset (−1, RE-149), or name a material +//! the name scan dropped, reads as having none. The type's native +//! `m_geomTag2MaterialId` (RE-203) tells them apart: every door and window +//! Revit exports as its opening alone (RE1 Architecture's CasedOpening, +//! Snowdon Towers' 9 Door-Opening and 38 Schematic Opening Cut) has a type +//! whose native map is empty, and no saved graphics, while the byte scan's +//! all-unset map (`unset_material_types`) also fires on CasedOpening and +//! Door-Opening, so it cannot. +//! +//! Prints each door and window record, with the five columns of +//! `probe_re84_void_only_families` (ElementId, class, type ElementId, the +//! type's map material count or `-`/`?`, family), then: +//! - `u` where the type's value block opens with an all-unset map +//! (`partition_type_materials::unset_material_types`), else `-`; +//! - the triangles of the element's saved graphics at fine detail, `-` where +//! it has none; +//! - its family's `shows_unset` (`native_family_forms`, RE-204): `t`, `f`, +//! `?` where undecided, `-` where the family is not read; +//! - the type's native `m_geomTag2MaterialId` (RE-203) as +//! `/`, `-` where the type record is not read: the +//! map itself, whatever either byte scan makes of it. +//! +//! `tools/re/void_only_vs_ifc.py` reads the first five columns, and counts +//! the rest where present. +//! +//! ```text +//! cargo run --profile ci -p rvt-probes --example probe_re299_unset_map_doors -- model.rvt +//! ``` + +use std::collections::{BTreeMap, BTreeSet}; + +use rvt::research::native_document::{Options, extract_graphs}; +use rvt::research::native_family_forms::family_forms; +use rvt::research::native_saved_scene::extract_at_detail; +use rvt::research::partition_schema_mvp::{FAMILY_NAME_FIELD, TYPE_ID_FIELD}; +use rvt::research::partition_type_materials::{type_material_names, unset_material_types}; +use rvt::walker::InstanceField; + +fn main() -> anyhow::Result<()> { + let Some(path) = std::env::args().nth(1) else { + anyhow::bail!("usage: probe_re299_unset_map_doors FILE.rvt"); + }; + let mut rf = rvt::RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let maps = type_material_names(&mut rf, version); + let unset = unset_material_types(&mut rf, version); + let mvp = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )?; + let elements: Vec<_> = mvp.doors.iter().chain(&mvp.windows).collect(); + let type_of = |element: &rvt::walker::DecodedElement| { + element.fields.iter().find_map(|(name, value)| match value { + InstanceField::ElementId { id, .. } if name == TYPE_ID_FIELD => Some(*id), + _ => None, + }) + }; + + let types: BTreeSet = elements + .iter() + .filter_map(|e| type_of(e)) + .map(u64::from) + .collect(); + let mut family_of: BTreeMap = BTreeMap::new(); + let mut native_map: BTreeMap = BTreeMap::new(); + let options = Options { + selected_ids: types, + ..Options::default() + }; + if let Err(e) = extract_graphs(&mut rf, &options, |record| { + let root = record + .graph + .as_ref() + .and_then(|graph| graph.objects.first()); + if let Some(map) = root + .and_then(|root| root.fields.get("m_geomTag2MaterialId")) + .and_then(serde_json::Value::as_array) + { + let unset = map + .iter() + .filter(|entry| { + entry + .pointer("/second/m_id/m_id64") + .and_then(serde_json::Value::as_i64) + == Some(-1) + }) + .count(); + native_map.insert(record.identity.element_id, (map.len(), unset)); + } + let family = root + .and_then(|root| root.fields.get("m_familyId")) + .and_then(|id| id.pointer("/m_id/m_id64")) + .and_then(serde_json::Value::as_i64) + .and_then(|id| u64::try_from(id).ok()); + if let Some(family) = family { + family_of.insert(record.identity.element_id, family); + } + Ok(()) + }) { + eprintln!("type records not read: {e}"); + } + let families: BTreeSet = family_of.values().copied().collect(); + let forms = family_forms(&mut rf, &families).unwrap_or_else(|e| { + eprintln!("family forms not read: {e}"); + BTreeMap::new() + }); + + let ids: BTreeSet = elements + .iter() + .filter_map(|e| e.id) + .map(u64::from) + .collect(); + let options = Options { + selected_ids: ids, + max_graph_values: 1_000_000, + max_graph_objects: 1_000_000, + ..Default::default() + }; + let mut triangles: BTreeMap = BTreeMap::new(); + match extract_at_detail(&mut rf, &options, 3) { + Ok(scene) => { + for element in &scene.elements { + let n: usize = element + .meshes + .primitives + .iter() + .map(|p| p.triangles.len()) + .sum(); + *triangles.entry(element.identity.element_id).or_default() += n; + } + } + Err(e) => eprintln!("saved graphics not read: {e}"), + } + + for element in elements { + let type_id = type_of(element); + let family = element.fields.iter().find_map(|(name, value)| match value { + InstanceField::String(text) if name == FAMILY_NAME_FIELD => Some(text.as_str()), + _ => None, + }); + let materials = match type_id { + None => "?".to_string(), + Some(id) => maps.get(&id).map_or("-".into(), |m| m.len().to_string()), + }; + let unset_map = if type_id.is_some_and(|id| unset.contains(&id)) { + "u" + } else { + "-" + }; + let saved = element + .id + .and_then(|id| triangles.get(&u64::from(id))) + .filter(|n| **n > 0) + .map_or("-".to_string(), |n| n.to_string()); + let shows = type_id + .and_then(|id| family_of.get(&u64::from(id))) + .and_then(|family| forms.get(family)) + .map_or("-", |forms| match forms.shows_unset { + Some(true) => "t", + Some(false) => "f", + None => "?", + }); + let native = type_id + .and_then(|id| native_map.get(&u64::from(id))) + .map_or("-".to_string(), |(n, unset)| format!("{n}/{unset}")); + println!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + element.id.map_or(String::new(), |id| id.to_string()), + element.class, + type_id.map_or(String::new(), |id| id.to_string()), + materials, + family.unwrap_or(""), + unset_map, + saved, + shows, + native, + ); + } + Ok(()) +} diff --git a/reports/element-framing/RE-299-native-map-void-only-doors.md b/reports/element-framing/RE-299-native-map-void-only-doors.md new file mode 100644 index 000000000..8c3fd0898 --- /dev/null +++ b/reports/element-framing/RE-299-native-map-void-only-doors.md @@ -0,0 +1,54 @@ +# RE-299 — A door or window only cuts its host where its type's native map is empty + +**Date:** 2026-11-02 +**Issues:** #1247 (73 doors of a Revit 2024 project written as `IfcOpeningElement`), #309, #227; refines RE-84 +**Probe:** `probes/examples/probe_re299_unset_map_doors.rs`; scoring `tools/re/void_only_vs_ifc.py` (now counts the probe's further columns) +**Run:** Measure [41538550670](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41538550670), the probe at 1fcad647 (then named `probe_re298_unset_map_doors`, the same code), every reference model, the BIM4LCA office, puzzbobb's R2025 models, Autodesk's 36 sample projects and the held-out models +**Result:** positive. RE-84 decides that a door's or window's type draws no geometry from a byte scan of the type's value block, which misses a map whose values it cannot name. The type's native `m_geomTag2MaterialId` is the map itself: it is empty on every door and window Revit exports as its opening alone, so a type whose native map holds entries is no longer taken as drawing nothing. + +## 1. The miss + +RE-84 writes a hosted door or window as its opening alone where its type has a parameter value block and RE-82's scan of it finds no geometry-material map. The scan (`partition_type_materials::map_at`) takes a map only where every value is either unset or a material whose name `scan_material_names` read, and at least one is a material. Two kinds of map fail that and leave the type with an empty list, which `attach_type_materials` reads as "draws no geometry": + +- a map whose every value is unset (−1): a family whose geometry takes no material of its own, as one drawn entirely in its category's or subcategories' materials does (RE-149); +- a map naming a material the name scan dropped, as it drops one whose copies in different partitions hold different names. + +RE-149 already lifts the flag from columns and products whose block opens with an all-unset map (`unset_material_types`), and gives them ``. Doors and windows were left out of that on purpose, and the probe shows why that byte signal cannot decide them either: it fires on RE1's CasedOpening and on Snowdon Towers' Door-Opening, which Revit exports as openings. + +## 2. Measured + +`probe_re299_unset_map_doors` prints every door and window record with its type's scanned map count (RE-84's column), whether the type's block opens with an all-unset map, its saved-graphics triangles, its family's `shows_unset` (RE-204) and its native map as entries and unset entries. Every door and window whose type the scan reads as having no map: + +| file | elements | Revit's export | all-unset block (RE-149) | saved graphics | native map | +|---|---:|---|---|---|---| +| RE1 Architecture (2025): CasedOpening | 1 | opening only | yes | none | 0 entries | +| Snowdon Towers architectural (2024): Door-Opening | 9 | opening only (RE-84, local export) | yes | none | 0 entries | +| Snowdon Towers architectural (2024): Schematic Opening Cut | 38 | opening only (RE-84, local export) | no | none | 0 entries | + +Snowdon Towers is read here as one of Autodesk's sample projects (`research/autodesk-sample-projects.tsv`); Revit's export of it is local only, and RE-84 measured these 47 against it. No other door or window on Core Interior (138), the office as built (537) and at permit (533), RE1's four models, the R2025 models or the other 35 sample projects has a type the scan reads as having no map, so none is moved: every one of them is exported as the element, as in Revit's exports where there is one (Core Interior 138 of 138, office 537 of 537 and 533 of 533, R2025 24 of 24, RE1 Architecture 5 of 5). + +The native map holds entries on every door and window type Revit exports as the element (Core Interior's 138, all naming materials only; the office's 431 and 425 that the scan leaves out, whose maps mix materials and unset values), and on none whose instances it exports as openings. The saved graphics agree: none of the 48 openings-only has any. + +## 3. What rvt-rs does + +`refine_type_materials` already reads each column, door, window and product type's native record (RE-203); `native_type_materials::geometry_maps` now also returns the types whose `m_geomTag2MaterialId` holds any entry, with whether every value is −1 and the type's family. A type among them that the scan left with an empty list is dropped from the list instead, so it is a type whose materials are not read rather than one that draws nothing. Its doors and windows are written as the elements, sized and named as other doors and windows are (the flag also gated RE-215's cut-loop sizes, the opening-index sizes and RE-154's type name), with no type material: what Revit's export gives such a door or window is not measured, so nothing is written for it. + +One case keeps the flag: a map whose every value is −1, of a family whose own document shows none of that geometry (`native_family_forms`, RE-204: every such form hidden or void, or no form and no nested instance). Such a type draws nothing visible, as the scan read it. No measured file holds one. Where the native record or the family is not read, the scan decides, as before. + +Every export of the corpora above is unchanged: the 48 openings-only keep an empty native map, and no other door or window had the flag. The positive case is #1247's project, which is not public. + +## 4. Not claimed + +- What material Revit's export gives a door or window whose map is all unset (RE-149 measured `` on products only). +- Whether #1247's 73 doors have entries in their native map: the reporter's run of the probe decides it. +- The all −1, hidden-or-void case of §3 is reasoned from RE-204, not measured: no file in the corpora has such a door or window type. + +## 5. Reproduce + +```bash +gh workflow run measure.yml --ref research/1247-unset-map-doors -f ref=1fcad647 -f base=none \ + -f probe=probe_re298_unset_map_doors -f probe_scorer=void_only_vs_ifc \ + -f nordic=true -f r2025=true -f samples=true -f holdout=true -f scorers=none +``` + +Where the branch is gone, push 1fcad647 to a branch first. The probe has the name `probe_re299_unset_map_doors` from 43bcb6b5 on; once this lands, `--ref main` carries the scorer and `-f ref=` the probe. diff --git a/reports/element-framing/RE-84-void-only-doors-windows.md b/reports/element-framing/RE-84-void-only-doors-windows.md index 1702cc4f5..9a7d4c860 100644 --- a/reports/element-framing/RE-84-void-only-doors-windows.md +++ b/reports/element-framing/RE-84-void-only-doors-windows.md @@ -44,6 +44,8 @@ Shape: each such opening takes #227's cut (RE-83), the element's box across and The 11 Snowdon elements left are void-only doors and windows RE-23 does not bind to a host: their host wall is in their reference list but not in the slot before their own ElementId (#439). Without a host there is no wall to cut, so they stay the element for now. +Update 2026-11-02 (RE-299): the scan misses a map whose values are all unset or name a material whose name it did not read, and so read such a type as drawing nothing; the type's native `m_geomTag2MaterialId` now overrides it where it holds entries. It is empty on all 48 void-only doors and windows above. + Update 2026-09-27 (RE-85): with hosts read from anywhere in the reference list, all 47 Snowdon ones are openings voiding the wall Revit's opening voids, and no door or window is left that Revit does not export. ## Not claimed diff --git a/src/research/native_type_materials.rs b/src/research/native_type_materials.rs index 2580d51eb..3dee0d5fb 100644 --- a/src/research/native_type_materials.rs +++ b/src/research/native_type_materials.rs @@ -36,9 +36,31 @@ pub fn geometry_materials( types: &BTreeSet, materials: &BTreeSet, ) -> anyhow::Result> { + Ok(geometry_maps(rf, types, materials)?.0) +} + +/// A type's `m_geomTag2MaterialId` that holds entries, whatever its values. +#[derive(Clone, Debug, Default)] +pub struct GeometryMap { + /// Every value is −1: no geometry has a material of its own. + pub all_unset: bool, + /// The type's family (`m_familyId`). + pub family: Option, +} + +/// [`geometry_materials`], and each type of `types` whose +/// `m_geomTag2MaterialId` holds any entry, whatever its values: a type with +/// geometry, though its map may name no material (every value −1, RE-149) +/// or only materials not among `materials` (RE-299). +pub fn geometry_maps( + rf: &mut RevitFile, + types: &BTreeSet, + materials: &BTreeSet, +) -> anyhow::Result<(BTreeMap, BTreeMap)> { let mut out = BTreeMap::new(); + let mut mapped = BTreeMap::new(); if types.is_empty() { - return Ok(out); + return Ok((out, mapped)); } let options = native_document::Options { selected_ids: types.clone(), @@ -63,6 +85,21 @@ pub fn geometry_materials( family: root.fields.get("m_familyId").and_then(element_id), ..GeometryMaterials::default() }; + if !map.is_empty() { + let all_unset = map.iter().all(|entry| { + entry + .pointer("/second/m_id/m_id64") + .and_then(serde_json::Value::as_i64) + == Some(-1) + }); + mapped.insert( + record.identity.element_id, + GeometryMap { + all_unset, + family: found.family, + }, + ); + } for entry in map { let Some(id) = entry .pointer("/second/m_id/m_id64") @@ -84,5 +121,5 @@ pub fn geometry_materials( } Ok(()) })?; - Ok(out) + Ok((out, mapped)) } diff --git a/src/research/partition_schema_mvp/materials.rs b/src/research/partition_schema_mvp/materials.rs index 480709688..8e914df0d 100644 --- a/src/research/partition_schema_mvp/materials.rs +++ b/src/research/partition_schema_mvp/materials.rs @@ -38,8 +38,10 @@ pub(super) fn attach_type_materials( /// name the same set. Returns the replaced types that also map geometry to /// no material that their family shows ([`crate::native_family_forms`]), /// whose instances take their own material parameters' or -/// `` for it ([`attach_partly_unset_materials`]). Left as scanned -/// where the native record path or the material names are not read. +/// `` for it ([`attach_partly_unset_materials`]). A type the scan +/// found no map for whose native map holds geometry loses its empty list +/// (RE-299). Left as scanned where the native record path or the material +/// names are not read. pub(super) fn refine_type_materials( rf: &mut RevitFile, revit_version: u32, @@ -74,14 +76,37 @@ pub(super) fn refine_type_materials( return partly_unset; }; let materials: BTreeSet = names.keys().copied().collect(); - let Ok(found) = crate::native_type_materials::geometry_materials(rf, &types, &materials) else { + let Ok((found, mapped)) = crate::native_type_materials::geometry_maps(rf, &types, &materials) + else { return partly_unset; }; // A family whose geometry with no material is all hidden or void has // no `` in Revit's export (RE-204), and a material only its // hidden forms have is not exported (RE-205). - let families: BTreeSet = found.values().filter_map(|found| found.family).collect(); + let families: BTreeSet = found + .values() + .filter_map(|found| found.family) + .chain(mapped.values().filter_map(|map| map.family)) + .collect(); let forms = crate::native_family_forms::family_forms(rf, &families).unwrap_or_default(); + // RE-299: a type whose native map holds entries has geometry, though the + // scan read no map in its block, every value being −1 or a material whose + // name it did not read. Left with no list it would read as a type that + // draws nothing, whose hosted doors and windows are written as their + // openings alone (RE-84). One whose map is all −1 and whose family shows + // none of that geometry (RE-204: every such form hidden or void, or none) + // still draws nothing, and keeps the empty list. + let draws = |type_id: u32| { + mapped.get(&u64::from(type_id)).is_some_and(|map| { + !map.all_unset + || map + .family + .and_then(|family| forms.get(&family)) + .and_then(|forms| forms.shows_unset) + != Some(false) + }) + }; + type_materials.retain(|type_id, list| !list.is_empty() || !draws(*type_id)); for (type_id, found) in found { let Ok(type_id) = u32::try_from(type_id) else { continue; diff --git a/tools/re/void_only_vs_ifc.py b/tools/re/void_only_vs_ifc.py index 4c5538d42..7bcf77222 100644 --- a/tools/re/void_only_vs_ifc.py +++ b/tools/re/void_only_vs_ifc.py @@ -14,7 +14,11 @@ It prints the count of each outcome for types with a map (`n`), types with a value block and no map (`0`), types with no value block (`-`) and elements -with no type (`?`). +with no type (`?`). Where the probe prints more columns +(`probe_re299_unset_map_doors`: all-unset map, saved triangles, family +forms, native map), it also prints the counts of each map count by those +columns, the saved triangles as `mesh` or `-` and the native map as +`none`, `unset` (every entry unset), `mixed` or `named` (no entry unset). Usage: @@ -48,6 +52,7 @@ def main(argv): o.Tag for o in f.by_type("IfcOpeningElement") if o.Tag and o.id() not in filled } table = collections.Counter() + extra = collections.Counter() rows = [] for line in open(args[0], encoding="utf-8"): parts = line.rstrip("\n").split("\t") @@ -63,11 +68,22 @@ def main(argv): else: outcome = "absent" table[(materials, outcome)] += 1 - rows.append((materials, outcome, tag, cls, family)) + more = parts[5:9] + if more: + if len(more) > 1: + more[1] = "-" if more[1] == "-" else "mesh" + if len(more) > 3 and more[3] != "-": + n, unset = (int(x) for x in more[3].split("/")) + more[3] = "none" if n == 0 else "unset" if unset == n else "mixed" if unset else "named" + extra[(materials, " ".join(more), outcome)] += 1 + rows.append((materials, outcome, tag, cls, family, *parts[5:9])) for materials in ("n", "0", "-", "?"): counts = {o: table[(materials, o)] for o in ("element", "opening only", "absent")} if any(counts.values()): print(f"type map materials {materials}: {counts}") + for key in sorted({(m, k) for m, k, _ in extra}): + counts = {o: extra[(*key, o)] for o in ("element", "opening only", "absent")} + print(f"type map materials {key[0]}, unset map / saved / forms / native map {key[1]}: {counts}") if listing: for row in sorted(rows): if row[0] != "n" or row[1] != "element":