From fb923e4b366d9c5858a66c5aea069f9295e6c302 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:02:17 -0500 Subject: [PATCH 01/31] research(rooms): a probe naming what is missing from each room solid that does not close on its box (#1260) (RE-316) --- .../examples/probe_re316_room_solid_breaks.rs | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 probes/examples/probe_re316_room_solid_breaks.rs diff --git a/probes/examples/probe_re316_room_solid_breaks.rs b/probes/examples/probe_re316_room_solid_breaks.rs new file mode 100644 index 00000000..bab10a64 --- /dev/null +++ b/probes/examples/probe_re316_room_solid_breaks.rs @@ -0,0 +1,430 @@ +//! RE-316: why a room's stored solid does not close on its record box. +//! +//! FACT under test: for each room whose own partition holds a solid (RE-101) +//! and whose wall faces do not close into an outline spanning the record box, +//! the probe says what is missing: faces the walk from the first one never +//! reached, vertical faces whose frame is not axis-aligned (no plan edge), faces +//! that stop short of the floor, edges shorter than the vertex tolerance, ends +//! that meet no other end, ends that lie on the inside of another edge, or loops +//! that close on a different extent than the box. +//! +//! Output, one block per room: `room box ... flags [...]`, the +//! solid's header and tag census, one `face` line per face of the walk, the +//! gaps between faces that are not the usual width, the bytes after the last +//! face, the faces found beyond the walk, and the vertices that do not have +//! exactly two edges. The last line tallies the flag sets. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_room_solid_breaks -- MODEL.rvt + +use std::collections::BTreeMap; + +use rvt::RevitFile; +use rvt::research::partition_room_boundaries::{ + BOX_TOLERANCE_FEET, FACE_GAP, FACE_TAG, FaceKind, ROOM_SOLID_TAG, ROOM_SOLID_WINDOW, + ROOM_VERTEX_TOLERANCE_FEET, RoomFace, face_at, room_outline, solid_headers, +}; +use rvt::walker::InstanceField; + +const FACE_LEN: usize = 158; +const MAX_FACE_LINES: usize = 60; +const MAX_VERTEX_LINES: usize = 16; +const MAX_ORPHAN_LINES: usize = 12; + +fn solid_start(buf: &[u8], header: usize) -> Option { + let after_id = header.checked_add(8)?; + if buf.get(after_id..after_id + 4)? == ROOM_SOLID_TAG { + return Some(after_id + 4); + } + (buf.get(after_id..after_id + 8)? == [0xff, 0xff, 0xff, 0xff, 0x04, 0x90, 0x08, 0x00]) + .then_some(after_id + 8) +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" ") +} + +fn chain(buf: &[u8], start: usize) -> Vec<(usize, RoomFace)> { + let end = buf.len().min(start + ROOM_SOLID_WINDOW); + let Some(window) = buf.get(start..end) else { + return Vec::new(); + }; + let Some(first) = memchr::memmem::find_iter(window, &FACE_TAG) + .map(|offset| start + offset) + .find(|&at| face_at(buf, at).is_some()) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut at = first; + while let Some(face) = face_at(buf, at) { + out.push((at, face)); + let from = at + FACE_TAG.len(); + let Some(window) = buf.get(from..buf.len().min(at + FACE_GAP)) else { + break; + }; + let Some(next) = memchr::memmem::find_iter(window, &FACE_TAG) + .map(|offset| from + offset) + .find(|&next| face_at(buf, next).is_some()) + else { + break; + }; + at = next; + } + out +} + +fn intern(points: &mut Vec<(f64, f64)>, degree: &mut Vec, p: (f64, f64)) -> usize { + let found = points.iter().position(|q| { + (q.0 - p.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET + && (q.1 - p.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET + }); + match found { + Some(index) => index, + None => { + points.push(p); + degree.push(0); + points.len() - 1 + } + } +} + +fn distance_to_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> (f64, f64) { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { + (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + let (qx, qy) = (a.0 + t * dx, a.1 + t * dy); + (((p.0 - qx).powi(2) + (p.1 - qy).powi(2)).sqrt(), t) +} + +fn kind_text(face: &RoomFace) -> String { + match face.kind() { + FaceKind::Horizontal(z) => format!("H z {z:.4}"), + FaceKind::Vertical { low, high } => match face.plan_edge() { + Some(([x0, y0, x1, y1], bottom)) => format!( + "V ({x0:.4},{y0:.4})-({x1:.4},{y1:.4}) low {low:.4} high {high:.4} bottom {bottom:.4}" + ), + None => format!( + "V-NOEDGE low {low:.4} high {high:.4} u {:.4?} v {:.4?}", + face.u, face.v + ), + }, + FaceKind::Other => format!("O u {:.4?} v {:.4?}", face.u, face.v), + } +} + +fn describe(buf: &[u8], header: usize, bbox: [f64; 6]) -> Vec { + let mut out = Vec::new(); + let Some(start) = solid_start(buf, header) else { + out.push(" solid header unreadable".into()); + return out; + }; + let walk = chain(buf, start); + let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); + let next_solid = memchr::memmem::find(&buf[start..window_end], &ROOM_SOLID_TAG) + .map_or(window_end, |offset| start + offset); + let all_tags: Vec = memchr::memmem::find_iter(&buf[start..window_end], &FACE_TAG) + .map(|offset| start + offset) + .collect(); + let valid_tags: Vec = all_tags + .iter() + .copied() + .filter(|&at| face_at(buf, at).is_some()) + .collect(); + let first_at = walk.first().map_or(0, |(at, _)| *at); + let last_at = walk.last().map_or(0, |(at, _)| *at); + out.push(format!( + " solid header {header} start {start} first face +{} last face +{} next solid tag +{} tags {} valid {} walk {}", + first_at.wrapping_sub(start), + last_at.wrapping_sub(start), + next_solid - start, + all_tags.len(), + valid_tags.len(), + walk.len() + )); + let mut previous = None; + for (index, (at, face)) in walk.iter().enumerate() { + let gap = previous.map_or(0, |p| at - p); + if index < MAX_FACE_LINES { + out.push(format!( + " face {index} @{} gap {gap} uv {:.4?} {}", + at - start, + face.uv_box, + kind_text(face) + )); + } + if previous.is_some() && ![170, 174, 206].contains(&gap) { + let from = previous.unwrap_or(0) + FACE_LEN; + let upto = (*at).min(from + 96); + if let Some(bytes) = buf.get(from..upto) { + out.push(format!( + " gap-bytes before face {index} ({gap}): {}", + hex(bytes) + )); + } + } + previous = Some(*at); + } + if walk.len() > MAX_FACE_LINES { + out.push(format!(" ... {} faces more", walk.len() - MAX_FACE_LINES)); + } + if let Some(bytes) = buf.get(last_at + FACE_LEN..buf.len().min(last_at + FACE_LEN + 112)) { + out.push(format!(" after last face: {}", hex(bytes))); + } + let orphans: Vec = valid_tags + .iter() + .copied() + .filter(|&at| at > last_at) + .collect(); + for &at in orphans.iter().take(MAX_ORPHAN_LINES) { + let kind = face_at(buf, at).map_or_else(String::new, |face| kind_text(&face)); + out.push(format!( + " beyond the walk: face @{} ({} past the last) {kind}", + at - start, + at - last_at + )); + } + if orphans.len() > MAX_ORPHAN_LINES { + out.push(format!( + " ... {} faces beyond the walk more", + orphans.len() - MAX_ORPHAN_LINES + )); + } + let floor = bbox[2]; + let mut lines: Vec<[f64; 4]> = Vec::new(); + let (mut short_walls, mut no_edge, mut other, mut horizontal) = (0, 0, 0, 0); + for (_, face) in &walk { + match face.kind() { + FaceKind::Horizontal(_) => horizontal += 1, + FaceKind::Other => other += 1, + FaceKind::Vertical { .. } => match face.plan_edge() { + Some((edge, bottom)) => { + if (bottom - floor).abs() <= BOX_TOLERANCE_FEET { + lines.push(edge); + } else { + short_walls += 1; + } + } + None => no_edge += 1, + }, + } + } + let mut points: Vec<(f64, f64)> = Vec::new(); + let mut degree: Vec = Vec::new(); + let mut edges: Vec<(usize, usize)> = Vec::new(); + let mut collapsed = 0; + for &[x0, y0, x1, y1] in &lines { + let a = intern(&mut points, &mut degree, (x0, y0)); + let b = intern(&mut points, &mut degree, (x1, y1)); + if a == b { + collapsed += 1; + continue; + } + degree[a] += 1; + degree[b] += 1; + edges.push((a, b)); + } + let mut duplicates = 0; + for (index, &(a, b)) in edges.iter().enumerate() { + if edges[..index] + .iter() + .any(|&(c, d)| (a, b) == (c, d) || (a, b) == (d, c)) + { + duplicates += 1; + } + } + let mut histogram: BTreeMap = BTreeMap::new(); + for &d in °ree { + *histogram.entry(d).or_default() += 1; + } + let mut open = 0; + let mut on_edge = 0; + let mut shown = 0; + for (index, &d) in degree.iter().enumerate() { + if d == 2 { + continue; + } + open += 1; + let p = points[index]; + let mut note = String::new(); + for &(a, b) in &edges { + if a == index || b == index { + continue; + } + let (distance, t) = distance_to_segment(p, points[a], points[b]); + if distance <= 1e-3 && t > 0.0 && t < 1.0 { + on_edge += 1; + note = format!( + " on the inside of edge ({:.4},{:.4})-({:.4},{:.4}) at t {t:.5}", + points[a].0, points[a].1, points[b].0, points[b].1 + ); + break; + } + } + if shown < MAX_VERTEX_LINES { + out.push(format!(" vertex ({:.4},{:.4}) edges {d}{note}", p.0, p.1)); + shown += 1; + } + } + let (mut min_x, mut min_y, mut max_x, mut max_y) = ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + for &(x, y) in &points { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + let extent_gap = [ + min_x - bbox[0], + min_y - bbox[1], + max_x - bbox[3], + max_y - bbox[4], + ]; + let mut flags = Vec::new(); + if lines.is_empty() { + flags.push("no-wall-edges"); + } + if walk.is_empty() { + flags.push("no-faces"); + } + if no_edge > 0 { + flags.push("vertical-without-axis-frame"); + } + if !orphans.is_empty() { + flags.push("faces-beyond-walk"); + } + if short_walls > 0 { + flags.push("walls-short-of-floor"); + } + if other > 0 { + flags.push("non-vertical-non-horizontal-faces"); + } + if collapsed > 0 { + flags.push("edges-shorter-than-tolerance"); + } + if duplicates > 0 { + flags.push("duplicate-edges"); + } + if open > 0 { + flags.push("ends-not-paired"); + } + if on_edge > 0 { + flags.push("end-inside-another-edge"); + } + if open == 0 && !lines.is_empty() { + flags.push("degrees-all-two"); + if extent_gap.iter().any(|g| g.abs() > BOX_TOLERANCE_FEET) { + flags.push("extent-differs-from-box"); + } + } + out.push(format!( + " walls {} (short of floor {short_walls}, no axis frame {no_edge}), horizontal {horizontal}, other {other}; edges {}, collapsed {collapsed}, duplicates {duplicates}; degrees {histogram:?}; open {open}, on an edge's inside {on_edge}; extent minus box {:.4?}", + lines.len(), + edges.len(), + extent_gap + )); + out.push(format!(" FLAGS {}", flags.join(","))); + out +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut tally: BTreeMap = BTreeMap::new(); + let mut kept = 0; + let mut absent = 0; + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + if room_outline(&mut rf, &stream, id, bbox)?.is_some() { + kept += 1; + continue; + } + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + let headers = solid_headers(buf, id); + if headers.is_empty() { + absent += 1; + continue; + } + println!( + "room {id} {stream} box [{:.4}, {:.4}, {:.4}, {:.4}, {:.4}, {:.4}] size {dx:.3} x {dy:.3} x {dz:.3} solids {}", + bbox[0], + bbox[1], + bbox[2], + bbox[3], + bbox[4], + bbox[5], + headers.len() + ); + let mut first_flags = String::new(); + for (index, &header) in headers.iter().enumerate() { + let block = describe(buf, header, bbox); + if index == 0 { + first_flags = block + .iter() + .find_map(|line| line.trim().strip_prefix("FLAGS ").map(str::to_string)) + .unwrap_or_default(); + } + for line in block { + println!("{line}"); + } + } + *tally.entry(first_flags).or_default() += 1; + } + println!( + "release {version}: {} rooms, {kept} outlines kept, {absent} with no solid in their partition, flag sets of the rest: {tally:#?}", + rooms.len() + ); + Ok(()) +} From b8d29ef83f5a7525cfa6175540fba8a2bfec3fa5 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:21:34 -0500 Subject: [PATCH 02/31] research(rooms): the break probe reads each unclosed solid by chain, span and anchor and says which closes (#1260) (RE-316) --- .../examples/probe_re316_room_solid_breaks.rs | 551 +++++++++++------- 1 file changed, 336 insertions(+), 215 deletions(-) diff --git a/probes/examples/probe_re316_room_solid_breaks.rs b/probes/examples/probe_re316_room_solid_breaks.rs index bab10a64..79c7c904 100644 --- a/probes/examples/probe_re316_room_solid_breaks.rs +++ b/probes/examples/probe_re316_room_solid_breaks.rs @@ -2,17 +2,21 @@ //! //! FACT under test: for each room whose own partition holds a solid (RE-101) //! and whose wall faces do not close into an outline spanning the record box, -//! the probe says what is missing: faces the walk from the first one never -//! reached, vertical faces whose frame is not axis-aligned (no plan edge), faces -//! that stop short of the floor, edges shorter than the vertex tolerance, ends -//! that meet no other end, ends that lie on the inside of another edge, or loops -//! that close on a different extent than the box. +//! the probe says what is missing. It reads the solid three ways and says for +//! each whether the plan edges close into an outline on the record box: //! -//! Output, one block per room: `room box ... flags [...]`, the -//! solid's header and tag census, one `face` line per face of the walk, the -//! gaps between faces that are not the usual width, the bytes after the last -//! face, the faces found beyond the walk, and the vertices that do not have -//! exactly two edges. The last line tallies the flag sets. +//! - `chain`: the faces RE-101 reads, each within `FACE_GAP` bytes of the one +//! before, and the faces that reach the box's floor; +//! - `span-floor`: every face between the first one and the next solid's tag, +//! whatever the gap, that reaches the floor and lies inside the box; +//! - `span-ceiling`: the same, for the faces that reach the box's ceiling; +//! - `span-any`: every vertical face, whatever its heights. +//! +//! Output, one block per room: the solid's header and tag census, one `face` +//! line per face of the chain, the gaps between faces that are not the usual +//! width, the faces beyond the chain and before the next solid, the vertices +//! that do not have exactly two edges and the loops' containment, then one +//! `variant` line for each reading. The last line tallies the verdicts. //! //! Usage: //! cargo run --profile ci -p rvt-probes --example probe_re316_room_solid_breaks -- MODEL.rvt @@ -20,6 +24,7 @@ use std::collections::BTreeMap; use rvt::RevitFile; +use rvt::research::element_record_plan_profiles::{inside, plan_profile_from_lines}; use rvt::research::partition_room_boundaries::{ BOX_TOLERANCE_FEET, FACE_GAP, FACE_TAG, FaceKind, ROOM_SOLID_TAG, ROOM_SOLID_WINDOW, ROOM_VERTEX_TOLERANCE_FEET, RoomFace, face_at, room_outline, solid_headers, @@ -27,9 +32,18 @@ use rvt::research::partition_room_boundaries::{ use rvt::walker::InstanceField; const FACE_LEN: usize = 158; -const MAX_FACE_LINES: usize = 60; -const MAX_VERTEX_LINES: usize = 16; -const MAX_ORPHAN_LINES: usize = 12; +const MAX_FACE_LINES: usize = 40; +const MAX_VERTEX_LINES: usize = 12; +const MAX_ORPHAN_LINES: usize = 16; + +type Faces = Vec<(usize, RoomFace)>; + +#[derive(Clone, Copy, PartialEq)] +enum Anchor { + Floor, + Ceiling, + Any, +} fn solid_start(buf: &[u8], header: usize) -> Option { let after_id = header.checked_add(8)?; @@ -48,7 +62,7 @@ fn hex(bytes: &[u8]) -> String { .join(" ") } -fn chain(buf: &[u8], start: usize) -> Vec<(usize, RoomFace)> { +fn chain(buf: &[u8], start: usize) -> Faces { let end = buf.len().min(start + ROOM_SOLID_WINDOW); let Some(window) = buf.get(start..end) else { return Vec::new(); @@ -78,21 +92,93 @@ fn chain(buf: &[u8], start: usize) -> Vec<(usize, RoomFace)> { out } -fn intern(points: &mut Vec<(f64, f64)>, degree: &mut Vec, p: (f64, f64)) -> usize { - let found = points.iter().position(|q| { - (q.0 - p.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET - && (q.1 - p.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET - }); - match found { - Some(index) => index, - None => { - points.push(p); - degree.push(0); - points.len() - 1 +/// Every valid face from `start` to the next solid's tag, and where that is. +fn span(buf: &[u8], start: usize) -> (Faces, usize) { + let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); + let Some(window) = buf.get(start..window_end) else { + return (Vec::new(), start); + }; + let end = memchr::memmem::find(window, &ROOM_SOLID_TAG).map_or(window_end, |o| start + o); + let faces = memchr::memmem::find_iter(&buf[start..end], &FACE_TAG) + .map(|offset| start + offset) + .filter_map(|at| face_at(buf, at).map(|face| (at, face))) + .collect(); + (faces, end) +} + +fn kind_text(face: &RoomFace) -> String { + match face.kind() { + FaceKind::Horizontal(z) => format!( + "H z {z:.4} u {:.3?} v {:.3?} origin {:.3?}", + face.u, face.v, face.origin + ), + FaceKind::Vertical { low, high } => match face.plan_edge() { + Some(([x0, y0, x1, y1], bottom)) => format!( + "V ({x0:.4},{y0:.4})-({x1:.4},{y1:.4}) low {low:.4} high {high:.4} bottom {bottom:.4}" + ), + None => format!( + "V-NOEDGE low {low:.4} high {high:.4} u {:.4?} v {:.4?}", + face.u, face.v + ), + }, + FaceKind::Other => format!("O u {:.4?} v {:.4?}", face.u, face.v), + } +} + +fn snap(lines: &mut [[f64; 4]]) { + let mut held: Vec<(f64, f64)> = Vec::new(); + for line in lines.iter_mut() { + for end in [0, 2] { + let point = (line[end], line[end + 1]); + let near = held.iter().copied().find(|q| { + (q.0 - point.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET + && (q.1 - point.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET + }); + match near { + Some(q) => (line[end], line[end + 1]) = q, + None => held.push(point), + } } } } +struct Graph { + points: Vec<(f64, f64)>, + degree: Vec, + edges: Vec<(usize, usize)>, + collapsed: usize, +} + +fn graph(lines: &[[f64; 4]]) -> Graph { + let mut g = Graph { + points: Vec::new(), + degree: Vec::new(), + edges: Vec::new(), + collapsed: 0, + }; + for &[x0, y0, x1, y1] in lines { + let mut ends = [0usize; 2]; + for (slot, p) in ends.iter_mut().zip([(x0, y0), (x1, y1)]) { + *slot = match g.points.iter().position(|q| *q == p) { + Some(index) => index, + None => { + g.points.push(p); + g.degree.push(0); + g.points.len() - 1 + } + }; + } + if ends[0] == ends[1] { + g.collapsed += 1; + continue; + } + g.degree[ends[0]] += 1; + g.degree[ends[1]] += 1; + g.edges.push((ends[0], ends[1])); + } + g +} + fn distance_to_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> (f64, f64) { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let len2 = dx * dx + dy * dy; @@ -105,51 +191,171 @@ fn distance_to_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> (f64, f64 (((p.0 - qx).powi(2) + (p.1 - qy).powi(2)).sqrt(), t) } -fn kind_text(face: &RoomFace) -> String { - match face.kind() { - FaceKind::Horizontal(z) => format!("H z {z:.4}"), - FaceKind::Vertical { low, high } => match face.plan_edge() { - Some(([x0, y0, x1, y1], bottom)) => format!( - "V ({x0:.4},{y0:.4})-({x1:.4},{y1:.4}) low {low:.4} high {high:.4} bottom {bottom:.4}" - ), - None => format!( - "V-NOEDGE low {low:.4} high {high:.4} u {:.4?} v {:.4?}", - face.u, face.v - ), - }, - FaceKind::Other => format!("O u {:.4?} v {:.4?}", face.u, face.v), +#[allow(clippy::needless_range_loop)] +fn loops(g: &Graph) -> Vec> { + let mut at: Vec> = vec![Vec::new(); g.points.len()]; + for (index, &(a, b)) in g.edges.iter().enumerate() { + at[a].push(index); + at[b].push(index); + } + let mut used = vec![false; g.edges.len()]; + let mut out = Vec::new(); + for first in 0..g.edges.len() { + if used[first] { + continue; + } + used[first] = true; + let (start, mut current) = g.edges[first]; + let mut ring = vec![g.points[start]]; + while current != start { + ring.push(g.points[current]); + let Some(next) = at[current].iter().copied().find(|&e| !used[e]) else { + break; + }; + used[next] = true; + let (a, b) = g.edges[next]; + current = if a == current { b } else { a }; + } + out.push(ring); + } + out +} + +fn ring_area(ring: &[(f64, f64)]) -> f64 { + let n = ring.len(); + (0..n) + .map(|i| { + let (a, b) = (ring[i], ring[(i + 1) % n]); + a.0 * b.1 - b.0 * a.1 + }) + .sum::() + .abs() + / 2.0 +} + +/// The plan edges of `faces` under `anchor`, inside `bbox`, snapped. +fn lines_of(faces: &Faces, bbox: [f64; 6], anchor: Anchor, inside_box: bool) -> Vec<[f64; 4]> { + let tol = BOX_TOLERANCE_FEET; + let mut lines = Vec::new(); + for (_, face) in faces { + let FaceKind::Vertical { high, .. } = face.kind() else { + continue; + }; + let Some((edge, bottom)) = face.plan_edge() else { + continue; + }; + let reaches = match anchor { + Anchor::Floor => (bottom - bbox[2]).abs() <= tol, + Anchor::Ceiling => (high - bbox[5]).abs() <= tol, + Anchor::Any => true, + }; + let within = edge[0].min(edge[2]) >= bbox[0] - tol + && edge[0].max(edge[2]) <= bbox[3] + tol + && edge[1].min(edge[3]) >= bbox[1] - tol + && edge[1].max(edge[3]) <= bbox[4] + tol; + if reaches && (within || !inside_box) { + lines.push(edge); + } + } + snap(&mut lines); + lines +} + +/// Whether the lines close into an outline on `bbox`, and what stops them. +fn verdict(lines: &[[f64; 4]], bbox: [f64; 6], detail: &mut Vec) -> String { + let g = graph(lines); + let open = g.degree.iter().filter(|&&d| d != 2).count(); + let mut histogram: BTreeMap = BTreeMap::new(); + for &d in &g.degree { + *histogram.entry(d).or_default() += 1; + } + let profile = plan_profile_from_lines(lines); + let mut text = format!( + "edges {} collapsed {} degrees {histogram:?} open {open}", + g.edges.len(), + g.collapsed + ); + if open == 0 && !g.edges.is_empty() { + let rings = loops(&g); + text.push_str(&format!(" loops {}", rings.len())); + for (i, one) in rings.iter().enumerate() { + for (j, other) in rings.iter().enumerate() { + if i == j { + continue; + } + let within = one.iter().filter(|p| inside(other, **p)).count(); + let touching = one + .iter() + .filter(|p| { + other.iter().enumerate().any(|(k, &b)| { + distance_to_segment(**p, other[(k + 1) % other.len()], b).0 <= 1e-6 + }) + }) + .count(); + if within > 0 && within < one.len() { + detail.push(format!( + " loop {i} ({} points, area {:.3}) is partly inside loop {j} ({} points, area {:.3}): {within} points in, {touching} on its boundary", + one.len(), + ring_area(one), + other.len(), + ring_area(other) + )); + } + } + } + } + match profile { + Some(profile) => { + let extent = profile.plan_extent_feet(); + let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; + let gap = extent.map(|e| { + [ + e[0] - wanted[0], + e[1] - wanted[1], + e[2] - wanted[2], + e[3] - wanted[3], + ] + }); + let holes: f64 = profile.inner_xy.iter().map(|r| ring_area(r)).sum(); + let on_box = gap.is_some_and(|d| d.iter().all(|v| v.abs() <= BOX_TOLERANCE_FEET)); + text.push_str(&format!( + " PROFILE area {:.3} extent-minus-box {:.4?} {}", + ring_area(&profile.outer_xy) - holes, + gap, + if on_box { "CLOSES" } else { "extent-differs" } + )); + if on_box { + format!("CLOSES; {text}") + } else { + format!("extent-differs; {text}") + } + } + None if open > 0 => format!("open; {text}"), + None => format!("no-profile; {text}"), } } -fn describe(buf: &[u8], header: usize, bbox: [f64; 6]) -> Vec { +fn describe(buf: &[u8], header: usize, bbox: [f64; 6]) -> (Vec, String) { let mut out = Vec::new(); let Some(start) = solid_start(buf, header) else { out.push(" solid header unreadable".into()); - return out; + return (out, "unreadable".into()); }; let walk = chain(buf, start); - let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); - let next_solid = memchr::memmem::find(&buf[start..window_end], &ROOM_SOLID_TAG) - .map_or(window_end, |offset| start + offset); - let all_tags: Vec = memchr::memmem::find_iter(&buf[start..window_end], &FACE_TAG) - .map(|offset| start + offset) - .collect(); - let valid_tags: Vec = all_tags - .iter() - .copied() - .filter(|&at| face_at(buf, at).is_some()) - .collect(); - let first_at = walk.first().map_or(0, |(at, _)| *at); + let (in_span, span_end) = span(buf, start); let last_at = walk.last().map_or(0, |(at, _)| *at); + let first_at = walk.first().map_or(0, |(at, _)| *at); out.push(format!( - " solid header {header} start {start} first face +{} last face +{} next solid tag +{} tags {} valid {} walk {}", + " solid header {header} start {start} first face +{} last face +{} next solid tag +{} faces in the chain {} in the span {}", first_at.wrapping_sub(start), last_at.wrapping_sub(start), - next_solid - start, - all_tags.len(), - valid_tags.len(), - walk.len() + span_end - start, + walk.len(), + in_span.len() )); + if let Some(bytes) = buf.get(start..start + 160) { + out.push(format!(" header words: {}", hex(bytes))); + } let mut previous = None; for (index, (at, face)) in walk.iter().enumerate() { let gap = previous.map_or(0, |p| at - p); @@ -161,9 +367,9 @@ fn describe(buf: &[u8], header: usize, bbox: [f64; 6]) -> Vec { kind_text(face) )); } - if previous.is_some() && ![170, 174, 206].contains(&gap) { + if previous.is_some() && ![170, 174, 206].contains(&gap) && index < MAX_FACE_LINES { let from = previous.unwrap_or(0) + FACE_LEN; - let upto = (*at).min(from + 96); + let upto = (*at).min(from + 64); if let Some(bytes) = buf.get(from..upto) { out.push(format!( " gap-bytes before face {index} ({gap}): {}", @@ -176,167 +382,85 @@ fn describe(buf: &[u8], header: usize, bbox: [f64; 6]) -> Vec { if walk.len() > MAX_FACE_LINES { out.push(format!(" ... {} faces more", walk.len() - MAX_FACE_LINES)); } - if let Some(bytes) = buf.get(last_at + FACE_LEN..buf.len().min(last_at + FACE_LEN + 112)) { - out.push(format!(" after last face: {}", hex(bytes))); - } - let orphans: Vec = valid_tags - .iter() - .copied() - .filter(|&at| at > last_at) - .collect(); - for &at in orphans.iter().take(MAX_ORPHAN_LINES) { - let kind = face_at(buf, at).map_or_else(String::new, |face| kind_text(&face)); + let beyond: Vec<&(usize, RoomFace)> = in_span.iter().filter(|(at, _)| *at > last_at).collect(); + for (at, face) in beyond.iter().take(MAX_ORPHAN_LINES).copied() { out.push(format!( - " beyond the walk: face @{} ({} past the last) {kind}", + " in the span beyond the chain: face @{} ({} past the last) uv {:.4?} {}", at - start, - at - last_at + at - last_at, + face.uv_box, + kind_text(face) )); } - if orphans.len() > MAX_ORPHAN_LINES { + if beyond.len() > MAX_ORPHAN_LINES { out.push(format!( - " ... {} faces beyond the walk more", - orphans.len() - MAX_ORPHAN_LINES + " ... {} faces more in the span", + beyond.len() - MAX_ORPHAN_LINES )); } - let floor = bbox[2]; - let mut lines: Vec<[f64; 4]> = Vec::new(); - let (mut short_walls, mut no_edge, mut other, mut horizontal) = (0, 0, 0, 0); - for (_, face) in &walk { - match face.kind() { - FaceKind::Horizontal(_) => horizontal += 1, - FaceKind::Other => other += 1, - FaceKind::Vertical { .. } => match face.plan_edge() { - Some((edge, bottom)) => { - if (bottom - floor).abs() <= BOX_TOLERANCE_FEET { - lines.push(edge); - } else { - short_walls += 1; - } + let heights: Vec = { + let mut seen: Vec = Vec::new(); + for (_, face) in &in_span { + if let Some((_, bottom)) = face.plan_edge() { + if !seen.iter().any(|h| (h - bottom).abs() <= 1e-3) { + seen.push(bottom); } - None => no_edge += 1, - }, - } - } - let mut points: Vec<(f64, f64)> = Vec::new(); - let mut degree: Vec = Vec::new(); - let mut edges: Vec<(usize, usize)> = Vec::new(); - let mut collapsed = 0; - for &[x0, y0, x1, y1] in &lines { - let a = intern(&mut points, &mut degree, (x0, y0)); - let b = intern(&mut points, &mut degree, (x1, y1)); - if a == b { - collapsed += 1; - continue; - } - degree[a] += 1; - degree[b] += 1; - edges.push((a, b)); - } - let mut duplicates = 0; - for (index, &(a, b)) in edges.iter().enumerate() { - if edges[..index] - .iter() - .any(|&(c, d)| (a, b) == (c, d) || (a, b) == (d, c)) - { - duplicates += 1; - } - } - let mut histogram: BTreeMap = BTreeMap::new(); - for &d in °ree { - *histogram.entry(d).or_default() += 1; - } - let mut open = 0; - let mut on_edge = 0; - let mut shown = 0; - for (index, &d) in degree.iter().enumerate() { - if d == 2 { - continue; - } - open += 1; - let p = points[index]; - let mut note = String::new(); - for &(a, b) in &edges { - if a == index || b == index { - continue; - } - let (distance, t) = distance_to_segment(p, points[a], points[b]); - if distance <= 1e-3 && t > 0.0 && t < 1.0 { - on_edge += 1; - note = format!( - " on the inside of edge ({:.4},{:.4})-({:.4},{:.4}) at t {t:.5}", - points[a].0, points[a].1, points[b].0, points[b].1 - ); - break; } } - if shown < MAX_VERTEX_LINES { - out.push(format!(" vertex ({:.4},{:.4}) edges {d}{note}", p.0, p.1)); - shown += 1; + seen.sort_by(f64::total_cmp); + seen + }; + out.push(format!( + " vertical faces' bottoms in the span: {heights:.4?}, box floor {:.4} ceiling {:.4}", + bbox[2], bbox[5] + )); + let mut detail = Vec::new(); + let chain_floor = { + let mut lines = Vec::new(); + for (_, face) in &walk { + if let Some((edge, bottom)) = face.plan_edge() { + if (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET { + lines.push(edge); + } + } } - } - let (mut min_x, mut min_y, mut max_x, mut max_y) = ( - f64::INFINITY, - f64::INFINITY, - f64::NEG_INFINITY, - f64::NEG_INFINITY, + snap(&mut lines); + lines + }; + let a = verdict(&chain_floor, bbox, &mut detail); + let b = verdict( + &lines_of(&in_span, bbox, Anchor::Floor, true), + bbox, + &mut detail, ); - for &(x, y) in &points { - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x); - max_y = max_y.max(y); - } - let extent_gap = [ - min_x - bbox[0], - min_y - bbox[1], - max_x - bbox[3], - max_y - bbox[4], - ]; - let mut flags = Vec::new(); - if lines.is_empty() { - flags.push("no-wall-edges"); - } - if walk.is_empty() { - flags.push("no-faces"); - } - if no_edge > 0 { - flags.push("vertical-without-axis-frame"); - } - if !orphans.is_empty() { - flags.push("faces-beyond-walk"); - } - if short_walls > 0 { - flags.push("walls-short-of-floor"); - } - if other > 0 { - flags.push("non-vertical-non-horizontal-faces"); - } - if collapsed > 0 { - flags.push("edges-shorter-than-tolerance"); - } - if duplicates > 0 { - flags.push("duplicate-edges"); - } - if open > 0 { - flags.push("ends-not-paired"); - } - if on_edge > 0 { - flags.push("end-inside-another-edge"); - } - if open == 0 && !lines.is_empty() { - flags.push("degrees-all-two"); - if extent_gap.iter().any(|g| g.abs() > BOX_TOLERANCE_FEET) { - flags.push("extent-differs-from-box"); - } + let c = verdict( + &lines_of(&in_span, bbox, Anchor::Ceiling, true), + bbox, + &mut detail, + ); + let d = verdict( + &lines_of(&in_span, bbox, Anchor::Any, true), + bbox, + &mut detail, + ); + for line in &detail { + out.push(line.clone()); } - out.push(format!( - " walls {} (short of floor {short_walls}, no axis frame {no_edge}), horizontal {horizontal}, other {other}; edges {}, collapsed {collapsed}, duplicates {duplicates}; degrees {histogram:?}; open {open}, on an edge's inside {on_edge}; extent minus box {:.4?}", - lines.len(), - edges.len(), - extent_gap - )); - out.push(format!(" FLAGS {}", flags.join(","))); - out + let word = |s: &str| s.split(';').next().unwrap_or("").to_string(); + out.push(format!(" variant chain-floor: {a}")); + out.push(format!(" variant span-floor: {b}")); + out.push(format!(" variant span-ceiling: {c}")); + out.push(format!(" variant span-any: {d}")); + let summary = format!( + "chain {} | span-floor {} | span-ceiling {} | span-any {}", + word(&a), + word(&b), + word(&c), + word(&d) + ); + let _ = MAX_VERTEX_LINES; + out.push(format!(" SUMMARY {summary}")); + (out, summary) } fn main() -> anyhow::Result<()> { @@ -407,23 +531,20 @@ fn main() -> anyhow::Result<()> { bbox[5], headers.len() ); - let mut first_flags = String::new(); + let mut first_summary = String::new(); for (index, &header) in headers.iter().enumerate() { - let block = describe(buf, header, bbox); + let (block, summary) = describe(buf, header, bbox); if index == 0 { - first_flags = block - .iter() - .find_map(|line| line.trim().strip_prefix("FLAGS ").map(str::to_string)) - .unwrap_or_default(); + first_summary = summary; } for line in block { println!("{line}"); } } - *tally.entry(first_flags).or_default() += 1; + *tally.entry(first_summary).or_default() += 1; } println!( - "release {version}: {} rooms, {kept} outlines kept, {absent} with no solid in their partition, flag sets of the rest: {tally:#?}", + "release {version}: {} rooms, {kept} outlines kept, {absent} with no solid in their partition, verdicts of the rest: {tally:#?}", rooms.len() ); Ok(()) From 411dc2f7ea193633c6939919ffa5b6581a4a071d Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:23:33 -0500 Subject: [PATCH 03/31] research(rooms): a probe reading the B-rep a room's native record holds and the plan edges at its floor (#1260) (RE-316) --- .../examples/probe_re316_room_native_faces.rs | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 probes/examples/probe_re316_room_native_faces.rs diff --git a/probes/examples/probe_re316_room_native_faces.rs b/probes/examples/probe_re316_room_native_faces.rs new file mode 100644 index 00000000..44be04b7 --- /dev/null +++ b/probes/examples/probe_re316_room_native_faces.rs @@ -0,0 +1,263 @@ +//! RE-316: the B-rep a room's native record holds, and the plan edges at its +//! floor. +//! +//! FACT under test: a room whose partition faces do not close into an outline +//! on its record box (RE-101) holds, in its native record, the same solid as +//! `Face`, `EdgeLoop` and `Edge` objects, and the edges that lie in the floor's +//! plane, with their ends evaluated on the plane of a face they border, close +//! into the outline. +//! +//! Output, per room that does not close: the classes the record holds, the +//! first `Face`, `Edge`, `EdgeLoop` and `Plane`, how many faces each surface +//! class has, and the plan edges at the box's floor with whether +//! `plan_profile_from_lines` closes them on the box. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_room_native_faces -- MODEL.rvt + +use std::collections::{BTreeMap, BTreeSet}; + +use rvt::RevitFile; +use rvt::research::element_record_plan_profiles::plan_profile_from_lines; +use rvt::research::native_document::{NATIVE_RELEASES, Options, extract_graphs}; +use rvt::research::native_parameters::ObjectGraph; +use rvt::research::native_saved_mesh::pointer; +use rvt::research::partition_room_boundaries::{BOX_TOLERANCE_FEET, room_outline}; +use rvt::walker::InstanceField; +use serde_json::Value; + +const FLOOR_TOLERANCE_FEET: f64 = 1e-3; + +fn short(value: &Value) -> String { + value.to_string().chars().take(1400).collect() +} + +fn triple(value: &Value) -> Option<[f64; 3]> { + let a = value.as_array()?; + Some([ + a.first()?.as_f64()?, + a.get(1)?.as_f64()?, + a.get(2)?.as_f64()?, + ]) +} + +fn pair(value: &Value) -> Option<[f64; 2]> { + let a = value.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) +} + +/// The plane `face` lies on: origin, u direction, v direction. +fn plane_of(g: &ObjectGraph, face: usize) -> Option<([f64; 3], [f64; 3], [f64; 3])> { + let surface = pointer(g, face, &g.objects.get(face)?.fields["m_pSurf"]) + .ok() + .flatten()?; + let plane = g.objects.get(surface)?; + if plane.class_name != "Plane" { + return None; + } + Some(( + triple(&plane.fields["m_origin"])?, + triple(&plane.fields["m_xVec"])?, + triple(&plane.fields["m_yVec"])?, + )) +} + +/// The edge's points on the plane of the face on `side`, model feet. +fn edge_points(g: &ObjectGraph, edge: usize, side: usize) -> Option> { + let object = g.objects.get(edge)?; + let faces = object.fields["m_pFace"].as_array()?; + let face = pointer(g, edge, faces.get(side)?).ok().flatten()?; + let (origin, x, y) = plane_of(g, face)?; + let ends = object.fields["m_firstAndLastEdgePnts"].as_array()?; + if ends.len() != 2 { + return None; + } + let mut uvs = vec![pair(&ends[0]["uv"][side])?]; + for sample in object.fields["m_interiorEdgePnts"].as_array()? { + uvs.push(pair(&sample["uv"][side])?); + } + uvs.push(pair(&ends[1]["uv"][side])?); + Some( + uvs.iter() + .map(|uv| std::array::from_fn(|k| origin[k] + uv[0] * x[k] + uv[1] * y[k])) + .collect(), + ) +} + +fn describe(g: &ObjectGraph, id: u64, bbox: [f64; 6]) { + let mut classes: BTreeMap<&str, usize> = BTreeMap::new(); + for object in &g.objects { + *classes.entry(object.class_name.as_str()).or_default() += 1; + } + println!( + " native room {id}: objects {} classes {classes:?}", + g.objects.len() + ); + for wanted in ["Face", "Edge", "EdgeLoop", "Plane", "RoomElem"] { + if let Some(object) = g.objects.iter().find(|o| o.class_name == wanted) { + println!(" first {wanted}: {}", short(&object.fields)); + } + } + let mut surfaces: BTreeMap = BTreeMap::new(); + let mut horizontal_planes: Vec = Vec::new(); + for (index, object) in g.objects.iter().enumerate() { + if object.class_name != "Face" { + continue; + } + let kind = pointer(g, index, &object.fields["m_pSurf"]) + .ok() + .flatten() + .and_then(|s| g.objects.get(s)) + .map_or("none".to_string(), |s| s.class_name.clone()); + *surfaces.entry(kind).or_default() += 1; + if let Some((origin, x, y)) = plane_of(g, index) { + let nz = x[0] * y[1] - x[1] * y[0]; + if nz.abs() > 0.5 { + horizontal_planes.push(format!("z {:.4} nz {nz:.3}", origin[2])); + } + } + } + println!(" faces by surface class {surfaces:?}; horizontal planes {horizontal_planes:?}"); + let floor = bbox[2]; + let mut lines: Vec<[f64; 4]> = Vec::new(); + let (mut edges, mut at_floor, mut unreadable) = (0, 0, 0); + for (index, object) in g.objects.iter().enumerate() { + if object.class_name != "Edge" { + continue; + } + edges += 1; + let Some(points) = (0..2).find_map(|side| edge_points(g, index, side)) else { + unreadable += 1; + continue; + }; + if points + .iter() + .all(|p| (p[2] - floor).abs() <= FLOOR_TOLERANCE_FEET) + { + at_floor += 1; + for w in points.windows(2) { + lines.push([w[0][0], w[0][1], w[1][0], w[1][1]]); + } + } + } + let mut pieces = lines.clone(); + pieces.retain(|l| (l[0] - l[2]).abs() > 1e-9 || (l[1] - l[3]).abs() > 1e-9); + println!( + " edges {edges}, at the floor {at_floor}, unreadable {unreadable}, plan segments {} ({} not degenerate)", + lines.len(), + pieces.len() + ); + match plan_profile_from_lines(&pieces) { + Some(profile) => { + let extent = profile.plan_extent_feet(); + let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; + let gap = extent.map(|e| std::array::from_fn::(|k| e[k] - wanted[k])); + let on_box = gap.is_some_and(|d| d.iter().all(|v| v.abs() <= BOX_TOLERANCE_FEET)); + let ring = |r: &[(f64, f64)]| { + let n = r.len(); + (0..n) + .map(|i| { + let (a, b) = (r[i], r[(i + 1) % n]); + a.0 * b.1 - b.0 * a.1 + }) + .sum::() + .abs() + / 2.0 + }; + let holes: f64 = profile.inner_xy.iter().map(|r| ring(r)).sum(); + println!( + " FLOOR-EDGES {}: area {:.3} (box {:.3}) holes {} pieces {} extent-minus-box {gap:.4?}", + if on_box { + "CLOSE ON THE BOX" + } else { + "close, extent differs" + }, + ring(&profile.outer_xy) - holes, + (bbox[3] - bbox[0]) * (bbox[4] - bbox[1]), + profile.inner_xy.len(), + profile.pieces.len() + 1 + ); + } + None => println!(" FLOOR-EDGES do not close"), + } +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut failing: BTreeMap = BTreeMap::new(); + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + if room_outline(&mut rf, &stream, id, bbox)?.is_none() { + failing.insert(u64::from(id), bbox); + } + } + println!( + "release {version}: {} rooms, {} do not close from their partition faces", + rooms.len(), + failing.len() + ); + let options = Options { + selected_ids: failing.keys().copied().collect::>(), + releases: NATIVE_RELEASES, + ..Options::default() + }; + let mut seen = 0; + extract_graphs(&mut rf, &options, |record| { + let id = record.identity.element_id; + let Some(&bbox) = failing.get(&id) else { + return Ok(()); + }; + seen += 1; + println!( + "room {id} stream {} class {:?} status {} box {:.3?}", + record.source.stream, record.class_name, record.status, bbox + ); + match record.graph.as_ref() { + Some(g) => describe(g, id, bbox), + None => println!(" no graph: {:?}", record.diagnostic), + } + Ok(()) + })?; + println!("native records read for {seen} of {} rooms", failing.len()); + Ok(()) +} From 9ac2c18ae1f0e580d69e3a58d30bf2238c0157b5 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:38:11 -0500 Subject: [PATCH 04/31] research(rooms): a probe finding the vertices a room's solid stores before its first face (#1260) (RE-316) --- probes/examples/probe_re316_solid_vertices.rs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 probes/examples/probe_re316_solid_vertices.rs diff --git a/probes/examples/probe_re316_solid_vertices.rs b/probes/examples/probe_re316_solid_vertices.rs new file mode 100644 index 00000000..dbe5d445 --- /dev/null +++ b/probes/examples/probe_re316_solid_vertices.rs @@ -0,0 +1,186 @@ +//! RE-316: the topology a room's solid stores before its first face. +//! +//! FACT under test: between a room's solid header (RE-101) and its first face +//! the solid stores its vertices as three consecutive `f64`, a model-space +//! point inside the room's record box, and the points at the box's floor give +//! the room's true outline where the planar faces' boxes do not. +//! +//! Output, per room whose planar faces do not close on its box: the solid's +//! start and first face, every offset between them where three consecutive +//! `f64` are a point inside the box (with the 24 bytes before it and the gap +//! from the point before), the points at the floor, and the 96 bytes after the +//! header's second box. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_solid_vertices -- MODEL.rvt + +use rvt::RevitFile; +use rvt::research::partition_room_boundaries::{ + FACE_TAG, ROOM_SOLID_TAG, ROOM_SOLID_WINDOW, face_at, room_outline, solid_headers, +}; +use rvt::walker::InstanceField; + +const MAX_ROOMS: usize = 14; +const MAX_POINTS: usize = 90; +const TOLERANCE: f64 = 1e-3; + +fn solid_start(buf: &[u8], header: usize) -> Option { + let after_id = header.checked_add(8)?; + if buf.get(after_id..after_id + 4)? == ROOM_SOLID_TAG { + return Some(after_id + 4); + } + (buf.get(after_id..after_id + 8)? == [0xff, 0xff, 0xff, 0xff, 0x04, 0x90, 0x08, 0x00]) + .then_some(after_id + 8) +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" ") +} + +fn f64_at(buf: &[u8], at: usize) -> Option { + let bytes: [u8; 8] = buf.get(at..at + 8)?.try_into().ok()?; + let value = f64::from_le_bytes(bytes); + value.is_finite().then_some(value) +} + +fn triple_at(buf: &[u8], at: usize) -> Option<[f64; 3]> { + Some([ + f64_at(buf, at)?, + f64_at(buf, at + 8)?, + f64_at(buf, at + 16)?, + ]) +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut shown = 0; + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + if room_outline(&mut rf, &stream, id, bbox)?.is_some() { + continue; + } + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + let Some(&header) = solid_headers(buf, id).first() else { + continue; + }; + let Some(start) = solid_start(buf, header) else { + continue; + }; + let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); + let first_face = memchr::memmem::find_iter(&buf[start..window_end], &FACE_TAG) + .map(|offset| start + offset) + .find(|&at| face_at(buf, at).is_some()); + let Some(first_face) = first_face else { + continue; + }; + if shown >= MAX_ROOMS { + break; + } + shown += 1; + println!( + "room {id} {stream} box [{:.4}, {:.4}, {:.4}, {:.4}, {:.4}, {:.4}] solid start {start} first face +{}", + bbox[0], + bbox[1], + bbox[2], + bbox[3], + bbox[4], + bbox[5], + first_face - start + ); + if let Some(bytes) = buf.get(start + 8 + 96..start + 8 + 96 + 96) { + println!(" after the two boxes: {}", hex(bytes)); + } + let inside = |p: [f64; 3]| { + p[0] >= bbox[0] - TOLERANCE + && p[0] <= bbox[3] + TOLERANCE + && p[1] >= bbox[1] - TOLERANCE + && p[1] <= bbox[4] + TOLERANCE + && p[2] >= bbox[2] - TOLERANCE + && p[2] <= bbox[5] + TOLERANCE + }; + let mut points: Vec<(usize, [f64; 3])> = Vec::new(); + let mut at = start; + while at + 24 <= first_face { + match triple_at(buf, at) { + Some(p) if inside(p) && p.iter().any(|v| v.abs() > 1e-9) => { + points.push((at, p)); + at += 24; + } + _ => at += 1, + } + } + let at_floor: Vec<&(usize, [f64; 3])> = points + .iter() + .filter(|(_, p)| (p[2] - bbox[2]).abs() <= TOLERANCE) + .collect(); + println!( + " points inside the box {} (at the floor {})", + points.len(), + at_floor.len() + ); + let mut previous = start; + for (index, (offset, p)) in points.iter().enumerate().take(MAX_POINTS) { + let context = buf + .get(offset.saturating_sub(24)..*offset) + .map_or(String::new(), hex); + println!( + " point {index} @{} (+{} from the last) [{:.4}, {:.4}, {:.4}] before: {context}", + offset - start, + offset - previous, + p[0], + p[1], + p[2] + ); + previous = *offset + 24; + } + if points.len() > MAX_POINTS { + println!(" ... {} points more", points.len() - MAX_POINTS); + } + } + Ok(()) +} From ff45098c34a191a758ac84b37dc8b8730f45678e Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:08:55 -0500 Subject: [PATCH 05/31] feat(rooms): where the faces RE-101 walks do not close, the faces up to the next solid's tag give the room's outline as the boundary of the region its directed plan edges wind around (#1260) (RE-316) --- probes/examples/probe_re316_room_closure.rs | 241 ++++++++++++++ src/research/partition_room_boundaries.rs | 329 ++++++++++++++++++-- 2 files changed, 549 insertions(+), 21 deletions(-) create mode 100644 probes/examples/probe_re316_room_closure.rs diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs new file mode 100644 index 00000000..14784f18 --- /dev/null +++ b/probes/examples/probe_re316_room_closure.rs @@ -0,0 +1,241 @@ +//! RE-316: which rooms whose stored solid did not close now give an outline, +//! and whether the file agrees. +//! +//! FACT under test: where the faces RE-101 walks do not close on a room's +//! record box, the faces up to the next solid's tag, each plan edge directed +//! so the room is on its left, wind around a region whose boundary is the room's +//! outline (`partition_room_boundaries::region_boundary`). +//! +//! For each room whose own partition holds a solid, one line: the room, the +//! outline RE-101 gave (area, or none), the outline now given, the outline of +//! the walls that reach the ceiling instead, the box's area, and for each area +//! whether Revit's cache of computed room areas in the same partition holds it +//! (`u64 · ff×8 · u32 0 · i32 · f64`, RE-101 §4). The last lines count the +//! rooms by what changed, and whether the plan edges of every room RE-101 kept +//! are directed head to tail. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_room_closure -- MODEL.rvt + +use std::collections::BTreeMap; + +use rvt::RevitFile; +use rvt::research::element_record_plan_profiles::{PlanProfile, plan_profile_from_lines}; +use rvt::research::partition_room_boundaries::{ + BOX_TOLERANCE_FEET, OutlineAnchor, ROOM_VERTEX_TOLERANCE_FEET, room_outline_at, solid_faces, + solid_headers, span_outline_at, +}; +use rvt::walker::InstanceField; + +fn ring_area(ring: &[(f64, f64)]) -> f64 { + let n = ring.len(); + (0..n) + .map(|i| { + let (a, b) = (ring[i], ring[(i + 1) % n]); + a.0 * b.1 - b.0 * a.1 + }) + .sum::() + .abs() + / 2.0 +} + +fn area(profile: &PlanProfile) -> f64 { + let net = |outer: &[(f64, f64)], inner: &[Vec<(f64, f64)>]| { + ring_area(outer) - inner.iter().map(|r| ring_area(r)).sum::() + }; + net(&profile.outer_xy, &profile.inner_xy) + + profile + .pieces + .iter() + .map(|p| net(&p.outer_xy, &p.inner_xy)) + .sum::() +} + +fn snap(lines: &mut [[f64; 4]]) { + let mut held: Vec<(f64, f64)> = Vec::new(); + for line in lines.iter_mut() { + for end in [0, 2] { + let point = (line[end], line[end + 1]); + let near = held.iter().copied().find(|q| { + (q.0 - point.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET + && (q.1 - point.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET + }); + match near { + Some(q) => (line[end], line[end + 1]) = q, + None => held.push(point), + } + } + } +} + +/// The areas Revit caches in a partition. +fn cached_areas(buf: &[u8]) -> Vec { + let pattern = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0]; + memchr::memmem::find_iter(buf, &pattern) + .filter_map(|at| { + let bytes: [u8; 8] = buf.get(at + 16..at + 24)?.try_into().ok()?; + let value = f64::from_le_bytes(bytes); + (value.is_finite() && value > 0.0 && value < 1.0e7).then_some(value) + }) + .collect() +} + +fn in_cache(cache: &[f64], value: f64) -> bool { + cache.iter().any(|c| (c - value).abs() <= 1e-5 * value) +} + +fn text(profile: &Option, cache: &[f64]) -> String { + match profile { + Some(p) => { + let a = area(p); + format!( + "{a:.3} ({})", + if in_cache(cache, a) { + "cached" + } else { + "not cached" + } + ) + } + None => "none".into(), + } +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut tally: BTreeMap<&str, usize> = BTreeMap::new(); + let (mut oriented_ok, mut oriented_total) = (0, 0); + let mut cached_new = (0, 0); + let mut cached_ceiling = (0, 0); + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + let headers = solid_headers(buf, id); + let Some(&header) = headers.first() else { + continue; + }; + let mut lines: Vec<[f64; 4]> = solid_faces(buf, header) + .iter() + .filter_map(|f| f.plan_edge()) + .filter(|(_, bottom)| (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET) + .map(|(edge, _)| edge) + .collect(); + snap(&mut lines); + let walked = plan_profile_from_lines(&lines).filter(|p| { + p.plan_extent_feet().is_some_and(|e| { + e.iter() + .zip([bbox[0], bbox[1], bbox[3], bbox[4]]) + .all(|(got, want)| (got - want).abs() <= BOX_TOLERANCE_FEET) + }) + }); + if walked.is_some() { + oriented_total += 1; + let mut balance: Vec<((i64, i64), i32)> = Vec::new(); + let key = |x: f64, y: f64| ((x * 1e4).round() as i64, (y * 1e4).round() as i64); + for l in &lines { + for (point, step) in [(key(l[0], l[1]), 1), (key(l[2], l[3]), -1)] { + match balance.iter_mut().find(|(k, _)| *k == point) { + Some(entry) => entry.1 += step, + None => balance.push((point, step)), + } + } + } + if balance.iter().all(|(_, b)| *b == 0) { + oriented_ok += 1; + } + } + let now = room_outline_at(buf, header, bbox); + let ceiling = span_outline_at(buf, header, bbox, OutlineAnchor::Ceiling); + let class = match (&walked, &now) { + (Some(_), Some(_)) => { + if walked == now { + "kept, unchanged" + } else { + "kept, CHANGED" + } + } + (None, Some(_)) => "newly closed", + (Some(_), None) => "LOST", + (None, None) => "still box", + }; + *tally.entry(class).or_default() += 1; + if walked.is_some() && now.is_some() && walked == now { + continue; + } + let cache = cached_areas(buf); + let box_area = dx * dy; + if walked.is_none() && now.is_some() { + cached_new.1 += 1; + if now.as_ref().is_some_and(|p| in_cache(&cache, area(p))) { + cached_new.0 += 1; + } + } + if walked.is_none() && now.is_none() && ceiling.is_some() { + cached_ceiling.1 += 1; + if ceiling.as_ref().is_some_and(|p| in_cache(&cache, area(p))) { + cached_ceiling.0 += 1; + } + } + println!( + "room {id} {stream} box {box_area:.3} ({}) walked {} now {} ceiling {} [{class}]", + if in_cache(&cache, box_area) { + "cached" + } else { + "not cached" + }, + text(&walked, &cache), + text(&now, &cache), + text(&ceiling, &cache), + ); + } + println!("release {version}: {} rooms, {tally:?}", rooms.len()); + println!( + "plan edges directed head to tail on {oriented_ok} of the {oriented_total} rooms RE-101 kept" + ); + println!( + "newly closed rooms whose area Revit's cache holds: {} of {}; ceiling-anchored outlines of rooms still boxes whose area it holds: {} of {}", + cached_new.0, cached_new.1, cached_ceiling.0, cached_ceiling.1 + ); + Ok(()) +} diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 20a84262..950b6b1f 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -31,6 +31,16 @@ //! vertical face that stops short of the floor is a step in the room's //! ceiling, inside the outline. //! +//! A record the walk does not read can sit between two faces and be longer +//! than [`FACE_GAP`] (626 bytes in office room 1422302), and a face's box is +//! the box of its plane, not its outline: where a column stands in the room +//! and overlaps a wall, the wall's face runs across the column's footprint. +//! When the faces the walk reads do not close, the faces up to the next +//! solid's tag are read instead ([`solid_faces_to_next_solid`]), each plan +//! edge directed so the room is on its left, and the room's outline is the +//! boundary of the region those edges wind around ([`region_boundary`], +//! RE-316). +//! //! Core Interior keeps a room's record and solid in more than one partition, //! and the copies can disagree: room 20832's outline spans 10.5 ft in one and //! 10.4167 ft in the other. The copy kept is the one in the partition of the @@ -81,6 +91,16 @@ pub const PRISM_TOLERANCE_FEET: f64 = 1e-3; /// 355008. pub const ROOM_VERTEX_TOLERANCE_FEET: f64 = 1e-4; +/// The most plan segments [`region_boundary`] resolves. +pub const BOUNDARY_SEGMENTS_MAX: usize = 4096; + +/// How far apart two points of a resolved boundary may lie and be one, +/// feet. +const BOUNDARY_POINT_EPS: f64 = 1e-6; + +/// How far to either side of a boundary piece its winding is read, feet. +const BOUNDARY_SIDE_EPS: f64 = 1e-5; + const FACE_LEN: usize = 158; const UNIT_EPS: f64 = 1e-6; const AXIS_EPS: f64 = 1e-9; @@ -246,24 +266,37 @@ pub fn solid_faces(buf: &[u8], header: usize) -> Vec { faces } -/// The room's outline from the solid whose header starts at `header`, where -/// its wall faces (vertical, reaching the floor of `bbox`, a record box -/// `[min x, min y, min z, max x, max y, max z]`) close into loops whose plan -/// extent is `bbox`'s within -/// [`BOX_TOLERANCE_FEET`]. A face's origin is its plane's, not a point on -/// the face, so only the plan edges are checked. -pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option { - let faces = solid_faces(buf, header); - let mut lines = Vec::new(); - for face in &faces { - if let Some((edge, bottom)) = face.plan_edge() { - if (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET { - lines.push(edge); - } - } - } - snap_ends(&mut lines); - let profile = plan_profile_from_lines(&lines)?; +/// Which end of the room its wall faces must reach to be a wall of its +/// outline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutlineAnchor { + /// The room's floor, the bottom of its record box. + Floor, + /// The room's ceiling, the top of its record box. + Ceiling, +} + +fn reaches_anchor(face: &RoomFace, bbox: [f64; 6], anchor: OutlineAnchor) -> Option<[f64; 4]> { + let (edge, bottom) = face.plan_edge()?; + let reached = match anchor { + OutlineAnchor::Floor => (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET, + OutlineAnchor::Ceiling => match face.kind() { + FaceKind::Vertical { high, .. } => (high - bbox[5]).abs() <= BOX_TOLERANCE_FEET, + _ => false, + }, + }; + reached.then_some(edge) +} + +fn edge_within_box(edge: [f64; 4], bbox: [f64; 6]) -> bool { + let tolerance = BOX_TOLERANCE_FEET; + edge[0].min(edge[2]) >= bbox[0] - tolerance + && edge[0].max(edge[2]) <= bbox[3] + tolerance + && edge[1].min(edge[3]) >= bbox[1] - tolerance + && edge[1].max(edge[3]) <= bbox[4] + tolerance +} + +fn profile_on_box(profile: PlanProfile, bbox: [f64; 6]) -> Option { let extent = profile.plan_extent_feet()?; let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; extent @@ -273,6 +306,75 @@ pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option Option { + let mut lines = Vec::new(); + for face in faces { + if let Some(edge) = reaches_anchor(face, bbox, anchor) { + if !resolve || edge_within_box(edge, bbox) { + lines.push(edge); + } + } + } + snap_ends(&mut lines); + let profile = if resolve { + plan_profile_from_lines(®ion_boundary(&lines)?)? + } else { + plan_profile_from_lines(&lines)? + }; + profile_on_box(profile, bbox) +} + +/// The room's outline from the solid whose header starts at `header` and the +/// faces it was made from. Its wall faces (vertical, reaching the floor of +/// `bbox`, a record box `[min x, min y, min z, max x, max y, max z]`) close +/// into loops whose plan extent is `bbox`'s within [`BOX_TOLERANCE_FEET`]; +/// where the faces the walk reads do not close, the faces up to the next +/// solid's tag are resolved to a boundary instead (RE-316). A face's origin is +/// its plane's, not a point on the face, so only the plan edges are checked. +fn outline_with_faces( + buf: &[u8], + header: usize, + bbox: [f64; 6], +) -> Option<(PlanProfile, Vec)> { + let walked = solid_faces(buf, header); + if let Some(profile) = outline_from_faces(&walked, bbox, OutlineAnchor::Floor, false) { + return Some((profile, walked)); + } + let span = solid_faces_to_next_solid(buf, header); + let profile = outline_from_faces(&span, bbox, OutlineAnchor::Floor, true)?; + Some((profile, span)) +} + +/// The room's outline from the solid whose header starts at `header`: its +/// wall faces' plan edges as they stand where the walk's faces close (RE-101), +/// and resolved to a boundary where the faces up to the next solid's tag do +/// (RE-316). Kept only where it spans `bbox`. +pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option { + outline_with_faces(buf, header, bbox).map(|(profile, _)| profile) +} + +/// The outline the faces up to the next solid's tag make, resolved to a +/// boundary, for walls that reach `anchor` (RE-316). A probe's reading: +/// [`room_outline_at`] uses only [`OutlineAnchor::Floor`]. +pub fn span_outline_at( + buf: &[u8], + header: usize, + bbox: [f64; 6], + anchor: OutlineAnchor, +) -> Option { + outline_from_faces(&solid_faces_to_next_solid(buf, header), bbox, anchor, true) +} + /// Whether `faces`, the faces of a room's solid, make a prism of the room's /// plan outline between the floor and ceiling of `bbox` (RE-296): there is a /// vertical face, every vertical face runs from the floor to the ceiling, and @@ -308,10 +410,10 @@ pub fn faces_form_prism(faces: &[RoomFace], bbox: [f64; 6]) -> bool { pub fn prism_of_solids(buf: &[u8], headers: &[usize], bbox: [f64; 6]) -> bool { let mut found = false; for &at in headers { - if room_outline_at(buf, at, bbox).is_none() { + let Some((_, faces)) = outline_with_faces(buf, at, bbox) else { continue; - } - if !faces_form_prism(&solid_faces(buf, at), bbox) { + }; + if !faces_form_prism(&faces, bbox) { return false; } found = true; @@ -353,6 +455,191 @@ pub fn solid_headers_by_id(buf: &[u8]) -> BTreeMap> { out } +/// Every face of the solid whose header starts at `header`, from its first +/// face to the tag of the next solid in `buf` or [`ROOM_SOLID_WINDOW`] bytes +/// past its start, whatever the gap between faces (RE-316). +pub fn solid_faces_to_next_solid(buf: &[u8], header: usize) -> Vec { + let Some(start) = solid_start(buf, header) else { + return Vec::new(); + }; + let window_end = buf.len().min(start.saturating_add(ROOM_SOLID_WINDOW)); + let Some(window) = buf.get(start..window_end) else { + return Vec::new(); + }; + let end = memchr::memmem::find(window, &ROOM_SOLID_TAG).unwrap_or(window.len()); + memchr::memmem::find_iter(&window[..end], &FACE_TAG) + .filter_map(|offset| face_at(buf, start + offset)) + .collect() +} + +/// Where each of two segments is cut by the other, as parameters from 0 at +/// its start to 1 at its end: at a crossing, and, where they overlap along +/// one line, at the other's ends. +fn cut_parameters(a: [f64; 4], b: [f64; 4]) -> (Vec, Vec) { + let (mut on_a, mut on_b) = (Vec::new(), Vec::new()); + let (ax, ay) = (a[2] - a[0], a[3] - a[1]); + let (bx, by) = (b[2] - b[0], b[3] - b[1]); + let (alen, blen) = (ax.hypot(ay), bx.hypot(by)); + if alen <= BOUNDARY_POINT_EPS || blen <= BOUNDARY_POINT_EPS { + return (on_a, on_b); + } + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let cross = ax * by - ay * bx; + if cross.abs() > 1e-9 * alen * blen { + let t = (dx * by - dy * bx) / cross; + let u = (dx * ay - dy * ax) / cross; + let (t_slack, u_slack) = (BOUNDARY_POINT_EPS / alen, BOUNDARY_POINT_EPS / blen); + if t >= -t_slack && t <= 1.0 + t_slack && u >= -u_slack && u <= 1.0 + u_slack { + on_a.push(t); + on_b.push(u); + } + } else if ((dx * ay - dy * ax) / alen).abs() <= 10.0 * BOUNDARY_POINT_EPS { + let along_a = |x: f64, y: f64| ((x - a[0]) * ax + (y - a[1]) * ay) / (alen * alen); + let along_b = |x: f64, y: f64| ((x - b[0]) * bx + (y - b[1]) * by) / (blen * blen); + on_a.push(along_a(b[0], b[1])); + on_a.push(along_a(b[2], b[3])); + on_b.push(along_b(a[0], a[1])); + on_b.push(along_b(a[2], a[3])); + } + (on_a, on_b) +} + +/// How many times the oriented `segments` wind around `p`: each upward +/// crossing of the ray to its right with `p` on the segment's left adds one, +/// each downward crossing with `p` on its right takes one off. +fn winding(segments: &[[f64; 4]], p: (f64, f64)) -> i32 { + let mut total = 0; + for &[x0, y0, x1, y1] in segments { + let side = (x1 - x0) * (p.1 - y0) - (p.0 - x0) * (y1 - y0); + if y0 <= p.1 { + if y1 > p.1 && side > 0.0 { + total += 1; + } + } else if y1 <= p.1 && side < 0.0 { + total -= 1; + } + } + total +} + +fn intern_point(points: &mut Vec<(f64, f64)>, p: (f64, f64)) -> usize { + let found = points.iter().position(|q| { + (q.0 - p.0).abs() <= BOUNDARY_POINT_EPS && (q.1 - p.1).abs() <= BOUNDARY_POINT_EPS + }); + match found { + Some(index) => index, + None => { + points.push(p); + points.len() - 1 + } + } +} + +/// The boundary of the region that the oriented plan `segments` wind around +/// at least once, as undirected segments that close into loops (RE-316). A +/// segment's room is on its left, so a wall of the room runs counter-clockwise +/// around it and a column's walls clockwise around the column, and a column +/// that overlaps a wall takes its footprint off the room where they overlap +/// and adds nothing outside it. Every segment is cut where another crosses it +/// or touches it, a piece is kept where the region lies on one side of it +/// only, and `None` is returned unless the kept pieces leave every point with +/// one piece in and one out. +#[allow(clippy::needless_range_loop)] +pub fn region_boundary(segments: &[[f64; 4]]) -> Option> { + let count = segments.len(); + if !(3..=BOUNDARY_SEGMENTS_MAX).contains(&count) { + return None; + } + if segments.iter().flatten().any(|v| !v.is_finite()) { + return None; + } + let mut cuts: Vec> = vec![Vec::new(); count]; + for first in 0..count { + for second in first + 1..count { + let (on_first, on_second) = cut_parameters(segments[first], segments[second]); + cuts[first].extend(on_first); + cuts[second].extend(on_second); + } + } + let mut points: Vec<(f64, f64)> = Vec::new(); + let mut pieces: Vec<(usize, usize)> = Vec::new(); + for (index, segment) in segments.iter().enumerate() { + let length = (segment[2] - segment[0]).hypot(segment[3] - segment[1]); + if length <= BOUNDARY_POINT_EPS { + continue; + } + let mut params = vec![0.0, 1.0]; + params.extend(cuts[index].iter().copied().filter(|t| { + t * length > BOUNDARY_POINT_EPS && (1.0 - t) * length > BOUNDARY_POINT_EPS + })); + params.sort_by(f64::total_cmp); + params.dedup_by(|a, b| (*a - *b).abs() * length <= BOUNDARY_POINT_EPS); + let at = |t: f64| { + ( + segment[0] + t * (segment[2] - segment[0]), + segment[1] + t * (segment[3] - segment[1]), + ) + }; + for pair in params.windows(2) { + let from = intern_point(&mut points, at(pair[0])); + let to = intern_point(&mut points, at(pair[1])); + if from != to { + pieces.push((from, to)); + } + } + } + let mut kept: Vec<(usize, usize)> = Vec::new(); + for &(from, to) in &pieces { + let (a, b) = (points[from], points[to]); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let length = dx.hypot(dy); + let (nx, ny) = (-dy / length, dx / length); + let middle = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let left = winding( + segments, + ( + middle.0 + nx * BOUNDARY_SIDE_EPS, + middle.1 + ny * BOUNDARY_SIDE_EPS, + ), + ) >= 1; + let right = winding( + segments, + ( + middle.0 - nx * BOUNDARY_SIDE_EPS, + middle.1 - ny * BOUNDARY_SIDE_EPS, + ), + ) >= 1; + match (left, right) { + (true, false) => kept.push((from, to)), + (false, true) => kept.push((to, from)), + _ => {} + } + } + kept.sort_unstable(); + kept.dedup(); + if kept.len() < 3 { + return None; + } + let mut leaving = vec![0usize; points.len()]; + let mut entering = vec![0usize; points.len()]; + for &(from, to) in &kept { + leaving[from] += 1; + entering[to] += 1; + } + if leaving + .iter() + .zip(&entering) + .any(|(out, into)| out != into || *out > 1) + { + return None; + } + Some( + kept.iter() + .map(|&(from, to)| [points[from].0, points[from].1, points[to].0, points[to].1]) + .collect(), + ) +} + /// Move every end within [`ROOM_VERTEX_TOLERANCE_FEET`] of an earlier one /// onto it. fn snap_ends(lines: &mut [[f64; 4]]) { From 4d54267b54e67667868b12cb8c6d6a5d8940769b Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:11:23 -0500 Subject: [PATCH 06/31] research(rooms): the closure probe also compares the resolved span with the walk on the rooms RE-101 kept (#1260) (RE-316) --- probes/examples/probe_re316_room_closure.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs index 14784f18..5db2c01b 100644 --- a/probes/examples/probe_re316_room_closure.rs +++ b/probes/examples/probe_re316_room_closure.rs @@ -113,6 +113,7 @@ fn main() -> anyhow::Result<()> { .rooms; let mut tally: BTreeMap<&str, usize> = BTreeMap::new(); let (mut oriented_ok, mut oriented_total) = (0, 0); + let (mut resolved_same, mut resolved_total) = (0, 0); let mut cached_new = (0, 0); let mut cached_ceiling = (0, 0); for room in &rooms { @@ -200,6 +201,23 @@ fn main() -> anyhow::Result<()> { (None, None) => "still box", }; *tally.entry(class).or_default() += 1; + if walked.is_some() { + let resolved = span_outline_at(buf, header, bbox, OutlineAnchor::Floor); + resolved_total += 1; + let same = match (&walked, &resolved) { + (Some(a), Some(b)) => (area(a) - area(b)).abs() <= 1e-6 * area(a), + _ => false, + }; + if same { + resolved_same += 1; + } else { + println!( + "room {id} {stream}: RE-101 outline {} but the resolved span gives {}", + walked.as_ref().map_or(0.0, area), + resolved.as_ref().map_or(f64::NAN, area) + ); + } + } if walked.is_some() && now.is_some() && walked == now { continue; } @@ -233,6 +251,9 @@ fn main() -> anyhow::Result<()> { println!( "plan edges directed head to tail on {oriented_ok} of the {oriented_total} rooms RE-101 kept" ); + println!( + "the resolved span's outline has the area of the walk's on {resolved_same} of the {resolved_total} rooms RE-101 kept" + ); println!( "newly closed rooms whose area Revit's cache holds: {} of {}; ceiling-anchored outlines of rooms still boxes whose area it holds: {} of {}", cached_new.0, cached_new.1, cached_ceiling.0, cached_ceiling.1 From b9e24e352bd9f2f7eb32cb2c36ae2aebfe9a7c52 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:19:29 -0500 Subject: [PATCH 07/31] fix(rooms): the span ends at the next solid's tag or the room is not resolved, and a face outside the room's box rules the solid out (#1260) (RE-316) --- src/research/partition_room_boundaries.rs | 50 ++++++++++++++++------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 109e3be3..e36024db 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -84,6 +84,10 @@ pub const ROOM_SOLID_WINDOW: usize = 0x1_0000; /// feet. pub const BOX_TOLERANCE_FEET: f64 = 1e-3; +/// How many bytes past a room's solid header the next solid's tag is looked +/// for (RE-316): the largest solid measured, 115 faces, ends 84 KB in. +pub const ROOM_SOLID_SPAN_MAX: usize = 4 << 20; + /// How far a vertical face's span may fall short of a room's box, and a /// horizontal face lie from its floor or ceiling, and still be it, feet /// (RE-296). @@ -292,12 +296,23 @@ fn reaches_anchor(face: &RoomFace, bbox: [f64; 6], anchor: OutlineAnchor) -> Opt reached.then_some(edge) } -fn edge_within_box(edge: [f64; 4], bbox: [f64; 6]) -> bool { +/// Whether a vertical `face` reaches outside the room's record box, which +/// holds the whole solid: a face of another element, read because the span +/// ran on past the solid's end. +fn strays_from_box(face: &RoomFace, bbox: [f64; 6]) -> bool { let tolerance = BOX_TOLERANCE_FEET; - edge[0].min(edge[2]) >= bbox[0] - tolerance - && edge[0].max(edge[2]) <= bbox[3] + tolerance - && edge[1].min(edge[3]) >= bbox[1] - tolerance - && edge[1].max(edge[3]) <= bbox[4] + tolerance + let FaceKind::Vertical { low, high } = face.kind() else { + return false; + }; + if low < bbox[2] - tolerance || high > bbox[5] + tolerance { + return true; + } + face.plan_edge().is_some_and(|(edge, _)| { + edge[0].min(edge[2]) < bbox[0] - tolerance + || edge[0].max(edge[2]) > bbox[3] + tolerance + || edge[1].min(edge[3]) < bbox[1] - tolerance + || edge[1].max(edge[3]) > bbox[4] + tolerance + }) } fn profile_on_box(profile: PlanProfile, bbox: [f64; 6]) -> Option { @@ -312,21 +327,22 @@ fn profile_on_box(profile: PlanProfile, bbox: [f64; 6]) -> Option { /// The outline the wall faces among `faces` make, kept where it spans /// `bbox`. With `resolve` off, the faces' plan edges must close into loops as -/// they are (RE-101). With it on, only the edges inside the box are used, -/// and the outline is the boundary of the region they wind around -/// ([`region_boundary`]). +/// they are (RE-101). With it on, the outline is the boundary of the region +/// they wind around ([`region_boundary`]), and a vertical face that reaches +/// outside the box rules the solid out. fn outline_from_faces( faces: &[RoomFace], bbox: [f64; 6], anchor: OutlineAnchor, resolve: bool, ) -> Option { + if resolve && faces.iter().any(|face| strays_from_box(face, bbox)) { + return None; + } let mut lines = Vec::new(); for face in faces { if let Some(edge) = reaches_anchor(face, bbox, anchor) { - if !resolve || edge_within_box(edge, bbox) { - lines.push(edge); - } + lines.push(edge); } } snap_ends(&mut lines); @@ -460,17 +476,21 @@ pub fn solid_headers_by_id(buf: &[u8]) -> BTreeMap> { } /// Every face of the solid whose header starts at `header`, from its first -/// face to the tag of the next solid in `buf` or [`ROOM_SOLID_WINDOW`] bytes -/// past its start, whatever the gap between faces (RE-316). +/// face to the tag of the next solid in `buf`, whatever the gap between faces +/// (RE-316). Empty where no tag follows within [`ROOM_SOLID_SPAN_MAX`] bytes: +/// without it the solid's end is not known, and the faces past it are another +/// element's. pub fn solid_faces_to_next_solid(buf: &[u8], header: usize) -> Vec { let Some(start) = solid_start(buf, header) else { return Vec::new(); }; - let window_end = buf.len().min(start.saturating_add(ROOM_SOLID_WINDOW)); + let window_end = buf.len().min(start.saturating_add(ROOM_SOLID_SPAN_MAX)); let Some(window) = buf.get(start..window_end) else { return Vec::new(); }; - let end = memchr::memmem::find(window, &ROOM_SOLID_TAG).unwrap_or(window.len()); + let Some(end) = memchr::memmem::find(window, &ROOM_SOLID_TAG) else { + return Vec::new(); + }; memchr::memmem::find_iter(&window[..end], &FACE_TAG) .filter_map(|offset| face_at(buf, start + offset)) .collect() From 4c06514bee8ae6b6084f46c57e61b50267211cb8 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:21:22 -0500 Subject: [PATCH 08/31] feat(rooms): walls on lines up to 0.0025 ft apart meet where the faces are resolved, and a failed resolution says where the pieces do not link (#1260) (RE-316) --- probes/examples/probe_re316_room_closure.rs | 54 ++++++++++++++-- src/research/partition_room_boundaries.rs | 70 ++++++++++++++------- 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs index 5db2c01b..d810f5f0 100644 --- a/probes/examples/probe_re316_room_closure.rs +++ b/probes/examples/probe_re316_room_closure.rs @@ -22,8 +22,9 @@ use std::collections::BTreeMap; use rvt::RevitFile; use rvt::research::element_record_plan_profiles::{PlanProfile, plan_profile_from_lines}; use rvt::research::partition_room_boundaries::{ - BOX_TOLERANCE_FEET, OutlineAnchor, ROOM_VERTEX_TOLERANCE_FEET, room_outline_at, solid_faces, - solid_headers, span_outline_at, + BOX_TOLERANCE_FEET, FaceKind, OutlineAnchor, ROOM_STEP_TOLERANCE_FEET, + ROOM_VERTEX_TOLERANCE_FEET, region_boundary, room_outline_at, solid_faces, + solid_faces_to_next_solid, solid_headers, span_outline_at, }; use rvt::walker::InstanceField; @@ -52,14 +53,18 @@ fn area(profile: &PlanProfile) -> f64 { } fn snap(lines: &mut [[f64; 4]]) { + snap_within(lines, ROOM_VERTEX_TOLERANCE_FEET); +} + +fn snap_within(lines: &mut [[f64; 4]], tolerance: f64) { let mut held: Vec<(f64, f64)> = Vec::new(); for line in lines.iter_mut() { for end in [0, 2] { let point = (line[end], line[end + 1]); - let near = held.iter().copied().find(|q| { - (q.0 - point.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET - && (q.1 - point.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET - }); + let near = held + .iter() + .copied() + .find(|q| (q.0 - point.0).abs() <= tolerance && (q.1 - point.1).abs() <= tolerance); match near { Some(q) => (line[end], line[end + 1]) = q, None => held.push(point), @@ -235,6 +240,43 @@ fn main() -> anyhow::Result<()> { cached_ceiling.0 += 1; } } + if walked.is_none() && now.is_none() { + let faces = solid_faces_to_next_solid(buf, header); + let tolerance = BOX_TOLERANCE_FEET; + let strays = faces + .iter() + .filter(|f| match f.kind() { + FaceKind::Vertical { low, high } => { + low < bbox[2] - tolerance + || high > bbox[5] + tolerance + || f.plan_edge().is_some_and(|(e, _)| { + e[0].min(e[2]) < bbox[0] - tolerance + || e[0].max(e[2]) > bbox[3] + tolerance + || e[1].min(e[3]) < bbox[1] - tolerance + || e[1].max(e[3]) > bbox[4] + tolerance + }) + } + _ => false, + }) + .count(); + let mut floor_lines: Vec<[f64; 4]> = faces + .iter() + .filter_map(|f| f.plan_edge()) + .filter(|(_, bottom)| (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET) + .map(|(edge, _)| edge) + .collect(); + snap_within(&mut floor_lines, ROOM_STEP_TOLERANCE_FEET); + let why = match region_boundary(&floor_lines) { + Ok(pieces) => format!("boundary of {} pieces, no profile on the box", pieces.len()), + Err(failure) => format!("{failure:?}"), + }; + println!( + "room {id}: span faces {} (stray {strays}), floor edges {}; {}", + faces.len(), + floor_lines.len(), + why.chars().take(600).collect::() + ); + } println!( "room {id} {stream} box {box_area:.3} ({}) walked {} now {} ceiling {} [{class}]", if in_cache(&cache, box_area) { diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index e36024db..1ec6b86e 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -99,6 +99,12 @@ pub const PRISM_TOLERANCE_FEET: f64 = 1e-3; /// 355008. pub const ROOM_VERTEX_TOLERANCE_FEET: f64 = 1e-4; +/// How far apart two wall faces' ends may lie and still meet where the room's +/// faces are resolved to a boundary (RE-316). Walls on lines 0.0007 to 0.0015 +/// ft apart leave a step the room's faces do not cross on six rooms of the +/// BIM4LCA office; Revit's own short-curve tolerance is 1/32 inch, 0.0026 ft. +pub const ROOM_STEP_TOLERANCE_FEET: f64 = 0.0025; + /// The most plan segments [`region_boundary`] resolves. pub const BOUNDARY_SEGMENTS_MAX: usize = 4096; @@ -345,10 +351,11 @@ fn outline_from_faces( lines.push(edge); } } - snap_ends(&mut lines); let profile = if resolve { - plan_profile_from_lines(®ion_boundary(&lines)?)? + snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); + plan_profile_from_lines(®ion_boundary(&lines).ok()?)? } else { + snap_ends(&mut lines); plan_profile_from_lines(&lines)? }; profile_on_box(profile, bbox) @@ -559,6 +566,19 @@ fn intern_point(points: &mut Vec<(f64, f64)>, p: (f64, f64)) -> usize { } } +/// Why [`region_boundary`] gave no boundary. +#[derive(Debug, Clone, PartialEq)] +pub enum BoundaryFailure { + /// There are fewer than three segments, more than + /// [`BOUNDARY_SEGMENTS_MAX`], or one has an end that is not finite. + Segments(usize), + /// Fewer than three pieces bound the region. + NoRegion, + /// The points where the pieces that bound the region do not run one in + /// and one out. + Unlinked(Vec<(f64, f64)>), +} + /// The boundary of the region that the oriented plan `segments` wind around /// at least once, as undirected segments that close into loops (RE-316). A /// segment's room is on its left, so a wall of the room runs counter-clockwise @@ -569,13 +589,12 @@ fn intern_point(points: &mut Vec<(f64, f64)>, p: (f64, f64)) -> usize { /// only, and `None` is returned unless the kept pieces leave every point with /// one piece in and one out. #[allow(clippy::needless_range_loop)] -pub fn region_boundary(segments: &[[f64; 4]]) -> Option> { +pub fn region_boundary(segments: &[[f64; 4]]) -> Result, BoundaryFailure> { let count = segments.len(); - if !(3..=BOUNDARY_SEGMENTS_MAX).contains(&count) { - return None; - } - if segments.iter().flatten().any(|v| !v.is_finite()) { - return None; + if !(3..=BOUNDARY_SEGMENTS_MAX).contains(&count) + || segments.iter().flatten().any(|v| !v.is_finite()) + { + return Err(BoundaryFailure::Segments(count)); } let mut cuts: Vec> = vec![Vec::new(); count]; for first in 0..count { @@ -642,7 +661,7 @@ pub fn region_boundary(segments: &[[f64; 4]]) -> Option> { kept.sort_unstable(); kept.dedup(); if kept.len() < 3 { - return None; + return Err(BoundaryFailure::NoRegion); } let mut leaving = vec![0usize; points.len()]; let mut entering = vec![0usize; points.len()]; @@ -650,31 +669,38 @@ pub fn region_boundary(segments: &[[f64; 4]]) -> Option> { leaving[from] += 1; entering[to] += 1; } - if leaving + let unlinked: Vec<(f64, f64)> = leaving .iter() .zip(&entering) - .any(|(out, into)| out != into || *out > 1) - { - return None; + .zip(&points) + .filter(|((out, into), _)| out != into || **out > 1) + .map(|(_, point)| *point) + .collect(); + if !unlinked.is_empty() { + return Err(BoundaryFailure::Unlinked(unlinked)); } - Some( - kept.iter() - .map(|&(from, to)| [points[from].0, points[from].1, points[to].0, points[to].1]) - .collect(), - ) + Ok(kept + .iter() + .map(|&(from, to)| [points[from].0, points[from].1, points[to].0, points[to].1]) + .collect()) } /// Move every end within [`ROOM_VERTEX_TOLERANCE_FEET`] of an earlier one /// onto it. fn snap_ends(lines: &mut [[f64; 4]]) { + snap_ends_within(lines, ROOM_VERTEX_TOLERANCE_FEET); +} + +/// Move every end within `tolerance` of an earlier one onto it. +fn snap_ends_within(lines: &mut [[f64; 4]], tolerance: f64) { let mut held: Vec<(f64, f64)> = Vec::new(); for line in lines.iter_mut() { for end in [0, 2] { let point = (line[end], line[end + 1]); - let near = held.iter().copied().find(|q| { - (q.0 - point.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET - && (q.1 - point.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET - }); + let near = held + .iter() + .copied() + .find(|q| (q.0 - point.0).abs() <= tolerance && (q.1 - point.1).abs() <= tolerance); match near { Some(q) => (line[end], line[end + 1]) = q, None => held.push(point), From 28ef568436f14a8210764ef74dbdad050f311c4c Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:29:01 -0500 Subject: [PATCH 09/31] research(rooms): the closure probe says what extent a resolved boundary has against the box (#1260) (RE-316) --- probes/examples/probe_re316_room_closure.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs index d810f5f0..4d92afca 100644 --- a/probes/examples/probe_re316_room_closure.rs +++ b/probes/examples/probe_re316_room_closure.rs @@ -210,7 +210,7 @@ fn main() -> anyhow::Result<()> { let resolved = span_outline_at(buf, header, bbox, OutlineAnchor::Floor); resolved_total += 1; let same = match (&walked, &resolved) { - (Some(a), Some(b)) => (area(a) - area(b)).abs() <= 1e-6 * area(a), + (Some(a), Some(b)) => (area(a) - area(b)).abs() <= 1e-4 * area(a), _ => false, }; if same { @@ -267,7 +267,20 @@ fn main() -> anyhow::Result<()> { .collect(); snap_within(&mut floor_lines, ROOM_STEP_TOLERANCE_FEET); let why = match region_boundary(&floor_lines) { - Ok(pieces) => format!("boundary of {} pieces, no profile on the box", pieces.len()), + Ok(pieces) => match plan_profile_from_lines(&pieces) { + Some(profile) => format!( + "boundary of {} pieces, profile area {:.3}, extent minus box {:.4?}", + pieces.len(), + area(&profile), + profile.plan_extent_feet().map(|e| [ + e[0] - bbox[0], + e[1] - bbox[1], + e[2] - bbox[3], + e[3] - bbox[4] + ]) + ), + None => format!("boundary of {} pieces, no profile", pieces.len()), + }, Err(failure) => format!("{failure:?}"), }; println!( From 826fb53f564e42ae6e9ab3a057c4edbfe89a739b Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:40:05 -0500 Subject: [PATCH 10/31] feat(rooms): a resolved outline reads round columns as arcs and may fall 0.02 ft short of the record box (#1260) (RE-316) --- src/research/partition_room_boundaries.rs | 166 ++++++++++++++++++++-- 1 file changed, 151 insertions(+), 15 deletions(-) diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 1ec6b86e..f4414143 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -105,6 +105,16 @@ pub const ROOM_VERTEX_TOLERANCE_FEET: f64 = 1e-4; /// BIM4LCA office; Revit's own short-curve tolerance is 1/32 inch, 0.0026 ft. pub const ROOM_STEP_TOLERANCE_FEET: f64 = 0.0025; +/// How far a resolved outline may fall short of the room's record box on a +/// side and still span it, feet: the back of a column that overlaps a wall +/// stands 0.0164 ft behind the wall's face on three rooms of the BIM4LCA +/// office, and the box holds it. +pub const ROOM_SLIVER_FEET: f64 = 0.02; + +/// How far a chord of a column's arc may stray from the arc, feet (as +/// [`crate::element_record_plan_profiles::SKETCH_ARC_TOLERANCE_FEET`]). +const ARC_CHORD_TOLERANCE_FEET: f64 = 0.0005; + /// The most plan segments [`region_boundary`] resolves. pub const BOUNDARY_SEGMENTS_MAX: usize = 4096; @@ -130,6 +140,21 @@ pub struct RoomFace { pub u: [f64; 3], /// Unit v direction. pub v: [f64; 3], + /// The axis and radius where the face is a cylinder's: its record is 44 + /// bytes longer than a plane's, with the axis (a unit vector at right + /// angles to u and v) and the radius after the 158 bytes a plane's + /// has, and its uv box is an angle about the axis and a distance along it + /// (RE-316). + pub cylinder: Option, +} + +/// The axis and radius of a cylindrical face of a room's solid. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RoomCylinder { + /// Unit axis. + pub axis: [f64; 3], + /// Radius, feet. + pub radius: f64, } impl RoomFace { @@ -229,9 +254,23 @@ pub fn face_at(buf: &[u8], at: usize) -> Option { origin, u, v, + cylinder: cylinder_at(buf, at, u, v), }) } +/// The cylinder the face record at `at` describes, where the 24 bytes after +/// its 158 are a unit axis at right angles to `u` and `v` and the 8 after +/// them a positive radius. A plane's record has `ff` bytes there. +fn cylinder_at(buf: &[u8], at: usize, u: [f64; 3], v: [f64; 3]) -> Option { + let axis: [f64; 3] = read_f64s(buf, at.checked_add(FACE_LEN)?)?; + let radius = le_bytes::f64_at(buf, at.checked_add(FACE_LEN + 24)?)?; + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let unit = (dot(axis, axis) - 1.0).abs() < UNIT_EPS; + let square = dot(axis, u).abs() < UNIT_EPS && dot(axis, v).abs() < UNIT_EPS; + (unit && square && radius.is_finite() && radius > 1e-4 && radius < 1e4) + .then_some(RoomCylinder { axis, radius }) +} + /// Where the solid whose header starts at `header` begins: past its /// [`ROOM_SOLID_TAG`], directly after the ElementId or, on Revit 2023, /// after `ff ff ff ff`. @@ -280,6 +319,69 @@ pub fn solid_faces(buf: &[u8], header: usize) -> Vec { faces } +impl RoomFace { + /// The heights, low and high, a cylindrical face with a vertical axis + /// spans, feet. + fn vertical_cylinder_heights(&self) -> Option<(f64, f64)> { + let cylinder = self.cylinder?; + let horizontal = |d: [f64; 3]| d[2].abs() < AXIS_EPS; + if (cylinder.axis[2].abs() - 1.0).abs() >= AXIS_EPS + || !horizontal(self.u) + || !horizontal(self.v) + { + return None; + } + let ends = [self.uv_box[1], self.uv_box[3]] + .map(|distance| self.origin[2] + cylinder.axis[2] * distance); + Some((ends[0].min(ends[1]), ends[0].max(ends[1]))) + } + + /// The chords of a vertical cylinder's arc as plan segments, run + /// clockwise around its axis, which is how the walls of a column run + /// round the room (the room is on their left); within + /// [`ARC_CHORD_TOLERANCE_FEET`] of the arc. + fn arc_segments(&self) -> Option> { + let cylinder = self.cylinder?; + self.vertical_cylinder_heights()?; + let (start, end) = (self.uv_box[0], self.uv_box[2]); + let sweep = end - start; + if !(sweep > 0.0 && sweep <= std::f64::consts::TAU + 1e-6) { + return None; + } + let radius = cylinder.radius; + let largest_chord_angle = 2.0 + * (1.0 - ARC_CHORD_TOLERANCE_FEET / radius) + .clamp(-1.0, 1.0) + .acos(); + let chords = ((sweep / largest_chord_angle.max(1e-3)).ceil() as usize).clamp(1, 1024); + let point = |angle: f64| { + let (sine, cosine) = angle.sin_cos(); + [ + self.origin[0] + radius * (cosine * self.u[0] + sine * self.v[0]), + self.origin[1] + radius * (cosine * self.u[1] + sine * self.v[1]), + ] + }; + let counter_clockwise = self.u[0] * self.v[1] - self.u[1] * self.v[0] > 0.0; + let angles: Vec = (0..=chords) + .map(|index| start + sweep * index as f64 / chords as f64) + .collect(); + let ordered: Vec = if counter_clockwise { + angles.into_iter().rev().collect() + } else { + angles + }; + Some( + ordered + .windows(2) + .map(|pair| { + let (from, to) = (point(pair[0]), point(pair[1])); + [from[0], from[1], to[0], to[1]] + }) + .collect(), + ) + } +} + /// Which end of the room its wall faces must reach to be a wall of its /// outline. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -291,6 +393,9 @@ pub enum OutlineAnchor { } fn reaches_anchor(face: &RoomFace, bbox: [f64; 6], anchor: OutlineAnchor) -> Option<[f64; 4]> { + if face.cylinder.is_some() { + return None; + } let (edge, bottom) = face.plan_edge()?; let reached = match anchor { OutlineAnchor::Floor => (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET, @@ -307,27 +412,45 @@ fn reaches_anchor(face: &RoomFace, bbox: [f64; 6], anchor: OutlineAnchor) -> Opt /// ran on past the solid's end. fn strays_from_box(face: &RoomFace, bbox: [f64; 6]) -> bool { let tolerance = BOX_TOLERANCE_FEET; + let outside = |edge: [f64; 4]| { + edge[0].min(edge[2]) < bbox[0] - tolerance + || edge[0].max(edge[2]) > bbox[3] + tolerance + || edge[1].min(edge[3]) < bbox[1] - tolerance + || edge[1].max(edge[3]) > bbox[4] + tolerance + }; + if face.cylinder.is_some() { + let (Some((low, high)), Some(arc)) = + (face.vertical_cylinder_heights(), face.arc_segments()) + else { + return true; + }; + return low < bbox[2] - tolerance + || high > bbox[5] + tolerance + || arc.iter().any(|segment| outside(*segment)); + } let FaceKind::Vertical { low, high } = face.kind() else { return false; }; if low < bbox[2] - tolerance || high > bbox[5] + tolerance { return true; } - face.plan_edge().is_some_and(|(edge, _)| { - edge[0].min(edge[2]) < bbox[0] - tolerance - || edge[0].max(edge[2]) > bbox[3] + tolerance - || edge[1].min(edge[3]) < bbox[1] - tolerance - || edge[1].max(edge[3]) > bbox[4] + tolerance - }) + face.plan_edge().is_some_and(|(edge, _)| outside(edge)) } -fn profile_on_box(profile: PlanProfile, bbox: [f64; 6]) -> Option { +/// `profile` where its plan extent is within `bbox`'s by [`BOX_TOLERANCE_FEET`] +/// and falls short of it by at most `slack` on each side. +fn profile_on_box(profile: PlanProfile, bbox: [f64; 6], slack: f64) -> Option { let extent = profile.plan_extent_feet()?; + let inward = [1.0, 1.0, -1.0, -1.0]; let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; extent .iter() .zip(wanted) - .all(|(got, want)| (got - want).abs() <= BOX_TOLERANCE_FEET) + .zip(inward) + .all(|((got, want), sign)| { + let short = (got - want) * sign; + short >= -BOX_TOLERANCE_FEET && short <= slack + BOX_TOLERANCE_FEET + }) .then_some(profile) } @@ -350,6 +473,17 @@ fn outline_from_faces( if let Some(edge) = reaches_anchor(face, bbox, anchor) { lines.push(edge); } + if resolve { + if let Some((low, high)) = face.vertical_cylinder_heights() { + let reached = match anchor { + OutlineAnchor::Floor => (low - bbox[2]).abs() <= BOX_TOLERANCE_FEET, + OutlineAnchor::Ceiling => (high - bbox[5]).abs() <= BOX_TOLERANCE_FEET, + }; + if reached { + lines.extend(face.arc_segments()?); + } + } + } } let profile = if resolve { snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); @@ -358,7 +492,7 @@ fn outline_from_faces( snap_ends(&mut lines); plan_profile_from_lines(&lines)? }; - profile_on_box(profile, bbox) + profile_on_box(profile, bbox, if resolve { ROOM_SLIVER_FEET } else { 0.0 }) } /// The room's outline from the solid whose header starts at `header` and the @@ -372,14 +506,14 @@ fn outline_with_faces( buf: &[u8], header: usize, bbox: [f64; 6], -) -> Option<(PlanProfile, Vec)> { +) -> Option<(PlanProfile, Vec, bool)> { let walked = solid_faces(buf, header); if let Some(profile) = outline_from_faces(&walked, bbox, OutlineAnchor::Floor, false) { - return Some((profile, walked)); + return Some((profile, walked, false)); } let span = solid_faces_to_next_solid(buf, header); let profile = outline_from_faces(&span, bbox, OutlineAnchor::Floor, true)?; - Some((profile, span)) + Some((profile, span, true)) } /// The room's outline from the solid whose header starts at `header`: its @@ -387,7 +521,7 @@ fn outline_with_faces( /// and resolved to a boundary where the faces up to the next solid's tag do /// (RE-316). Kept only where it spans `bbox`. pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option { - outline_with_faces(buf, header, bbox).map(|(profile, _)| profile) + outline_with_faces(buf, header, bbox).map(|(profile, _, _)| profile) } /// The outline the faces up to the next solid's tag make, resolved to a @@ -437,10 +571,12 @@ pub fn faces_form_prism(faces: &[RoomFace], bbox: [f64; 6]) -> bool { pub fn prism_of_solids(buf: &[u8], headers: &[usize], bbox: [f64; 6]) -> bool { let mut found = false; for &at in headers { - let Some((_, faces)) = outline_with_faces(buf, at, bbox) else { + let Some((_, faces, resolved)) = outline_with_faces(buf, at, bbox) else { continue; }; - if !faces_form_prism(&faces, bbox) { + if !faces_form_prism(&faces, bbox) + || (resolved && faces.iter().any(|face| face.cylinder.is_some())) + { return false; } found = true; From 728afe1e2a119e332d0a56bbea40bef373b08e5f Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:51:41 -0500 Subject: [PATCH 11/31] fix(rooms): an outline resolved from the faces up to the next solid carries no prism marker, its volume being unmeasured (#1260) (RE-316) --- src/research/partition_room_boundaries.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index f4414143..d0cd16e8 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -567,16 +567,16 @@ pub fn faces_form_prism(faces: &[RoomFace], bbox: [f64; 6]) -> bool { /// Whether the room's solids whose headers are at `headers` that give an /// outline against `bbox` ([`outline_of_solids`]) are all prisms of it -/// ([`faces_form_prism`]), and at least one gives an outline. +/// ([`faces_form_prism`]), and at least one gives an outline. An outline +/// resolved from the faces up to the next solid's tag (RE-316) is never a +/// prism here: no room's volume is measured against Revit's for them yet. pub fn prism_of_solids(buf: &[u8], headers: &[usize], bbox: [f64; 6]) -> bool { let mut found = false; for &at in headers { let Some((_, faces, resolved)) = outline_with_faces(buf, at, bbox) else { continue; }; - if !faces_form_prism(&faces, bbox) - || (resolved && faces.iter().any(|face| face.cylinder.is_some())) - { + if resolved || !faces_form_prism(&faces, bbox) { return false; } found = true; From c75433a18e1f9cb8797493c40bf7c1f6c45922ba Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:55:19 -0500 Subject: [PATCH 12/31] docs(rooms): RE-316, the cause of each unclosed room solid, what resolving them gives against Revit's export, and what keeps its box (#1260) (RE-316) --- .../changed/1260-room-solids-resolved.md | 17 +++ docs/evidence.md | 2 +- docs/rooms.md | 15 +-- docs/status.md | 6 +- docs/supported-profile.md | 2 +- .../RE-316-room-solids-resolved.md | 110 ++++++++++++++++++ 6 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 changelog.d/changed/1260-room-solids-resolved.md create mode 100644 reports/element-framing/RE-316-room-solids-resolved.md diff --git a/changelog.d/changed/1260-room-solids-resolved.md b/changelog.d/changed/1260-room-solids-resolved.md new file mode 100644 index 00000000..a3836533 --- /dev/null +++ b/changelog.d/changed/1260-room-solids-resolved.md @@ -0,0 +1,17 @@ +- **A room whose stored solid the walk did not close takes the outline of + the faces up to the solid's end instead of its record's box, where those + close on the box.** The walk stopped at a record longer than 0x200 bytes + between two faces, walls on lines up to 0.0015 ft apart left a step no + face crosses, a column overlapping a wall put a loop partly inside the + wall's, a solid ran past the 64 KB window, and a cylinder's face (a round + column) was read as a plane. On the BIM4LCA office as built 12 of the 13 + rooms that kept their box take an outline, and of the 11 that Revit's + export pairs 9 have its area to 0.1% and 2 are 0.5% and 0.7% off (the + solid has a column Revit's export does not take off); at building permit + 10 of the 12 take an outline, all 10 with Revit's area to 0.1%. Their + boxes were up to 329% over Revit's area. The six `rac_advanced` rooms + with round columns close in every release of the sample, which has no + Revit export to score them against; office room 1671995 and permit room + 3095831 and 49 of the 50 `rme` rooms keep their box, each for a measured + reason. Core Interior and RE1 Architecture are unchanged. An outline + read this way carries no `NetVolume` (#1260, RE-316). diff --git a/docs/evidence.md b/docs/evidence.md index 5f219080..dfc2fbf2 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -75,7 +75,7 @@ On Revit 2024 and 2025 project files, rvt-rs reads each element from its partiti | Electrical and mechanical devices and equipment | `IfcAlarm` (fire alarm devices), `IfcElectricAppliance` (data devices), proxies (switches, sensors, receptacles, junction boxes, panels, mechanical equipment) | RE1 Electrical and Mechanical, every one Revit exports and nothing else; the symbols nested in switches and receptacles left out, as Revit leaves them out (RE-79) | | Structural framing, columns, foundations | `IfcBeam` along its location line, `IfcColumn`, `IfcFooting` | Snowdon structural sample: all 1,078 in the category its VIM export gives them; 923 of 942 beams run along their line, with Revit's own section on 613 of the 839 a later edition kept unchanged, and on 179 more less the top a floor join cuts (RE-49); 377 steel beams and 40 steel columns are their type's I section, Revit's own on all 271 beams and 24 columns the VIM scores (RE-103, RE-104) | | Site elements, generic models, slab edges, elevators, ramps | proxies, `IfcTransportElement`, `IfcRamp` | Snowdon Towers, every one Revit exports | -| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid does not close keeps its box | +| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid does not close is resolved from the faces up to the solid's end where that closes on the box (the BIM4LCA office 12 of 13 rooms as built and 10 of 12 at permit, RE-316), and keeps its box otherwise | | Areas | `IfcSpace .SPACE.` for each placed Area of a Revit 2024 file, named as Revit's export names it (`Name` the number, `LongName` the name), in its Level's storey and typed by its own `IfcSpaceType`; no body, since the Area's boundary is not decoded | the BIM4LCA office as built and at building permit: all 30 of Revit's area spaces, number, name and GlobalId as Revit's (RE-191) | | Storeys | `IfcBuildingStorey` for each Revit Level that is a building story, with its name and elevation | Core Interior 15, Snowdon Towers 18, every RE1 model 2; name, elevation and GlobalId all Revit's (RE-24, RE-51); Snowdon Towers Structural 12 of its 19 Levels, its VIM export's building stories, 208 of the 210 elements on them in their storey (RE-118) | | Storey containment | each element in the storey of the Level its record names, of its base constraint where it names two, and, where it names none, of a railing's host, of the Level its work planes carry, or of its base elevation | Core Interior all 854 in Revit's storey; Snowdon Towers 5,949 in Revit's storey, 1 in another and 1 on none (RE-27, RE-59, RE-60, RE-68) | diff --git a/docs/rooms.md b/docs/rooms.md index ce10e989..ccfdceb1 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -1,6 +1,6 @@ # Rooms and spaces in an rvt-rs IFC export -This page is for the importer of an rvt-rs export: what an `IfcSpace` holds for each release, the one property that says whether to trust its area, what is measured against Revit's own export and where, and what is not. Every `IfcSpace` rvt-rs writes in a geometry export (the default) carries that property: rooms, Revit Spaces, family instances written as spaces and Areas alike. An export with no geometry (`typed_no_geometry`) has no outlines and no property. The evidence is in [RE-313](../reports/element-framing/RE-313-space-outlines-measured.md); the earlier findings are RE-29, RE-101, RE-102, RE-117, RE-153, RE-196, RE-255 and RE-296. +This page is for the importer of an rvt-rs export: what an `IfcSpace` holds for each release, the one property that says whether to trust its area, what is measured against Revit's own export and where, and what is not. Every `IfcSpace` rvt-rs writes in a geometry export (the default) carries that property: rooms, Revit Spaces, family instances written as spaces and Areas alike. An export with no geometry (`typed_no_geometry`) has no outlines and no property. The evidence is in [RE-313](../reports/element-framing/RE-313-space-outlines-measured.md) and, for the rooms whose stored solid the first reader did not close, [RE-316](../reports/element-framing/RE-316-room-solids-resolved.md); the earlier findings are RE-29, RE-101, RE-102, RE-117, RE-153, RE-196, RE-255 and RE-296. ## Which `IfcSpace`s rvt-rs writes @@ -19,8 +19,8 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: | Value | The body is | Its plan area is | |---|---|---| -| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on 604 of the 605 `stored_solid` rooms scored below, the office counted at both stages (RE-313 §2, §3) | -| `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's 24 room scores that keep their box | +| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on 623 of the 626 `stored_solid` rooms scored below, the office counted at both stages; the three others are 0.5%, 0.7% and 66% off (RE-313 §2, §3, RE-316 §3) | +| `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's 3 room scores that keep their box (1 as built, 2 at permit; RE-316 §4) | | `saved_mesh` | the mesh Revit saved for the room (`rvt-ifc --saved-meshes`) | the mesh's; not scored | | `none` | no body (an Area) | not available | | `family_body` | a Revit Space or a family instance written as an `IfcSpace`, drawn as more than its box; its `BodySource` says how | not scored | @@ -59,8 +59,8 @@ Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ |---|---:|---:|---:|---:|---| | Core Interior (2024) | 116 | 116 | 116 | 116 | 116 rooms, stored solid: 116, 116 | | RE1 Architecture (2025) | 11 | 11 | 11 | 11 | 11 rooms, stored solid: 11, 11 | -| BIM4LCA office as built (2024) | 276 of Revit's 308 | 221 | 221 | 211 | 220 rooms, stored solid: 220, 220; 12 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | -| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 222 | 222 | 216 | 222 rooms, stored solid: 221, 221; 12 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | +| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 231 rooms, stored solid: 229, 229; 1 room, record box: 0, 0; 44 family instances, record box: 1, 1 | +| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 232 | 232 | 225 | 232 rooms, stored solid: 231, 231; 2 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | | R2025 purpose-made models with rooms (2025), 6 models | 12 | 12 | 12 | 12 | 12 rooms, stored solid: 12, 12 | | Held-out models (2022, 2024, 2025), 6 models | 24 | 24 | 24 | 24 | 24 rooms, stored solid: 24, 24 | | Held-out models (2026, 2027), 4 models | 16 | 16 | 16 | 16 | 16 rooms, record box: 16, 16 (all rectangles) | @@ -70,7 +70,8 @@ A model with no rooms in Revit's export (the other R2025 models, RE1 Electrical, What the misses are: -- **Rooms whose stored solid does not close on the record's box (12 on each office file)** keep the box, and its area is wrong on all 12. +- **Rooms that keep their box (1 on the office as built, 2 at building permit)**: the span to the next solid's tag holds other elements' faces (room 1671995, whose box is 329% over Revit's area as built), or collinear overlaps leave 14 ends unjoined (room 3095831, permit). 12 rooms on each file kept their box before RE-316 resolved the rest from the faces up to the next solid's tag (RE-316 §1, §4). +- **Two rooms as built, 0.5% and 0.7% off in area** (2jhaHNpo55guKudM9wZa8C, 0qZtwNut1C6QT5uDplIc_N): the stored solid has a column that overlaps a wall and Revit's export does not take it off, as it does on seven rooms at building permit (RE-316 §3). - **Family instances written as spaces (44 on the office)** are 42 "Turning Free Space" discs and two "VOLUME" families that Revit's export draws from the family's geometry (a mapped representation); rvt-rs draws their record box, a square where the family is a circle (24.22 ft² against 19.01 ft² on the 1500 mm turning spaces listed). No issue covers these yet; #1262 and PR #1279 are about the terrain office's parking bays, a different set, though its rule (a family instance written as a space is left out where its family draws no model form) may change this one too. - **The office's other 30 spaces are its Areas**: Revit's export gives them a body, rvt-rs writes them without one (RE-191). - **One room at building permit** is read from its stored solid and is 66% off in area (172.77 against 103.90 ft²); its cause is not found (RE-313 §3). @@ -82,7 +83,7 @@ What the misses are: - Rooms of Revit 2026 and 2027 beyond the rectangles above (#1260, PR #1288). - A room's `Height` and `NetVolume` as scored by `tools/re/qtos_vs_ifc.py`, not by this scorer. - Revit's phase filter: Revit leaves out a room by its phase parameter, which is not measured to be its created phase (docs/status.md). -- Why 12 of the BIM4LCA office's 234 rooms keep their box on each file: the RE-101 probe says their solids do not close on the record's box; what is different about those solids is not found. +- Rooms whose stored solid closes only once read as a boundary (RE-316) on any release but Revit 2024: the evidence is one building in two states. The six `rac_advanced` rooms with round columns close in every release of the Autodesk sample, with no Revit export to score them against, and 49 of the 50 `rme` rooms (stepped floors and ceilings) keep their box. ## Check it yourself diff --git a/docs/status.md b/docs/status.md index 015b2439..d080e029 100644 --- a/docs/status.md +++ b/docs/status.md @@ -110,10 +110,10 @@ geometry (doors and windows are drawn from the mesh Revit saved for their display by default, and any other family instance with the experimental `--saved-meshes` option, RE-78), hip roofs over a T or U footprint, roofs sloped by several slope arrows of the configuration of puzzbobb's U, where Revit slopes the two long outer walls along the arms only (RE-307; no other roof of the configuration is measured), and roofs by extrusion outside Revit 2025, the top of walls Revit steps over a floor that covers them in part (29 of the BIM4LCA office's as built and 26 at permit, #1067), the boundaries of -rooms whose stored solid does not close and opening profiles are not -decoded, and rvt-rs does not edit Revit model data. +the rooms whose stored solid still does not close (RE-316 §4) and opening +profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). The rooms of Revit 2026 and 2027 are boxes until their stored solids are read (#1260, PR #1288). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the first reader did not close takes the outline of the faces up to the solid's end, resolved to a boundary: 12 of the BIM4LCA office's 13 such rooms as built and 10 of 12 at building permit, with Revit's area to 0.1% on 19 of the 21 Revit's export pairs and 0.5% and 0.7% on the other two (RE-316); the 6 `rac_advanced` rooms with round columns close with no Revit export to score them against, and 49 of the 50 `rme` rooms keep their box. [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history diff --git a/docs/supported-profile.md b/docs/supported-profile.md index 92ce82a1..d4865f98 100644 --- a/docs/supported-profile.md +++ b/docs/supported-profile.md @@ -112,7 +112,7 @@ counts reported on 2026-11-02 and 2026-11-03 on 0.12.0): | Stairs | aggregates of flights, landings and supports, each stair's and flight's `PredefinedType` from its runs and landings; straight flights as their steps, monolithic ones included; riserless flights as their treads, spiral ones as their sector-shaped treads; a monolithic landing as its slab, and a landing that is one tread as that tread | Snowdon Towers 33 of 38 drawn flights equal to Revit's; on the BIM4LCA office (Revit 2024) all 35 flights have Revit's volume, its 34 straight ones and its spiral one, its 21 monolithic landings Revit's top and outline, and its tread landing Revit's body; on puzzbobb's Revit 2025 stair models every flight of models 13 to 16 has Revit's volume (RE-214, RE-217). A monolithic landing's underside is drawn flat where Revit's follows a flight's soffit, 100.4% to 104.7% of Revit's volume, and its `BodySource` says so; monolithic spiral runs and riserless runs with a nosing keep their box (#357) | | Curtain walls | aggregates of panels and mullions placed by their grids; a mullion or panel turned or tilted off the model's axes as the box along its own axes (RE-106) | every Snowdon panel and mullion relation Revit's; 1,019 turned or tilted mullions and panels with Revit's extents along their axes | | Doors, windows, openings | the element's box, turned with the instance where it is turned (RE-87); its opening is as deep as the host wall | host is Revit's on 138 of 138 (Core Interior) and 192 of 194 (Snowdon; the other 2 in another wall Revit cuts, RE-85), and on Revit 2024 and 2025 the wall its native record names, Revit's on all 463 of the BIM4LCA office's as built (RE-245); on Revit 2024 and 2025 the opening is the outline the symbol that draws it cuts, through the instance's transform: 138 of 138 on Core Interior and 4 of 4 on RE1 within 0.001 ft of Revit's (RE-215, #227; Measure run 40927543524); a window whose type stores Width, Height and Default Sill Height takes Revit's opening exactly (56 of 68 on Snowdon, RE-93), and a door whose body is its type's Rough Height tall its rough opening (57 of 126, RE-94); any other door whose type stores Width and Height carries them as its overall size (137 of 137 on Core Interior and RE1, RE-187; Measure run 40927543524); on Revit 2024 and 2025 a door's or window's overall size is the outline the symbol that draws it cuts, Revit's on every one with such an outline (138 of 138 on Core Interior, 4 of 5 on RE1, RE-215); a door or window whose symbol cuts no outline takes its own extents as its overall size, Revit's on all 269 windows and 266 of 269 doors of the BIM4LCA office (RE-218); otherwise not Revit's opening profile (#227, RE-83) | -| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); all 220 of the BIM4LCA office's rooms read from their stored solid as built (221 of 222 at building permit) at Revit's area and outline, while its 12 rooms that keep their box and its 44 family-instance spaces drawn as boxes almost never are (RE-313 §3); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | +| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); 229 of the BIM4LCA office's 231 rooms read from their stored solid as built (231 of 232 at building permit) at Revit's area and outline, the others 0.5%, 0.7% and 66% off, while its 3 room scores that keep their box and its 44 family-instance spaces drawn as boxes almost never are (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | | Areas | `IfcSpace` with number, name and storey; no body | all 30 of Revit's area spaces on the BIM4LCA office, as built and at building permit (RE-191); Revit 2024 only | | Furniture, fixtures, equipment, MEP devices, site | the element's bounding box, turned with the instance where it is turned (RE-87) | placement and extent only; no family geometry. MEP devices and equipment are measured on RE1 Electrical and Mechanical only (RE-79) | | Family instances from their saved meshes (doors and windows by default on Revit 2023 to 2027, every other family with `--saved-meshes`) | the mesh Revit saved for the element's display | within 0.01 ft of Revit's export: Core Interior 158 of 158, RE1 (2025) 207 of 214, Snowdon Towers 3,332 of 3,736. Revit's own shape, every vertex within 0.001 ft: Core Interior 138 of 138 doors and windows, the BIM4LCA office 1,294 of 1,709 scored (all its windows; 48 of 269 doors), RE1 148 of 214; a mesh that is not exact is part of the body (RE-78). Since RE-224 a mesh some face of which did not decode keeps its box: on the office 1,294 of the 1,352 bodies then drawn are exact (1,376 of 1,438 at permit) | diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md new file mode 100644 index 00000000..f17a1932 --- /dev/null +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -0,0 +1,110 @@ +# RE-316 — Rooms whose stored solid the walk did not close + +**Date:** 2026-11-03 +**Issues:** #1260 (rooms first); follows RE-101 and RE-102 (a room's outline from its stored solid's faces), RE-313 (every `IfcSpace` scored against Revit's export) and RE-314 +**Artefacts:** the BIM4LCA office as built and at building permit (Revit 2024) with Revit 24.0.5's IFC4 exports; Autodesk's `rac_advanced`, `rac_basic`, `rme_basic` and `rme_advanced` sample projects (no Revit export of them is in CI); Core Interior and RE1 Architecture with Revit's exports; the R2025 models and the held-out models (counts only), all as Measure fetches them +**Probes:** `probes/examples/probe_re316_room_solid_breaks.rs`, `probe_re316_room_native_faces.rs`, `probe_re316_solid_vertices.rs` and `probe_re316_room_closure.rs` +**Runs:** Measure [41670081379](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41670081379) and [41673400892](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673400892) (what breaks), [41673755000](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673755000) (the native record), [41676245766](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41676245766) (the topology before the first face), [41686457484](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41686457484) (the rule, with the space scorer); the final change against `main`, unfiltered, in §3 +**Result:** positive on the office, narrowed. Of the 13 rooms of the office as built whose solids did not close, 12 now take an outline. The scorer pairs 11 of those with Revit's: 9 have Revit's area to 0.1% and Revit's outline to 0.01 ft, and 2 are 0.5% and 0.7% off. At building permit 10 of the 12 take an outline, all 10 with Revit's area to 0.1% and Revit's outline to 0.01 ft. The record box was off Revit's area by up to 329% on these rooms. Room 1671995 (both files) and 3095831 (permit) keep their box, and so do 49 of the 50 `rme` rooms, each for a measured reason (§4). + +## 1. Why the solids did not close + +RE-101 reads a room's stored solid as a run of planar faces, each within 0x200 bytes of the one before, and joins the plan edges of the faces that reach the room's floor. `probe_re316_room_solid_breaks` printed, for every room that kept its box, the faces of the walk, the faces beyond it and up to the next solid's tag, and the vertices that had fewer or more than two edges. Six things stop the walk or the loops: + +| Cause | Office as built | Office at permit | Autodesk samples | +|---|---|---|---| +| **A record longer than 0x200 bytes sits between two faces.** The walk stops there; the faces after it, up to the next solid's tag, are the room's own (a 626 byte record in room 1422302) | 1422302, 1444351, 1736726, 1832504 | 1436086, 1480292, 1743273, 1743715 | | +| **Two walls stand on lines 0.0007 to 0.0015 ft apart** and the room's faces leave a step with no face across it; the ends are further apart than the 1e-4 ft RE-101 joins (RE1 room 355008 had 3e-5) | 1383998, 1493531, 1681182, 1700966, 1736690, 2297732 | | | +| **A column overlaps a wall.** A face's box is its plane's, not its outline: a wall's face runs the whole wall and the column's footprint overlaps it, so a loop of the column is partly inside the wall's loop and RE-101 refuses it | 1820322, 1832504 (also), 2066902 | 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 (also), 2302311 | | +| **A solid longer than the 64 KB window**: room 1681182 has 115 faces and the last is 84 KB in | 1681182 | | | +| **Round columns.** A face whose record is 44 bytes longer than a plane's (202 bytes against 174 on 2024) is a cylinder's: the axis (a unit vector at right angles to the face's u and v) at +158, the radius at +182, and a uv box that is an angle and a distance along the axis. `face_at` read it as a plane and `kind()` calls it horizontal, so its arc was not an edge and the loops stayed open | | | `rac_advanced`, 6 of 91 rooms in every release 2016 to 2023: room 177330's arcs, worked out from the radius 0.4921 ft and the angles 0.2007 and 6.0825, end at (41.9941, -50.1639) and (41.9941, -50.3601), the ends of the planar necks between them and the wall, to the four decimals the probe prints | +| **Stepped floors and ceilings.** A room whose floor is raised (a 0.4921 ft step) has walls that start at two heights, a riser between them, and soffits that stop short of the ceiling; the walls that reach the floor do not enclose it | | | `rme_basic`, `rme_advanced`: 49 of 50 | + +Two readings were measured and are negative: + +- **The room's native record does not hold the solid.** On all 13 office rooms, 50 `rme` rooms and 6 `rac_advanced` rooms it holds two `Face` objects, the floor and ceiling planes, with no trim loop, and no `Edge` or `EdgeLoop` object (`probe_re316_room_native_faces`). +- **The topology before the first face holds no vertices.** Between the header (`01 00 00 00 03 00 00 00` and the solid's box twice) and the first face (4,788 to 64,547 bytes later) the solid lists its objects as `u16 class · u32 token` pointers after a counted list tag (`04 c0 08 00 · u32 count`); no run of three `f64` that is a point of the room's box appears in it other than the two boxes (`probe_re316_solid_vertices`). + +## 2. The rule + +Where the faces RE-101 walks close on the room's record box, nothing changes. Where they do not, `partition_room_boundaries` now reads every face up to the next solid's tag (`solid_faces_to_next_solid`; none if no tag follows within 4 MiB) and rules the solid out if any vertical face reaches outside the record box, which holds the whole solid (113 of room 1671995's 327 faces do). Each wall face's plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office RE-101 keeps, on Core Interior's 116, RE1's 11 and the 26 to 85 each of the Autodesk samples keep, the directed edges join head to tail. A round column's cylinder is read as an arc, sampled to 0.0005 ft, run clockwise, and joined to the walls only where its ends meet theirs. + +`region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column that overlaps a wall takes its footprint off the room where they overlap and adds nothing outside; a step of up to 0.0025 ft (Revit's short-curve tolerance is 1/32 inch) joins two ends; and the outline may fall up to 0.02 ft short of the record box where a column's back stands behind the wall's face (0.0164 ft on three office rooms), but never beyond it. + +An outline resolved this way carries no prism marker: no room's volume is measured against Revit's for them. + +## 3. Measured + +### The office, against Revit's own IFC4 export + +Room by room, the record box's area, Revit's area, and the outline's area (ft²), as built: + +| Room | Box | Revit | Outline | Cause | +|---|---:|---:|---:|---| +| 1383998 | 1708.89 | 1020.46 | 1020.46 | step | +| 1422302 | 362.30 | 291.54 | 291.54 | record between faces | +| 1444351 | 362.31 | 291.56 | 291.56 | record between faces | +| 1493531 | 362.31 | 291.56 | 291.55 | step | +| 1681182 | 5963.96 | 3818.36 | 3818.36 | step, long solid | +| 1700966 | 4774.18 | 3259.86 | 3259.85 | step | +| 1736690 | 2846.73 | 2049.24 | 2049.25 | step | +| 1736726 | 367.14 | 293.43 | 293.43 | record between faces | +| 1820322 | 1882.11 | none (not paired) | 1865.97 | column overlaps wall | +| 1832504 | 465.18 | 440.31 | 437.31 | column overlaps wall | +| 2066902 | 1542.77 | 638.23 | 635.23 | column overlaps wall | +| 2297732 | 135.82 | 131.76 | 131.76 | step | +| 1671995 | 5228.48 | 1218.33 | box | the span runs into other elements | + +The two rooms 0.5% and 0.7% off are the ones where the stored solid has a column that overlaps the wall by 0.0164 ft and Revit's export does not take it off: the outline is the solid's, with the 3.0 ft² notch, and the boundary distance to Revit's is the notch's depth, 1.329 ft. On seven rooms at building permit Revit takes the same kind of column off (1.722 ft² each), as the outline does. Room 1820322 is one of the two rooms whose top surface Revit's export gives no area, so it is not scored. + +At building permit the 10 rooms 1436086, 1480292, 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 take outlines and all 10 have Revit's area to 0.1% (the largest, 1736720, 3265.29 against Revit's 3265.29). Rooms 1671995 (box 5228.48, Revit 1145.89) and 3095831 (box 6642.03, Revit 1864.74) keep their box. + +`space_profiles_vs_ifc` (run 41686457484, `main` with this change): + +| | Area within 0.1% | within 1% | Room, stored solid | Room, record box | +|---|---:|---:|---|---:| +| Office as built | 221 → 230 | 221 → 232 | 220 → 231 (229 within 0.1%) | 12 → 1 | +| Office at permit | 222 → 232 | 222 → 232 | 222 → 232 (231 within 0.1%) | 12 → 2 | +| Core Interior | 116 | 116 | 116 | 0 | +| RE1 Architecture | 11 | 11 | 11 | 0 | + +The one stored-solid room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, 66% in area) is the one RE-313 left unexplained and is unchanged. + +### The Autodesk samples (no Revit export) + +`probe_re316_room_closure` on the 2019 and 2023 saves (the other releases' counts are in the run summary): + +| Project | Rooms | Closed by RE-101 | Closed now | Box | +|---|---:|---:|---:|---:| +| `rac_basic` | 14 | 14 | 14 | 0 | +| `rac_advanced` | 91 | 85 | 91 | 0 | +| `rme_basic`, `rme_advanced` | 76 | 26 | 27 | 49 | + +The six `rac_advanced` rooms close because of their cylinders. Their arcs end at the ends of the planar necks, which is a check no guess of the radius would pass, but no Revit export of the sample says their area. 43 of the 85 rooms RE-101 keeps in `rac_advanced` have round columns it ignores: the outline with the columns taken off is 0.2% to 1.2% smaller (room 177304: 3531.7 ft² against 3521.8). They are left as they were. + +### Everything else + +PENDING: Measure unfiltered against `main` (run 41688293656). + +## 4. What keeps its box + +- **Office room 1671995, both files**: the span to the next solid's tag holds 327 faces, 113 of which reach outside the room's box. Without that rule the same code gave an outline of 5154 ft² against Revit's 1218 (1146 at permit), spanning the box: a wrong outline. The room's solid is not bounded by its record. +- **Office room 3095831 (permit)**: 14 ends at y = 35.269 ft, in pairs 0.656 and 1.312 ft apart down a 100 ft wall, are not joined; the columns along it share the wall's line. +- **49 of the 50 `rme` rooms**: stepped floors and ceilings. The faces that reach the ceiling instead of the floor close for 33 of the 50, and 24 of those are the box itself, but 9 differ from it (8 by 1% to 11%, and room 575874 at 22%: 5105 ft² against a box of 23323), no Revit export of these rooms exists, and a bulkhead that hangs from the ceiling would be taken for a wall. They stay boxes. +- **The 44 family instances written as spaces** are #1262's. + +## 5. Not claimed + +- That a resolved outline equals Revit's outline on a release other than 2024: the evidence is one building, in two states. No room of another release is scored. +- That a cylinder's arc is Revit's on any room: `rac_advanced` has no export. +- Any volume of a resolved room (no prism marker is written). +- That 12 of 13 and 10 of 12 will hold on another building: the rules were found on these 25 rooms, and the held-out counts in §3 are the only test on others. + +## 6. Reproduce + +```bash +gh workflow run measure.yml --ref main -f ref= -f base=none -f samples=true -f sample_years="2019 2023" \ + -f nordic=true -f models="nordic-office-* core-interior re1-architecture" \ + -f probe=probe_re316_room_closure -f probe_scorer=- -f scorers=space_profiles_vs_ifc +gh workflow run measure.yml --ref main -f ref= -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true +``` From 151c855ff7f52e7b7113e635493dcc56a1085bd2 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:01:26 -0500 Subject: [PATCH 13/31] fix(rooms): an outline that falls short of the record box is not resolved, and a solid with a round column is left to its box, since Revit's export does not always take either off (#1260) (RE-316) --- probes/examples/probe_re316_room_closure.rs | 27 ++--------- src/research/partition_room_boundaries.rs | 51 +++++++++++---------- 2 files changed, 32 insertions(+), 46 deletions(-) diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs index 4d92afca..379eb66e 100644 --- a/probes/examples/probe_re316_room_closure.rs +++ b/probes/examples/probe_re316_room_closure.rs @@ -118,7 +118,6 @@ fn main() -> anyhow::Result<()> { .rooms; let mut tally: BTreeMap<&str, usize> = BTreeMap::new(); let (mut oriented_ok, mut oriented_total) = (0, 0); - let (mut resolved_same, mut resolved_total) = (0, 0); let mut cached_new = (0, 0); let mut cached_ceiling = (0, 0); for room in &rooms { @@ -192,7 +191,8 @@ fn main() -> anyhow::Result<()> { } } let now = room_outline_at(buf, header, bbox); - let ceiling = span_outline_at(buf, header, bbox, OutlineAnchor::Ceiling); + let ceiling = span_outline_at(buf, header, bbox, OutlineAnchor::Ceiling, false); + let with_arcs = span_outline_at(buf, header, bbox, OutlineAnchor::Floor, true); let class = match (&walked, &now) { (Some(_), Some(_)) => { if walked == now { @@ -206,23 +206,6 @@ fn main() -> anyhow::Result<()> { (None, None) => "still box", }; *tally.entry(class).or_default() += 1; - if walked.is_some() { - let resolved = span_outline_at(buf, header, bbox, OutlineAnchor::Floor); - resolved_total += 1; - let same = match (&walked, &resolved) { - (Some(a), Some(b)) => (area(a) - area(b)).abs() <= 1e-4 * area(a), - _ => false, - }; - if same { - resolved_same += 1; - } else { - println!( - "room {id} {stream}: RE-101 outline {} but the resolved span gives {}", - walked.as_ref().map_or(0.0, area), - resolved.as_ref().map_or(f64::NAN, area) - ); - } - } if walked.is_some() && now.is_some() && walked == now { continue; } @@ -291,7 +274,7 @@ fn main() -> anyhow::Result<()> { ); } println!( - "room {id} {stream} box {box_area:.3} ({}) walked {} now {} ceiling {} [{class}]", + "room {id} {stream} box {box_area:.3} ({}) walked {} now {} ceiling {} with arcs {} [{class}]", if in_cache(&cache, box_area) { "cached" } else { @@ -300,15 +283,13 @@ fn main() -> anyhow::Result<()> { text(&walked, &cache), text(&now, &cache), text(&ceiling, &cache), + text(&with_arcs, &cache), ); } println!("release {version}: {} rooms, {tally:?}", rooms.len()); println!( "plan edges directed head to tail on {oriented_ok} of the {oriented_total} rooms RE-101 kept" ); - println!( - "the resolved span's outline has the area of the walk's on {resolved_same} of the {resolved_total} rooms RE-101 kept" - ); println!( "newly closed rooms whose area Revit's cache holds: {} of {}; ceiling-anchored outlines of rooms still boxes whose area it holds: {} of {}", cached_new.0, cached_new.1, cached_ceiling.0, cached_ceiling.1 diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index d0cd16e8..f95ad9dc 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -39,7 +39,12 @@ //! solid's tag are read instead ([`solid_faces_to_next_solid`]), each plan //! edge directed so the room is on its left, and the room's outline is the //! boundary of the region those edges wind around ([`region_boundary`], -//! RE-316). +//! RE-316). A face whose record is 44 bytes longer than a plane's is a +//! cylinder's (a round column): its axis, a unit vector at right angles to +//! the face's u and v, at +158, and its radius at +182 ([`RoomCylinder`]). Its +//! arc closes the loops of the six `rac_advanced` rooms RE-101 left open, but +//! whether Revit takes a column off a room's area is unmeasured (it does not +//! on two office rooms), so a solid with one is left unresolved. //! //! Core Interior keeps a room's record and solid in more than one partition, //! and the copies can disagree: room 20832's outline spans 10.5 ft in one and @@ -105,12 +110,6 @@ pub const ROOM_VERTEX_TOLERANCE_FEET: f64 = 1e-4; /// BIM4LCA office; Revit's own short-curve tolerance is 1/32 inch, 0.0026 ft. pub const ROOM_STEP_TOLERANCE_FEET: f64 = 0.0025; -/// How far a resolved outline may fall short of the room's record box on a -/// side and still span it, feet: the back of a column that overlaps a wall -/// stands 0.0164 ft behind the wall's face on three rooms of the BIM4LCA -/// office, and the box holds it. -pub const ROOM_SLIVER_FEET: f64 = 0.02; - /// How far a chord of a column's arc may stray from the arc, feet (as /// [`crate::element_record_plan_profiles::SKETCH_ARC_TOLERANCE_FEET`]). const ARC_CHORD_TOLERANCE_FEET: f64 = 0.0005; @@ -437,20 +436,13 @@ fn strays_from_box(face: &RoomFace, bbox: [f64; 6]) -> bool { face.plan_edge().is_some_and(|(edge, _)| outside(edge)) } -/// `profile` where its plan extent is within `bbox`'s by [`BOX_TOLERANCE_FEET`] -/// and falls short of it by at most `slack` on each side. -fn profile_on_box(profile: PlanProfile, bbox: [f64; 6], slack: f64) -> Option { +fn profile_on_box(profile: PlanProfile, bbox: [f64; 6]) -> Option { let extent = profile.plan_extent_feet()?; - let inward = [1.0, 1.0, -1.0, -1.0]; let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; extent .iter() .zip(wanted) - .zip(inward) - .all(|((got, want), sign)| { - let short = (got - want) * sign; - short >= -BOX_TOLERANCE_FEET && short <= slack + BOX_TOLERANCE_FEET - }) + .all(|(got, want)| (got - want).abs() <= BOX_TOLERANCE_FEET) .then_some(profile) } @@ -464,16 +456,20 @@ fn outline_from_faces( bbox: [f64; 6], anchor: OutlineAnchor, resolve: bool, + arcs: bool, ) -> Option { if resolve && faces.iter().any(|face| strays_from_box(face, bbox)) { return None; } + if resolve && !arcs && faces.iter().any(|face| face.cylinder.is_some()) { + return None; + } let mut lines = Vec::new(); for face in faces { if let Some(edge) = reaches_anchor(face, bbox, anchor) { lines.push(edge); } - if resolve { + if resolve && arcs { if let Some((low, high)) = face.vertical_cylinder_heights() { let reached = match anchor { OutlineAnchor::Floor => (low - bbox[2]).abs() <= BOX_TOLERANCE_FEET, @@ -492,7 +488,7 @@ fn outline_from_faces( snap_ends(&mut lines); plan_profile_from_lines(&lines)? }; - profile_on_box(profile, bbox, if resolve { ROOM_SLIVER_FEET } else { 0.0 }) + profile_on_box(profile, bbox) } /// The room's outline from the solid whose header starts at `header` and the @@ -508,11 +504,11 @@ fn outline_with_faces( bbox: [f64; 6], ) -> Option<(PlanProfile, Vec, bool)> { let walked = solid_faces(buf, header); - if let Some(profile) = outline_from_faces(&walked, bbox, OutlineAnchor::Floor, false) { + if let Some(profile) = outline_from_faces(&walked, bbox, OutlineAnchor::Floor, false, false) { return Some((profile, walked, false)); } let span = solid_faces_to_next_solid(buf, header); - let profile = outline_from_faces(&span, bbox, OutlineAnchor::Floor, true)?; + let profile = outline_from_faces(&span, bbox, OutlineAnchor::Floor, true, false)?; Some((profile, span, true)) } @@ -525,15 +521,24 @@ pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option Option { - outline_from_faces(&solid_faces_to_next_solid(buf, header), bbox, anchor, true) + outline_from_faces( + &solid_faces_to_next_solid(buf, header), + bbox, + anchor, + true, + arcs, + ) } /// Whether `faces`, the faces of a room's solid, make a prism of the room's From fdaa161521b80db0a336f2cfb78bacfe6c03b3d2 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:03:40 -0500 Subject: [PATCH 14/31] docs(rooms): RE-316 as it lands, the nine and ten rooms that close at Revit's area and the rooms that keep their box (#1260) (RE-316) --- .../changed/1260-room-solids-resolved.md | 23 +++++----- docs/evidence.md | 2 +- docs/rooms.md | 11 ++--- docs/status.md | 2 +- docs/supported-profile.md | 2 +- .../RE-316-room-solids-resolved.md | 46 +++++++++---------- 6 files changed, 40 insertions(+), 46 deletions(-) diff --git a/changelog.d/changed/1260-room-solids-resolved.md b/changelog.d/changed/1260-room-solids-resolved.md index a3836533..f2c9d5c3 100644 --- a/changelog.d/changed/1260-room-solids-resolved.md +++ b/changelog.d/changed/1260-room-solids-resolved.md @@ -3,15 +3,14 @@ close on the box.** The walk stopped at a record longer than 0x200 bytes between two faces, walls on lines up to 0.0015 ft apart left a step no face crosses, a column overlapping a wall put a loop partly inside the - wall's, a solid ran past the 64 KB window, and a cylinder's face (a round - column) was read as a plane. On the BIM4LCA office as built 12 of the 13 - rooms that kept their box take an outline, and of the 11 that Revit's - export pairs 9 have its area to 0.1% and 2 are 0.5% and 0.7% off (the - solid has a column Revit's export does not take off); at building permit - 10 of the 12 take an outline, all 10 with Revit's area to 0.1%. Their - boxes were up to 329% over Revit's area. The six `rac_advanced` rooms - with round columns close in every release of the sample, which has no - Revit export to score them against; office room 1671995 and permit room - 3095831 and 49 of the 50 `rme` rooms keep their box, each for a measured - reason. Core Interior and RE1 Architecture are unchanged. An outline - read this way carries no `NetVolume` (#1260, RE-316). + wall's, and a solid ran past the 64 KB window. On the BIM4LCA office as + built 9 of the 13 rooms that kept their box take an outline and all 9 + have Revit's area to 0.1% (their boxes were 3% to 67% over it); at + building permit 10 of the 12 do, all 10 to 0.1%. Rooms whose outline + would stand short of their box, whose span runs into other elements, + whose solid holds a round column or whose floors and ceilings step keep + their box: three more office rooms as built, two at permit, the six + `rac_advanced` rooms with round columns and 49 of the 50 `rme` rooms, + each for a measured reason. Core Interior and RE1 Architecture are + unchanged. An outline read this way carries no `NetVolume` (#1260, + RE-316). diff --git a/docs/evidence.md b/docs/evidence.md index 23431db5..6a8551e1 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -75,7 +75,7 @@ On Revit 2024 and 2025 project files, rvt-rs reads each element from its partiti | Electrical and mechanical devices and equipment | `IfcAlarm` (fire alarm devices), `IfcElectricAppliance` (data devices), proxies (switches, sensors, receptacles, junction boxes, panels, mechanical equipment) | RE1 Electrical and Mechanical, every one Revit exports and nothing else; the symbols nested in switches and receptacles left out, as Revit leaves them out (RE-79) | | Structural framing, columns, foundations | `IfcBeam` along its location line, `IfcColumn`, `IfcFooting` | Snowdon structural sample: all 1,078 in the category its VIM export gives them; 923 of 942 beams run along their line, with Revit's own section on 613 of the 839 a later edition kept unchanged, and on 179 more less the top a floor join cuts (RE-49); 377 steel beams and 40 steel columns are their type's I section, Revit's own on all 271 beams and 24 columns the VIM scores (RE-103, RE-104) | | Site elements, generic models, slab edges, elevators, ramps | proxies, `IfcTransportElement`, `IfcRamp` | Snowdon Towers, every one Revit exports | -| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid does not close is resolved from the faces up to the solid's end where that closes on the box (the BIM4LCA office 12 of 13 rooms as built and 10 of 12 at permit, RE-316), and keeps its box otherwise | +| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid does not close is resolved from the faces up to the solid's end where that closes on the box (the BIM4LCA office 9 of 13 rooms as built and 10 of 12 at permit, all at Revit's area, RE-316), and keeps its box otherwise | | Areas | `IfcSpace .SPACE.` for each placed Area of a Revit 2024 file, named as Revit's export names it (`Name` the number, `LongName` the name), in its Level's storey and typed by its own `IfcSpaceType`; no body, since the Area's boundary is not decoded | the BIM4LCA office as built and at building permit: all 30 of Revit's area spaces, number, name and GlobalId as Revit's (RE-191) | | Storeys | `IfcBuildingStorey` for each Revit Level that is a building story, with its name and elevation | Core Interior 15, Snowdon Towers 18, every RE1 model 2; name, elevation and GlobalId all Revit's (RE-24, RE-51); Snowdon Towers Structural 12 of its 19 Levels, its VIM export's building stories, 208 of the 210 elements on them in their storey (RE-118) | | Storey containment | each element in the storey of the Level its record names, of its base constraint where it names two, and, where it names none, of a railing's host, of the Level its work planes carry, or of its base elevation | Core Interior all 854 in Revit's storey; Snowdon Towers 5,949 in Revit's storey, 1 in another and 1 on none (RE-27, RE-59, RE-60, RE-68) | diff --git a/docs/rooms.md b/docs/rooms.md index 77677715..dc5a59e0 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -19,8 +19,8 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: | Value | The body is | Its plan area is | |---|---|---| -| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on 623 of the 626 `stored_solid` rooms scored below, the office counted at both stages; the three others are 0.5%, 0.7% and 66% off (RE-313 §2, §3, RE-316 §3) | -| `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's 3 room scores that keep their box (1 as built, 2 at permit; RE-316 §4) | +| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on 623 of the 624 `stored_solid` rooms scored below, the office counted at both stages; the other is 66% off (RE-313 §2, §3, RE-316 §3) | +| `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's 5 room scores that keep their box (3 as built, 2 at permit; RE-316 §4) | | `saved_mesh` | the mesh Revit saved for the room (`rvt-ifc --saved-meshes`) | the mesh's; not scored | | `none` | no body (an Area) | not available | | `family_body` | a Revit Space or a family instance written as an `IfcSpace`, drawn as more than its box; its `BodySource` says how | not scored | @@ -59,7 +59,7 @@ Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ |---|---:|---:|---:|---:|---| | Core Interior (2024) | 116 | 116 | 116 | 116 | 116 rooms, stored solid: 116, 116 | | RE1 Architecture (2025) | 11 | 11 | 11 | 11 | 11 rooms, stored solid: 11, 11 | -| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 231 rooms, stored solid: 229, 229; 1 room, record box: 0, 0; 44 family instances, record box: 1, 1 | +| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 229 rooms, stored solid: 229, 229; 3 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | | BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 232 | 232 | 225 | 232 rooms, stored solid: 231, 231; 2 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | | R2025 purpose-made models with rooms (2025), 6 models | 12 | 12 | 12 | 12 | 12 rooms, stored solid: 12, 12 | | Held-out models (2022, 2024, 2025), 6 models | 24 | 24 | 24 | 24 | 24 rooms, stored solid: 24, 24 | @@ -74,8 +74,7 @@ Held-out figures are Measure run 41680717786 (`main` at 21cb2efd), counts only. What the misses are: -- **Rooms that keep their box (1 on the office as built, 2 at building permit)**: the span to the next solid's tag holds other elements' faces (room 1671995, whose box is 329% over Revit's area as built), or collinear overlaps leave 14 ends unjoined (room 3095831, permit). 12 rooms on each file kept their box before RE-316 resolved the rest from the faces up to the next solid's tag (RE-316 §1, §4). -- **Two rooms as built, 0.5% and 0.7% off in area** (2jhaHNpo55guKudM9wZa8C, 0qZtwNut1C6QT5uDplIc_N): the stored solid has a column that overlaps a wall and Revit's export does not take it off, as it does on seven rooms at building permit (RE-316 §3). +- **Rooms that keep their box (4 on the office as built, 2 at building permit; 3 and 2 of them scored)**: the span to the next solid's tag holds other elements' faces (room 1671995, whose box is 329% over Revit's area as built); a column overlaps a wall and the outline falls 0.0164 ft short of the box (1820322, 1832504 and 2066902 as built, which a 0.02 ft slack would close at 0.5% and 0.7% off Revit's area on the two scored, because Revit's export does not take that column off); or collinear overlaps leave 14 ends unjoined (room 3095831, permit). 12 rooms on each file kept their box before RE-316 resolved the rest from the faces up to the next solid's tag (RE-316 §1, §4). - **Family instances written as spaces (44 on the office)** are 42 "Turning Free Space" discs and two "VOLUME" families that Revit's export draws from the family's geometry (a mapped representation); rvt-rs draws their record box, a square where the family is a circle (24.22 ft² against 19.01 ft² on the 1500 mm turning spaces listed). No issue covers these yet; #1262 and PR #1279 are about the terrain office's parking bays, a different set, though its rule (a family instance written as a space is left out where its family draws no model form) may change this one too. - **The office's other 30 spaces are its Areas**: Revit's export gives them a body, rvt-rs writes them without one (RE-191). - **One room at building permit** is read from its stored solid and is 66% off in area (172.77 against 103.90 ft²); its cause is not found (RE-313 §3). @@ -87,7 +86,7 @@ What the misses are: - Room outlines of Revit 2026 and 2027 beyond the rectangles: on the held-out house none is within 0.01 ft of Revit's (#1260). - A room's `Height` and `NetVolume` as scored by `tools/re/qtos_vs_ifc.py`, not by this scorer. - Revit's phase filter: Revit leaves out a room by its phase parameter, which is not measured to be its created phase (docs/status.md). -- Rooms whose stored solid closes only once read as a boundary (RE-316) on any release but Revit 2024: the evidence is one building in two states. The six `rac_advanced` rooms with round columns close in every release of the Autodesk sample, with no Revit export to score them against, and 49 of the 50 `rme` rooms (stepped floors and ceilings) keep their box. +- Rooms whose stored solid closes only once read as a boundary (RE-316) on any release but Revit 2024: the evidence is one building in two states. The six `rac_advanced` rooms with round columns (read as arcs, they would close in every release of the Autodesk sample, but whether Revit takes a column off a room's area is not measured) and 49 of the 50 `rme` rooms (stepped floors and ceilings) keep their box. ## Check it yourself diff --git a/docs/status.md b/docs/status.md index d080e029..2d64a01d 100644 --- a/docs/status.md +++ b/docs/status.md @@ -113,7 +113,7 @@ walls Revit steps over a floor that covers them in part (29 of the BIM4LCA offic the rooms whose stored solid still does not close (RE-316 §4) and opening profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the first reader did not close takes the outline of the faces up to the solid's end, resolved to a boundary: 12 of the BIM4LCA office's 13 such rooms as built and 10 of 12 at building permit, with Revit's area to 0.1% on 19 of the 21 Revit's export pairs and 0.5% and 0.7% on the other two (RE-316); the 6 `rac_advanced` rooms with round columns close with no Revit export to score them against, and 49 of the 50 `rme` rooms keep their box. [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the first reader did not close takes the outline of the faces up to the solid's end, resolved to a boundary: 9 of the BIM4LCA office's 13 such rooms as built and 10 of 12 at building permit, all 19 with Revit's area to 0.1% (RE-316); the rest, the 6 `rac_advanced` rooms with round columns and 49 of the 50 `rme` rooms keep their box. [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history diff --git a/docs/supported-profile.md b/docs/supported-profile.md index d4865f98..d1b5f73d 100644 --- a/docs/supported-profile.md +++ b/docs/supported-profile.md @@ -112,7 +112,7 @@ counts reported on 2026-11-02 and 2026-11-03 on 0.12.0): | Stairs | aggregates of flights, landings and supports, each stair's and flight's `PredefinedType` from its runs and landings; straight flights as their steps, monolithic ones included; riserless flights as their treads, spiral ones as their sector-shaped treads; a monolithic landing as its slab, and a landing that is one tread as that tread | Snowdon Towers 33 of 38 drawn flights equal to Revit's; on the BIM4LCA office (Revit 2024) all 35 flights have Revit's volume, its 34 straight ones and its spiral one, its 21 monolithic landings Revit's top and outline, and its tread landing Revit's body; on puzzbobb's Revit 2025 stair models every flight of models 13 to 16 has Revit's volume (RE-214, RE-217). A monolithic landing's underside is drawn flat where Revit's follows a flight's soffit, 100.4% to 104.7% of Revit's volume, and its `BodySource` says so; monolithic spiral runs and riserless runs with a nosing keep their box (#357) | | Curtain walls | aggregates of panels and mullions placed by their grids; a mullion or panel turned or tilted off the model's axes as the box along its own axes (RE-106) | every Snowdon panel and mullion relation Revit's; 1,019 turned or tilted mullions and panels with Revit's extents along their axes | | Doors, windows, openings | the element's box, turned with the instance where it is turned (RE-87); its opening is as deep as the host wall | host is Revit's on 138 of 138 (Core Interior) and 192 of 194 (Snowdon; the other 2 in another wall Revit cuts, RE-85), and on Revit 2024 and 2025 the wall its native record names, Revit's on all 463 of the BIM4LCA office's as built (RE-245); on Revit 2024 and 2025 the opening is the outline the symbol that draws it cuts, through the instance's transform: 138 of 138 on Core Interior and 4 of 4 on RE1 within 0.001 ft of Revit's (RE-215, #227; Measure run 40927543524); a window whose type stores Width, Height and Default Sill Height takes Revit's opening exactly (56 of 68 on Snowdon, RE-93), and a door whose body is its type's Rough Height tall its rough opening (57 of 126, RE-94); any other door whose type stores Width and Height carries them as its overall size (137 of 137 on Core Interior and RE1, RE-187; Measure run 40927543524); on Revit 2024 and 2025 a door's or window's overall size is the outline the symbol that draws it cuts, Revit's on every one with such an outline (138 of 138 on Core Interior, 4 of 5 on RE1, RE-215); a door or window whose symbol cuts no outline takes its own extents as its overall size, Revit's on all 269 windows and 266 of 269 doors of the BIM4LCA office (RE-218); otherwise not Revit's opening profile (#227, RE-83) | -| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); 229 of the BIM4LCA office's 231 rooms read from their stored solid as built (231 of 232 at building permit) at Revit's area and outline, the others 0.5%, 0.7% and 66% off, while its 3 room scores that keep their box and its 44 family-instance spaces drawn as boxes almost never are (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | +| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); all 229 of the BIM4LCA office's rooms read from their stored solid as built (231 of 232 at building permit) at Revit's area and outline, while its 5 room scores that keep their box and its 44 family-instance spaces drawn as boxes almost never are (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | | Areas | `IfcSpace` with number, name and storey; no body | all 30 of Revit's area spaces on the BIM4LCA office, as built and at building permit (RE-191); Revit 2024 only | | Furniture, fixtures, equipment, MEP devices, site | the element's bounding box, turned with the instance where it is turned (RE-87) | placement and extent only; no family geometry. MEP devices and equipment are measured on RE1 Electrical and Mechanical only (RE-79) | | Family instances from their saved meshes (doors and windows by default on Revit 2023 to 2027, every other family with `--saved-meshes`) | the mesh Revit saved for the element's display | within 0.01 ft of Revit's export: Core Interior 158 of 158, RE1 (2025) 207 of 214, Snowdon Towers 3,332 of 3,736. Revit's own shape, every vertex within 0.001 ft: Core Interior 138 of 138 doors and windows, the BIM4LCA office 1,294 of 1,709 scored (all its windows; 48 of 269 doors), RE1 148 of 214; a mesh that is not exact is part of the body (RE-78). Since RE-224 a mesh some face of which did not decode keeps its box: on the office 1,294 of the 1,352 bodies then drawn are exact (1,376 of 1,438 at permit) | diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md index f17a1932..1fdffb50 100644 --- a/reports/element-framing/RE-316-room-solids-resolved.md +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -4,8 +4,8 @@ **Issues:** #1260 (rooms first); follows RE-101 and RE-102 (a room's outline from its stored solid's faces), RE-313 (every `IfcSpace` scored against Revit's export) and RE-314 **Artefacts:** the BIM4LCA office as built and at building permit (Revit 2024) with Revit 24.0.5's IFC4 exports; Autodesk's `rac_advanced`, `rac_basic`, `rme_basic` and `rme_advanced` sample projects (no Revit export of them is in CI); Core Interior and RE1 Architecture with Revit's exports; the R2025 models and the held-out models (counts only), all as Measure fetches them **Probes:** `probes/examples/probe_re316_room_solid_breaks.rs`, `probe_re316_room_native_faces.rs`, `probe_re316_solid_vertices.rs` and `probe_re316_room_closure.rs` -**Runs:** Measure [41670081379](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41670081379) and [41673400892](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673400892) (what breaks), [41673755000](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673755000) (the native record), [41676245766](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41676245766) (the topology before the first face), [41686457484](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41686457484) (the rule, with the space scorer); the final change against `main`, unfiltered, in §3 -**Result:** positive on the office, narrowed. Of the 13 rooms of the office as built whose solids did not close, 12 now take an outline. The scorer pairs 11 of those with Revit's: 9 have Revit's area to 0.1% and Revit's outline to 0.01 ft, and 2 are 0.5% and 0.7% off. At building permit 10 of the 12 take an outline, all 10 with Revit's area to 0.1% and Revit's outline to 0.01 ft. The record box was off Revit's area by up to 329% on these rooms. Room 1671995 (both files) and 3095831 (permit) keep their box, and so do 49 of the 50 `rme` rooms, each for a measured reason (§4). +**Runs:** Measure [41670081379](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41670081379) and [41673400892](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673400892) (what breaks), [41673755000](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673755000) (the native record), [41676245766](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41676245766) (the topology before the first face), [41684789474](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41684789474) and [41686457484](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41686457484) (the rule with and without a 0.02 ft slack and with arcs, §4); the change as it lands, unfiltered, in §3 +**Result:** positive on the office, narrowed to what Revit's export agrees with. Of the 13 rooms of the office as built whose stored solids did not close, 9 now take an outline, and all 9 have Revit's area to 0.1% and Revit's outline to 0.01 ft (their record boxes were off by 3% to 67%). At building permit 10 of the 12 do, all 10 to 0.1% and 0.01 ft. The other rooms keep their box, each for a measured reason (§4): office rooms 1671995 (329% over Revit's area as built), 1820322, 1832504 and 2066902 as built, 1671995 and 3095831 at permit, the six `rac_advanced` rooms with round columns, and 49 of the 50 `rme` rooms. ## 1. Why the solids did not close @@ -27,9 +27,9 @@ Two readings were measured and are negative: ## 2. The rule -Where the faces RE-101 walks close on the room's record box, nothing changes. Where they do not, `partition_room_boundaries` now reads every face up to the next solid's tag (`solid_faces_to_next_solid`; none if no tag follows within 4 MiB) and rules the solid out if any vertical face reaches outside the record box, which holds the whole solid (113 of room 1671995's 327 faces do). Each wall face's plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office RE-101 keeps, on Core Interior's 116, RE1's 11 and the 26 to 85 each of the Autodesk samples keep, the directed edges join head to tail. A round column's cylinder is read as an arc, sampled to 0.0005 ft, run clockwise, and joined to the walls only where its ends meet theirs. +Where the faces RE-101 walks close on the room's record box, nothing changes. Where they do not, `partition_room_boundaries` now reads every face up to the next solid's tag (`solid_faces_to_next_solid`; none if no tag follows within 4 MiB), and gives up on the solid if any vertical face reaches outside the record box, which holds the whole solid (113 of room 1671995's 327 faces do), or if it holds a cylinder. Each wall face's plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office RE-101 keeps, Core Interior's 116, RE1's 11 and the 14 to 85 each of the Autodesk samples keep, the directed edges join head to tail. -`region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column that overlaps a wall takes its footprint off the room where they overlap and adds nothing outside; a step of up to 0.0025 ft (Revit's short-curve tolerance is 1/32 inch) joins two ends; and the outline may fall up to 0.02 ft short of the record box where a column's back stands behind the wall's face (0.0164 ft on three office rooms), but never beyond it. +`region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column that overlaps a wall takes its footprint off the room where they overlap and adds nothing outside it. A step of up to 0.0025 ft joins two ends (Revit's short-curve tolerance is 1/32 inch, 0.0026 ft). The outline must span the record box to 0.001 ft, as RE-101's does. An outline resolved this way carries no prism marker: no room's volume is measured against Revit's for them. @@ -37,7 +37,7 @@ An outline resolved this way carries no prism marker: no room's volume is measur ### The office, against Revit's own IFC4 export -Room by room, the record box's area, Revit's area, and the outline's area (ft²), as built: +Room by room, as built: the record box's area, Revit's area, and the outline's area (ft²). | Room | Box | Revit | Outline | Cause | |---|---:|---:|---:|---| @@ -49,47 +49,43 @@ Room by room, the record box's area, Revit's area, and the outline's area (ft²) | 1700966 | 4774.18 | 3259.86 | 3259.85 | step | | 1736690 | 2846.73 | 2049.24 | 2049.25 | step | | 1736726 | 367.14 | 293.43 | 293.43 | record between faces | -| 1820322 | 1882.11 | none (not paired) | 1865.97 | column overlaps wall | -| 1832504 | 465.18 | 440.31 | 437.31 | column overlaps wall | -| 2066902 | 1542.77 | 638.23 | 635.23 | column overlaps wall | | 2297732 | 135.82 | 131.76 | 131.76 | step | -| 1671995 | 5228.48 | 1218.33 | box | the span runs into other elements | -The two rooms 0.5% and 0.7% off are the ones where the stored solid has a column that overlaps the wall by 0.0164 ft and Revit's export does not take it off: the outline is the solid's, with the 3.0 ft² notch, and the boundary distance to Revit's is the notch's depth, 1.329 ft. On seven rooms at building permit Revit takes the same kind of column off (1.722 ft² each), as the outline does. Room 1820322 is one of the two rooms whose top surface Revit's export gives no area, so it is not scored. +At building permit the 10 rooms 1436086, 1480292, 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 take outlines, all with Revit's area to 0.1% (1736720: 4783.05 box, 3265.29 Revit, 3265.29 outline). -At building permit the 10 rooms 1436086, 1480292, 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 take outlines and all 10 have Revit's area to 0.1% (the largest, 1736720, 3265.29 against Revit's 3265.29). Rooms 1671995 (box 5228.48, Revit 1145.89) and 3095831 (box 6642.03, Revit 1864.74) keep their box. +`space_profiles_vs_ifc`, `main` against this change: -`space_profiles_vs_ifc` (run 41686457484, `main` with this change): +| | Area within 0.1% | Room, stored solid | Room, record box | +|---|---:|---|---:| +| Office as built | 221 → 230 | 220 → 229, all within 0.1% | 12 → 3 | +| Office at permit | 222 → 232 | 222 → 232 (231 within 0.1%) | 12 → 2 | +| Core Interior | 116 | 116 | 0 | +| RE1 Architecture | 11 | 11 | 0 | -| | Area within 0.1% | within 1% | Room, stored solid | Room, record box | -|---|---:|---:|---|---:| -| Office as built | 221 → 230 | 221 → 232 | 220 → 231 (229 within 0.1%) | 12 → 1 | -| Office at permit | 222 → 232 | 222 → 232 | 222 → 232 (231 within 0.1%) | 12 → 2 | -| Core Interior | 116 | 116 | 116 | 0 | -| RE1 Architecture | 11 | 11 | 11 | 0 | - -The one stored-solid room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, 66% in area) is the one RE-313 left unexplained and is unchanged. +The one stored-solid room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, 66% in area) is the one RE-313 left unexplained, and it is unchanged. PENDING: its ElementId. ### The Autodesk samples (no Revit export) -`probe_re316_room_closure` on the 2019 and 2023 saves (the other releases' counts are in the run summary): +`probe_re316_room_closure` on the 2019 and 2023 saves: | Project | Rooms | Closed by RE-101 | Closed now | Box | |---|---:|---:|---:|---:| | `rac_basic` | 14 | 14 | 14 | 0 | -| `rac_advanced` | 91 | 85 | 91 | 0 | +| `rac_advanced` | 91 | 85 | 85 | 6 | | `rme_basic`, `rme_advanced` | 76 | 26 | 27 | 49 | -The six `rac_advanced` rooms close because of their cylinders. Their arcs end at the ends of the planar necks, which is a check no guess of the radius would pass, but no Revit export of the sample says their area. 43 of the 85 rooms RE-101 keeps in `rac_advanced` have round columns it ignores: the outline with the columns taken off is 0.2% to 1.2% smaller (room 177304: 3531.7 ft² against 3521.8). They are left as they were. +With their arcs the six `rac_advanced` rooms would close (the probe's `with arcs` column), and 43 of the 85 rooms RE-101 keeps in the same file have round columns it ignores, its outline 0.2% to 1.2% larger than the one with the columns taken off (room 177304: 3531.7 ft² against 3521.8). One of those two readings is wrong and no Revit export of the sample says which, so both are left as they are. ### Everything else -PENDING: Measure unfiltered against `main` (run 41688293656). +PENDING: Measure unfiltered against `main`. ## 4. What keeps its box - **Office room 1671995, both files**: the span to the next solid's tag holds 327 faces, 113 of which reach outside the room's box. Without that rule the same code gave an outline of 5154 ft² against Revit's 1218 (1146 at permit), spanning the box: a wrong outline. The room's solid is not bounded by its record. +- **Office rooms 1820322, 1832504 and 2066902, as built**: a column overlaps the wall by 0.0164 ft and its back stands behind the wall's face, so the resolved outline falls 0.0164 ft short of the record box on one side. Allowing that (a 0.02 ft slack, run 41686457484) closes all three, and the two that Revit's export scores (1832504, 2066902) came out 0.7% and 0.5% off Revit's area (437.31 against 440.31, 635.23 against 638.23) with the boundary 1.329 ft from Revit's: the stored solid takes the column off and Revit's export does not take that column off, while it takes off the 1.722 ft² columns of seven rooms at permit. Room 1820322 is one of the two rooms whose top surface Revit's export gives no area, so it cannot be scored. Every room that closes on the box without a slack has Revit's area to 0.1%; with it, two do not, so it was dropped, and the boxes of the two (465.18 and 1542.77 ft² against Revit's 440.31 and 638.23) stay. - **Office room 3095831 (permit)**: 14 ends at y = 35.269 ft, in pairs 0.656 and 1.312 ft apart down a 100 ft wall, are not joined; the columns along it share the wall's line. +- **The six `rac_advanced` rooms** (177330, 214970, 215140, 215177, 216415, 217813): round columns, §3. - **49 of the 50 `rme` rooms**: stepped floors and ceilings. The faces that reach the ceiling instead of the floor close for 33 of the 50, and 24 of those are the box itself, but 9 differ from it (8 by 1% to 11%, and room 575874 at 22%: 5105 ft² against a box of 23323), no Revit export of these rooms exists, and a bulkhead that hangs from the ceiling would be taken for a wall. They stay boxes. - **The 44 family instances written as spaces** are #1262's. @@ -98,7 +94,7 @@ PENDING: Measure unfiltered against `main` (run 41688293656). - That a resolved outline equals Revit's outline on a release other than 2024: the evidence is one building, in two states. No room of another release is scored. - That a cylinder's arc is Revit's on any room: `rac_advanced` has no export. - Any volume of a resolved room (no prism marker is written). -- That 12 of 13 and 10 of 12 will hold on another building: the rules were found on these 25 rooms, and the held-out counts in §3 are the only test on others. +- That the 9 and 10 rooms will hold on another building: the rules were found on these 25 rooms, and the held-out counts in §3 are the only test on others. ## 6. Reproduce From 95e31a96bce2239bef9a8a1d8bb2215b6b3dbf4b Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:06:23 -0500 Subject: [PATCH 15/31] docs(rooms): RE-316 names the permit room RE-313 left 66% off (#1260) (RE-316) --- reports/element-framing/RE-316-room-solids-resolved.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md index 1fdffb50..2a06e5b6 100644 --- a/reports/element-framing/RE-316-room-solids-resolved.md +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -62,7 +62,7 @@ At building permit the 10 rooms 1436086, 1480292, 1736676, 1736690, 1736720, 174 | Core Interior | 116 | 116 | 0 | | RE1 Architecture | 11 | 11 | 0 | -The one stored-solid room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, 66% in area) is the one RE-313 left unexplained, and it is unchanged. PENDING: its ElementId. +The one stored-solid room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, 66% in area) is the one RE-313 left unexplained, and it is unchanged. It is room 2611271 (number 003, STAIRCASE; paired through the IfcSpace's `ElementId` property in a `keep_ifc` run, 41689880331): the walk closes a loop of 172.47 ft² on its box where Revit's area is 103.90, the same room as built is 166.77 ft² and not among those off, and the faces up to the next solid's tag do not resolve to an outline at permit (the rule gives up on it), so its cause stays open. ### The Autodesk samples (no Revit export) From c4cc93f9f8bd12eccc62bfc6ad0e833093078c2d Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:06:52 -0500 Subject: [PATCH 16/31] docs(rooms): RE-316 with the unfiltered Measure against main, and the held-out Revit 2021 project's six rooms (#1260) (RE-316) --- changelog.d/changed/1260-room-solids-resolved.md | 16 ++++++++-------- docs/rooms.md | 4 +++- docs/status.md | 2 +- .../RE-316-room-solids-resolved.md | 14 ++++++++++---- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/changelog.d/changed/1260-room-solids-resolved.md b/changelog.d/changed/1260-room-solids-resolved.md index f2c9d5c3..1c8dc7e2 100644 --- a/changelog.d/changed/1260-room-solids-resolved.md +++ b/changelog.d/changed/1260-room-solids-resolved.md @@ -6,11 +6,11 @@ wall's, and a solid ran past the 64 KB window. On the BIM4LCA office as built 9 of the 13 rooms that kept their box take an outline and all 9 have Revit's area to 0.1% (their boxes were 3% to 67% over it); at - building permit 10 of the 12 do, all 10 to 0.1%. Rooms whose outline - would stand short of their box, whose span runs into other elements, - whose solid holds a round column or whose floors and ceilings step keep - their box: three more office rooms as built, two at permit, the six - `rac_advanced` rooms with round columns and 49 of the 50 `rme` rooms, - each for a measured reason. Core Interior and RE1 Architecture are - unchanged. An outline read this way carries no `NetVolume` (#1260, - RE-316). + building permit 10 of the 12 do, all 10 to 0.1%; and 6 of 6 do on a + held-out Revit 2021 project. Rooms whose outline would stand short of + their box, whose span runs into other elements, whose solid holds a round + column or whose floors and ceilings step keep their box: three more + office rooms as built, two at permit, the six `rac_advanced` rooms with + round columns and 49 of the 50 `rme` rooms, each for a measured reason. + Core Interior and RE1 Architecture are unchanged. An outline read this + way carries no `NetVolume` (#1260, RE-316). diff --git a/docs/rooms.md b/docs/rooms.md index dc5a59e0..f4086ef4 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -19,7 +19,7 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: | Value | The body is | Its plan area is | |---|---|---| -| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on 623 of the 624 `stored_solid` rooms scored below, the office counted at both stages; the other is 66% off (RE-313 §2, §3, RE-316 §3) | +| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on every `stored_solid` room scored below except one, the permit office's STAIRCASE room 2611271 at 66% (RE-313 §2, §3, RE-316 §3) | | `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's 5 room scores that keep their box (3 as built, 2 at permit; RE-316 §4) | | `saved_mesh` | the mesh Revit saved for the room (`rvt-ifc --saved-meshes`) | the mesh's; not scored | | `none` | no body (an Area) | not available | @@ -68,6 +68,8 @@ Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ | Held-out Gdynia (2021, Revit's IFC2X3 export) | 18 of 19 | 18 | 0 | 0 | 18 rooms, stored solid: 18, 0 | | Held-out Warmond (2021, export passed through another tool) | 179 | 174 | 174 | 174 | 171 rooms, stored solid: 171, 171; 8 rooms, record box: 3, 3 | +Held out (counts only), BIM Speed's Revit 2021 project: 6 more rooms take their stored solid's outline under RE-316, and all 177 of its stored-solid rooms are within 0.1% and 0.01 ft of Revit's (area within 0.1% 174 → 179 of 179 spaces; 2 rooms keep their box, both rectangles). + A model with no rooms in Revit's export (the other R2025 models, RE1 Electrical, Mechanical and Plumbing) scores 0 of 0. Two held-out models write none of Revit's spaces: Vitoria's 2018 project (73), which exports no element until PR #1286 (#1261), and BIM Whale's 2021 project (8), whose spaces are Areas, which rvt-rs reads on Revit 2024 only. Held-out figures are Measure run 41680717786 (`main` at 21cb2efd), counts only. On the 2026 house and on Gdynia, every room's area is right where it is scored, but none of their outlines is within 0.01 ft of Revit's; the files are held out, so why is not examined. diff --git a/docs/status.md b/docs/status.md index 2d64a01d..6c6554f8 100644 --- a/docs/status.md +++ b/docs/status.md @@ -113,7 +113,7 @@ walls Revit steps over a floor that covers them in part (29 of the BIM4LCA offic the rooms whose stored solid still does not close (RE-316 §4) and opening profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the first reader did not close takes the outline of the faces up to the solid's end, resolved to a boundary: 9 of the BIM4LCA office's 13 such rooms as built and 10 of 12 at building permit, all 19 with Revit's area to 0.1% (RE-316); the rest, the 6 `rac_advanced` rooms with round columns and 49 of the 50 `rme` rooms keep their box. [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the first reader did not close takes the outline of the faces up to the solid's end, resolved to a boundary: 9 of the BIM4LCA office's 13 such rooms as built and 10 of 12 at building permit, all 19 with Revit's area to 0.1%, and 6 of 6 on a held-out Revit 2021 project (RE-316); the rest, the 6 `rac_advanced` rooms with round columns and 49 of the 50 `rme` rooms keep their box. [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md index 2a06e5b6..b3e40758 100644 --- a/reports/element-framing/RE-316-room-solids-resolved.md +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -66,7 +66,7 @@ The one stored-solid room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, 66% in ### The Autodesk samples (no Revit export) -`probe_re316_room_closure` on the 2019 and 2023 saves: +`probe_re316_room_closure` on the 2019 and 2023 saves (run 41689876297; Measure's export diagnostics give the same box counts on all 12 releases of `rme`): | Project | Rooms | Closed by RE-101 | Closed now | Box | |---|---:|---:|---:|---:| @@ -78,7 +78,13 @@ With their arcs the six `rac_advanced` rooms would close (the probe's `with arcs ### Everything else -PENDING: Measure unfiltered against `main`. +Measure unfiltered, `-f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true` ([41689872644](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41689872644), this change at 669856bf against `main`; 93 models). Every scorer's output is identical on every model but three, and the exports' hashes move on exactly the models with a room that now closes: + +- **Office as built and at permit**: the rows above; their `exported.bounding_box_spaces` go from 57 to 48 and from 56 to 46. +- **`rme_basic` and `rme_advanced`, all 12 releases 2015 to 2027**: one room each (576007, 1887.2 ft² against its box's 1917.9) takes an outline, 50 → 49 box spaces; no Revit export of them exists. +- **Held out, counts only (BIM Speed's Revit 2021 project)**: the one model outside the office whose scorer output changes. Area within 0.1%: 174 → 179 of the 179 spaces paired, off 5 → 0; rooms on their record box 8 → 2, both within 0.1% (rectangles); stored-solid rooms 171 → 177, all 177 within 0.1% and 0.01 ft. This model was not read while the rule was found. +- **Core Interior, RE1 Architecture, the R2025 models and the other held-out models**: unchanged, so no witness observation moves. `rac_advanced` and `rac_basic` in every release: unchanged. +- **Cost**: Measure's `rvt-ifc` export times are 1.27 times main's at the median across the 93 models (the head job ran on a slower runner) and 1.40 on Core Interior, which this change does not touch; the office's 1.43 and 1.46 are within that spread. ## 4. What keeps its box @@ -99,8 +105,8 @@ PENDING: Measure unfiltered against `main`. ## 6. Reproduce ```bash -gh workflow run measure.yml --ref main -f ref= -f base=none -f samples=true -f sample_years="2019 2023" \ +gh workflow run measure.yml --ref main -f ref=669856bff569c56d9488381e22ea6bb0762cefc5 -f base=none -f samples=true -f sample_years="2019 2023" \ -f nordic=true -f models="nordic-office-* core-interior re1-architecture" \ -f probe=probe_re316_room_closure -f probe_scorer=- -f scorers=space_profiles_vs_ifc -gh workflow run measure.yml --ref main -f ref= -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true +gh workflow run measure.yml --ref main -f ref=669856bff569c56d9488381e22ea6bb0762cefc5 -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true ``` From 385a0ed22d37beb763d38ca1647409c1bc0975e7 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:47:00 -0500 Subject: [PATCH 17/31] refactor(rooms): the walk RE-101 reads is tried alone per room, a room it does not close is resolved apart with its own profile source, and the work of a resolution is bounded (#1260) (RE-316) --- docs/schemas/export-diagnostics.schema.json | 10 + probes/examples/probe_re316_room_closure.rs | 94 +++++--- probes/examples/probe_re316_solid_extent.rs | 209 +++++++++++++++++ src/bin/rvt_ifc.rs | 14 ++ src/ifc/export_content.rs | 64 +++-- src/ifc/mod.rs | 29 +++ src/ifc/schedule_csv.rs | 4 +- src/research/element_record_plan_profiles.rs | 2 +- src/research/partition_room_boundaries.rs | 232 +++++++++++++------ src/research/partition_schema_mvp/rooms.rs | 27 ++- 10 files changed, 559 insertions(+), 126 deletions(-) create mode 100644 probes/examples/probe_re316_solid_extent.rs diff --git a/docs/schemas/export-diagnostics.schema.json b/docs/schemas/export-diagnostics.schema.json index c39e45d9..89b94cca 100644 --- a/docs/schemas/export-diagnostics.schema.json +++ b/docs/schemas/export-diagnostics.schema.json @@ -284,6 +284,16 @@ "type": "integer", "minimum": 0, "description": "Exported IfcSpaces drawn as their record's bounding box (SpaceOutlineSource record_box): a room whose outline was not read from its stored solid, or a family instance written as a space and drawn as its box. Their body's plan area is the box's, not the space's (RE-313). Each is also in bounding_box_bodies, except a family instance turned or tilted off the model's axes." + }, + "resolved_outline_spaces": { + "type": "integer", + "minimum": 0, + "description": "Exported IfcSpaces that are rooms whose outline was resolved from the faces of their stored solid up to the solid's end (SpaceOutlineSource stored_solid_faces, RE-316): equal to Revit's area on every such room measured, read by a rule that cannot tell a face of another element inside the room's box from the room's own." + }, + "approximate_outline_spaces": { + "type": "integer", + "minimum": 0, + "description": "Exported IfcSpaces that are rooms whose resolved outline falls up to 0.02 ft short of their record box on a side (SpaceOutlineSource stored_solid_approximate, RE-316): their area is approximate, 0.5% and 0.7% under Revit's on the two measured." } } }, diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs index 379eb66e..1d5c2e38 100644 --- a/probes/examples/probe_re316_room_closure.rs +++ b/probes/examples/probe_re316_room_closure.rs @@ -6,7 +6,8 @@ //! so the room is on its left, wind around a region whose boundary is the room's //! outline (`partition_room_boundaries::region_boundary`). //! -//! For each room whose own partition holds a solid, one line: the room, the +//! For each room whose own partition holds a solid and is not left exactly as +//! RE-101 reads it, one line: the room, the //! outline RE-101 gave (area, or none), the outline now given, the outline of //! the walls that reach the ceiling instead, the box's area, and for each area //! whether Revit's cache of computed room areas in the same partition holds it @@ -23,8 +24,8 @@ use rvt::RevitFile; use rvt::research::element_record_plan_profiles::{PlanProfile, plan_profile_from_lines}; use rvt::research::partition_room_boundaries::{ BOX_TOLERANCE_FEET, FaceKind, OutlineAnchor, ROOM_STEP_TOLERANCE_FEET, - ROOM_VERTEX_TOLERANCE_FEET, region_boundary, room_outline_at, solid_faces, - solid_faces_to_next_solid, solid_headers, span_outline_at, + ROOM_VERTEX_TOLERANCE_FEET, ResolvedKind, SpanRead, outline_of_solids, region_boundary, + resolved_outline_of_solids, solid_faces, solid_headers, solid_span, span_outline_at, }; use rvt::walker::InstanceField; @@ -119,7 +120,8 @@ fn main() -> anyhow::Result<()> { let mut tally: BTreeMap<&str, usize> = BTreeMap::new(); let (mut oriented_ok, mut oriented_total) = (0, 0); let mut cached_new = (0, 0); - let mut cached_ceiling = (0, 0); + let mut cached_slack = (0, 0); + let mut kept_cache = (0, 0); for room in &rooms { let Some(id) = room.id else { continue }; let float = |wanted: &str| { @@ -190,41 +192,64 @@ fn main() -> anyhow::Result<()> { oriented_ok += 1; } } - let now = room_outline_at(buf, header, bbox); - let ceiling = span_outline_at(buf, header, bbox, OutlineAnchor::Ceiling, false); - let with_arcs = span_outline_at(buf, header, bbox, OutlineAnchor::Floor, true); - let class = match (&walked, &now) { - (Some(_), Some(_)) => { - if walked == now { - "kept, unchanged" - } else { - "kept, CHANGED" - } - } - (None, Some(_)) => "newly closed", - (Some(_), None) => "LOST", + let walk_all = outline_of_solids(buf, &headers, bbox); + let resolved = if walk_all.is_none() { + resolved_outline_of_solids(buf, &headers, bbox) + } else { + None + }; + let now = walk_all + .clone() + .or_else(|| resolved.as_ref().map(|(profile, _)| profile.clone())); + let exact = SpanRead { + arcs: false, + slack: 0.0, + }; + let ceiling = span_outline_at(buf, header, bbox, OutlineAnchor::Ceiling, exact); + let with_arcs = span_outline_at( + buf, + header, + bbox, + OutlineAnchor::Floor, + SpanRead { + arcs: true, + slack: 0.0, + }, + ); + let class = match (&walk_all, &resolved) { + (Some(_), _) => "kept", + (None, Some((_, ResolvedKind::Faces))) => "faces", + (None, Some((_, ResolvedKind::Approximate))) => "approximate", (None, None) => "still box", }; *tally.entry(class).or_default() += 1; - if walked.is_some() && now.is_some() && walked == now { - continue; - } let cache = cached_areas(buf); - let box_area = dx * dy; - if walked.is_none() && now.is_some() { - cached_new.1 += 1; - if now.as_ref().is_some_and(|p| in_cache(&cache, area(p))) { - cached_new.0 += 1; + if let Some(profile) = walk_all + .as_ref() + .filter(|p| (area(p) - dx * dy).abs() > 1e-3 * dx * dy || p.outer_xy.len() > 4) + { + kept_cache.1 += 1; + if in_cache(&cache, area(profile)) { + kept_cache.0 += 1; } } - if walked.is_none() && now.is_none() && ceiling.is_some() { - cached_ceiling.1 += 1; - if ceiling.as_ref().is_some_and(|p| in_cache(&cache, area(p))) { - cached_ceiling.0 += 1; + if walked.is_some() && walk_all.is_some() { + continue; + } + let box_area = dx * dy; + if let Some((profile, kind)) = resolved.as_ref() { + let slot = if *kind == ResolvedKind::Faces { + &mut cached_new + } else { + &mut cached_slack + }; + slot.1 += 1; + if in_cache(&cache, area(profile)) { + slot.0 += 1; } } - if walked.is_none() && now.is_none() { - let faces = solid_faces_to_next_solid(buf, header); + if walk_all.is_none() && resolved.is_none() { + let faces = solid_span(buf, header); let tolerance = BOX_TOLERANCE_FEET; let strays = faces .iter() @@ -242,6 +267,7 @@ fn main() -> anyhow::Result<()> { _ => false, }) .count(); + let cylinders = faces.iter().filter(|f| f.cylinder.is_some()).count(); let mut floor_lines: Vec<[f64; 4]> = faces .iter() .filter_map(|f| f.plan_edge()) @@ -267,7 +293,7 @@ fn main() -> anyhow::Result<()> { Err(failure) => format!("{failure:?}"), }; println!( - "room {id}: span faces {} (stray {strays}), floor edges {}; {}", + "room {id}: span faces {} (stray {strays}, cylinders {cylinders}), floor edges {}; {}", faces.len(), floor_lines.len(), why.chars().take(600).collect::() @@ -291,8 +317,8 @@ fn main() -> anyhow::Result<()> { "plan edges directed head to tail on {oriented_ok} of the {oriented_total} rooms RE-101 kept" ); println!( - "newly closed rooms whose area Revit's cache holds: {} of {}; ceiling-anchored outlines of rooms still boxes whose area it holds: {} of {}", - cached_new.0, cached_new.1, cached_ceiling.0, cached_ceiling.1 + "Revit's cached room areas (u64 · ff x8 · u32 0 · i32 · f64, RE-101 section 4) hold the outline's area on {} of {} non-rectangular rooms RE-101 kept (the baseline), on {} of {} exact span outlines and on {} of {} approximate ones", + kept_cache.0, kept_cache.1, cached_new.0, cached_new.1, cached_slack.0, cached_slack.1 ); Ok(()) } diff --git a/probes/examples/probe_re316_solid_extent.rs b/probes/examples/probe_re316_solid_extent.rs new file mode 100644 index 00000000..e38fa507 --- /dev/null +++ b/probes/examples/probe_re316_solid_extent.rs @@ -0,0 +1,209 @@ +//! RE-316: where a room's stored solid ends. +//! +//! FACT under test: a room's solid lists its objects after its two boxes as a +//! counted list (`04 c0 08 00 · u32 count · u32 4 · count × (u16 class · u32 +//! token)`), and the count, or a gap no solid's own faces exceed, says where +//! the faces of the solid stop and another element's begin. +//! +//! One line per room whose own partition holds a solid: the list's count and +//! its first entries, the faces the walk reads (W) and those up to the next +//! solid's tag (S) with how many of each are horizontal, vertical and +//! cylindrical, the offsets of the first face, the last walked face, the last +//! face of the span and the next solid's tag from the solid's start, the +//! largest gap between faces of the walk and of the span, every gap in the +//! span above 210 bytes, and the gap from the last walked face to the next +//! valid face. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_solid_extent -- MODEL.rvt + +use rvt::RevitFile; +use rvt::research::partition_room_boundaries::{ + FACE_GAP, FACE_TAG, FaceKind, ROOM_SOLID_TAG, ROOM_SOLID_WINDOW, RoomFace, face_at, + room_outline, solid_headers, +}; +use rvt::walker::InstanceField; + +fn solid_start(buf: &[u8], header: usize) -> Option { + let after_id = header.checked_add(8)?; + if buf.get(after_id..after_id + 4)? == ROOM_SOLID_TAG { + return Some(after_id + 4); + } + (buf.get(after_id..after_id + 8)? == [0xff, 0xff, 0xff, 0xff, 0x04, 0x90, 0x08, 0x00]) + .then_some(after_id + 8) +} + +fn u32_at(buf: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(buf.get(at..at + 4)?.try_into().ok()?)) +} + +fn u16_at(buf: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(buf.get(at..at + 2)?.try_into().ok()?)) +} + +fn walk(buf: &[u8], first: usize) -> Vec<(usize, RoomFace)> { + let mut out = Vec::new(); + let mut at = first; + while let Some(face) = face_at(buf, at) { + out.push((at, face)); + let from = at + FACE_TAG.len(); + let Some(window) = buf.get(from..buf.len().min(at + FACE_GAP)) else { + break; + }; + let Some(next) = memchr::memmem::find_iter(window, &FACE_TAG) + .map(|offset| from + offset) + .find(|&next| face_at(buf, next).is_some()) + else { + break; + }; + at = next; + } + out +} + +fn tally(faces: &[(usize, RoomFace)]) -> (usize, usize, usize) { + let (mut h, mut v, mut c) = (0, 0, 0); + for (_, face) in faces { + if face.cylinder.is_some() { + c += 1; + } else { + match face.kind() { + FaceKind::Horizontal(_) => h += 1, + FaceKind::Vertical { .. } => v += 1, + FaceKind::Other => {} + } + } + } + (h, v, c) +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + let kept = room_outline(&mut rf, &stream, id, bbox)?.is_some(); + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + for (index, &header) in solid_headers(buf, id).iter().enumerate() { + let Some(start) = solid_start(buf, header) else { + continue; + }; + let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); + let Some(window) = buf.get(start..window_end) else { + continue; + }; + let tag_at = memchr::memmem::find(window, &ROOM_SOLID_TAG).map(|o| start + o); + let span_end = tag_at.unwrap_or(window_end); + let valid: Vec<(usize, RoomFace)> = + memchr::memmem::find_iter(&buf[start..span_end], &FACE_TAG) + .filter_map(|o| face_at(buf, start + o).map(|f| (start + o, f))) + .collect(); + let Some(&(first, _)) = valid.first() else { + continue; + }; + let walked = walk(buf, first); + let (wh, wv, wc) = tally(&walked); + let (sh, sv, sc) = tally(&valid); + let list = memchr::memmem::find(&buf[start..first.min(start + 400)], &[4, 0xc0, 8, 0]) + .map(|o| start + o); + let (count, lead, entries) = match list { + Some(p) => { + let n = u32_at(buf, p + 4).unwrap_or(0); + let lead = u32_at(buf, p + 8).unwrap_or(0); + let mut e = Vec::new(); + for k in 0..(n as usize).min(3) { + let at = p + 12 + 6 * k; + e.push(format!( + "{:04x}:{}", + u16_at(buf, at).unwrap_or(0), + u32_at(buf, at + 2).unwrap_or(0) + )); + } + let last_at = p + 12 + 6 * (n as usize).saturating_sub(1); + e.push(format!( + "last {:04x}:{}", + u16_at(buf, last_at).unwrap_or(0), + u32_at(buf, last_at + 2).unwrap_or(0) + )); + (n as i64, lead as i64, e.join(" ")) + } + None => (-1, -1, String::new()), + }; + let gaps = |faces: &[(usize, RoomFace)]| -> Vec { + faces.windows(2).map(|w| w[1].0 - w[0].0).collect() + }; + let wgaps = gaps(&walked); + let sgaps = gaps(&valid); + let big: Vec = sgaps + .iter() + .enumerate() + .filter(|(_, g)| **g > 210) + .take(8) + .map(|(i, g)| format!("{i}:{g}")) + .collect(); + let last_walk = walked.last().map_or(0, |(a, _)| *a); + let after = valid + .iter() + .map(|(a, _)| *a) + .find(|a| *a > last_walk) + .map(|a| a - last_walk); + let last_span = valid.last().map_or(0, |(a, _)| *a); + println!( + "room {id} {stream} hdr {index} {} N {count} lead {lead} [{entries}] W {} (h{wh} v{wv} c{wc}) S {} (h{sh} v{sv} c{sc}) first +{} lastW +{} lastS +{} tag {} maxgapW {} maxgapS {} bigS [{}] afterW {:?} stray_to_end {}", + if kept { "KEPT" } else { "FAILING" }, + walked.len(), + valid.len(), + first - start, + last_walk - start, + last_span - start, + tag_at.map_or("none".to_string(), |t| format!("+{}", t - start)), + wgaps.iter().copied().max().unwrap_or(0), + sgaps.iter().copied().max().unwrap_or(0), + big.join(" "), + after, + span_end - start, + ); + } + } + Ok(()) +} diff --git a/src/bin/rvt_ifc.rs b/src/bin/rvt_ifc.rs index 8ae6eed3..5134cd34 100644 --- a/src/bin/rvt_ifc.rs +++ b/src/bin/rvt_ifc.rs @@ -931,6 +931,20 @@ fn export_summary(diagnostics: &ExportDiagnostics, has_sidecar: bool) -> Vec 0 { + lines.push(format!( + " {} rooms take an outline resolved from the faces up to their stored solid's end (SpaceOutlineSource stored_solid_faces)", + exported.resolved_outline_spaces + )); + } + if exported.approximate_outline_spaces > 0 { + lines.push(format!( + " {} rooms take an approximate outline, up to 0.02 ft short of their record box on a side: the area is within about 1% of the space's, not 0.1% (SpaceOutlineSource stored_solid_approximate)", + exported.approximate_outline_spaces + )); + } } let left_out: Vec = diagnostics .skipped diff --git a/src/ifc/export_content.rs b/src/ifc/export_content.rs index 77489f8b..2915be26 100644 --- a/src/ifc/export_content.rs +++ b/src/ifc/export_content.rs @@ -4174,9 +4174,26 @@ pub const RECORD_BBOX_BODY_SOURCE: &str = "partition_element_record_bbox"; pub const SPACE_OUTLINE_SOURCE_PROPERTY: &str = "SpaceOutlineSource"; /// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was read from the -/// faces of its stored solid. +/// faces of its stored solid, which close into loops as they are (RE-101). pub const SPACE_OUTLINE_STORED_SOLID: &str = "stored_solid"; +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was resolved from +/// the faces of its stored solid up to the solid's end, the boundary of the +/// region its wall faces wind around, spanning the record box (RE-316). Equal +/// to Revit's area to 0.1% on every room of the BIM4LCA office and the +/// held-out project it was measured on; a face of another element inside the +/// box would not be told from the room's own, which is why it is not +/// `stored_solid`. +pub const SPACE_OUTLINE_STORED_SOLID_FACES: &str = "stored_solid_faces"; + +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was resolved as +/// [`SPACE_OUTLINE_STORED_SOLID_FACES`] but falls up to 0.02 ft short of its +/// record box on a side, a column's back behind the wall's face (RE-316). The +/// area is approximate: 0.5% and 0.7% under Revit's on the two such rooms of +/// the BIM4LCA office that Revit's export pairs, which does not take that +/// column off the room. +pub const SPACE_OUTLINE_STORED_SOLID_APPROXIMATE: &str = "stored_solid_approximate"; + /// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a space drawn as its record's bounding /// box (a room whose outline was not read, a Revit Space or a family instance /// written as a space): placed and sized by the box, its plan area the box's, @@ -6581,24 +6598,39 @@ fn element_record_geometry_from_decoded(decoded: &DecodedElement) -> Option + { + Some(source.as_str()) + } + _ => None, + }) + }) + .flatten(); + let marker = match source { + Some(crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE) => { + SPACE_OUTLINE_STORED_SOLID + } + Some(crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_FACES) => { + SPACE_OUTLINE_STORED_SOLID_FACES + } + Some(crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_APPROXIMATE) => { + SPACE_OUTLINE_STORED_SOLID_APPROXIMATE + } + _ => SPACE_OUTLINE_RECORD_BOX, + }; properties.push(Property { name: SPACE_OUTLINE_SOURCE_PROPERTY.into(), - value: PropertyValue::Text( - if from_solid { - SPACE_OUTLINE_STORED_SOLID - } else { - SPACE_OUTLINE_RECORD_BOX - } - .into(), - ), + value: PropertyValue::Text(marker.into()), }); } if let Some(number) = room_number { diff --git a/src/ifc/mod.rs b/src/ifc/mod.rs index 5a48f126..a0ff447e 100644 --- a/src/ifc/mod.rs +++ b/src/ifc/mod.rs @@ -678,6 +678,20 @@ pub struct ExportedModelDiagnostics { /// names the turned box. #[serde(default)] pub bounding_box_spaces: usize, + /// Of the exported `IfcSpace`s, the rooms whose outline was resolved from + /// the faces of their stored solid up to the solid's end + /// (`SpaceOutlineSource` is `stored_solid_faces`, RE-316): equal to + /// Revit's area on every such room measured, but read by a rule that + /// cannot tell a face of another element inside the room's box from the + /// room's own. + #[serde(default)] + pub resolved_outline_spaces: usize, + /// Of the exported `IfcSpace`s, the rooms whose resolved outline falls up + /// to 0.02 ft short of their record box on a side + /// (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316): their + /// area is approximate, 0.5% and 0.7% under Revit's on the two measured. + #[serde(default)] + pub approximate_outline_spaces: usize, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -4169,6 +4183,8 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { let mut body_sources = std::collections::BTreeMap::::new(); let mut bounding_box_bodies = 0usize; let mut bounding_box_spaces = 0usize; + let mut resolved_outline_spaces = 0usize; + let mut approximate_outline_spaces = 0usize; for (index, entity) in model.entities.iter().enumerate() { if let entities::IfcEntity::BuildingElement { ifc_type, @@ -4216,6 +4232,17 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { { bounding_box_spaces += 1; } + if ifc_type == "IFCSPACE" { + match space_outline.map(String::as_str) { + Some(export_content::SPACE_OUTLINE_STORED_SOLID_FACES) => { + resolved_outline_spaces += 1; + } + Some(export_content::SPACE_OUTLINE_STORED_SOLID_APPROXIMATE) => { + approximate_outline_spaces += 1; + } + _ => {} + } + } building_elements += 1; *by_ifc_type.entry(ifc_type.clone()).or_insert(0) += 1; if storey_index.is_some() { @@ -4265,6 +4292,8 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { body_sources, bounding_box_bodies, bounding_box_spaces, + resolved_outline_spaces, + approximate_outline_spaces, } } diff --git a/src/ifc/schedule_csv.rs b/src/ifc/schedule_csv.rs index 82969e53..e34ee140 100644 --- a/src/ifc/schedule_csv.rs +++ b/src/ifc/schedule_csv.rs @@ -303,7 +303,9 @@ pub fn rooms_csv(model: &IfcModel, options: &CsvOptions) -> String { number(storey.map(|s| unit.convert(s.elevation_feet))), number( (property("ProfileSource") - == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE) + == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE + || property("ProfileSource") + == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_FACES) .then(|| extrusion.as_ref().and_then(outline_area_square_feet)) .flatten() .map(|area| unit.convert_area(area)), diff --git a/src/research/element_record_plan_profiles.rs b/src/research/element_record_plan_profiles.rs index 85a3e436..55618e45 100644 --- a/src/research/element_record_plan_profiles.rs +++ b/src/research/element_record_plan_profiles.rs @@ -289,7 +289,7 @@ pub fn plan_profile_from_fields(fields: &[(String, InstanceField)]) -> Option { source_ok = text == PLAN_PROFILE_SOURCE - || text == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE + || crate::partition_room_boundaries::is_room_outline_source(text) || text == crate::partition_schema_mvp::LANDING_OUTLINE_SOURCE; } (PLAN_PROFILE_OUTER_FIELD, field) => outer = points_from_field(field), diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index f95ad9dc..4a8ca7a9 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -36,7 +36,7 @@ //! the box of its plane, not its outline: where a column stands in the room //! and overlaps a wall, the wall's face runs across the column's footprint. //! When the faces the walk reads do not close, the faces up to the next -//! solid's tag are read instead ([`solid_faces_to_next_solid`]), each plan +//! solid's tag are read instead ([`solid_span`]), each plan //! edge directed so the room is on its left, and the room's outline is the //! boundary of the region those edges wind around ([`region_boundary`], //! RE-316). A face whose record is 44 bytes longer than a plane's is a @@ -60,9 +60,30 @@ use crate::{Result, RevitFile}; use std::collections::BTreeMap; /// Value of [`crate::element_record_plan_profiles::PLAN_PROFILE_SOURCE_FIELD`] -/// on a room outline read here. +/// on a room outline read here from the faces the walk reads, closing as they +/// are (RE-101). pub const ROOM_OUTLINE_SOURCE: &str = "partition_room_solid_faces"; +/// Value of the same field on a room outline resolved from the faces up to +/// the solid's end, spanning the record box (RE-316). Not the walk's: a face +/// of another element inside the box could still be read into it, so an +/// importer tells the two apart. +pub const ROOM_OUTLINE_SOURCE_FACES: &str = "partition_room_solid_faces_resolved"; + +/// Value of the same field on a room outline resolved the same way that +/// falls up to [`ROOM_SLIVER_FEET`] short of the record box on a side +/// (RE-316): approximate, 0.5% and 0.7% under Revit's area on the two office +/// rooms scored. +pub const ROOM_OUTLINE_SOURCE_APPROXIMATE: &str = "partition_room_solid_faces_approximate"; + +/// Whether `source` is a value of the profile source field that a room's +/// outline from its stored solid carries. +pub fn is_room_outline_source(source: &str) -> bool { + source == ROOM_OUTLINE_SOURCE + || source == ROOM_OUTLINE_SOURCE_FACES + || source == ROOM_OUTLINE_SOURCE_APPROXIMATE +} + /// Releases this layout is measured on. On Revit 2026 and 2027 it is the /// 2025 layout: every room of Autodesk's `rac_basic`, `rac_advanced`, /// `rme_basic` and `rme_advanced` sample projects (257) gives the same @@ -90,9 +111,17 @@ pub const ROOM_SOLID_WINDOW: usize = 0x1_0000; pub const BOX_TOLERANCE_FEET: f64 = 1e-3; /// How many bytes past a room's solid header the next solid's tag is looked -/// for (RE-316): the largest solid measured, 115 faces, ends 84 KB in. +/// for (RE-316, interim). pub const ROOM_SOLID_SPAN_MAX: usize = 4 << 20; +/// The most faces a solid's span may hold before it is not resolved (RE-316): +/// the largest solid measured has 115. +pub const RESOLVE_FACES_MAX: usize = 512; + +/// The most faces the walk reads from one solid; a solid with more is not +/// read at all. +pub const SOLID_FACES_MAX: usize = 4096; + /// How far a vertical face's span may fall short of a room's box, and a /// horizontal face lie from its floor or ceiling, and still be it, feet /// (RE-296). @@ -114,8 +143,19 @@ pub const ROOM_STEP_TOLERANCE_FEET: f64 = 0.0025; /// [`crate::element_record_plan_profiles::SKETCH_ARC_TOLERANCE_FEET`]). const ARC_CHORD_TOLERANCE_FEET: f64 = 0.0005; +/// How far a resolved outline may fall short of the room's record box on a +/// side and still be a room's outline, labelled approximate, feet (RE-316): +/// the back of a column that overlaps a wall stands 0.0164 ft behind the +/// wall's face on three rooms of the BIM4LCA office, and the box holds it. +pub const ROOM_SLIVER_FEET: f64 = 0.02; + /// The most plan segments [`region_boundary`] resolves. -pub const BOUNDARY_SEGMENTS_MAX: usize = 4096; +pub const BOUNDARY_SEGMENTS_MAX: usize = RESOLVE_FACES_MAX; + +/// The most pieces the segments of [`region_boundary`] may be cut into: a +/// room's own faces cross a few times, and a crafted grid of them crosses +/// quadratically. +pub const BOUNDARY_PIECES_MAX: usize = 4096; /// How far apart two points of a resolved boundary may lie and be one, /// feet. @@ -303,6 +343,9 @@ pub fn solid_faces(buf: &[u8], header: usize) -> Vec { let mut at = first; while let Some(face) = face_at(buf, at) { faces.push(face); + if faces.len() > SOLID_FACES_MAX { + return Vec::new(); + } let from = at + FACE_TAG.len(); let Some(window) = buf.get(from..buf.len().min(at + FACE_GAP)) else { break; @@ -436,40 +479,62 @@ fn strays_from_box(face: &RoomFace, bbox: [f64; 6]) -> bool { face.plan_edge().is_some_and(|(edge, _)| outside(edge)) } -fn profile_on_box(profile: PlanProfile, bbox: [f64; 6]) -> Option { +/// `profile` where its plan extent equals `bbox`'s on each side within +/// [`BOX_TOLERANCE_FEET`], or, where `slack` is set, falls short of it by no +/// more than `slack` on any side. +fn profile_on_box(profile: PlanProfile, bbox: [f64; 6], slack: f64) -> Option { let extent = profile.plan_extent_feet()?; + let inward = [1.0, 1.0, -1.0, -1.0]; let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; extent .iter() .zip(wanted) - .all(|(got, want)| (got - want).abs() <= BOX_TOLERANCE_FEET) + .zip(inward) + .all(|((got, want), sign)| { + let short = (got - want) * sign; + short >= -BOX_TOLERANCE_FEET && short <= slack + BOX_TOLERANCE_FEET + }) .then_some(profile) } +/// How the faces of a span are read into an outline (RE-316). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpanRead { + /// Read the arcs of round columns too. Off, a solid with a cylinder is + /// not resolved. + pub arcs: bool, + /// How far the outline may fall short of the record box on a side. + pub slack: f64, +} + /// The outline the wall faces among `faces` make, kept where it spans -/// `bbox`. With `resolve` off, the faces' plan edges must close into loops as -/// they are (RE-101). With it on, the outline is the boundary of the region +/// `bbox`. With `span` unset, the faces' plan edges must close into loops as +/// they are (RE-101). With it set, the outline is the boundary of the region /// they wind around ([`region_boundary`]), and a vertical face that reaches /// outside the box rules the solid out. fn outline_from_faces( faces: &[RoomFace], bbox: [f64; 6], anchor: OutlineAnchor, - resolve: bool, - arcs: bool, + span: Option, ) -> Option { - if resolve && faces.iter().any(|face| strays_from_box(face, bbox)) { + if faces.len() > RESOLVE_FACES_MAX && span.is_some() { return None; } - if resolve && !arcs && faces.iter().any(|face| face.cylinder.is_some()) { - return None; + if let Some(read) = span { + if faces.iter().any(|face| strays_from_box(face, bbox)) { + return None; + } + if !read.arcs && faces.iter().any(|face| face.cylinder.is_some()) { + return None; + } } let mut lines = Vec::new(); for face in faces { if let Some(edge) = reaches_anchor(face, bbox, anchor) { lines.push(edge); } - if resolve && arcs { + if span.is_some_and(|read| read.arcs) { if let Some((low, high)) = face.vertical_cylinder_heights() { let reached = match anchor { OutlineAnchor::Floor => (low - bbox[2]).abs() <= BOX_TOLERANCE_FEET, @@ -481,64 +546,85 @@ fn outline_from_faces( } } } - let profile = if resolve { - snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); - plan_profile_from_lines(®ion_boundary(&lines).ok()?)? - } else { - snap_ends(&mut lines); - plan_profile_from_lines(&lines)? - }; - profile_on_box(profile, bbox) -} - -/// The room's outline from the solid whose header starts at `header` and the -/// faces it was made from. Its wall faces (vertical, reaching the floor of -/// `bbox`, a record box `[min x, min y, min z, max x, max y, max z]`) close -/// into loops whose plan extent is `bbox`'s within [`BOX_TOLERANCE_FEET`]; -/// where the faces the walk reads do not close, the faces up to the next -/// solid's tag are resolved to a boundary instead (RE-316). A face's origin is -/// its plane's, not a point on the face, so only the plan edges are checked. -fn outline_with_faces( - buf: &[u8], - header: usize, - bbox: [f64; 6], -) -> Option<(PlanProfile, Vec, bool)> { - let walked = solid_faces(buf, header); - if let Some(profile) = outline_from_faces(&walked, bbox, OutlineAnchor::Floor, false, false) { - return Some((profile, walked, false)); + match span { + None => { + snap_ends(&mut lines); + profile_on_box(plan_profile_from_lines(&lines)?, bbox, 0.0) + } + Some(read) => { + snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); + let boundary = region_boundary(&lines).ok()?; + profile_on_box(plan_profile_from_lines(&boundary)?, bbox, read.slack) + } } - let span = solid_faces_to_next_solid(buf, header); - let profile = outline_from_faces(&span, bbox, OutlineAnchor::Floor, true, false)?; - Some((profile, span, true)) -} - -/// The room's outline from the solid whose header starts at `header`: its -/// wall faces' plan edges as they stand where the walk's faces close (RE-101), -/// and resolved to a boundary where the faces up to the next solid's tag do -/// (RE-316). Kept only where it spans `bbox`. -pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option { - outline_with_faces(buf, header, bbox).map(|(profile, _, _)| profile) } -/// The outline the faces up to the next solid's tag make, resolved to a -/// boundary, for walls that reach `anchor`, with the arcs of round columns -/// where `arcs` is set (RE-316). A probe's reading: [`room_outline_at`] uses -/// only [`OutlineAnchor::Floor`] and leaves a solid with a cylinder unresolved, -/// since whether Revit takes a column off a room's area is unmeasured. +/// The outline the faces up to the solid's end make, resolved to a boundary, +/// for walls that reach `anchor`, read as `read` says (RE-316). A probe's +/// reading: [`resolved_outline_of_solids`] uses only [`OutlineAnchor::Floor`]. pub fn span_outline_at( buf: &[u8], header: usize, bbox: [f64; 6], anchor: OutlineAnchor, - arcs: bool, + read: SpanRead, ) -> Option { - outline_from_faces( - &solid_faces_to_next_solid(buf, header), - bbox, - anchor, - true, - arcs, - ) + outline_from_faces(&solid_span(buf, header), bbox, anchor, Some(read)) +} + +/// How a resolved outline was read, and so what to trust of its area +/// (RE-316). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolvedKind { + /// The faces up to the solid's end wind around a region whose boundary + /// spans the record box to [`BOX_TOLERANCE_FEET`] on every side. + Faces, + /// The same, spanning the box to [`ROOM_SLIVER_FEET`] short of it on a + /// side: a column that overlaps a wall and whose back stands behind the + /// wall's face leaves the outline that far short. Revit's export does not + /// always take such a column off the room's area, so this is + /// approximate: 0.5% and 0.7% under Revit's area on the two office rooms + /// scored. + Approximate, +} + +/// The outline of a room whose solids at `headers` none gives an outline the +/// walk reads ([`outline_of_solids`] is `None`), from the faces up to each +/// solid's end resolved to a boundary (RE-316): exactly, else with +/// [`ROOM_SLIVER_FEET`] of slack. Where more than one solid gives one, every +/// one must give the same. +pub fn resolved_outline_of_solids( + buf: &[u8], + headers: &[usize], + bbox: [f64; 6], +) -> Option<(PlanProfile, ResolvedKind)> { + let mut found: Option<(PlanProfile, ResolvedKind)> = None; + for &at in headers { + let faces = solid_span(buf, at); + let exact = SpanRead { + arcs: false, + slack: 0.0, + }; + let slack = SpanRead { + arcs: false, + slack: ROOM_SLIVER_FEET, + }; + let Some(this) = outline_from_faces(&faces, bbox, OutlineAnchor::Floor, Some(exact)) + .map(|profile| (profile, ResolvedKind::Faces)) + .or_else(|| { + outline_from_faces(&faces, bbox, OutlineAnchor::Floor, Some(slack)) + .map(|profile| (profile, ResolvedKind::Approximate)) + }) + else { + continue; + }; + match &found { + None => found = Some(this), + Some(held) if *held == this => {} + Some(_) => return None, + } + } + found } /// Whether `faces`, the faces of a room's solid, make a prism of the room's @@ -572,16 +658,14 @@ pub fn faces_form_prism(faces: &[RoomFace], bbox: [f64; 6]) -> bool { /// Whether the room's solids whose headers are at `headers` that give an /// outline against `bbox` ([`outline_of_solids`]) are all prisms of it -/// ([`faces_form_prism`]), and at least one gives an outline. An outline -/// resolved from the faces up to the next solid's tag (RE-316) is never a -/// prism here: no room's volume is measured against Revit's for them yet. +/// ([`faces_form_prism`]), and at least one gives an outline. pub fn prism_of_solids(buf: &[u8], headers: &[usize], bbox: [f64; 6]) -> bool { let mut found = false; for &at in headers { - let Some((_, faces, resolved)) = outline_with_faces(buf, at, bbox) else { + if room_outline_at(buf, at, bbox).is_none() { continue; - }; - if resolved || !faces_form_prism(&faces, bbox) { + } + if !faces_form_prism(&solid_faces(buf, at), bbox) { return false; } found = true; @@ -628,7 +712,7 @@ pub fn solid_headers_by_id(buf: &[u8]) -> BTreeMap> { /// (RE-316). Empty where no tag follows within [`ROOM_SOLID_SPAN_MAX`] bytes: /// without it the solid's end is not known, and the faces past it are another /// element's. -pub fn solid_faces_to_next_solid(buf: &[u8], header: usize) -> Vec { +pub fn solid_span(buf: &[u8], header: usize) -> Vec { let Some(start) = solid_start(buf, header) else { return Vec::new(); }; @@ -713,6 +797,9 @@ pub enum BoundaryFailure { /// There are fewer than three segments, more than /// [`BOUNDARY_SEGMENTS_MAX`], or one has an end that is not finite. Segments(usize), + /// The segments cross so often that they would be cut into more than + /// [`BOUNDARY_PIECES_MAX`] pieces. + Pieces, /// Fewer than three pieces bound the region. NoRegion, /// The points where the pieces that bound the region do not run one in @@ -738,9 +825,14 @@ pub fn region_boundary(segments: &[[f64; 4]]) -> Result, BoundaryF return Err(BoundaryFailure::Segments(count)); } let mut cuts: Vec> = vec![Vec::new(); count]; + let mut cut_total = 0usize; for first in 0..count { for second in first + 1..count { let (on_first, on_second) = cut_parameters(segments[first], segments[second]); + cut_total += on_first.len() + on_second.len(); + if cut_total + count > BOUNDARY_PIECES_MAX { + return Err(BoundaryFailure::Pieces); + } cuts[first].extend(on_first); cuts[second].extend(on_second); } diff --git a/src/research/partition_schema_mvp/rooms.rs b/src/research/partition_schema_mvp/rooms.rs index adfa6e3e..e6a20486 100644 --- a/src/research/partition_schema_mvp/rooms.rs +++ b/src/research/partition_schema_mvp/rooms.rs @@ -104,7 +104,9 @@ pub(super) fn attach_room_parameter_entries( /// Give each room the outline of its stored solid (RE-101, /// [`crate::partition_room_boundaries`]), read from the partition of the /// room's own record and kept only where it spans the record's box. A room -/// whose solid does not close keeps its box. +/// whose solid does not close is resolved from the faces up to the solid's +/// end where that spans the box (RE-316), and says so in its profile source; +/// otherwise it keeps its box. pub(super) fn attach_room_outlines( rf: &mut RevitFile, revit_version: u32, @@ -158,16 +160,33 @@ pub(super) fn attach_room_outlines( let Some(headers) = headers.get(&u64::from(id)) else { continue; }; - if let Some(profile) = prb::outline_of_solids(inflated.bytes(), headers, bbox) { + // RE-316: only a room none of whose solids the walk closes is resolved + // from the faces up to the solid's end, so a room RE-101 reads is read + // exactly as it was. + let walked = prb::outline_of_solids(inflated.bytes(), headers, bbox); + let outline = match walked { + Some(profile) => Some((profile, prb::ROOM_OUTLINE_SOURCE, true)), + None => prb::resolved_outline_of_solids(inflated.bytes(), headers, bbox).map( + |(profile, kind)| { + let source = match kind { + prb::ResolvedKind::Faces => prb::ROOM_OUTLINE_SOURCE_FACES, + prb::ResolvedKind::Approximate => prb::ROOM_OUTLINE_SOURCE_APPROXIMATE, + }; + (profile, source, false) + }, + ), + }; + if let Some((profile, source, walk)) = outline { let mut fields = profile.fields(); for (name, value) in fields.iter_mut() { if name == crate::element_record_plan_profiles::PLAN_PROFILE_SOURCE_FIELD { - *value = InstanceField::String(prb::ROOM_OUTLINE_SOURCE.into()); + *value = InstanceField::String(source.into()); } } room.fields.extend(fields); // RE-296: a room whose solid is a prism of that outline. - if ROOM_PRISM_REVIT_VERSIONS.contains(&revit_version) + if walk + && ROOM_PRISM_REVIT_VERSIONS.contains(&revit_version) && prb::prism_of_solids(inflated.bytes(), headers, bbox) { room.fields From ac101d6ba1fac25168036455231cc807b734b868 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:50:53 -0500 Subject: [PATCH 18/31] fix(rooms): restore room_outline_at and keep the walk's reading of cylinder faces as RE-101 had it (#1260) (RE-316) --- docs/export-diagnostics.md | 2 ++ src/research/partition_room_boundaries.rs | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/export-diagnostics.md b/docs/export-diagnostics.md index d16ceaad..217a10f6 100644 --- a/docs/export-diagnostics.md +++ b/docs/export-diagnostics.md @@ -197,6 +197,8 @@ Important nested fields: | `exported.storey_bound_elements` | integer | Building elements contained in a specific storey. The rest are contained in the `IfcBuilding`, never in a named storey. | | `exported.layered_element_count` | integer | Elements whose compound layers and layer colours were read (RE-53). The glTF export draws each in its layers where they add up to its body's thickness; the rest are drawn whole. | | `exported.bounding_box_spaces` | integer | Exported `IfcSpace`s whose body is their record's bounding box (`RvtElementRecordGeometry.SpaceOutlineSource` is `record_box`): a room whose outline was not read from its stored solid, or a family instance written as a space and drawn as its box. The body's plan area is the box's, not the space's (RE-313, [rooms](rooms.md)). Each is also in `exported.bounding_box_bodies`, except a family instance turned or tilted off the model's axes. | +| `exported.resolved_outline_spaces` | integer | Exported rooms whose outline was resolved from the faces of their stored solid up to the solid's end (`RvtElementRecordGeometry.SpaceOutlineSource` is `stored_solid_faces`, RE-316, [rooms](rooms.md)): the faces the exact walk reads did not close. Equal to Revit's area on every such room measured, but read by a rule that cannot tell a face of another element inside the room's box from the room's own. | +| `exported.approximate_outline_spaces` | integer | Exported rooms whose resolved outline falls up to 0.02 ft short of their record box on a side (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316, [rooms](rooms.md)): a column overlaps a wall and its back stands behind the wall's face. The area is approximate: 0.5% and 0.7% under Revit's on the two such rooms of the BIM4LCA office that Revit's export pairs, which does not take that column off the room. | | `confidence.level` | string | `scaffold`, `typed_no_geometry`, `geometry`, `diagnostic_partial`, or `proxy_only`. | | `confidence.score` | number | Heuristic 0..1 readiness score for UI sorting and dashboards. Its element terms (elements, typed elements, geometry) are scaled by the exported share when `unexported_element_records` is non-zero. | | `confidence.unexported_element_records` | integer | Element records of a recovered category (walls, doors, windows, columns, floors, rooms and the RE-33 product categories) the partition scan found but could not export because no ElementId is attributable (RE-30). Non-zero means the model is incomplete. Zero is not a completeness claim. | diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 4a8ca7a9..8d3e7610 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -435,9 +435,6 @@ pub enum OutlineAnchor { } fn reaches_anchor(face: &RoomFace, bbox: [f64; 6], anchor: OutlineAnchor) -> Option<[f64; 4]> { - if face.cylinder.is_some() { - return None; - } let (edge, bottom) = face.plan_edge()?; let reached = match anchor { OutlineAnchor::Floor => (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET, @@ -531,8 +528,10 @@ fn outline_from_faces( } let mut lines = Vec::new(); for face in faces { - if let Some(edge) = reaches_anchor(face, bbox, anchor) { - lines.push(edge); + if span.is_none() || face.cylinder.is_none() { + if let Some(edge) = reaches_anchor(face, bbox, anchor) { + lines.push(edge); + } } if span.is_some_and(|read| read.arcs) { if let Some((low, high)) = face.vertical_cylinder_heights() { @@ -559,6 +558,15 @@ fn outline_from_faces( } } +/// The room's outline from the solid whose header starts at `header`, where +/// its wall faces (vertical, reaching the floor of `bbox`, a record box +/// `[min x, min y, min z, max x, max y, max z]`) close into loops whose plan +/// extent is `bbox`'s within [`BOX_TOLERANCE_FEET`]. A face's origin is its +/// plane's, not a point on the face, so only the plan edges are checked. +pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option { + outline_from_faces(&solid_faces(buf, header), bbox, OutlineAnchor::Floor, None) +} + /// The outline the faces up to the solid's end make, resolved to a boundary, /// for walls that reach `anchor`, read as `read` says (RE-316). A probe's /// reading: [`resolved_outline_of_solids`] uses only [`OutlineAnchor::Floor`]. From 7d7c098c8ede1ae8bb55d559a4bfdf270a5e70fc Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:11:22 -0500 Subject: [PATCH 19/31] feat(rooms): a solid's span ends at the faces its list of objects counts and at a gap of 1024 bytes, and the work of a resolution is bounded and measured on crafted solids (#1260) (RE-316) --- probes/examples/probe_re316_hostile_solid.rs | 139 +++++++++++++++++++ src/research/partition_room_boundaries.rs | 113 +++++++++------ 2 files changed, 212 insertions(+), 40 deletions(-) create mode 100644 probes/examples/probe_re316_hostile_solid.rs diff --git a/probes/examples/probe_re316_hostile_solid.rs b/probes/examples/probe_re316_hostile_solid.rs new file mode 100644 index 00000000..21596793 --- /dev/null +++ b/probes/examples/probe_re316_hostile_solid.rs @@ -0,0 +1,139 @@ +//! RE-316: how long the room outline readers take on a crafted solid. +//! +//! FACT under test: a partition is untrusted input, and the readers that +//! resolve a room's stored solid ([`outline_of_solids`], the walk of RE-101, +//! and [`resolved_outline_of_solids`], RE-316) do bounded work on any solid: +//! the walk reads no more than `SOLID_FACES_MAX` faces, the span no more than +//! the solid's list of objects counts and `RESOLVE_FACES_MAX`, and +//! `region_boundary` gives up before it builds more than `BOUNDARY_PIECES_MAX` +//! pieces however often its segments cross. +//! +//! The probe builds solids in memory, runs both readers on each and prints the +//! time they took and whether they gave an outline. No model is read. +//! +//! - `grid`: a square grid of full-length wall faces, `n` across and `n` down, +//! whose segments cross `n * n` times (512 faces at most); +//! - `collinear`: wall faces that overlap along one line; +//! - `rooms`: concentric squares, the shape of a room with holes; +//! - `unbounded list`: faces whose list of objects counts more than the reader +//! resolves; +//! - `no list`: faces with no list of objects. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_hostile_solid + +use std::time::Instant; + +use rvt::research::partition_room_boundaries::{ + FACE_TAG, ROOM_SOLID_TAG, outline_of_solids, resolved_outline_of_solids, +}; + +const HEIGHT: f64 = 10.0; + +fn put(buf: &mut Vec, values: &[f64]) { + for value in values { + buf.extend_from_slice(&value.to_le_bytes()); + } +} + +/// A wall face along `direction` (a unit vector in plan) from `from` for +/// `length` feet, from the floor to `HEIGHT`, in the 174-byte layout. +fn wall(from: (f64, f64), direction: (f64, f64), length: f64) -> Vec { + let mut face = Vec::with_capacity(174); + face.extend_from_slice(&FACE_TAG); + face.extend_from_slice(&[0xff; 16]); + put(&mut face, &[0.0, 0.0, HEIGHT, length]); + face.resize(85, 0xff); + face.push(1); + put(&mut face, &[from.0, from.1, 0.0]); + put(&mut face, &[0.0, 0.0, 1.0]); + put(&mut face, &[direction.0, direction.1, 0.0]); + face.resize(174, 0xff); + face +} + +/// A solid's bytes: the room's id, the solid tag, the two boxes' room, the +/// list of objects (`list` of them, or none) and `faces`. +fn solid(faces: &[Vec], list: Option) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&7u64.to_le_bytes()); + buf.extend_from_slice(&ROOM_SOLID_TAG); + buf.extend_from_slice(&[0u8; 96]); + if let Some(count) = list { + buf.extend_from_slice(&[0x04, 0xc0, 0x08, 0x00]); + buf.extend_from_slice(&count.to_le_bytes()); + buf.extend_from_slice(&4u32.to_le_bytes()); + for token in 0..count { + buf.extend_from_slice(&[0x97, 0x06]); + buf.extend_from_slice(&(token + 5).to_le_bytes()); + } + } + buf.extend_from_slice(&[0u8; 16]); + for face in faces { + buf.extend_from_slice(face); + } + buf.extend_from_slice(&[0u8; 64]); + buf +} + +fn run(name: &str, faces: &[Vec], list: Option, extent: f64) { + let buf = solid(faces, list); + let bbox = [0.0, 0.0, 0.0, extent, extent, HEIGHT]; + let started = Instant::now(); + let walked = outline_of_solids(&buf, &[0], bbox); + let walk_ms = started.elapsed().as_secs_f64() * 1000.0; + let started = Instant::now(); + let resolved = resolved_outline_of_solids(&buf, &[0], bbox); + let resolve_ms = started.elapsed().as_secs_f64() * 1000.0; + println!( + "{name}: {} faces, {} bytes; walk {walk_ms:.1} ms ({}), resolve {resolve_ms:.1} ms ({})", + faces.len(), + buf.len(), + if walked.is_some() { "outline" } else { "none" }, + if resolved.is_some() { + "outline" + } else { + "none" + }, + ); +} + +fn main() { + for n in [10usize, 40, 100, 256] { + let extent = n as f64 + 1.0; + let mut faces = Vec::new(); + for k in 0..n { + faces.push(wall((0.0, k as f64 + 0.5), (1.0, 0.0), extent)); + faces.push(wall((k as f64 + 0.5, 0.0), (0.0, 1.0), extent)); + } + run( + &format!("grid {n} x {n}"), + &faces, + Some(faces.len() as u32), + extent, + ); + } + let overlapping: Vec> = (0..512) + .map(|k| wall((k as f64 * 0.01, 0.0), (1.0, 0.0), 100.0)) + .collect(); + run("collinear 512", &overlapping, Some(512), 100.0); + let squares: Vec> = (0..64) + .flat_map(|k| { + let a = k as f64 * 0.5; + let side = 100.0 - 2.0 * a; + [ + wall((a, a), (1.0, 0.0), side), + wall((a + side, a), (0.0, 1.0), side), + wall((a + side, a + side), (-1.0, 0.0), side), + wall((a, a + side), (0.0, -1.0), side), + ] + }) + .collect(); + run("rooms 64 concentric squares", &squares, Some(256), 100.0); + run("unbounded list", &squares, Some(100_000), 100.0); + run("no list", &squares, None, 100.0); + let many: Vec> = (0..5000) + .map(|k| wall((0.0, k as f64 * 0.01), (1.0, 0.0), 100.0)) + .collect(); + run("5000 faces", &many, Some(512), 100.0); +} diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 8d3e7610..a0b19a0b 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -110,17 +110,17 @@ pub const ROOM_SOLID_WINDOW: usize = 0x1_0000; /// feet. pub const BOX_TOLERANCE_FEET: f64 = 1e-3; -/// How many bytes past a room's solid header the next solid's tag is looked -/// for (RE-316, interim). -pub const ROOM_SOLID_SPAN_MAX: usize = 4 << 20; +/// The most bytes from one face's tag to the next face's within one solid +/// when its list of objects bounds the span (RE-316). +pub const SPAN_FACE_GAP: usize = 0x400; -/// The most faces a solid's span may hold before it is not resolved (RE-316): -/// the largest solid measured has 115. -pub const RESOLVE_FACES_MAX: usize = 512; +/// The most faces a solid may have to be read at all (RE-316): the largest +/// solid measured lists 136 objects. A solid with more is left to its box, so +/// the work of snapping and resolving its edges is bounded. +pub const SOLID_FACES_MAX: usize = 512; -/// The most faces the walk reads from one solid; a solid with more is not -/// read at all. -pub const SOLID_FACES_MAX: usize = 4096; +/// The most faces a solid's span may hold before it is not resolved (RE-316). +pub const RESOLVE_FACES_MAX: usize = SOLID_FACES_MAX; /// How far a vertical face's span may fall short of a room's box, and a /// horizontal face lie from its floor or ceiling, and still be it, feet @@ -322,32 +322,28 @@ fn solid_start(buf: &[u8], header: usize) -> Option { .then_some(after_id + 8) } -/// The faces of the solid whose header starts at `header`: the first face -/// within [`ROOM_SOLID_WINDOW`], and each face after it within -/// [`FACE_GAP`] of the one before. -pub fn solid_faces(buf: &[u8], header: usize) -> Vec { - let Some(start) = solid_start(buf, header) else { - return Vec::new(); - }; - let end = buf.len().min(start + ROOM_SOLID_WINDOW); - let Some(window) = buf.get(start..end) else { - return Vec::new(); - }; - let Some(first) = memchr::memmem::find_iter(window, &FACE_TAG) +/// The first face of the solid that starts at `start`, within +/// [`ROOM_SOLID_WINDOW`]. +fn first_face_at(buf: &[u8], start: usize) -> Option { + let end = buf.len().min(start.checked_add(ROOM_SOLID_WINDOW)?); + let window = buf.get(start..end)?; + memchr::memmem::find_iter(window, &FACE_TAG) .map(|offset| start + offset) .find(|&at| face_at(buf, at).is_some()) - else { - return Vec::new(); - }; +} + +/// The faces from the one at `first`, each the first face within `max_gap` +/// bytes of its tag after the one before, up to `limit` of them. +fn follow_faces(buf: &[u8], first: usize, max_gap: usize, limit: usize) -> Vec { let mut faces = Vec::new(); let mut at = first; while let Some(face) = face_at(buf, at) { faces.push(face); - if faces.len() > SOLID_FACES_MAX { - return Vec::new(); + if faces.len() >= limit { + break; } let from = at + FACE_TAG.len(); - let Some(window) = buf.get(from..buf.len().min(at + FACE_GAP)) else { + let Some(window) = buf.get(from..buf.len().min(at + max_gap)) else { break; }; let Some(next) = memchr::memmem::find_iter(window, &FACE_TAG) @@ -361,6 +357,44 @@ pub fn solid_faces(buf: &[u8], header: usize) -> Vec { faces } +/// The faces of the solid whose header starts at `header`: the first face +/// within [`ROOM_SOLID_WINDOW`], and each face after it within +/// [`FACE_GAP`] of the one before. None if there are more than +/// [`SOLID_FACES_MAX`]. +pub fn solid_faces(buf: &[u8], header: usize) -> Vec { + let Some(start) = solid_start(buf, header) else { + return Vec::new(); + }; + let Some(first) = first_face_at(buf, start) else { + return Vec::new(); + }; + let faces = follow_faces(buf, first, FACE_GAP, SOLID_FACES_MAX + 1); + if faces.len() > SOLID_FACES_MAX { + return Vec::new(); + } + faces +} + +/// The bytes that open the list of a solid's objects, after its two boxes. +const SOLID_LIST_TAG: [u8; 4] = [0x04, 0xc0, 0x08, 0x00]; + +/// How far past its start a solid's list of objects is looked for. +const SOLID_LIST_WINDOW: usize = 0x200; + +/// The number of objects the solid that starts at `start` lists (RE-316): the +/// list is `04 c0 08 00 · u32 count · u32 4 · count × (u16 class · u32 +/// token)` and counts every face, including those whose record +/// [`face_at`] does not read (a solid's floor and ceiling, usually). None +/// unless the list is there with a count from 1 to [`RESOLVE_FACES_MAX`]. +pub fn solid_object_count(buf: &[u8], start: usize) -> Option { + let end = buf.len().min(start.checked_add(SOLID_LIST_WINDOW)?); + let window = buf.get(start..end)?; + let at = start + memchr::memmem::find(window, &SOLID_LIST_TAG)?; + let count = le_bytes::u32_at(buf, at + 4)? as usize; + let lead = le_bytes::u32_at(buf, at + 8)?; + (lead == 4 && (1..=RESOLVE_FACES_MAX).contains(&count)).then_some(count) +} + impl RoomFace { /// The heights, low and high, a cylindrical face with a vertical axis /// spans, feet. @@ -715,25 +749,24 @@ pub fn solid_headers_by_id(buf: &[u8]) -> BTreeMap> { out } -/// Every face of the solid whose header starts at `header`, from its first -/// face to the tag of the next solid in `buf`, whatever the gap between faces -/// (RE-316). Empty where no tag follows within [`ROOM_SOLID_SPAN_MAX`] bytes: -/// without it the solid's end is not known, and the faces past it are another -/// element's. +/// The faces of the solid whose header starts at `header` that the walk +/// stops short of (RE-316): from its first face, each next face within +/// [`SPAN_FACE_GAP`] bytes of the one before, and no more than the solid's +/// list of objects counts ([`solid_object_count`]). A record longer than +/// [`FACE_GAP`] can sit between a solid's own faces (592 to 732 bytes on the +/// BIM4LCA office and Autodesk's samples). On the 47 solids measured whose list +/// counts exactly the faces the walk reads, the next face after the walk, which +/// is another element's, is 6826 bytes or more on. Empty without the list or a +/// first face. pub fn solid_span(buf: &[u8], header: usize) -> Vec { let Some(start) = solid_start(buf, header) else { return Vec::new(); }; - let window_end = buf.len().min(start.saturating_add(ROOM_SOLID_SPAN_MAX)); - let Some(window) = buf.get(start..window_end) else { - return Vec::new(); - }; - let Some(end) = memchr::memmem::find(window, &ROOM_SOLID_TAG) else { + let (Some(count), Some(first)) = (solid_object_count(buf, start), first_face_at(buf, start)) + else { return Vec::new(); }; - memchr::memmem::find_iter(&window[..end], &FACE_TAG) - .filter_map(|offset| face_at(buf, start + offset)) - .collect() + follow_faces(buf, first, SPAN_FACE_GAP, count) } /// Where each of two segments is cut by the other, as parameters from 0 at From edd7a72a943ee0d99da416ecf86269722977c80c Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:24:59 -0500 Subject: [PATCH 20/31] docs(rooms): the module says how a resolved room's solid is bounded and labelled (#1260) (RE-316) --- src/research/partition_room_boundaries.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index a0b19a0b..539532d0 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -35,11 +35,15 @@ //! than [`FACE_GAP`] (626 bytes in office room 1422302), and a face's box is //! the box of its plane, not its outline: where a column stands in the room //! and overlaps a wall, the wall's face runs across the column's footprint. -//! When the faces the walk reads do not close, the faces up to the next -//! solid's tag are read instead ([`solid_span`]), each plan -//! edge directed so the room is on its left, and the room's outline is the -//! boundary of the region those edges wind around ([`region_boundary`], -//! RE-316). A face whose record is 44 bytes longer than a plane's is a +//! Only where the walk closes none of a room's solids is the solid read again +//! (RE-316): its faces are followed with a gap of up to [`SPAN_FACE_GAP`] +//! bytes, and no more than its list of objects counts ([`solid_span`], +//! [`solid_object_count`]), so the faces of the element that follows it are +//! not read. Each plan edge is directed so the room is on its left, and the +//! room's outline is the boundary of the region those edges wind around +//! ([`region_boundary`]). A room read this way says so in its profile source +//! ([`ROOM_OUTLINE_SOURCE_FACES`], [`ROOM_OUTLINE_SOURCE_APPROXIMATE`]), never +//! as the walk's. A face whose record is 44 bytes longer than a plane's is a //! cylinder's (a round column): its axis, a unit vector at right angles to //! the face's u and v, at +158, and its radius at +182 ([`RoomCylinder`]). Its //! arc closes the loops of the six `rac_advanced` rooms RE-101 left open, but From d25ef670bc09be3be9e3f58fdff732178d9a08f2 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:14:07 -0500 Subject: [PATCH 21/31] docs(rooms): RE-316 after the review: the span is bounded by the solid's list of objects, resolved rooms are labelled stored_solid_faces and stored_solid_approximate and counted, and the measured misses are stated (#1260) (RE-316) --- .../changed/1260-room-solids-resolved.md | 42 +++-- docs/evidence.md | 2 +- docs/rooms.md | 25 +-- docs/status.md | 2 +- docs/supported-profile.md | 2 +- .../examples/probe_re316_room_native_faces.rs | 5 + probes/examples/probe_re316_solid_vertices.rs | 6 + .../RE-316-room-solids-resolved.md | 174 ++++++++++++------ 8 files changed, 172 insertions(+), 86 deletions(-) diff --git a/changelog.d/changed/1260-room-solids-resolved.md b/changelog.d/changed/1260-room-solids-resolved.md index 1c8dc7e2..4e7a11b9 100644 --- a/changelog.d/changed/1260-room-solids-resolved.md +++ b/changelog.d/changed/1260-room-solids-resolved.md @@ -1,16 +1,26 @@ -- **A room whose stored solid the walk did not close takes the outline of - the faces up to the solid's end instead of its record's box, where those - close on the box.** The walk stopped at a record longer than 0x200 bytes - between two faces, walls on lines up to 0.0015 ft apart left a step no - face crosses, a column overlapping a wall put a loop partly inside the - wall's, and a solid ran past the 64 KB window. On the BIM4LCA office as - built 9 of the 13 rooms that kept their box take an outline and all 9 - have Revit's area to 0.1% (their boxes were 3% to 67% over it); at - building permit 10 of the 12 do, all 10 to 0.1%; and 6 of 6 do on a - held-out Revit 2021 project. Rooms whose outline would stand short of - their box, whose span runs into other elements, whose solid holds a round - column or whose floors and ceilings step keep their box: three more - office rooms as built, two at permit, the six `rac_advanced` rooms with - round columns and 49 of the 50 `rme` rooms, each for a measured reason. - Core Interior and RE1 Architecture are unchanged. An outline read this - way carries no `NetVolume` (#1260, RE-316). +- **A room whose stored solid the exact walk did not close is read again + from the faces its solid lists, resolved to a boundary, and labelled + apart.** The walk stopped at a record longer than 0x200 bytes between two + faces, walls on lines up to 0.0015 ft apart left a step no face crosses, a + column overlapping a wall put a loop partly inside the wall's, and a solid + ran past the 64 KB window. The solid ends at the count of its list of + objects and at a gap of 1,024 bytes, so the faces of the element that + follows are not read, and the work is bounded (512 faces, 4,096 pieces; + crafted solids take under 10 ms). `SpaceOutlineSource` is + `stored_solid_faces` for these rooms, and `stored_solid_approximate` where + a column's back leaves the outline up to 0.02 ft short of the record box; + `stored_solid` stays the exact walk's. The diagnostics count them + (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`) + and the room schedule leaves an approximate room's area blank. On the + BIM4LCA office as built the 13 rooms that kept their box take an outline. + Ten are `stored_solid_faces`: nine with Revit's area to 0.1% (their boxes + were 3% to 67% over it) and room 1671995, 323% over it (its box, 329%; + Revit's export splits it into 9 pieces). Three are + `stored_solid_approximate`, the two Revit's export pairs 0.68% and 0.47% + under its area. At building permit 11 of 12 are `stored_solid_faces`, 10 + with Revit's area to 0.1% and room 1671995 350% over, and room 3095831 + keeps its box. 6 of the 8 rooms that kept their box do on a held-out + Revit 2021 project, all 6 at Revit's area. The six `rac_advanced` rooms + with round columns, 49 of the 50 `rme` rooms and Snowdon Towers' seven keep + their box. Core Interior and RE1 Architecture are unchanged. A resolved + room carries no `NetVolume` (#1260, RE-316). diff --git a/docs/evidence.md b/docs/evidence.md index 6a8551e1..0720955d 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -75,7 +75,7 @@ On Revit 2024 and 2025 project files, rvt-rs reads each element from its partiti | Electrical and mechanical devices and equipment | `IfcAlarm` (fire alarm devices), `IfcElectricAppliance` (data devices), proxies (switches, sensors, receptacles, junction boxes, panels, mechanical equipment) | RE1 Electrical and Mechanical, every one Revit exports and nothing else; the symbols nested in switches and receptacles left out, as Revit leaves them out (RE-79) | | Structural framing, columns, foundations | `IfcBeam` along its location line, `IfcColumn`, `IfcFooting` | Snowdon structural sample: all 1,078 in the category its VIM export gives them; 923 of 942 beams run along their line, with Revit's own section on 613 of the 839 a later edition kept unchanged, and on 179 more less the top a floor join cuts (RE-49); 377 steel beams and 40 steel columns are their type's I section, Revit's own on all 271 beams and 24 columns the VIM scores (RE-103, RE-104) | | Site elements, generic models, slab edges, elevators, ramps | proxies, `IfcTransportElement`, `IfcRamp` | Snowdon Towers, every one Revit exports | -| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid does not close is resolved from the faces up to the solid's end where that closes on the box (the BIM4LCA office 9 of 13 rooms as built and 10 of 12 at permit, all at Revit's area, RE-316), and keeps its box otherwise | +| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid the exact walk does not close is read again from the faces its solid lists, labelled `stored_solid_faces` or `stored_solid_approximate` in `SpaceOutlineSource` (the BIM4LCA office 13 of 13 rooms as built and 11 of 12 at permit; Revit's area to 0.1% on 19 of the 21 faces rooms scored, RE-316), and keeps its box otherwise | | Areas | `IfcSpace .SPACE.` for each placed Area of a Revit 2024 file, named as Revit's export names it (`Name` the number, `LongName` the name), in its Level's storey and typed by its own `IfcSpaceType`; no body, since the Area's boundary is not decoded | the BIM4LCA office as built and at building permit: all 30 of Revit's area spaces, number, name and GlobalId as Revit's (RE-191) | | Storeys | `IfcBuildingStorey` for each Revit Level that is a building story, with its name and elevation | Core Interior 15, Snowdon Towers 18, every RE1 model 2; name, elevation and GlobalId all Revit's (RE-24, RE-51); Snowdon Towers Structural 12 of its 19 Levels, its VIM export's building stories, 208 of the 210 elements on them in their storey (RE-118) | | Storey containment | each element in the storey of the Level its record names, of its base constraint where it names two, and, where it names none, of a railing's host, of the Level its work planes carry, or of its base elevation | Core Interior all 854 in Revit's storey; Snowdon Towers 5,949 in Revit's storey, 1 in another and 1 on none (RE-27, RE-59, RE-60, RE-68) | diff --git a/docs/rooms.md b/docs/rooms.md index f4086ef4..771f3f5d 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -19,18 +19,21 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: | Value | The body is | Its plan area is | |---|---|---| -| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on every `stored_solid` room scored below except one, the permit office's STAIRCASE room 2611271 at 66% (RE-313 §2, §3, RE-316 §3) | -| `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's 5 room scores that keep their box (3 as built, 2 at permit; RE-316 §4) | +| `stored_solid` | the outline of the room's stored solid, extruded to the room's height, read by the exact walk of RE-101 | Revit's room area: within 0.1% of Revit's export on every `stored_solid` room scored below except one, the permit office's STAIRCASE room 2611271 at 66% (RE-313 §2, §3) | +| `stored_solid_faces` | the outline of the room's stored solid, resolved to a boundary from the faces up to the solid's end where the exact walk did not close (RE-316) | Revit's room area on the rooms measured except one: 19 of the office's 21 scored (to 0.1%), and 6 of 6 of a held-out project. The 2 others are one room, 1671995, as built and at permit, 323% and 350% over Revit's area (Revit's export splits it into 9 pieces; its stored solid is one region with 6 holes), nothing in the file telling it from the rooms that agree. Read by a rule that bounds the solid by its list of objects and a gap, not by the exact walk, which is why it is labelled apart (RE-316 §2, §3) | +| `stored_solid_approximate` | the same, falling up to 0.02 ft short of the room's record box on a side: a column overlaps a wall and its back stands behind the wall's face | approximate: 0.68% and 0.47% under Revit's on the two rooms of the office that Revit's export pairs, the third not paired. Revit's export does not take that column off the room, the stored solid does. The room schedule leaves the area blank (RE-316 §2) | +| `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's room scores that keep their box (1 room at permit, 3095831; RE-316 §4) | | `saved_mesh` | the mesh Revit saved for the room (`rvt-ifc --saved-meshes`) | the mesh's; not scored | | `none` | no body (an Area) | not available | | `family_body` | a Revit Space or a family instance written as an `IfcSpace`, drawn as more than its box; its `BodySource` says how | not scored | -A room's `BodySource` is `partition_element_record_bbox` whichever of the first two values it has, because the placement and the height always come from the record's box: read `SpaceOutlineSource`, not `BodySource`. An export from before this property (rvt-rs 0.12.x and earlier) says the same through `ProfileSource` (`partition_room_solid_faces`) and `ProfileResolved`. +A room's `BodySource` is `partition_element_record_bbox` whichever of the stored-solid or `record_box` values it has, because the placement and the height always come from the record's box: read `SpaceOutlineSource`, not `BodySource`. An export from before this property (rvt-rs 0.12.x and earlier) says the same through `ProfileSource` (`partition_room_solid_faces`, and since RE-316 `partition_room_solid_faces_resolved` and `partition_room_solid_faces_approximate`) and `ProfileResolved`. A room with `stored_solid_faces` or `stored_solid_approximate` carries no `NetVolume`. The export also counts the spaces drawn as their record's box (rooms and family instances alike), so an importer can check a whole file before reading a space: - `exported.bounding_box_spaces` in the diagnostics JSON (`rvt-ifc --diagnostics`), out of `exported.by_ifc_type.IFCSPACE`; -- a line in `rvt-ifc`'s summary: "N of M spaces are their record's box". +- `exported.resolved_outline_spaces` (`stored_solid_faces`) and `exported.approximate_outline_spaces` (`stored_solid_approximate`), the rooms whose outline was not read by the exact walk; +- a line in `rvt-ifc`'s summary for each: "N of M spaces are their record's box", "N rooms take an outline resolved from the faces up to their stored solid's end", "N rooms take an approximate outline". ## Which releases read a room's outline @@ -59,24 +62,24 @@ Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ |---|---:|---:|---:|---:|---| | Core Interior (2024) | 116 | 116 | 116 | 116 | 116 rooms, stored solid: 116, 116 | | RE1 Architecture (2025) | 11 | 11 | 11 | 11 | 11 rooms, stored solid: 11, 11 | -| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 229 rooms, stored solid: 229, 229; 3 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | -| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 232 | 232 | 225 | 232 rooms, stored solid: 231, 231; 2 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | +| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 220 rooms, stored solid: 220, 220; 10 rooms, stored solid faces: 9, 9; 2 rooms, stored solid approximate: 0, 0; 44 family instances, record box: 1, 1 | +| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 232 | 232 | 225 | 222 rooms, stored solid: 221, 221; 11 rooms, stored solid faces: 10, 10; 1 room, record box: 0, 0; 44 family instances, record box: 1, 1 | | R2025 purpose-made models with rooms (2025), 6 models | 12 | 12 | 12 | 12 | 12 rooms, stored solid: 12, 12 | | Held-out models (2022, 2024, 2025), 6 models | 24 | 24 | 24 | 24 | 24 rooms, stored solid: 24, 24 | | Held-out models (2026, 2027), 4 models | 16 | 16 | 16 | 16 | 16 rooms, stored solid: 16, 16 (all rectangles) | | One held-out house (2026) | 12 of 13 | 10 | 0 | 0 | 12 rooms, stored solid: 10, 0 | | Held-out Gdynia (2021, Revit's IFC2X3 export) | 18 of 19 | 18 | 0 | 0 | 18 rooms, stored solid: 18, 0 | -| Held-out Warmond (2021, export passed through another tool) | 179 | 174 | 174 | 174 | 171 rooms, stored solid: 171, 171; 8 rooms, record box: 3, 3 | +| Held-out Warmond (2021, export passed through another tool) | 179 | 179 | 179 | 179 | 171 rooms, stored solid: 171, 171; 6 rooms, stored solid faces: 6, 6; 2 rooms, record box: 2, 2 (rectangles) | -Held out (counts only), BIM Speed's Revit 2021 project: 6 more rooms take their stored solid's outline under RE-316, and all 177 of its stored-solid rooms are within 0.1% and 0.01 ft of Revit's (area within 0.1% 174 → 179 of 179 spaces; 2 rooms keep their box, both rectangles). +On Warmond, RE-316 closes 6 of the 8 rooms that kept their box (5 were off in area; one was a rectangle already equal to Revit's, whose vertices are no longer the box's: vertices within 0.01 ft 127 → 126); the 2 that keep their box are rectangles. Area within 0.1% goes from 174 to 179 of the 179 paired spaces (Measure run 41712392709, `main` at 8664b94a against this change, counts only). A model with no rooms in Revit's export (the other R2025 models, RE1 Electrical, Mechanical and Plumbing) scores 0 of 0. Two held-out models write none of Revit's spaces: Vitoria's 2018 project (73), which exports no element until PR #1286 (#1261), and BIM Whale's 2021 project (8), whose spaces are Areas, which rvt-rs reads on Revit 2024 only. -Held-out figures are Measure run 41680717786 (`main` at 21cb2efd), counts only. On the 2026 house and on Gdynia, every room's area is right where it is scored, but none of their outlines is within 0.01 ft of Revit's; the files are held out, so why is not examined. +The other held-out figures are Measure run 41712392709 (`main` at 8664b94a), counts only. On the 2026 house and on Gdynia, every room's area is right where it is scored, but none of their outlines is within 0.01 ft of Revit's; the files are held out, so why is not examined. What the misses are: -- **Rooms that keep their box (4 on the office as built, 2 at building permit; 3 and 2 of them scored)**: the span to the next solid's tag holds other elements' faces (room 1671995, whose box is 329% over Revit's area as built); a column overlaps a wall and the outline falls 0.0164 ft short of the box (1820322, 1832504 and 2066902 as built, which a 0.02 ft slack would close at 0.5% and 0.7% off Revit's area on the two scored, because Revit's export does not take that column off); or collinear overlaps leave 14 ends unjoined (room 3095831, permit). 12 rooms on each file kept their box before RE-316 resolved the rest from the faces up to the next solid's tag (RE-316 §1, §4). +- **Rooms the exact walk did not close** (13 as built, 12 at permit; RE-316): as built 10 are `stored_solid_faces` and 3 `stored_solid_approximate`; at permit 11 are `stored_solid_faces` and 1, 3095831, keeps its box (14 ends down a 100 ft wall are not joined). One room, 1671995 (both files), is `stored_solid_faces` and 323% (350% at permit) over Revit's area, where its box was 329% (356%): its stored solid and Revit's export disagree. Before RE-316 all 13 and 12 kept their box (RE-316 §1, §3, §4). - **Family instances written as spaces (44 on the office)** are 42 "Turning Free Space" discs and two "VOLUME" families that Revit's export draws from the family's geometry (a mapped representation); rvt-rs draws their record box, a square where the family is a circle (24.22 ft² against 19.01 ft² on the 1500 mm turning spaces listed). No issue covers these yet; #1262 and PR #1279 are about the terrain office's parking bays, a different set, though its rule (a family instance written as a space is left out where its family draws no model form) may change this one too. - **The office's other 30 spaces are its Areas**: Revit's export gives them a body, rvt-rs writes them without one (RE-191). - **One room at building permit** is read from its stored solid and is 66% off in area (172.77 against 103.90 ft²); its cause is not found (RE-313 §3). @@ -88,7 +91,7 @@ What the misses are: - Room outlines of Revit 2026 and 2027 beyond the rectangles: on the held-out house none is within 0.01 ft of Revit's (#1260). - A room's `Height` and `NetVolume` as scored by `tools/re/qtos_vs_ifc.py`, not by this scorer. - Revit's phase filter: Revit leaves out a room by its phase parameter, which is not measured to be its created phase (docs/status.md). -- Rooms whose stored solid closes only once read as a boundary (RE-316) on any release but Revit 2024: the evidence is one building in two states. The six `rac_advanced` rooms with round columns (read as arcs, they would close in every release of the Autodesk sample, but whether Revit takes a column off a room's area is not measured) and 49 of the 50 `rme` rooms (stepped floors and ceilings) keep their box. +- Rooms resolved as `stored_solid_faces` or `stored_solid_approximate` (RE-316) on any release but Revit 2024: the evidence is one building in two states and one held-out project. No face of another element inside the room's box is excluded by anything but the solid's list of objects and a gap of 1,024 bytes (RE-316 §2). The six `rac_advanced` rooms with round columns (read as arcs, they would close in every release of the Autodesk sample, but whether Revit takes a column off a room's area is not measured), 49 of the 50 `rme` rooms (stepped floors and ceilings) and Snowdon Towers' seven keep their box. ## Check it yourself diff --git a/docs/status.md b/docs/status.md index 6c6554f8..8c2c3260 100644 --- a/docs/status.md +++ b/docs/status.md @@ -113,7 +113,7 @@ walls Revit steps over a floor that covers them in part (29 of the BIM4LCA offic the rooms whose stored solid still does not close (RE-316 §4) and opening profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the first reader did not close takes the outline of the faces up to the solid's end, resolved to a boundary: 9 of the BIM4LCA office's 13 such rooms as built and 10 of 12 at building permit, all 19 with Revit's area to 0.1%, and 6 of 6 on a held-out Revit 2021 project (RE-316); the rest, the 6 `rac_advanced` rooms with round columns and 49 of the 50 `rme` rooms keep their box. [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, resolved to a boundary, and says so: `stored_solid_faces` (10 of the BIM4LCA office's 13 such rooms as built and 11 of 12 at building permit; Revit's area to 0.1% on 19 of the 21 Revit's export pairs, and the 2 others are one room, 1671995, whose stored solid and Revit's export disagree by 323% and 350%; 6 of 6 on a held-out Revit 2021 project) or `stored_solid_approximate` (3 rooms as built, 0.68% and 0.47% under Revit's area on the two it pairs), counted in `exported.resolved_outline_spaces` and `exported.approximate_outline_spaces`; the 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history diff --git a/docs/supported-profile.md b/docs/supported-profile.md index d1b5f73d..2d375dfb 100644 --- a/docs/supported-profile.md +++ b/docs/supported-profile.md @@ -112,7 +112,7 @@ counts reported on 2026-11-02 and 2026-11-03 on 0.12.0): | Stairs | aggregates of flights, landings and supports, each stair's and flight's `PredefinedType` from its runs and landings; straight flights as their steps, monolithic ones included; riserless flights as their treads, spiral ones as their sector-shaped treads; a monolithic landing as its slab, and a landing that is one tread as that tread | Snowdon Towers 33 of 38 drawn flights equal to Revit's; on the BIM4LCA office (Revit 2024) all 35 flights have Revit's volume, its 34 straight ones and its spiral one, its 21 monolithic landings Revit's top and outline, and its tread landing Revit's body; on puzzbobb's Revit 2025 stair models every flight of models 13 to 16 has Revit's volume (RE-214, RE-217). A monolithic landing's underside is drawn flat where Revit's follows a flight's soffit, 100.4% to 104.7% of Revit's volume, and its `BodySource` says so; monolithic spiral runs and riserless runs with a nosing keep their box (#357) | | Curtain walls | aggregates of panels and mullions placed by their grids; a mullion or panel turned or tilted off the model's axes as the box along its own axes (RE-106) | every Snowdon panel and mullion relation Revit's; 1,019 turned or tilted mullions and panels with Revit's extents along their axes | | Doors, windows, openings | the element's box, turned with the instance where it is turned (RE-87); its opening is as deep as the host wall | host is Revit's on 138 of 138 (Core Interior) and 192 of 194 (Snowdon; the other 2 in another wall Revit cuts, RE-85), and on Revit 2024 and 2025 the wall its native record names, Revit's on all 463 of the BIM4LCA office's as built (RE-245); on Revit 2024 and 2025 the opening is the outline the symbol that draws it cuts, through the instance's transform: 138 of 138 on Core Interior and 4 of 4 on RE1 within 0.001 ft of Revit's (RE-215, #227; Measure run 40927543524); a window whose type stores Width, Height and Default Sill Height takes Revit's opening exactly (56 of 68 on Snowdon, RE-93), and a door whose body is its type's Rough Height tall its rough opening (57 of 126, RE-94); any other door whose type stores Width and Height carries them as its overall size (137 of 137 on Core Interior and RE1, RE-187; Measure run 40927543524); on Revit 2024 and 2025 a door's or window's overall size is the outline the symbol that draws it cuts, Revit's on every one with such an outline (138 of 138 on Core Interior, 4 of 5 on RE1, RE-215); a door or window whose symbol cuts no outline takes its own extents as its overall size, Revit's on all 269 windows and 266 of 269 doors of the BIM4LCA office (RE-218); otherwise not Revit's opening profile (#227, RE-83) | -| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); all 229 of the BIM4LCA office's rooms read from their stored solid as built (231 of 232 at building permit) at Revit's area and outline, while its 5 room scores that keep their box and its 44 family-instance spaces drawn as boxes almost never are (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | +| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); all 220 of the BIM4LCA office's scored rooms read from their stored solid by the exact walk as built (221 of 222 at building permit) at Revit's area and outline; its rooms the walk did not close take a resolved outline, `stored_solid_faces` (19 of the 21 scored at Revit's area, the other two being one room whose stored solid and Revit's export disagree) or `stored_solid_approximate` (0.68% and 0.47% under Revit's), and its 1 room (permit) that keeps its box and 44 family-instance spaces drawn as boxes almost never are Revit's (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | | Areas | `IfcSpace` with number, name and storey; no body | all 30 of Revit's area spaces on the BIM4LCA office, as built and at building permit (RE-191); Revit 2024 only | | Furniture, fixtures, equipment, MEP devices, site | the element's bounding box, turned with the instance where it is turned (RE-87) | placement and extent only; no family geometry. MEP devices and equipment are measured on RE1 Electrical and Mechanical only (RE-79) | | Family instances from their saved meshes (doors and windows by default on Revit 2023 to 2027, every other family with `--saved-meshes`) | the mesh Revit saved for the element's display | within 0.01 ft of Revit's export: Core Interior 158 of 158, RE1 (2025) 207 of 214, Snowdon Towers 3,332 of 3,736. Revit's own shape, every vertex within 0.001 ft: Core Interior 138 of 138 doors and windows, the BIM4LCA office 1,294 of 1,709 scored (all its windows; 48 of 269 doors), RE1 148 of 214; a mesh that is not exact is part of the body (RE-78). Since RE-224 a mesh some face of which did not decode keeps its box: on the office 1,294 of the 1,352 bodies then drawn are exact (1,376 of 1,438 at permit) | diff --git a/probes/examples/probe_re316_room_native_faces.rs b/probes/examples/probe_re316_room_native_faces.rs index 44be04b7..58e74ed0 100644 --- a/probes/examples/probe_re316_room_native_faces.rs +++ b/probes/examples/probe_re316_room_native_faces.rs @@ -1,6 +1,11 @@ //! RE-316: the B-rep a room's native record holds, and the plan edges at its //! floor. //! +//! MEASURED NEGATIVE (Measure run 41673755000, RE-316 section 1): the native +//! record of a room that is left to its box holds two `Face` objects, the +//! floor and ceiling planes, with no trim loop, and no `Edge` or `EdgeLoop` +//! object, so it cannot give the outline the partition's faces do not. +//! //! FACT under test: a room whose partition faces do not close into an outline //! on its record box (RE-101) holds, in its native record, the same solid as //! `Face`, `EdgeLoop` and `Edge` objects, and the edges that lie in the floor's diff --git a/probes/examples/probe_re316_solid_vertices.rs b/probes/examples/probe_re316_solid_vertices.rs index dbe5d445..fc7d3b02 100644 --- a/probes/examples/probe_re316_solid_vertices.rs +++ b/probes/examples/probe_re316_solid_vertices.rs @@ -1,5 +1,11 @@ //! RE-316: the topology a room's solid stores before its first face. //! +//! MEASURED NEGATIVE (Measure run 41676245766, RE-316 section 1): no run of +//! three `f64` in a room's solid before its first face is a point of the +//! room's box other than the two boxes; what lies there is the list of the +//! solid's objects (`u16 class · u32 token`), whose count bounds the solid +//! (`probe_re316_solid_extent`). +//! //! FACT under test: between a room's solid header (RE-101) and its first face //! the solid stores its vertices as three consecutive `f64`, a model-space //! point inside the room's record box, and the points at the box's floor give diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md index b3e40758..876d2c5b 100644 --- a/reports/element-framing/RE-316-room-solids-resolved.md +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -2,10 +2,10 @@ **Date:** 2026-11-03 **Issues:** #1260 (rooms first); follows RE-101 and RE-102 (a room's outline from its stored solid's faces), RE-313 (every `IfcSpace` scored against Revit's export) and RE-314 -**Artefacts:** the BIM4LCA office as built and at building permit (Revit 2024) with Revit 24.0.5's IFC4 exports; Autodesk's `rac_advanced`, `rac_basic`, `rme_basic` and `rme_advanced` sample projects (no Revit export of them is in CI); Core Interior and RE1 Architecture with Revit's exports; the R2025 models and the held-out models (counts only), all as Measure fetches them -**Probes:** `probes/examples/probe_re316_room_solid_breaks.rs`, `probe_re316_room_native_faces.rs`, `probe_re316_solid_vertices.rs` and `probe_re316_room_closure.rs` -**Runs:** Measure [41670081379](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41670081379) and [41673400892](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673400892) (what breaks), [41673755000](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673755000) (the native record), [41676245766](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41676245766) (the topology before the first face), [41684789474](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41684789474) and [41686457484](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41686457484) (the rule with and without a 0.02 ft slack and with arcs, §4); the change as it lands, unfiltered, in §3 -**Result:** positive on the office, narrowed to what Revit's export agrees with. Of the 13 rooms of the office as built whose stored solids did not close, 9 now take an outline, and all 9 have Revit's area to 0.1% and Revit's outline to 0.01 ft (their record boxes were off by 3% to 67%). At building permit 10 of the 12 do, all 10 to 0.1% and 0.01 ft. The other rooms keep their box, each for a measured reason (§4): office rooms 1671995 (329% over Revit's area as built), 1820322, 1832504 and 2066902 as built, 1671995 and 3095831 at permit, the six `rac_advanced` rooms with round columns, and 49 of the 50 `rme` rooms. +**Artefacts:** the BIM4LCA office as built and at building permit (Revit 2024) with Revit 24.0.5's IFC4 exports; Autodesk's `rac_advanced`, `rac_basic`, `rme_basic`, `rme_advanced` and Snowdon Towers sample projects (no Revit export of them is in CI); Core Interior and RE1 Architecture with Revit's exports; the R2025 models and the held-out models (counts only), all as Measure fetches them +**Probes:** `probes/examples/probe_re316_room_solid_breaks.rs`, `probe_re316_room_native_faces.rs` and `probe_re316_solid_vertices.rs` (the first finds what breaks; the other two are measured negatives, §1), `probe_re316_solid_extent.rs`, `probe_re316_room_closure.rs` and `probe_re316_hostile_solid.rs` +**Runs:** Measure [41670081379](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41670081379) and [41673400892](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673400892) (what breaks), [41673755000](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673755000) (the native record), [41676245766](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41676245766) (the topology before the first face), [41707078214](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41707078214) (where a solid ends, §2), [41710248227](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41710248227) (the rule, with the space scorer and the crafted solids), and the change against `main`, unfiltered, [41712392709](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41712392709) (§3) +**Result:** positive on the office, with one room that disagrees with Revit's export. Of the 13 rooms of the office as built that kept their record box, all 13 now take an outline: 10 are labelled `stored_solid_faces` and 3 `stored_solid_approximate`. Nine of the 10 have Revit's area to 0.1% and Revit's boundary to 0.01 ft (their boxes were 3% to 67% over Revit's area); the tenth, room 1671995, is 323% over Revit's area, where its box was 329% over. The two approximate rooms Revit's export pairs are 0.68% and 0.47% under its area, the third is not paired. At building permit 11 of the 12 take an outline, all `stored_solid_faces`: 10 have Revit's area to 0.1% and Revit's boundary to 0.01 ft, and room 1671995 is 350% over (its box, 356%). Room 3095831 keeps its box. No room carries `stored_solid`, the label of RE-101's walk, that the walk did not close. ## 1. Why the solids did not close @@ -13,100 +13,162 @@ RE-101 reads a room's stored solid as a run of planar faces, each within 0x200 b | Cause | Office as built | Office at permit | Autodesk samples | |---|---|---|---| -| **A record longer than 0x200 bytes sits between two faces.** The walk stops there; the faces after it, up to the next solid's tag, are the room's own (a 626 byte record in room 1422302) | 1422302, 1444351, 1736726, 1832504 | 1436086, 1480292, 1743273, 1743715 | | +| **A record longer than 0x200 bytes sits between two faces.** The walk stops there; the faces after it are the room's own (a 626 byte record in room 1422302) | 1422302, 1444351, 1736726, 1832504 | 1436086, 1480292, 1743273, 1743715 | | | **Two walls stand on lines 0.0007 to 0.0015 ft apart** and the room's faces leave a step with no face across it; the ends are further apart than the 1e-4 ft RE-101 joins (RE1 room 355008 had 3e-5) | 1383998, 1493531, 1681182, 1700966, 1736690, 2297732 | | | -| **A column overlaps a wall.** A face's box is its plane's, not its outline: a wall's face runs the whole wall and the column's footprint overlaps it, so a loop of the column is partly inside the wall's loop and RE-101 refuses it | 1820322, 1832504 (also), 2066902 | 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 (also), 2302311 | | +| **A column overlaps a wall.** A face's box is its plane's, not its outline: a wall's face runs the whole wall and the column's footprint overlaps it, so a loop of the column is partly inside the wall's loop and RE-101 refuses it | 1820322, 1832504 (also), 2066902, 1671995 | 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 (also), 2302311, 1671995 | | | **A solid longer than the 64 KB window**: room 1681182 has 115 faces and the last is 84 KB in | 1681182 | | | | **Round columns.** A face whose record is 44 bytes longer than a plane's (202 bytes against 174 on 2024) is a cylinder's: the axis (a unit vector at right angles to the face's u and v) at +158, the radius at +182, and a uv box that is an angle and a distance along the axis. `face_at` read it as a plane and `kind()` calls it horizontal, so its arc was not an edge and the loops stayed open | | | `rac_advanced`, 6 of 91 rooms in every release 2016 to 2023: room 177330's arcs, worked out from the radius 0.4921 ft and the angles 0.2007 and 6.0825, end at (41.9941, -50.1639) and (41.9941, -50.3601), the ends of the planar necks between them and the wall, to the four decimals the probe prints | | **Stepped floors and ceilings.** A room whose floor is raised (a 0.4921 ft step) has walls that start at two heights, a riser between them, and soffits that stop short of the ceiling; the walls that reach the floor do not enclose it | | | `rme_basic`, `rme_advanced`: 49 of 50 | -Two readings were measured and are negative: +Two readings were measured and are **negative**: -- **The room's native record does not hold the solid.** On all 13 office rooms, 50 `rme` rooms and 6 `rac_advanced` rooms it holds two `Face` objects, the floor and ceiling planes, with no trim loop, and no `Edge` or `EdgeLoop` object (`probe_re316_room_native_faces`). -- **The topology before the first face holds no vertices.** Between the header (`01 00 00 00 03 00 00 00` and the solid's box twice) and the first face (4,788 to 64,547 bytes later) the solid lists its objects as `u16 class · u32 token` pointers after a counted list tag (`04 c0 08 00 · u32 count`); no run of three `f64` that is a point of the room's box appears in it other than the two boxes (`probe_re316_solid_vertices`). +- **The room's native record does not hold the solid** (`probe_re316_room_native_faces`, run 41673755000). On all 13 office rooms, 50 `rme` rooms and 6 `rac_advanced` rooms it holds two `Face` objects, the floor and ceiling planes, with no trim loop, and no `Edge` or `EdgeLoop` object. +- **The topology before the first face holds no vertices** (`probe_re316_solid_vertices`, run 41676245766). Between the header (`01 00 00 00 03 00 00 00` and the solid's box twice) and the first face (4,788 to 64,547 bytes later) the solid lists its objects as `u16 class · u32 token` pointers after a counted list tag (`04 c0 08 00 · u32 count`); no run of three `f64` that is a point of the room's box appears in it other than the two boxes. The list's count is what bounds the solid (§2). ## 2. The rule -Where the faces RE-101 walks close on the room's record box, nothing changes. Where they do not, `partition_room_boundaries` now reads every face up to the next solid's tag (`solid_faces_to_next_solid`; none if no tag follows within 4 MiB), and gives up on the solid if any vertical face reaches outside the record box, which holds the whole solid (113 of room 1671995's 327 faces do), or if it holds a cylinder. Each wall face's plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office RE-101 keeps, Core Interior's 116, RE1's 11 and the 14 to 85 each of the Autodesk samples keep, the directed edges join head to tail. +**The walk comes first, per room.** A room none of whose solids the walk closes on the record box is the only room read again: `attach_room_outlines` calls the RE-101 reader over all of the room's solids, as before, and only if it gives nothing resolves them (`resolved_outline_of_solids`). A room RE-101 keeps is therefore read exactly as it was, with its `NetVolume` marker, whatever a second solid of the room holds. The cap on the faces the walk reads (512, the largest solid measured lists 136) is the one change to it. -`region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column that overlaps a wall takes its footprint off the room where they overlap and adds nothing outside it. A step of up to 0.0025 ft joins two ends (Revit's short-curve tolerance is 1/32 inch, 0.0026 ft). The outline must span the record box to 0.001 ft, as RE-101's does. +**Where a solid ends.** The span from the walk's stop to the next room solid's tag is not the solid: it runs on into the faces of other elements, and the first version of this change read them (113 of room 1671995's 327 faces lay outside its box, and a foreign face inside the box would have passed). A solid lists its objects after its two boxes, `04 c0 08 00 · u32 count · u32 4 · count × (u16 class · u32 token)`, and the count includes the faces `face_at` does not read, a solid's floor and ceiling among them. `probe_re316_solid_extent` (run 41707078214: the office both files, Core Interior, RE1, Autodesk's 2019 and 2024 samples, 1,597 solids, 1,328 of them closing under the walk): -An outline resolved this way carries no prism marker: no room's volume is measured against Revit's for them. +- the list is present on all 1,597 with the 4 after the count, the count is never below the faces the walk reads, and is at most 136; +- the count minus the walk's faces is 0 on 472 of the solids the walk closes, 1 on 456, 2 on 360, 3 on 15, 4 on 24 and 12 on one; +- on the 47 solids whose count equals the walk's faces and which have a valid face after the walk, that face is another element's, 6,826 bytes or more on (the nearest is the office as built's, then Core Interior's at 6,858); +- the faces that are a solid's own beyond the walk's 0x200 byte gap lie 592 to 732 bytes on (the office and Autodesk's samples; 908 and 1,406 on two of Snowdon Towers' unresolved rooms). + +So the span is the faces from the first, each within 0x400 bytes of the one before (`SPAN_FACE_GAP`), and no more than the list counts (`solid_object_count`). Where the count equals the walk's faces nothing is added. Where it is larger, at most the difference could be another element's, and only if it follows within 1,024 bytes of a solid's last face: no solid measured does that, but none is measured with a difference above 4 and a face within 6,826 bytes. That is why a span-resolved outline is not `stored_solid` (below). The rme room 576007, which has no Revit export, has a count of 21 and 20 faces in the walk and the span alike: its outline is its own faces'. + +**From faces to an outline.** Each wall face's plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office the walk keeps, Core Interior's 116, RE1's 11 and the 14 to 85 each the Autodesk samples keep, the directed edges join head to tail (run 41710248227). `region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column that overlaps a wall takes its footprint off the room where they overlap and adds nothing outside it. Ends within 0.0025 ft meet: the steps measured are 0.0007 to 0.0015 ft, and the shortest real edge 0.0052 ft; the value is chosen from these, not copied, since revit-ifc takes the short-curve tolerance from the Revit API at run time (`IFCProject.cs` sets it from `application.ShortCurveTolerance`) and has no constant. The outline must span the record box to 0.001 ft, as RE-101's does. + +**What a resolved room says.** `SpaceOutlineSource`: + +| Value | Read | Area | +|---|---|---| +| `stored_solid` | RE-101's walk closed on the box | as RE-313 | +| `stored_solid_faces` | the span above, spanning the box to 0.001 ft | Revit's to 0.1% on 19 of the office's 21 scored rooms; room 1671995 is off (§3) | +| `stored_solid_approximate` | the same, spanning the box to within 0.02 ft short of it on a side, tried only where the exact span fails | 0.68% and 0.47% under Revit's on the two rooms Revit's export pairs | + +The approximate rooms are three as built: a column overlaps a wall and its back stands 0.0164 ft behind the wall's face, which leaves the outline 0.0164 ft short of the box on one side; the stored solid takes the column off the room and Revit's export does not take that column off, though it takes off the 1.722 ft² columns of eight rooms at permit; the two rooms it pairs differ from the outline by 3.0 ft², the column's 3.001. Griffin chose to close them, labelled (2026-11-03). The exports count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`; `rvt-ifc` prints a line for each), and the room schedule gives an area for `stored_solid` and `stored_solid_faces` rooms and none for approximate ones. + +A solid with a cylinder is not resolved, and a resolved outline carries no prism marker, so no `NetVolume`. + +**Bounded work.** A partition is untrusted input. The walk reads at most 512 faces (a solid with more is left to its box); the span at most the list's count, never above 512; `region_boundary` takes at most 512 segments and gives up before it builds more than 4,096 pieces, however often they cross. `probe_re316_hostile_solid` builds crafted solids in memory and times both readers (run 41710248227, GitHub's runner): + +| Solid | Walk | Resolve | +|---|---:|---:| +| grid 10 x 10 (20 faces) | 0.0 ms | 0.1 ms | +| grid 40 x 40 (80 faces, 1,600 crossings) | 0.0 ms | 9.1 ms | +| grid 100 x 100 (200 faces) | 0.1 ms | 0.4 ms | +| grid 256 x 256 (512 faces, 65,536 crossings) | 0.5 ms | 0.8 ms | +| 512 faces overlapping on one line | 0.5 ms | 0.0 ms | +| 64 concentric squares (256 faces) | 0.2 ms | 0.8 ms | +| a list counting 100,000 | 0.0 ms | 0.0 ms | +| no list | 0.2 ms | 0.0 ms | +| 5,000 faces | 0.1 ms | 6.1 ms | + +Every one gives its outline or none and takes under 10 ms. The review measured the first version, which took the faces up to the next solid's tag and cut them without a bound on the pieces, at about 10 s on about 500 crafted faces. ## 3. Measured ### The office, against Revit's own IFC4 export -Room by room, as built: the record box's area, Revit's area, and the outline's area (ft²). +As built, the rooms that kept their box (ft²): -| Room | Box | Revit | Outline | Cause | -|---|---:|---:|---:|---| -| 1383998 | 1708.89 | 1020.46 | 1020.46 | step | -| 1422302 | 362.30 | 291.54 | 291.54 | record between faces | -| 1444351 | 362.31 | 291.56 | 291.56 | record between faces | -| 1493531 | 362.31 | 291.56 | 291.55 | step | -| 1681182 | 5963.96 | 3818.36 | 3818.36 | step, long solid | -| 1700966 | 4774.18 | 3259.86 | 3259.85 | step | -| 1736690 | 2846.73 | 2049.24 | 2049.25 | step | -| 1736726 | 367.14 | 293.43 | 293.43 | record between faces | -| 2297732 | 135.82 | 131.76 | 131.76 | step | +| Room | Box | Revit | Outline | `SpaceOutlineSource` | Cause | +|---|---:|---:|---:|---|---| +| 1383998 | 1708.89 | 1020.46 | 1020.46 | `stored_solid_faces` | step | +| 1422302 | 362.30 | 291.54 | 291.54 | `stored_solid_faces` | record between faces | +| 1444351 | 362.31 | 291.56 | 291.56 | `stored_solid_faces` | record between faces | +| 1493531 | 362.31 | 291.56 | 291.55 | `stored_solid_faces` | step | +| 1681182 | 5963.96 | 3818.36 | 3818.36 | `stored_solid_faces` | step, long solid | +| 1700966 | 4774.18 | 3259.86 | 3259.85 | `stored_solid_faces` | step | +| 1736690 | 2846.73 | 2049.24 | 2049.25 | `stored_solid_faces` | step | +| 1736726 | 367.14 | 293.43 | 293.43 | `stored_solid_faces` | record between faces | +| 2297732 | 135.82 | 131.76 | 131.76 | `stored_solid_faces` | step | +| 1671995 | 5228.48 | 1218.33 | 5154.12 | `stored_solid_faces` | **disagrees with Revit** | +| 1832504 | 465.18 | 440.31 | 437.31 | `stored_solid_approximate` | column overlaps wall | +| 2066902 | 1542.77 | 638.23 | 635.23 | `stored_solid_approximate` | column overlaps wall | +| 1820322 | 1882.11 | not paired | 1865.97 | `stored_solid_approximate` | column overlaps wall | -At building permit the 10 rooms 1436086, 1480292, 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 take outlines, all with Revit's area to 0.1% (1736720: 4783.05 box, 3265.29 Revit, 3265.29 outline). +**Room 1671995 disagrees with Revit's export, as built and at permit.** Its stored solid is one region with 6 holes whose outline is 5154 ft² (the box, 5228); Revit's export gives it 9 pieces with 1 hole totalling 1218 ft² (1146 at permit), their centroids 25.8 ft apart. Its list counts 69 objects and the walk reads 67 faces, as for the rooms that agree; nothing in the file tells it from them. It was a box 329% over Revit's area (356% at permit) and is now an outline 323% over (350%), with its boundary 1.80 ft from Revit's (2.30 ft as a box). It is the one room of the office where the stored solid and Revit's export disagree; whether Revit's room is split by something the file does not say, or the stored solid is not the room Revit exports, is not found. It carries `stored_solid_faces`, so an importer can see that this reading is not the walk's. -`space_profiles_vs_ifc`, `main` against this change: +At building permit, 11 rooms are `stored_solid_faces`: 1436086, 1480292, 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 (all with Revit's area to 0.1%: their boxes were 2.6% to 58% over it) and 1671995. Room 3095831 keeps its box: 14 ends at y = 35.269 ft, in pairs 0.656 and 1.312 ft apart down a 100 ft wall, are not joined, since the columns along it share the wall's line. -| | Area within 0.1% | Room, stored solid | Room, record box | -|---|---:|---|---:| -| Office as built | 221 → 230 | 220 → 229, all within 0.1% | 12 → 3 | -| Office at permit | 222 → 232 | 222 → 232 (231 within 0.1%) | 12 → 2 | -| Core Interior | 116 | 116 | 0 | -| RE1 Architecture | 11 | 11 | 0 | +Rooms per outline source (the export's own counts, run 41712392709; the scorer pairs 276 of the as-built file's 308 spaces and 278 of the permit's): -The one stored-solid room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, 66% in area) is the one RE-313 left unexplained, and it is unchanged. It is room 2611271 (number 003, STAIRCASE; paired through the IfcSpace's `ElementId` property in a `keep_ifc` run, 41689880331): the walk closes a loop of 172.47 ft² on its box where Revit's area is 103.90, the same room as built is 166.77 ft² and not among those off, and the faces up to the next solid's tag do not resolve to an outline at permit (the rule gives up on it), so its cause stays open. +| | `stored_solid` | `stored_solid_faces` | `stored_solid_approximate` | `record_box` | family instances, `record_box` | +|---|---:|---:|---:|---:|---:| +| Office as built (234 rooms) | 221 | 10 | 3 | 0 | 44 | +| Office at permit (234 rooms) | 222 | 11 | 0 | 1 | 44 | + +`space_profiles_vs_ifc`, `main` against this change (run 41712392709): + +| | Area within 0.1% | `stored_solid` scored | `stored_solid_faces` scored, area within 0.1% | `stored_solid_approximate` scored, area within 0.1% | Room, record box | +|---|---:|---|---|---|---:| +| Office as built | 221 → 230 | 220, all | 10, 9 | 2, 0 | 12 → 0 | +| Office at permit | 222 → 232 | 222, 221 | 11, 10 | 0 | 12 → 1 | +| Core Interior | 116 | 116 | 0 | 0 | 0 | +| RE1 Architecture | 11 | 11 | 0 | 0 | 0 | + +At permit the boundary distance is within 0.01 ft on 10 of the 11 `stored_solid_faces` rooms; the outline metric (which counts the vertices of Revit's ceiling steps, RE-313 §3) on 9: room 1436086 is 0.492 ft off on it and 0.000 ft on the boundary. The one `stored_solid` room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, room 2611271, STAIRCASE, 66% in area) is RE-313's, unchanged; the walk closes a loop of 172.47 ft² on its box where Revit's area is 103.90, and the span does not change that, since the walk comes first. + +### Revit's cached room areas + +RE-101 §4 found a cache of each room's computed area in its partition (`u64 · ff×8 · u32 0 · i32 · f64`) and 40 of 40 non-rectangular Core Interior rooms' outline areas in it. `probe_re316_room_closure` repeats the check (run 41710248227). It does not confirm the span areas, and does not contradict them either: + +| | Cache holds the outline's area: non-rectangular rooms the walk keeps | exact span outlines | approximate ones | +|---|---:|---:|---:| +| Core Interior | 40 of 40 | | | +| RE1 Architecture | 1 of 2 | | | +| Office as built | 71 of 103 | 0 of 10 | 0 of 3 | +| Office at permit | 20 of 116 | 2 of 11 | | +| `rac_advanced` 2019 | 0 of 21 | | | +| `rac_basic` 2019 | 0 of 7 | | | + +For rooms 1422302, 1444351 and 1493531 the cache holds the record box's area (362.3 ft²), not the 291.5 ft² of Revit's export and of the outline. The cache is not there on every release (`rac_advanced`: none of 21 on 2019, 19 of 21 on 2024), so it is not used as evidence. ### The Autodesk samples (no Revit export) -`probe_re316_room_closure` on the 2019 and 2023 saves (run 41689876297; Measure's export diagnostics give the same box counts on all 12 releases of `rme`): +`probe_re316_room_closure` on the 2019 and 2024 saves (run 41710248227): -| Project | Rooms | Closed by RE-101 | Closed now | Box | +| Project | Rooms | Closed by RE-101 | Resolved now | Box | |---|---:|---:|---:|---:| -| `rac_basic` | 14 | 14 | 14 | 0 | -| `rac_advanced` | 91 | 85 | 85 | 6 | -| `rme_basic`, `rme_advanced` | 76 | 26 | 27 | 49 | - -With their arcs the six `rac_advanced` rooms would close (the probe's `with arcs` column), and 43 of the 85 rooms RE-101 keeps in the same file have round columns it ignores, its outline 0.2% to 1.2% larger than the one with the columns taken off (room 177304: 3531.7 ft² against 3521.8). One of those two readings is wrong and no Revit export of the sample says which, so both are left as they are. +| `rac_basic` | 14 | 14 | 0 | 0 | +| `rac_advanced` | 91 | 85 | 0 | 6 | +| `rme_basic`, `rme_advanced` | 76 | 26 | 1 (`stored_solid_faces`, room 576007: 1887.2 ft² against its box's 1917.9) | 49 | +| Snowdon Towers (architectural, 2024) | 54 | 47 | 0 | 7 | ### Everything else -Measure unfiltered, `-f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true` ([41689872644](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41689872644), this change at 669856bf against `main`; 93 models). Every scorer's output is identical on every model but three, and the exports' hashes move on exactly the models with a room that now closes: +Measure unfiltered, `-f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true` ([41712392709](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41712392709): this change at edd7a72a against `main` at 8664b94a; 93 models, every scorer including `space_profiles_vs_ifc`). Every scorer's output is identical on every model but three, and the exports' hashes move on exactly the models with a room that now closes: -- **Office as built and at permit**: the rows above; their `exported.bounding_box_spaces` go from 57 to 48 and from 56 to 46. -- **`rme_basic` and `rme_advanced`, all 12 releases 2015 to 2027**: one room each (576007, 1887.2 ft² against its box's 1917.9) takes an outline, 50 → 49 box spaces; no Revit export of them exists. -- **Held out, counts only (BIM Speed's Revit 2021 project)**: the one model outside the office whose scorer output changes. Area within 0.1%: 174 → 179 of the 179 spaces paired, off 5 → 0; rooms on their record box 8 → 2, both within 0.1% (rectangles); stored-solid rooms 171 → 177, all 177 within 0.1% and 0.01 ft. This model was not read while the rule was found. -- **Core Interior, RE1 Architecture, the R2025 models and the other held-out models**: unchanged, so no witness observation moves. `rac_advanced` and `rac_basic` in every release: unchanged. -- **Cost**: Measure's `rvt-ifc` export times are 1.27 times main's at the median across the 93 models (the head job ran on a slower runner) and 1.40 on Core Interior, which this change does not touch; the office's 1.43 and 1.46 are within that spread. +- **Office as built and at permit**: the rows above. `exported.bounding_box_spaces` goes from 57 to 44 and from 56 to 45 (the 44 family instances, and room 3095831 at permit). +- **`rme_basic` and `rme_advanced`, 12 files over the 6 releases 2015, 2019, 2024, 2025, 2026 and 2027**: one room each (576007, 1887.2 ft² against its box's 1917.9) takes an outline, `stored_solid_faces`, and box spaces go 50 → 49; no Revit export of them exists. +- **Held out, counts only (BIM Speed's Revit 2021 project, Warmond)**: the one model outside the office whose scorer output changes. Area within 0.1%: 174 → 179 of the 179 paired spaces, off 5 → 0; outline and boundary within 0.01 ft 174 → 179; rooms on their record box 8 → 2 (both within 0.1%: rectangles), `stored_solid_faces` 6 (all within 0.1% and 0.01 ft), `stored_solid` 171 unchanged. Of the 8 rooms that kept their box, 5 were off in area and 6 now take an outline: the sixth was a rectangle already equal to Revit's. Vertices within 0.01 ft go from 127 to 126, which the scorer prints and no earlier summary did: that room's outline has vertices the box had not. This model was not read while the rule was found. +- **Core Interior, RE1 Architecture, the R2025 models and the other held-out models**: unchanged, so no witness observation moves. `rac_advanced`, `rac_basic` and Snowdon Towers' architectural sample in every release: unchanged. +- **Cost**: Measure's `rvt-ifc` export times are 0.98 times main's at the median across the 93 models, 1.03 and 1.05 on the office. ## 4. What keeps its box -- **Office room 1671995, both files**: the span to the next solid's tag holds 327 faces, 113 of which reach outside the room's box. Without that rule the same code gave an outline of 5154 ft² against Revit's 1218 (1146 at permit), spanning the box: a wrong outline. The room's solid is not bounded by its record. -- **Office rooms 1820322, 1832504 and 2066902, as built**: a column overlaps the wall by 0.0164 ft and its back stands behind the wall's face, so the resolved outline falls 0.0164 ft short of the record box on one side. Allowing that (a 0.02 ft slack, run 41686457484) closes all three, and the two that Revit's export scores (1832504, 2066902) came out 0.7% and 0.5% off Revit's area (437.31 against 440.31, 635.23 against 638.23) with the boundary 1.329 ft from Revit's: the stored solid takes the column off and Revit's export does not take that column off, while it takes off the 1.722 ft² columns of seven rooms at permit. Room 1820322 is one of the two rooms whose top surface Revit's export gives no area, so it cannot be scored. Every room that closes on the box without a slack has Revit's area to 0.1%; with it, two do not, so it was dropped, and the boxes of the two (465.18 and 1542.77 ft² against Revit's 440.31 and 638.23) stay. -- **Office room 3095831 (permit)**: 14 ends at y = 35.269 ft, in pairs 0.656 and 1.312 ft apart down a 100 ft wall, are not joined; the columns along it share the wall's line. -- **The six `rac_advanced` rooms** (177330, 214970, 215140, 215177, 216415, 217813): round columns, §3. -- **49 of the 50 `rme` rooms**: stepped floors and ceilings. The faces that reach the ceiling instead of the floor close for 33 of the 50, and 24 of those are the box itself, but 9 differ from it (8 by 1% to 11%, and room 575874 at 22%: 5105 ft² against a box of 23323), no Revit export of these rooms exists, and a bulkhead that hangs from the ceiling would be taken for a wall. They stay boxes. +- **Office room 3095831 (permit)**, above. +- **The six `rac_advanced` rooms** (177330, 214970, 215140, 215177, 216415, 217813): round columns. With their arcs the six close (the probe's `with arcs` column). 43 of the 85 rooms RE-101 keeps in the same file have round columns it ignores, its outline 0.2% to 1.2% larger than the one with the columns taken off (room 177304: 3531.7 ft² against 3521.8). One of those two readings is wrong and no Revit export of the sample says which, so both are left as they are. +- **49 of the 50 `rme` rooms**: stepped floors and ceilings. The walls that reach the ceiling instead of the floor close for 18 of the 49 (run 41710248227; `rme_basic` and `rme_advanced` are the same), 13 of those equal the box and 5 differ from it by 1% to 11%; no Revit export of these rooms exists, and a bulkhead that hangs from the ceiling would be taken for a wall. They stay boxes. +- **Snowdon Towers' seven rooms** (#90: 828537, 828548, 830902, 830912, 2181757, 2181762, 2285535, in Autodesk's 2024 sample): their lists count more objects than the walk reads on three of them (25, 20 and 61 more: 828537, 828548 and 2181762), and no Revit export of the sample is in CI. - **The 44 family instances written as spaces** are #1262's. ## 5. Not claimed -- That a resolved outline equals Revit's outline on a release other than 2024: the evidence is one building, in two states. No room of another release is scored. +- That a `stored_solid_faces` outline equals Revit's outline on a release other than 2024: the evidence is one building, in two states, and one held-out project. 19 of the office's 21 scored `stored_solid_faces` rooms agree and 2 do not (1671995, twice), and nothing in the file tells them apart. +- That the span holds no face of another element: no solid measured has one inside 6,826 bytes of its last face where the list counts the walk's faces exactly, and the rule admits at most the list's difference elsewhere. - That a cylinder's arc is Revit's on any room: `rac_advanced` has no export. - Any volume of a resolved room (no prism marker is written). -- That the 9 and 10 rooms will hold on another building: the rules were found on these 25 rooms, and the held-out counts in §3 are the only test on others. +- That the 0.02 ft slack is Revit's: it is the 0.0164 ft the three rooms measured need, and the two paired are 0.68% and 0.47% under Revit's area. ## 6. Reproduce ```bash -gh workflow run measure.yml --ref main -f ref=669856bff569c56d9488381e22ea6bb0762cefc5 -f base=none -f samples=true -f sample_years="2019 2023" \ +gh workflow run measure.yml --ref main -f ref=7d7c098c8ede1ae8bb55d559a4bfdf270a5e70fc -f base=none -f samples=true -f sample_years="2019 2024" \ -f nordic=true -f models="nordic-office-* core-interior re1-architecture" \ - -f probe=probe_re316_room_closure -f probe_scorer=- -f scorers=space_profiles_vs_ifc -gh workflow run measure.yml --ref main -f ref=669856bff569c56d9488381e22ea6bb0762cefc5 -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true + -f probe="probe_re316_solid_extent probe_re316_room_closure probe_re316_hostile_solid" -f probe_scorer="- - -" -f scorers=space_profiles_vs_ifc +gh workflow run measure.yml --ref main -f ref=edd7a72a943ee0d99da416ecf86269722977c80c -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true ``` From 4f0dee83e3974e12aa95a804b6fe58e31303d0ad Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:14:55 -0500 Subject: [PATCH 22/31] docs(rooms): the held-out project closes 6 of the 8 rooms that kept their box (#1260) (RE-316) --- docs/status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/status.md b/docs/status.md index 8c2c3260..2fed9517 100644 --- a/docs/status.md +++ b/docs/status.md @@ -113,7 +113,7 @@ walls Revit steps over a floor that covers them in part (29 of the BIM4LCA offic the rooms whose stored solid still does not close (RE-316 §4) and opening profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, resolved to a boundary, and says so: `stored_solid_faces` (10 of the BIM4LCA office's 13 such rooms as built and 11 of 12 at building permit; Revit's area to 0.1% on 19 of the 21 Revit's export pairs, and the 2 others are one room, 1671995, whose stored solid and Revit's export disagree by 323% and 350%; 6 of 6 on a held-out Revit 2021 project) or `stored_solid_approximate` (3 rooms as built, 0.68% and 0.47% under Revit's area on the two it pairs), counted in `exported.resolved_outline_spaces` and `exported.approximate_outline_spaces`; the 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, resolved to a boundary, and says so: `stored_solid_faces` (10 of the BIM4LCA office's 13 such rooms as built and 11 of 12 at building permit; Revit's area to 0.1% on 19 of the 21 Revit's export pairs, and the 2 others are one room, 1671995, whose stored solid and Revit's export disagree by 323% and 350%; 6 of the 8 rooms that kept their box on a held-out Revit 2021 project, all 6 at Revit's area) or `stored_solid_approximate` (3 rooms as built, 0.68% and 0.47% under Revit's area on the two it pairs), counted in `exported.resolved_outline_spaces` and `exported.approximate_outline_spaces`; the 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history From 779fac69503e7983bb48ca2b8116569e11cf7dda Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:27:57 -0500 Subject: [PATCH 23/31] research(rooms): a scorer measuring how much of a room's outline lies inside the walls and rooms around it, to tell a resolved outline Revit disagrees with (#1260) (RE-316) --- tools/ci/measure-reference-models.sh | 2 +- tools/re/space_overlaps_vs_ifc.py | 177 +++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tools/re/space_overlaps_vs_ifc.py diff --git a/tools/ci/measure-reference-models.sh b/tools/ci/measure-reference-models.sh index c51071e0..c10d5b0a 100755 --- a/tools/ci/measure-reference-models.sh +++ b/tools/ci/measure-reference-models.sh @@ -133,7 +133,7 @@ IFC_SCORERS="class_sets_vs_ifc aggregates_vs_ifc global_ids_vs_ifc names_vs_ifc room_names_vs_ifc type_objects_vs_ifc layer_sets_vs_ifc element_materials_vs_ifc material_colours_vs_ifc openings_vs_ifc opening_hosts_vs_ifc opening_boxes_vs_ifc door_bodies_vs_ifc door_window_sizes_vs_ifc oriented_boxes_vs_ifc plan_profiles_vs_ifc -pipe_bodies_vs_ifc duct_bodies_vs_ifc roof_bodies_vs_ifc stair_bodies_vs_ifc ports_vs_ifc psets_vs_ifc qtos_vs_ifc saved_meshes_vs_ifc tagged_spaces_vs_ifc port_places_vs_ifc predefined_types_vs_ifc box_bodies_vs_ifc column_bodies_vs_ifc mesh_bodies_vs_ifc wall_ends_list wall_heights_vs_ifc frames_vs_ifc space_profiles_vs_ifc" +pipe_bodies_vs_ifc duct_bodies_vs_ifc roof_bodies_vs_ifc stair_bodies_vs_ifc ports_vs_ifc psets_vs_ifc qtos_vs_ifc saved_meshes_vs_ifc tagged_spaces_vs_ifc port_places_vs_ifc predefined_types_vs_ifc box_bodies_vs_ifc column_bodies_vs_ifc mesh_bodies_vs_ifc wall_ends_list wall_heights_vs_ifc frames_vs_ifc space_profiles_vs_ifc space_overlaps_vs_ifc" GLB_SCORERS="wall_bodies_vs_ifc wall_ends_cv_vs_ifc wall_layers_vs_ifc slab_layers_vs_ifc glb_material_colours_vs_ifc" read -ra MODEL_PATTERNS <<< "${MEASURE_MODELS:-}" diff --git a/tools/re/space_overlaps_vs_ifc.py b/tools/re/space_overlaps_vs_ifc.py new file mode 100644 index 00000000..79a8edfd --- /dev/null +++ b/tools/re/space_overlaps_vs_ifc.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""RE-316: does a room's outline cover the plan of the walls and rooms around it? + +A room's outline lies inside the walls that bound it and beside the outlines of +the rooms next to it. An outline that covers a wall's plan or another room's +is not the room's. For each room rvt-rs writes with an outline read from its +stored solid (`SpaceOutlineSource` `stored_solid`, `stored_solid_faces` or +`stored_solid_approximate`), this scorer measures + +- the area of the room's top surface that lies inside the top surfaces of the + walls on the room's level (walls that span at least a foot of its height), + each shrunk by 0.02 ft so that a room beside a wall is not inside it, and +- the area that lies inside the outlines of the other such rooms on the level, + shrunk the same way, + +against Revit's area for the same GlobalId, so a resolved outline that +disagrees with Revit's can be told from the rest by what it covers. + +The lines starting `space overlaps:` are per room, with a GlobalId, and then +counts by source. + +Usage: + + python3 tools/re/space_overlaps_vs_ifc.py +""" + +import sys + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.element +import numpy as np +import shapely +from shapely.geometry import Polygon + +import plan_profiles_vs_ifc as plan +from ifc_common import site_frame + +SET = "RvtElementRecordGeometry" +ROOM_SOURCES = ("stored_solid", "stored_solid_faces", "stored_solid_approximate") +SHRINK_FEET = 0.02 +LEVEL_FEET = 1.0 + + +def bodies(path, classes): + """Every element of `classes` with its top surface (the union of its + upward faces in plan) and the heights its mesh spans, in Revit's feet.""" + f = ifcopenshell.open(path) + frame = site_frame(f) + settings = ifcopenshell.geom.settings() + settings.set("use-world-coords", True) + out = [] + for cls in classes: + try: + elements = f.by_type(cls) + except RuntimeError: + continue + for element in elements: + if not element.Representation: + continue + try: + shape = ifcopenshell.geom.create_shape(settings, element) + except Exception: + continue + verts = np.array(shape.geometry.verts, dtype=float).reshape(-1, 3) + faces = np.array(shape.geometry.faces, dtype=int).reshape(-1, 3) + if not len(verts) or not len(faces): + continue + v = frame(verts) + a, b, c = v[faces[:, 0]], v[faces[:, 1]], v[faces[:, 2]] + normal = np.cross(b - a, c - a) + length = np.linalg.norm(normal, axis=1) + up = (length > 1e-12) & (normal[:, 2] > 0.9 * length) + triangles = [ + Polygon([tuple(p[:2]) for p in tri]).buffer(1e-7) + for tri in zip(a[up], b[up], c[up]) + ] + surface = shapely.union_all(triangles) if triangles else Polygon() + out.append( + { + "id": element.GlobalId, + "surface": surface, + "z0": float(v[:, 2].min()), + "z1": float(v[:, 2].max()), + "element": element, + } + ) + return out + + +def shrunk_union(surfaces): + shrunk = [s.buffer(-SHRINK_FEET) for s in surfaces] + shrunk = [s for s in shrunk if not s.is_empty] + return shapely.union_all(shrunk) if shrunk else Polygon() + + +def main(argv): + if len(argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + ours_path, revit_path = argv[1], argv[2] + spaces = bodies(ours_path, ["IfcSpace"]) + walls = bodies(ours_path, ["IfcWall", "IfcWallStandardCase"]) + revit = plan.tops(revit_path, ["IfcSpace"]) + rooms = [] + for space in spaces: + props = ifcopenshell.util.element.get_psets(space["element"]).get(SET, {}) + source = props.get("SpaceOutlineSource") + if source in ROOM_SOURCES and space["surface"].area > 0: + space["source"] = source + rooms.append(space) + + def on_level(a, b): + return a["z0"] < b["z1"] - LEVEL_FEET and a["z1"] > b["z0"] + LEVEL_FEET + + rows = [] + for room in rooms: + surface = room["surface"] + near_walls = [ + w["surface"] + for w in walls + if on_level(w, room) and w["surface"].intersects(surface) + ] + near_rooms = [ + o["surface"] + for o in rooms + if o is not room and on_level(o, room) and o["surface"].intersects(surface) + ] + area = float(surface.area) + wall_over = float(surface.intersection(shrunk_union(near_walls)).area) + room_over = float(surface.intersection(shrunk_union(near_rooms)).area) + theirs = revit.get(room["id"]) + rows.append( + { + "id": room["id"], + "source": room["source"], + "area": area, + "walls": wall_over, + "rooms": room_over, + "revit": float(theirs[0]) if theirs else None, + } + ) + + for source in ROOM_SOURCES: + mine = [r for r in rows if r["source"] == source] + if not mine: + continue + wall_share = [r["walls"] / r["area"] for r in mine] + room_share = [r["rooms"] / r["area"] for r in mine] + print( + f"space overlaps: {source.replace('_', ' ')}: {len(mine)} rooms; " + f"walls: above 0.1% of the area on {sum(s > 0.001 for s in wall_share)}, " + f"above 1% on {sum(s > 0.01 for s in wall_share)}, " + f"above 5% on {sum(s > 0.05 for s in wall_share)}, " + f"largest {100 * max(wall_share):.2f}%; " + f"other rooms: above 0.1% on {sum(s > 0.001 for s in room_share)}, " + f"above 1% on {sum(s > 0.01 for s in room_share)}, " + f"above 5% on {sum(s > 0.05 for s in room_share)}, " + f"largest {100 * max(room_share):.2f}%" + ) + listed = [r for r in rows if r["source"] != "stored_solid"] + listed += sorted( + (r for r in rows if r["source"] == "stored_solid"), + key=lambda r: -(r["walls"] + r["rooms"]) / r["area"], + )[:8] + for r in listed: + revit_text = "no Revit space" if r["revit"] is None else f"Revit {r['revit']:.2f} ft2" + print( + f" {r['id']}: {r['source']}, area {r['area']:.2f} ft2 ({revit_text}), " + f"inside walls {r['walls']:.2f} ft2 ({100 * r['walls'] / r['area']:.2f}%), " + f"inside other rooms {r['rooms']:.2f} ft2 ({100 * r['rooms'] / r['area']:.2f}%)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 15c9abab5e4e4218e647230483a5fda951fe4502 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:32:37 -0500 Subject: [PATCH 24/31] fix(measure): space_overlaps_vs_ifc reads each space's properties while its file is open (#1260) (RE-316) --- tools/re/space_overlaps_vs_ifc.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/re/space_overlaps_vs_ifc.py b/tools/re/space_overlaps_vs_ifc.py index 79a8edfd..2bf75894 100644 --- a/tools/re/space_overlaps_vs_ifc.py +++ b/tools/re/space_overlaps_vs_ifc.py @@ -82,7 +82,9 @@ def bodies(path, classes): "surface": surface, "z0": float(v[:, 2].min()), "z1": float(v[:, 2].max()), - "element": element, + "props": ifcopenshell.util.element.get_psets(element).get(SET, {}) + if cls == "IfcSpace" + else {}, } ) return out @@ -104,8 +106,7 @@ def main(argv): revit = plan.tops(revit_path, ["IfcSpace"]) rooms = [] for space in spaces: - props = ifcopenshell.util.element.get_psets(space["element"]).get(SET, {}) - source = props.get("SpaceOutlineSource") + source = space["props"].get("SpaceOutlineSource") if source in ROOM_SOURCES and space["surface"].area > 0: space["source"] = source rooms.append(space) From 8d48c3fa17105739862ebfa36da43c102ec0c6fb Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:37:51 -0500 Subject: [PATCH 25/31] research(rooms): the overlap scorer also measures each space's floor and every-face plan area, ours and Revit's, to test whether Revit's top surface is the room's plan (#1260) (RE-316) --- tools/re/space_overlaps_vs_ifc.py | 60 +++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/tools/re/space_overlaps_vs_ifc.py b/tools/re/space_overlaps_vs_ifc.py index 2bf75894..4f575fac 100644 --- a/tools/re/space_overlaps_vs_ifc.py +++ b/tools/re/space_overlaps_vs_ifc.py @@ -42,7 +42,7 @@ LEVEL_FEET = 1.0 -def bodies(path, classes): +def bodies(path, classes, full=False): """Every element of `classes` with its top surface (the union of its upward faces in plan) and the heights its mesh spans, in Revit's feet.""" f = ifcopenshell.open(path) @@ -76,10 +76,31 @@ def bodies(path, classes): for tri in zip(a[up], b[up], c[up]) ] surface = shapely.union_all(triangles) if triangles else Polygon() + floor = every = Polygon() + if full: + down = (length > 1e-12) & (normal[:, 2] < -0.9 * length) + floor = shapely.union_all( + [ + Polygon([tuple(p[:2]) for p in tri]).buffer(1e-7) + for tri in zip(a[down], b[down], c[down]) + ] + or [Polygon()] + ) + solid = length > 1e-12 + every = shapely.union_all( + [ + Polygon([tuple(p[:2]) for p in tri]).buffer(1e-7) + for tri in zip(a[solid], b[solid], c[solid]) + if abs(Polygon([tuple(p[:2]) for p in tri]).area) > 1e-9 + ] + or [Polygon()] + ) out.append( { "id": element.GlobalId, "surface": surface, + "floor": floor, + "every": every, "z0": float(v[:, 2].min()), "z1": float(v[:, 2].max()), "props": ifcopenshell.util.element.get_psets(element).get(SET, {}) @@ -101,9 +122,9 @@ def main(argv): print(__doc__, file=sys.stderr) return 2 ours_path, revit_path = argv[1], argv[2] - spaces = bodies(ours_path, ["IfcSpace"]) + spaces = bodies(ours_path, ["IfcSpace"], full=True) walls = bodies(ours_path, ["IfcWall", "IfcWallStandardCase"]) - revit = plan.tops(revit_path, ["IfcSpace"]) + revit = {r["id"]: r for r in bodies(revit_path, ["IfcSpace"], full=True)} rooms = [] for space in spaces: source = space["props"].get("SpaceOutlineSource") @@ -138,7 +159,12 @@ def on_level(a, b): "area": area, "walls": wall_over, "rooms": room_over, - "revit": float(theirs[0]) if theirs else None, + "up": area, + "floor": float(room["floor"].area), + "every": float(room["every"].area), + "revit": float(theirs["surface"].area) if theirs else None, + "revit_floor": float(theirs["floor"].area) if theirs else None, + "revit_every": float(theirs["every"].area) if theirs else None, } ) @@ -159,15 +185,37 @@ def on_level(a, b): f"above 5% on {sum(s > 0.05 for s in room_share)}, " f"largest {100 * max(room_share):.2f}%" ) + paired = [r for r in rows if r["revit"] is not None and r["up"] > 0] + for name, ours_key, revit_key in ( + ("upward faces", "up", "revit"), + ("downward faces", "floor", "revit_floor"), + ("every face", "every", "revit_every"), + ): + for source in ROOM_SOURCES: + mine = [r for r in paired if r["source"] == source] + if not mine: + continue + equal = sum( + abs(r[ours_key] - r[revit_key]) <= 0.001 * max(r[revit_key], 1e-9) for r in mine + ) + print( + f"space overlaps: {name}, {source.replace('_', ' ')}: " + f"{len(mine)} paired rooms, area within 0.1% of Revit's on {equal}" + ) listed = [r for r in rows if r["source"] != "stored_solid"] listed += sorted( (r for r in rows if r["source"] == "stored_solid"), key=lambda r: -(r["walls"] + r["rooms"]) / r["area"], )[:8] for r in listed: - revit_text = "no Revit space" if r["revit"] is None else f"Revit {r['revit']:.2f} ft2" + revit_text = ( + "no Revit space" + if r["revit"] is None + else f"Revit up {r['revit']:.2f}, floor {r['revit_floor']:.2f}, every face {r['revit_every']:.2f} ft2" + ) print( - f" {r['id']}: {r['source']}, area {r['area']:.2f} ft2 ({revit_text}), " + f" {r['id']}: {r['source']}, up {r['up']:.2f}, floor {r['floor']:.2f}, " + f"every face {r['every']:.2f} ft2 ({revit_text}), " f"inside walls {r['walls']:.2f} ft2 ({100 * r['walls'] / r['area']:.2f}%), " f"inside other rooms {r['rooms']:.2f} ft2 ({100 * r['rooms'] / r['area']:.2f}%)" ) From 2e75389bc927a4de77efbfd82eddebd8c5b544a3 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:46:04 -0500 Subject: [PATCH 26/31] feat(rooms): a span is stored_solid_faces only where its wall edges are one simple loop; a column subtracted or a shortfall makes it stored_solid_approximate (#1260) (RE-316) --- src/research/partition_room_boundaries.rs | 131 ++++++++++++++++------ 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 539532d0..7f80ce45 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -542,28 +542,14 @@ pub struct SpanRead { pub slack: f64, } -/// The outline the wall faces among `faces` make, kept where it spans -/// `bbox`. With `span` unset, the faces' plan edges must close into loops as -/// they are (RE-101). With it set, the outline is the boundary of the region -/// they wind around ([`region_boundary`]), and a vertical face that reaches -/// outside the box rules the solid out. -fn outline_from_faces( +/// The plan edges of the wall faces among `faces` that reach `anchor`, and, +/// where `span` reads arcs, of the round columns that do. +fn wall_edges( faces: &[RoomFace], bbox: [f64; 6], anchor: OutlineAnchor, span: Option, -) -> Option { - if faces.len() > RESOLVE_FACES_MAX && span.is_some() { - return None; - } - if let Some(read) = span { - if faces.iter().any(|face| strays_from_box(face, bbox)) { - return None; - } - if !read.arcs && faces.iter().any(|face| face.cylinder.is_some()) { - return None; - } - } +) -> Option> { let mut lines = Vec::new(); for face in faces { if span.is_none() || face.cylinder.is_none() { @@ -583,16 +569,77 @@ fn outline_from_faces( } } } + Some(lines) +} + +/// Whether any two of the plan `segments` cross, or overlap along a line, +/// anywhere but at an end both have. +fn crosses(segments: &[[f64; 4]]) -> bool { + const END: f64 = 1e-9; + for (index, first) in segments.iter().enumerate() { + for second in &segments[index + 1..] { + let (on_first, on_second) = cut_parameters(*first, *second); + if on_first + .iter() + .chain(&on_second) + .any(|t| *t > END && *t < 1.0 - END) + { + return true; + } + } + } + false +} + +/// The outline the faces of a span make (RE-316): with the plan edges of its +/// walls that reach `anchor`, ends within [`ROOM_STEP_TOLERANCE_FEET`] joined, +/// and a flag, set where those edges are exactly one simple loop (every end +/// shared by two edges, no edge crossing another, no loop inside it) and +/// otherwise unset, the outline then being the boundary of the region the +/// edges wind around ([`region_boundary`]), which subtracts a column that +/// overlaps a wall or stands in the room. `None` unless the outline spans +/// `bbox` as `read` allows; a face outside `bbox`, a cylinder not read as an +/// arc and more than [`RESOLVE_FACES_MAX`] faces rule the span out. +fn span_outline( + faces: &[RoomFace], + bbox: [f64; 6], + anchor: OutlineAnchor, + read: SpanRead, +) -> Option<(PlanProfile, bool)> { + if faces.len() > RESOLVE_FACES_MAX + || faces.iter().any(|face| strays_from_box(face, bbox)) + || (!read.arcs && faces.iter().any(|face| face.cylinder.is_some())) + { + return None; + } + let mut lines = wall_edges(faces, bbox, anchor, Some(read))?; + snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); + if let Some(profile) = plan_profile_from_lines(&lines) { + if profile.inner_xy.is_empty() && profile.pieces.is_empty() && !crosses(&lines) { + return profile_on_box(profile, bbox, read.slack).map(|profile| (profile, true)); + } + } + let boundary = region_boundary(&lines).ok()?; + profile_on_box(plan_profile_from_lines(&boundary)?, bbox, read.slack) + .map(|profile| (profile, false)) +} + +/// The outline the wall faces among `faces` make, kept where it spans +/// `bbox`: for the walk of RE-101 (`span` unset) the faces' plan edges must +/// close into loops as they are; for a span they are read by [`span_outline`]. +fn outline_from_faces( + faces: &[RoomFace], + bbox: [f64; 6], + anchor: OutlineAnchor, + span: Option, +) -> Option { match span { None => { + let mut lines = wall_edges(faces, bbox, anchor, None)?; snap_ends(&mut lines); profile_on_box(plan_profile_from_lines(&lines)?, bbox, 0.0) } - Some(read) => { - snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); - let boundary = region_boundary(&lines).ok()?; - profile_on_box(plan_profile_from_lines(&boundary)?, bbox, read.slack) - } + Some(read) => span_outline(faces, bbox, anchor, read).map(|(profile, _)| profile), } } @@ -622,21 +669,24 @@ pub fn span_outline_at( /// (RE-316). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResolvedKind { - /// The faces up to the solid's end wind around a region whose boundary - /// spans the record box to [`BOX_TOLERANCE_FEET`] on every side. + /// The wall edges of the span are exactly one simple loop, spanning the + /// record box to [`BOX_TOLERANCE_FEET`] on every side: no column is + /// subtracted, no edge crosses another and no loop stands inside it. A + /// face of another element would have to close that loop to be read. Faces, - /// The same, spanning the box to [`ROOM_SLIVER_FEET`] short of it on a - /// side: a column that overlaps a wall and whose back stands behind the - /// wall's face leaves the outline that far short. Revit's export does not - /// always take such a column off the room's area, so this is - /// approximate: 0.5% and 0.7% under Revit's area on the two office rooms - /// scored. + /// The outline is the boundary of the region the edges wind around, a + /// column overlapping a wall or standing in the room taken off it, or it + /// falls up to [`ROOM_SLIVER_FEET`] short of the record box on a side. + /// Revit's export does not always take such a column off the room's + /// area, so the area is approximate: 0.45% to 0.7% under Revit's floor + /// area on the four office rooms where it does not. Approximate, } /// The outline of a room whose solids at `headers` none gives an outline the -/// walk reads ([`outline_of_solids`] is `None`), from the faces up to each -/// solid's end resolved to a boundary (RE-316): exactly, else with +/// walk reads ([`outline_of_solids`] is `None`), from the faces of each +/// solid's span (RE-316): [`ResolvedKind::Faces`] where they are one simple +/// loop, else [`ResolvedKind::Approximate`], also tried with /// [`ROOM_SLIVER_FEET`] of slack. Where more than one solid gives one, every /// one must give the same. pub fn resolved_outline_of_solids( @@ -655,11 +705,18 @@ pub fn resolved_outline_of_solids( arcs: false, slack: ROOM_SLIVER_FEET, }; - let Some(this) = outline_from_faces(&faces, bbox, OutlineAnchor::Floor, Some(exact)) - .map(|profile| (profile, ResolvedKind::Faces)) + let Some(this) = span_outline(&faces, bbox, OutlineAnchor::Floor, exact) + .map(|(profile, simple)| { + let kind = if simple { + ResolvedKind::Faces + } else { + ResolvedKind::Approximate + }; + (profile, kind) + }) .or_else(|| { - outline_from_faces(&faces, bbox, OutlineAnchor::Floor, Some(slack)) - .map(|profile| (profile, ResolvedKind::Approximate)) + span_outline(&faces, bbox, OutlineAnchor::Floor, slack) + .map(|(profile, _)| (profile, ResolvedKind::Approximate)) }) else { continue; From b898a06337ff9e7d181e3c9e49fe6b3661f04eb8 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:57:40 -0500 Subject: [PATCH 27/31] fix(rooms): a span is stored_solid_faces where its loops close as the walk reads them, and stored_solid_approximate where a column overlaps a wall or the outline falls short (#1260) (RE-316) --- src/research/partition_room_boundaries.rs | 38 ++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 7f80ce45..bd374fff 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -593,11 +593,11 @@ fn crosses(segments: &[[f64; 4]]) -> bool { /// The outline the faces of a span make (RE-316): with the plan edges of its /// walls that reach `anchor`, ends within [`ROOM_STEP_TOLERANCE_FEET`] joined, -/// and a flag, set where those edges are exactly one simple loop (every end -/// shared by two edges, no edge crossing another, no loop inside it) and -/// otherwise unset, the outline then being the boundary of the region the -/// edges wind around ([`region_boundary`]), which subtracts a column that -/// overlaps a wall or stands in the room. `None` unless the outline spans +/// and a flag, set where those edges close as RE-101's walk reads them (every +/// end shared by two edges, loops that nest, one outer loop, no edge crossing +/// another) and otherwise unset, the outline then being the boundary of the +/// region the edges wind around ([`region_boundary`]), which subtracts a +/// column that overlaps a wall. `None` unless the outline spans /// `bbox` as `read` allows; a face outside `bbox`, a cylinder not read as an /// arc and more than [`RESOLVE_FACES_MAX`] faces rule the span out. fn span_outline( @@ -615,7 +615,7 @@ fn span_outline( let mut lines = wall_edges(faces, bbox, anchor, Some(read))?; snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); if let Some(profile) = plan_profile_from_lines(&lines) { - if profile.inner_xy.is_empty() && profile.pieces.is_empty() && !crosses(&lines) { + if profile.pieces.is_empty() && !crosses(&lines) { return profile_on_box(profile, bbox, read.slack).map(|profile| (profile, true)); } } @@ -669,24 +669,26 @@ pub fn span_outline_at( /// (RE-316). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResolvedKind { - /// The wall edges of the span are exactly one simple loop, spanning the - /// record box to [`BOX_TOLERANCE_FEET`] on every side: no column is - /// subtracted, no edge crosses another and no loop stands inside it. A - /// face of another element would have to close that loop to be read. + /// The wall edges of the span close as the walk of RE-101 reads them: + /// one outer loop, the loops inside it nested, no edge crossing another, + /// and the outline spanning the record box to [`BOX_TOLERANCE_FEET`] on + /// every side. A face of another element would have to close a loop of + /// its own inside the box, within the gap and the count that bound the + /// span, to be read. Faces, - /// The outline is the boundary of the region the edges wind around, a - /// column overlapping a wall or standing in the room taken off it, or it - /// falls up to [`ROOM_SLIVER_FEET`] short of the record box on a side. - /// Revit's export does not always take such a column off the room's - /// area, so the area is approximate: 0.45% to 0.7% under Revit's floor - /// area on the four office rooms where it does not. + /// The outline is the boundary of the region the edges wind around, + /// because a column overlaps a wall and its loop lies partly inside the + /// wall's, or it falls up to [`ROOM_SLIVER_FEET`] short of the record box + /// on a side. Revit's export does not always take such a column off the + /// room's area, so the area is approximate: 0.45% to 0.7% under Revit's + /// floor area on the four office rooms where it does not. Approximate, } /// The outline of a room whose solids at `headers` none gives an outline the /// walk reads ([`outline_of_solids`] is `None`), from the faces of each -/// solid's span (RE-316): [`ResolvedKind::Faces`] where they are one simple -/// loop, else [`ResolvedKind::Approximate`], also tried with +/// solid's span (RE-316): [`ResolvedKind::Faces`] where they close as the walk +/// reads them, else [`ResolvedKind::Approximate`], also tried with /// [`ROOM_SLIVER_FEET`] of slack. Where more than one solid gives one, every /// one must give the same. pub fn resolved_outline_of_solids( From f39099bd91278c55c588e1eb9202e33b8349bc79 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:09:03 -0500 Subject: [PATCH 28/31] docs(rooms): the two resolved outline sources say what they hold and how far Revit's floor area is from them, and the scrub keeps the overlap scorer's counts (#1260) (RE-316) --- docs/export-diagnostics.md | 4 +-- docs/schemas/export-diagnostics.schema.json | 4 +-- src/bin/rvt_ifc.rs | 4 +-- src/ifc/export_content.rs | 30 +++++++++++--------- src/ifc/mod.rs | 21 ++++++++------ src/research/partition_room_boundaries.rs | 31 +++++++++++++-------- tools/ci/scrub-holdout-results.sh | 8 +++++- 7 files changed, 61 insertions(+), 41 deletions(-) diff --git a/docs/export-diagnostics.md b/docs/export-diagnostics.md index 217a10f6..2594d26e 100644 --- a/docs/export-diagnostics.md +++ b/docs/export-diagnostics.md @@ -197,8 +197,8 @@ Important nested fields: | `exported.storey_bound_elements` | integer | Building elements contained in a specific storey. The rest are contained in the `IfcBuilding`, never in a named storey. | | `exported.layered_element_count` | integer | Elements whose compound layers and layer colours were read (RE-53). The glTF export draws each in its layers where they add up to its body's thickness; the rest are drawn whole. | | `exported.bounding_box_spaces` | integer | Exported `IfcSpace`s whose body is their record's bounding box (`RvtElementRecordGeometry.SpaceOutlineSource` is `record_box`): a room whose outline was not read from its stored solid, or a family instance written as a space and drawn as its box. The body's plan area is the box's, not the space's (RE-313, [rooms](rooms.md)). Each is also in `exported.bounding_box_bodies`, except a family instance turned or tilted off the model's axes. | -| `exported.resolved_outline_spaces` | integer | Exported rooms whose outline was resolved from the faces of their stored solid up to the solid's end (`RvtElementRecordGeometry.SpaceOutlineSource` is `stored_solid_faces`, RE-316, [rooms](rooms.md)): the faces the exact walk reads did not close. Equal to Revit's area on every such room measured, but read by a rule that cannot tell a face of another element inside the room's box from the room's own. | -| `exported.approximate_outline_spaces` | integer | Exported rooms whose resolved outline falls up to 0.02 ft short of their record box on a side (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316, [rooms](rooms.md)): a column overlaps a wall and its back stands behind the wall's face. The area is approximate: 0.5% and 0.7% under Revit's on the two such rooms of the BIM4LCA office that Revit's export pairs, which does not take that column off the room. | +| `exported.resolved_outline_spaces` | integer | Exported rooms whose outline was read from the faces their stored solid lists, up to the solid's own end, where the walk of RE-101 did not close them (`RvtElementRecordGeometry.SpaceOutlineSource` is `stored_solid_faces`, RE-316, [rooms](rooms.md)). Equal to Revit's area on every such room measured, but bounded by the solid's list of objects and a gap of 1,024 bytes, so a closed loop of another element's faces inside the room's box would be read as the room's. | +| `exported.approximate_outline_spaces` | integer | Exported rooms whose resolved outline is the boundary of a region, a column that overlaps a wall having been taken off it, or falls up to 0.02 ft short of their record box on a side (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316, [rooms](rooms.md)). The area is approximate: up to 0.68% under Revit's floor area where Revit's export does not take that column off the room, and equal to it on 10 of the 15 BIM4LCA office room scores. | | `confidence.level` | string | `scaffold`, `typed_no_geometry`, `geometry`, `diagnostic_partial`, or `proxy_only`. | | `confidence.score` | number | Heuristic 0..1 readiness score for UI sorting and dashboards. Its element terms (elements, typed elements, geometry) are scaled by the exported share when `unexported_element_records` is non-zero. | | `confidence.unexported_element_records` | integer | Element records of a recovered category (walls, doors, windows, columns, floors, rooms and the RE-33 product categories) the partition scan found but could not export because no ElementId is attributable (RE-30). Non-zero means the model is incomplete. Zero is not a completeness claim. | diff --git a/docs/schemas/export-diagnostics.schema.json b/docs/schemas/export-diagnostics.schema.json index 89b94cca..f5d54e5b 100644 --- a/docs/schemas/export-diagnostics.schema.json +++ b/docs/schemas/export-diagnostics.schema.json @@ -288,12 +288,12 @@ "resolved_outline_spaces": { "type": "integer", "minimum": 0, - "description": "Exported IfcSpaces that are rooms whose outline was resolved from the faces of their stored solid up to the solid's end (SpaceOutlineSource stored_solid_faces, RE-316): equal to Revit's area on every such room measured, read by a rule that cannot tell a face of another element inside the room's box from the room's own." + "description": "Exported IfcSpaces that are rooms whose outline was read from the faces their stored solid lists, up to the solid's own end, where the walk of RE-101 did not close them (SpaceOutlineSource stored_solid_faces, RE-316): equal to Revit's area on every such room measured, bounded by the solid's list of objects and a gap, so a closed loop of another element's faces inside the room's box would be read as the room's." }, "approximate_outline_spaces": { "type": "integer", "minimum": 0, - "description": "Exported IfcSpaces that are rooms whose resolved outline falls up to 0.02 ft short of their record box on a side (SpaceOutlineSource stored_solid_approximate, RE-316): their area is approximate, 0.5% and 0.7% under Revit's on the two measured." + "description": "Exported IfcSpaces that are rooms whose resolved outline is the boundary of a region, a column overlapping a wall having been taken off it, or falls up to 0.02 ft short of their record box on a side (SpaceOutlineSource stored_solid_approximate, RE-316): their area is approximate, up to 0.68% under Revit's floor area where Revit's export does not take the column off." } } }, diff --git a/src/bin/rvt_ifc.rs b/src/bin/rvt_ifc.rs index 5134cd34..824869fa 100644 --- a/src/bin/rvt_ifc.rs +++ b/src/bin/rvt_ifc.rs @@ -935,13 +935,13 @@ fn export_summary(diagnostics: &ExportDiagnostics, has_sidecar: bool) -> Vec 0 { lines.push(format!( - " {} rooms take an outline resolved from the faces up to their stored solid's end (SpaceOutlineSource stored_solid_faces)", + " {} rooms take an outline read from the faces their stored solid lists, where the exact walk did not close them (SpaceOutlineSource stored_solid_faces)", exported.resolved_outline_spaces )); } if exported.approximate_outline_spaces > 0 { lines.push(format!( - " {} rooms take an approximate outline, up to 0.02 ft short of their record box on a side: the area is within about 1% of the space's, not 0.1% (SpaceOutlineSource stored_solid_approximate)", + " {} rooms take an approximate outline, a column that overlaps a wall taken off it or up to 0.02 ft short of their record box on a side: the area is within about 1% of the space's, not 0.1% (SpaceOutlineSource stored_solid_approximate)", exported.approximate_outline_spaces )); } diff --git a/src/ifc/export_content.rs b/src/ifc/export_content.rs index 2915be26..513edfc7 100644 --- a/src/ifc/export_content.rs +++ b/src/ifc/export_content.rs @@ -4177,21 +4177,25 @@ pub const SPACE_OUTLINE_SOURCE_PROPERTY: &str = "SpaceOutlineSource"; /// faces of its stored solid, which close into loops as they are (RE-101). pub const SPACE_OUTLINE_STORED_SOLID: &str = "stored_solid"; -/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was resolved from -/// the faces of its stored solid up to the solid's end, the boundary of the -/// region its wall faces wind around, spanning the record box (RE-316). Equal -/// to Revit's area to 0.1% on every room of the BIM4LCA office and the -/// held-out project it was measured on; a face of another element inside the -/// box would not be told from the room's own, which is why it is not -/// `stored_solid`. +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was read from the +/// faces its stored solid lists, up to the solid's own end, where the walk of +/// RE-101 did not close them (RE-316): the wall edges close as that walk reads +/// them (one outer loop, the loops inside it nested, no edge crossing +/// another) and span the record box. Equal to Revit's area to 0.1% on every +/// such room measured, 9 of 9 on the BIM4LCA office as built. It is not +/// `stored_solid`: the solid's end is bounded by its list of objects and a gap, +/// not by the walk, so a closed loop of another element's faces inside the box +/// would be read as one of the room's. pub const SPACE_OUTLINE_STORED_SOLID_FACES: &str = "stored_solid_faces"; -/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was resolved as -/// [`SPACE_OUTLINE_STORED_SOLID_FACES`] but falls up to 0.02 ft short of its -/// record box on a side, a column's back behind the wall's face (RE-316). The -/// area is approximate: 0.5% and 0.7% under Revit's on the two such rooms of -/// the BIM4LCA office that Revit's export pairs, which does not take that -/// column off the room. +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was read as +/// [`SPACE_OUTLINE_STORED_SOLID_FACES`] but is the boundary of the region the +/// edges wind around, because a column overlaps a wall and its loop lies partly +/// inside the wall's, or falls up to 0.02 ft short of its record box on a side +/// (RE-316). Whether the area has the column taken off depends on Revit's +/// export, which does not always: of the 15 room scores of the BIM4LCA office +/// so read (4 as built, 11 at building permit), 5 are 0.45% to 0.68% under +/// Revit's floor area (four rooms, 1671995 in both files) and 10 are equal. pub const SPACE_OUTLINE_STORED_SOLID_APPROXIMATE: &str = "stored_solid_approximate"; /// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a space drawn as its record's bounding diff --git a/src/ifc/mod.rs b/src/ifc/mod.rs index 88a03ecc..e0bae427 100644 --- a/src/ifc/mod.rs +++ b/src/ifc/mod.rs @@ -678,18 +678,21 @@ pub struct ExportedModelDiagnostics { /// names the turned box. #[serde(default)] pub bounding_box_spaces: usize, - /// Of the exported `IfcSpace`s, the rooms whose outline was resolved from - /// the faces of their stored solid up to the solid's end - /// (`SpaceOutlineSource` is `stored_solid_faces`, RE-316): equal to - /// Revit's area on every such room measured, but read by a rule that - /// cannot tell a face of another element inside the room's box from the - /// room's own. + /// Of the exported `IfcSpace`s, the rooms whose outline was read from the + /// faces their stored solid lists, up to the solid's own end, where the + /// walk of RE-101 did not close them (`SpaceOutlineSource` is + /// `stored_solid_faces`, RE-316): equal to Revit's area on every such room + /// measured, but bounded by the solid's list of objects and a gap, so a + /// closed loop of another element's faces inside the room's box would be + /// read as the room's. #[serde(default)] pub resolved_outline_spaces: usize, - /// Of the exported `IfcSpace`s, the rooms whose resolved outline falls up - /// to 0.02 ft short of their record box on a side + /// Of the exported `IfcSpace`s, the rooms whose resolved outline is the + /// boundary of a region, a column overlapping a wall having been taken + /// off it, or falls up to 0.02 ft short of their record box on a side /// (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316): their - /// area is approximate, 0.5% and 0.7% under Revit's on the two measured. + /// area is approximate, up to 0.68% under Revit's floor area where + /// Revit's export does not take the column off. #[serde(default)] pub approximate_outline_spaces: usize, } diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index bd374fff..c108f071 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -39,10 +39,15 @@ //! (RE-316): its faces are followed with a gap of up to [`SPAN_FACE_GAP`] //! bytes, and no more than its list of objects counts ([`solid_span`], //! [`solid_object_count`]), so the faces of the element that follows it are -//! not read. Each plan edge is directed so the room is on its left, and the -//! room's outline is the boundary of the region those edges wind around -//! ([`region_boundary`]). A room read this way says so in its profile source -//! ([`ROOM_OUTLINE_SOURCE_FACES`], [`ROOM_OUTLINE_SOURCE_APPROXIMATE`]), never +//! not read. Where the span's wall edges close as the walk reads them (one +//! outer loop, the loops inside it nested, no edge crossing another) that is +//! the outline ([`ROOM_OUTLINE_SOURCE_FACES`]). Where a column overlaps a wall +//! and its loop lies partly inside the wall's, each plan edge is directed so +//! the room is on its left and the outline is the boundary of the region those +//! edges wind around ([`region_boundary`]), the column taken off +//! ([`ROOM_OUTLINE_SOURCE_APPROXIMATE`]: whether Revit's export takes it off is +//! not settled by the file). A room read either way says so in its profile +//! source, never //! as the walk's. A face whose record is 44 bytes longer than a plane's is a //! cylinder's (a round column): its axis, a unit vector at right angles to //! the face's u and v, at +158, and its radius at +182 ([`RoomCylinder`]). Its @@ -68,16 +73,18 @@ use std::collections::BTreeMap; /// are (RE-101). pub const ROOM_OUTLINE_SOURCE: &str = "partition_room_solid_faces"; -/// Value of the same field on a room outline resolved from the faces up to -/// the solid's end, spanning the record box (RE-316). Not the walk's: a face -/// of another element inside the box could still be read into it, so an -/// importer tells the two apart. +/// Value of the same field on a room outline read from the faces its solid +/// lists, up to the solid's own end, where the walk did not close them and +/// the span's wall edges close as the walk reads them (RE-316). Not the +/// walk's: a closed loop of another element's faces inside the box could still +/// be read into it, so an importer tells the two apart. pub const ROOM_OUTLINE_SOURCE_FACES: &str = "partition_room_solid_faces_resolved"; -/// Value of the same field on a room outline resolved the same way that -/// falls up to [`ROOM_SLIVER_FEET`] short of the record box on a side -/// (RE-316): approximate, 0.5% and 0.7% under Revit's area on the two office -/// rooms scored. +/// Value of the same field on a room outline that is the boundary of the region +/// the span's edges wind around, a column overlapping a wall taken off it, or +/// falls up to [`ROOM_SLIVER_FEET`] short of the record box on a side (RE-316): +/// approximate, up to 0.68% under Revit's floor area where its export does not +/// take the column off. pub const ROOM_OUTLINE_SOURCE_APPROXIMATE: &str = "partition_room_solid_faces_approximate"; /// Whether `source` is a value of the profile source field that a room's diff --git a/tools/ci/scrub-holdout-results.sh b/tools/ci/scrub-holdout-results.sh index b875dfc1..122ce897 100755 --- a/tools/ci/scrub-holdout-results.sh +++ b/tools/ci/scrub-holdout-results.sh @@ -36,7 +36,9 @@ # its property set census (standard Pset_ and Qto_ names only; RE-281), # the `space profiles:` count lines of tools/re/space_profiles_vs_ifc.py # (RE-313: how many spaces pair, and their area and outline within a tolerance, -# never a GlobalId), and +# never a GlobalId), the `space overlaps:` count lines of +# tools/re/space_overlaps_vs_ifc.py (RE-316: how many rooms of each source have +# the area of Revit's upward faces, floor or every face, never a GlobalId), and # ifc.sha256, the hash of rvt-rs's own export; every other file is deleted. set -euo pipefail @@ -102,6 +104,10 @@ for dir in "$RESULTS"/holdout-*/ "$RESULTS"/bimcollab-*/; do [ -f "$f" ] || continue grep -E '^space profiles: [A-Za-z0-9 %.,:()-]+$' "$f" || true done + for f in "$dir"space_overlaps_vs_ifc.txt; do + [ -f "$f" ] || continue + grep -E "^space overlaps: (upward|downward|every) faces, [a-z ]+: [0-9]+ paired rooms, area within 0\.1% of Revit's on [0-9]+$" "$f" || true + done for f in "$dir"storeys_vs_ifc.txt; do [ -f "$f" ] || continue grep -E "^([0-9]+ elements, [0-9]+ not on a storey|against Revit's export:| +[0-9]+ Ifc[A-Za-z0-9]+| +[0-9]+ (same storey as Revit's|on no storey; Revit's is (a storey|another container)|on a storey; Revit contains it in |a different storey from Revit's)| different, by type: Ifc[A-Za-z0-9]+ [0-9]+(, Ifc[A-Za-z0-9]+ [0-9]+)*)$" "$f" | sed 's/^/storeys: /' || true From b714122a343e659dc2834269fd0c217569069f8d Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:11:31 -0500 Subject: [PATCH 29/31] research(rooms): the closure probe says why each resolved room's edges do or do not close as the walk reads them (#1260) (RE-316) --- probes/examples/probe_re316_room_closure.rs | 56 +++++++++++++++++++ .../RE-316-room-solids-resolved.md | 23 ++++---- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs index 1d5c2e38..b7258a6c 100644 --- a/probes/examples/probe_re316_room_closure.rs +++ b/probes/examples/probe_re316_room_closure.rs @@ -53,6 +53,40 @@ fn area(profile: &PlanProfile) -> f64 { .sum::() } +/// How many pairs of `lines` cross at a point that is an end of neither, and +/// how many overlap along a line for more than 1e-5 ft. +fn crossing_pairs(lines: &[[f64; 4]]) -> (usize, usize) { + let (mut crossings, mut overlaps) = (0, 0); + for (index, a) in lines.iter().enumerate() { + for b in &lines[index + 1..] { + let (ax, ay) = (a[2] - a[0], a[3] - a[1]); + let (bx, by) = (b[2] - b[0], b[3] - b[1]); + let (alen, blen) = (ax.hypot(ay), bx.hypot(by)); + if alen < 1e-6 || blen < 1e-6 { + continue; + } + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let cross = ax * by - ay * bx; + if cross.abs() > 1e-9 * alen * blen { + let t = (dx * by - dy * bx) / cross; + let u = (dx * ay - dy * ax) / cross; + let (te, ue) = (1e-6 / alen, 1e-6 / blen); + if t > te && t < 1.0 - te && u > ue && u < 1.0 - ue { + crossings += 1; + } + } else if ((dx * ay - dy * ax) / alen).abs() <= 1e-5 { + let along = |x: f64, y: f64| ((x - a[0]) * ax + (y - a[1]) * ay) / alen; + let (s0, s1) = (along(b[0], b[1]), along(b[2], b[3])); + let (lo, hi) = (s0.min(s1).max(0.0), s0.max(s1).min(alen)); + if hi - lo > 1e-5 { + overlaps += 1; + } + } + } + } + (crossings, overlaps) +} + fn snap(lines: &mut [[f64; 4]]) { snap_within(lines, ROOM_VERTEX_TOLERANCE_FEET); } @@ -223,6 +257,28 @@ fn main() -> anyhow::Result<()> { (None, None) => "still box", }; *tally.entry(class).or_default() += 1; + if resolved.is_some() { + let mut edges: Vec<[f64; 4]> = solid_span(buf, header) + .iter() + .filter_map(|f| f.plan_edge()) + .filter(|(_, bottom)| (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET) + .map(|(edge, _)| edge) + .collect(); + snap_within(&mut edges, ROOM_STEP_TOLERANCE_FEET); + let (crossings, overlaps) = crossing_pairs(&edges); + let shape = match plan_profile_from_lines(&edges) { + Some(p) => format!( + "loops close, {} holes, {} further pieces", + p.inner_xy.len(), + p.pieces.len() + ), + None => "loops do not close as the walk reads them".to_string(), + }; + println!( + "room {id}: {class}; {} edges, {shape}, {crossings} crossing pairs, {overlaps} overlapping pairs", + edges.len() + ); + } let cache = cached_areas(buf); if let Some(profile) = walk_all .as_ref() diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md index 876d2c5b..5cb980f4 100644 --- a/reports/element-framing/RE-316-room-solids-resolved.md +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -5,7 +5,7 @@ **Artefacts:** the BIM4LCA office as built and at building permit (Revit 2024) with Revit 24.0.5's IFC4 exports; Autodesk's `rac_advanced`, `rac_basic`, `rme_basic`, `rme_advanced` and Snowdon Towers sample projects (no Revit export of them is in CI); Core Interior and RE1 Architecture with Revit's exports; the R2025 models and the held-out models (counts only), all as Measure fetches them **Probes:** `probes/examples/probe_re316_room_solid_breaks.rs`, `probe_re316_room_native_faces.rs` and `probe_re316_solid_vertices.rs` (the first finds what breaks; the other two are measured negatives, §1), `probe_re316_solid_extent.rs`, `probe_re316_room_closure.rs` and `probe_re316_hostile_solid.rs` **Runs:** Measure [41670081379](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41670081379) and [41673400892](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673400892) (what breaks), [41673755000](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673755000) (the native record), [41676245766](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41676245766) (the topology before the first face), [41707078214](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41707078214) (where a solid ends, §2), [41710248227](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41710248227) (the rule, with the space scorer and the crafted solids), and the change against `main`, unfiltered, [41712392709](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41712392709) (§3) -**Result:** positive on the office, with one room that disagrees with Revit's export. Of the 13 rooms of the office as built that kept their record box, all 13 now take an outline: 10 are labelled `stored_solid_faces` and 3 `stored_solid_approximate`. Nine of the 10 have Revit's area to 0.1% and Revit's boundary to 0.01 ft (their boxes were 3% to 67% over Revit's area); the tenth, room 1671995, is 323% over Revit's area, where its box was 329% over. The two approximate rooms Revit's export pairs are 0.68% and 0.47% under its area, the third is not paired. At building permit 11 of the 12 take an outline, all `stored_solid_faces`: 10 have Revit's area to 0.1% and Revit's boundary to 0.01 ft, and room 1671995 is 350% over (its box, 356%). Room 3095831 keeps its box. No room carries `stored_solid`, the label of RE-101's walk, that the walk did not close. +**Result:** positive on the office, and the 323% that round 3 of the review found is the scorer's, not the outline's. Of the 13 rooms of the office as built that kept their record box, all 13 take an outline: 9 are `stored_solid_faces`, equal to Revit's area to 0.1% by every metric (their boxes were 3% to 67% over it), and 4 are `stored_solid_approximate`, 0.45% to 0.68% under Revit's floor area (1671995, 1820322, 1832504, 2066902). At building permit 11 of the 12 take an outline, all `stored_solid_approximate`: 10 equal to Revit's floor area to 0.1% and room 1671995 0.45% under; room 3095831 keeps its box. Room 1671995 is not 323% over Revit's area: that is the area of Revit's upward faces, a ceiling Revit's export draws mostly off the horizontal, 1218.33 ft² in 9 flat patches; Revit's floor under it is 5177.37 ft² and ours 5154.12 (§3). None of the four guards the review proposed separates it from the rooms that agree (§3), so none is applied; the label `stored_solid_approximate` says what the file cannot settle. ## 1. Why the solids did not close @@ -36,21 +36,24 @@ Two readings were measured and are **negative**: - on the 47 solids whose count equals the walk's faces and which have a valid face after the walk, that face is another element's, 6,826 bytes or more on (the nearest is the office as built's, then Core Interior's at 6,858); - the faces that are a solid's own beyond the walk's 0x200 byte gap lie 592 to 732 bytes on (the office and Autodesk's samples; 908 and 1,406 on two of Snowdon Towers' unresolved rooms). -So the span is the faces from the first, each within 0x400 bytes of the one before (`SPAN_FACE_GAP`), and no more than the list counts (`solid_object_count`). Where the count equals the walk's faces nothing is added. Where it is larger, at most the difference could be another element's, and only if it follows within 1,024 bytes of a solid's last face: no solid measured does that, but none is measured with a difference above 4 and a face within 6,826 bytes. That is why a span-resolved outline is not `stored_solid` (below). The rme room 576007, which has no Revit export, has a count of 21 and 20 faces in the walk and the span alike: its outline is its own faces'. +So the span is the faces from the first, each within 0x400 bytes of the one before (`SPAN_FACE_GAP`), and no more than the list counts (`solid_object_count`). Where the count equals the walk's faces nothing is added. Where it is larger, at most the difference could be another element's, and only if it follows within 1,024 bytes of a solid's last face: no solid measured does that, but none is measured with a difference above 4 and a face within 6,826 bytes. That is why a span-read outline is not `stored_solid` (below). The rme room 576007, which has no Revit export, has a count of 21 and 20 faces in the walk and the span alike: its outline is its own faces'. -**From faces to an outline.** Each wall face's plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office the walk keeps, Core Interior's 116, RE1's 11 and the 14 to 85 each the Autodesk samples keep, the directed edges join head to tail (run 41710248227). `region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column that overlaps a wall takes its footprint off the room where they overlap and adds nothing outside it. Ends within 0.0025 ft meet: the steps measured are 0.0007 to 0.0015 ft, and the shortest real edge 0.0052 ft; the value is chosen from these, not copied, since revit-ifc takes the short-curve tolerance from the Revit API at run time (`IFCProject.cs` sets it from `application.ShortCurveTolerance`) and has no constant. The outline must span the record box to 0.001 ft, as RE-101's does. +**From faces to an outline.** The wall faces that reach the floor give plan edges, ends within 0.0025 ft joined: the steps measured are 0.0007 to 0.0015 ft and the shortest real edge 0.0052 ft; the value is chosen from these, not copied, since revit-ifc takes the short-curve tolerance from the Revit API at run time (`IFCProject.cs` sets it from `application.ShortCurveTolerance`) and has no constant. Two readings, in this order, each spanning the record box to 0.001 ft as RE-101's does: + +- **`stored_solid_faces`**: the edges close as the walk of RE-101 reads them (`plan_profile_from_lines`: every end shared by two edges, one outer loop, the loops inside it nested) and no edge crosses or overlaps another. A face of another element would have to close a loop of its own, nested and clear of every wall, inside the box and inside the gap and the count above, to be read. +- **`stored_solid_approximate`**: otherwise the outline is the boundary of the region the edges wind around (`region_boundary`). Each plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office the walk keeps, Core Interior's 116, RE1's 11 and the 14 to 85 each the Autodesk samples keep, the directed edges join head to tail (run 41710248227). `region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column whose loop lies partly inside a wall's takes its footprint off the room where they overlap and adds nothing outside it. The outline may also fall up to 0.02 ft short of the box on a side, which Griffin chose (2026-11-03): a column's back stands 0.0164 ft behind the wall's face on three office rooms. + +The first version labelled every span-read room `stored_solid_faces` whatever its edges did; the split above is the narrowing the standing rule asks for. **What a resolved room says.** `SpaceOutlineSource`: -| Value | Read | Area | +| Value | Read | Area, against Revit's floor area (downward faces) | |---|---|---| -| `stored_solid` | RE-101's walk closed on the box | as RE-313 | -| `stored_solid_faces` | the span above, spanning the box to 0.001 ft | Revit's to 0.1% on 19 of the office's 21 scored rooms; room 1671995 is off (§3) | -| `stored_solid_approximate` | the same, spanning the box to within 0.02 ft short of it on a side, tried only where the exact span fails | 0.68% and 0.47% under Revit's on the two rooms Revit's export pairs | - -The approximate rooms are three as built: a column overlaps a wall and its back stands 0.0164 ft behind the wall's face, which leaves the outline 0.0164 ft short of the box on one side; the stored solid takes the column off the room and Revit's export does not take that column off, though it takes off the 1.722 ft² columns of eight rooms at permit; the two rooms it pairs differ from the outline by 3.0 ft², the column's 3.001. Griffin chose to close them, labelled (2026-11-03). The exports count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`; `rvt-ifc` prints a line for each), and the room schedule gives an area for `stored_solid` and `stored_solid_faces` rooms and none for approximate ones. +| `stored_solid` | RE-101's walk closed on the box | as RE-313: 221 of 221 as built and 222 of 222 at permit within 0.1% (§3) | +| `stored_solid_faces` | the span's edges close as the walk reads them | within 0.1% on 9 of 9 (office as built) | +| `stored_solid_approximate` | the boundary of a region, a column taken off, or up to 0.02 ft short of the box | within 0.1% on 10 of 15 room scores (4 as built, 11 at permit), 0.45% to 0.68% under on the other 5 (four rooms; 1671995 in both files): Revit's export does not take the column off | -A solid with a cylinder is not resolved, and a resolved outline carries no prism marker, so no `NetVolume`. +The exports count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`; `rvt-ifc` prints a line for each), and the room schedule gives an area for `stored_solid` and `stored_solid_faces` rooms and none for approximate ones. A solid with a cylinder is not resolved, and a resolved outline carries no prism marker, so no `NetVolume`. **Bounded work.** A partition is untrusted input. The walk reads at most 512 faces (a solid with more is left to its box); the span at most the list's count, never above 512; `region_boundary` takes at most 512 segments and gives up before it builds more than 4,096 pieces, however often they cross. `probe_re316_hostile_solid` builds crafted solids in memory and times both readers (run 41710248227, GitHub's runner): From fd756bd10a541e850d0adf216c6a83e83f71eeb1 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:00:06 -0500 Subject: [PATCH 30/31] docs(rooms): RE-316 round 3: room 1671995 is 0.45% under Revit's floor, the scorer's upward faces read it and RE-313's 66% room as a fraction of themselves, the four guards were measured and none separates it, and the resolved outlines are labelled by how their edges close (#1260) (RE-316) --- .../changed/1260-room-solids-resolved.md | 49 ++++---- docs/evidence.md | 2 +- docs/rooms.md | 21 ++-- docs/status.md | 2 +- docs/supported-profile.md | 2 +- .../RE-316-room-solids-resolved.md | 117 ++++++++++++------ 6 files changed, 116 insertions(+), 77 deletions(-) diff --git a/changelog.d/changed/1260-room-solids-resolved.md b/changelog.d/changed/1260-room-solids-resolved.md index 4e7a11b9..6b5b537f 100644 --- a/changelog.d/changed/1260-room-solids-resolved.md +++ b/changelog.d/changed/1260-room-solids-resolved.md @@ -1,26 +1,29 @@ - **A room whose stored solid the exact walk did not close is read again - from the faces its solid lists, resolved to a boundary, and labelled - apart.** The walk stopped at a record longer than 0x200 bytes between two - faces, walls on lines up to 0.0015 ft apart left a step no face crosses, a - column overlapping a wall put a loop partly inside the wall's, and a solid - ran past the 64 KB window. The solid ends at the count of its list of - objects and at a gap of 1,024 bytes, so the faces of the element that - follows are not read, and the work is bounded (512 faces, 4,096 pieces; - crafted solids take under 10 ms). `SpaceOutlineSource` is - `stored_solid_faces` for these rooms, and `stored_solid_approximate` where - a column's back leaves the outline up to 0.02 ft short of the record box; - `stored_solid` stays the exact walk's. The diagnostics count them + from the faces its solid lists, and labelled apart.** The walk stopped at a + record longer than 0x200 bytes between two faces, walls on lines up to + 0.0015 ft apart left a step no face crosses, a column overlapping a wall + put a loop partly inside the wall's, and a solid ran past the 64 KB window. + The solid ends at the count of its list of objects and at a gap of 1,024 + bytes, so the faces of the element that follows are not read, and the work + is bounded (512 faces, 4,096 pieces; crafted solids take under 10 ms). + `SpaceOutlineSource` is `stored_solid_faces` where the faces' edges close as + the walk reads them, and `stored_solid_approximate` where a column overlaps + a wall (its area depends on whether Revit's export takes the column off) or + the outline falls up to 0.02 ft short of the record box; `stored_solid` + stays the exact walk's. The diagnostics count both (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`) and the room schedule leaves an approximate room's area blank. On the - BIM4LCA office as built the 13 rooms that kept their box take an outline. - Ten are `stored_solid_faces`: nine with Revit's area to 0.1% (their boxes - were 3% to 67% over it) and room 1671995, 323% over it (its box, 329%; - Revit's export splits it into 9 pieces). Three are - `stored_solid_approximate`, the two Revit's export pairs 0.68% and 0.47% - under its area. At building permit 11 of 12 are `stored_solid_faces`, 10 - with Revit's area to 0.1% and room 1671995 350% over, and room 3095831 - keeps its box. 6 of the 8 rooms that kept their box do on a held-out - Revit 2021 project, all 6 at Revit's area. The six `rac_advanced` rooms - with round columns, 49 of the 50 `rme` rooms and Snowdon Towers' seven keep - their box. Core Interior and RE1 Architecture are unchanged. A resolved - room carries no `NetVolume` (#1260, RE-316). + BIM4LCA office as built the 13 rooms that kept their box take an outline: + nine are `stored_solid_faces`, all at Revit's area to 0.1% (their boxes were + 3% to 67% over it), and four `stored_solid_approximate`, 0.45% to 0.68% + under Revit's floor area. At building permit 11 of 12 take an outline, all + `stored_solid_approximate`, ten at Revit's floor area to 0.1%; room 3095831 + keeps its box. 6 of the 8 rooms that kept their box do on a held-out Revit + 2021 project, all 6 at Revit's area. Measure's `space_profiles_vs_ifc` + compares the faces of Revit's mesh that point up, which read a room under a + stepped or sloped ceiling as a fraction of itself (room 1671995: 1218 of + 5177 ft²; RE-313's 66% room); the new `space_overlaps_vs_ifc` also compares + the floor, where all 443 office `stored_solid` room scores agree. The six + `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms and + Snowdon Towers' seven keep their box. Core Interior and RE1 Architecture are + unchanged. A resolved room carries no `NetVolume` (#1260, RE-316). diff --git a/docs/evidence.md b/docs/evidence.md index 80c68536..e612d6a0 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -75,7 +75,7 @@ On Revit 2024 and 2025 project files, rvt-rs reads each element from its partiti | Electrical and mechanical devices and equipment | `IfcAlarm` (fire alarm devices), `IfcElectricAppliance` (data devices), proxies (switches, sensors, receptacles, junction boxes, panels, mechanical equipment) | RE1 Electrical and Mechanical, every one Revit exports and nothing else; the symbols nested in switches and receptacles left out, as Revit leaves them out (RE-79) | | Structural framing, columns, foundations | `IfcBeam` along its location line, `IfcColumn`, `IfcFooting` | Snowdon structural sample: all 1,078 in the category its VIM export gives them; 923 of 942 beams run along their line, with Revit's own section on 613 of the 839 a later edition kept unchanged, and on 179 more less the top a floor join cuts (RE-49); 377 steel beams and 40 steel columns are their type's I section, Revit's own on all 271 beams and 24 columns the VIM scores (RE-103, RE-104) | | Site elements, generic models, slab edges, elevators, ramps | proxies, `IfcTransportElement`, `IfcRamp` | Snowdon Towers, every one Revit exports | -| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid the exact walk does not close is read again from the faces its solid lists, labelled `stored_solid_faces` or `stored_solid_approximate` in `SpaceOutlineSource` (the BIM4LCA office 13 of 13 rooms as built and 11 of 12 at permit; Revit's area to 0.1% on 19 of the 21 faces rooms scored, RE-316), and keeps its box otherwise | +| Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid the exact walk does not close is read again from the faces its solid lists, labelled `stored_solid_faces` or `stored_solid_approximate` in `SpaceOutlineSource` (the BIM4LCA office 13 of 13 rooms as built and 11 of 12 at permit; `stored_solid_faces` at Revit's area to 0.1% on 9 of 9, `stored_solid_approximate` on 10 of 15 room scores and 0.45% to 0.68% under Revit's floor area on the other 5, RE-316), and keeps its box otherwise | | Areas | `IfcSpace .SPACE.` for each placed Area of a Revit 2024 file, named as Revit's export names it (`Name` the number, `LongName` the name), in its Level's storey and typed by its own `IfcSpaceType`; no body, since the Area's boundary is not decoded | the BIM4LCA office as built and at building permit: all 30 of Revit's area spaces, number, name and GlobalId as Revit's (RE-191) | | Storeys | `IfcBuildingStorey` for each Revit Level that is a building story, with its name and elevation | Core Interior 15, Snowdon Towers 18, every RE1 model 2; name, elevation and GlobalId all Revit's (RE-24, RE-51); Snowdon Towers Structural 12 of its 19 Levels, its VIM export's building stories, 208 of the 210 elements on them in their storey (RE-118) | | Storey containment | each element in the storey of the Level its record names, of its base constraint where it names two, and, where it names none, of a railing's host, of the Level its work planes carry, or of its base elevation | Core Interior all 854 in Revit's storey; Snowdon Towers 5,949 in Revit's storey, 1 in another and 1 on none (RE-27, RE-59, RE-60, RE-68) | diff --git a/docs/rooms.md b/docs/rooms.md index c17b3b2a..4531bdef 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -19,9 +19,9 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: | Value | The body is | Its plan area is | |---|---|---| -| `stored_solid` | the outline of the room's stored solid, extruded to the room's height, read by the exact walk of RE-101 | Revit's room area: within 0.1% of Revit's export on every `stored_solid` room scored below except one, the permit office's STAIRCASE room 2611271 at 66% (RE-313 §2, §3) | -| `stored_solid_faces` | the outline of the room's stored solid, resolved to a boundary from the faces up to the solid's end where the exact walk did not close (RE-316) | Revit's room area on the rooms measured except one: 19 of the office's 21 scored (to 0.1%), and 6 of 6 of a held-out project. The 2 others are one room, 1671995, as built and at permit, 323% and 350% over Revit's area (Revit's export splits it into 9 pieces; its stored solid is one region with 6 holes), nothing in the file telling it from the rooms that agree. Read by a rule that bounds the solid by its list of objects and a gap, not by the exact walk, which is why it is labelled apart (RE-316 §2, §3) | -| `stored_solid_approximate` | the same, falling up to 0.02 ft short of the room's record box on a side: a column overlaps a wall and its back stands behind the wall's face | approximate: 0.68% and 0.47% under Revit's on the two rooms of the office that Revit's export pairs, the third not paired. Revit's export does not take that column off the room, the stored solid does. The room schedule leaves the area blank (RE-316 §2) | +| `stored_solid` | the outline of the room's stored solid, extruded to the room's height, read by the exact walk of RE-101 | Revit's room area: within 0.1% of Revit's export on every `stored_solid` room scored below by the scorer's upward faces except one, the permit office's STAIRCASE room 2611271 at 66%, which by Revit's floor is within 0.1% like the others (221 of 221 as built, 222 of 222 at permit; RE-313 §2, §3, RE-316 §3) | +| `stored_solid_faces` | the outline of the room's stored solid, read from the faces its solid lists, up to the solid's own end, where the walk did not close them: their edges close as the walk reads them (RE-316) | Revit's room area: within 0.1% on 9 of 9 office rooms as built, by every metric. Bounded by the solid's list of objects and a gap, not by the walk, so a closed loop of another element's faces inside the room's box would be read as the room's: that is why it is labelled apart (RE-316 §2, §3) | +| `stored_solid_approximate` | the same, as the boundary of a region, a column that overlaps a wall taken off it, or up to 0.02 ft short of the room's record box on a side | approximate. Of the office's 15 room scores (4 as built, 11 at permit) 10 are within 0.1% of Revit's floor area and 5 are 0.45% to 0.68% under it (four rooms, 1671995 in both files): Revit's export does not take such a column off the room, and nothing in the file says which. The room schedule leaves the area blank (RE-316 §2, §3) | | `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's room scores that keep their box (1 room at permit, 3095831; RE-316 §4) | | `saved_mesh` | the mesh Revit saved for the room (`rvt-ifc --saved-meshes`) | the mesh's; not scored | | `none` | no body (an Area) | not available | @@ -54,7 +54,7 @@ The export also counts the spaces drawn as their record's box (rooms and family - **area**: the plan area, within 0.1% of Revit's; - **boundary**: the largest distance from a point of either outline to the other's outline, within 0.01 ft. This is the room's outline, and the figure used for "outline" in the table and in the text below; -- **outline**: the largest distance from a vertex of either mesh's upward faces to the other's outline, within 0.01 ft. Revit meshes a ceiling step inside a room as an upward face of its own, and its vertices count against this one, so it reads some correct outlines as wrong (RE-313 §3). The CI gate pins both. +- **outline**: the largest distance from a vertex of either mesh's upward faces to the other's outline, within 0.01 ft. Revit meshes a ceiling step inside a room as an upward face of its own, and its vertices count against this one, so it reads some correct outlines as wrong (RE-313 §3). The CI gate pins both. These three read the faces of Revit's mesh that point up, within 25 degrees of vertical, so a room under a stepped or sloped ceiling is read as the flat part of it: `tools/re/space_overlaps_vs_ifc.py` also measures the plan area of each space's floor (its downward faces) and of every face projected, in both exports, and counts the rooms within 0.1% of Revit's by each (RE-316 §3). The floor is the room's plan. Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ifc`); the IfcOpenShell job of CI runs it on the first two rows, pairing by GlobalId (`--match globalid`), and fails below their counts in area, outline and boundary. @@ -62,8 +62,8 @@ Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ |---|---:|---:|---:|---:|---| | Core Interior (2024) | 116 | 116 | 116 | 116 | 116 rooms, stored solid: 116, 116 | | RE1 Architecture (2025) | 11 | 11 | 11 | 11 | 11 rooms, stored solid: 11, 11 | -| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 220 rooms, stored solid: 220, 220; 10 rooms, stored solid faces: 9, 9; 2 rooms, stored solid approximate: 0, 0; 44 family instances, record box: 1, 1 | -| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 232 | 232 | 225 | 222 rooms, stored solid: 221, 221; 11 rooms, stored solid faces: 10, 10; 1 room, record box: 0, 0; 44 family instances, record box: 1, 1 | +| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 220 rooms, stored solid: 220, 220; 9 rooms, stored solid faces: 9, 9; 3 rooms, stored solid approximate: 0, 0 (4 by Revit's floor, 0 within 0.1%); 44 family instances, record box: 1, 1 | +| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 232 | 232 | 225 | 222 rooms, stored solid: 221, 221; 11 rooms, stored solid approximate: 10, 10; 1 room, record box: 0, 0; 44 family instances, record box: 1, 1 | | R2025 purpose-made models with rooms (2025), 6 models | 12 | 12 | 12 | 12 | 12 rooms, stored solid: 12, 12 | | Held-out models (2022, 2024, 2025), 6 models | 24 | 24 | 24 | 24 | 24 rooms, stored solid: 24, 24 | | Held-out models (2026, 2027), 4 models | 16 | 16 | 16 | 16 | 16 rooms, stored solid: 16, 16 (all rectangles) | @@ -72,18 +72,19 @@ Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ | Held-out Warmond (2021, export passed through another tool) | 179 | 179 | 179 | 179 | 171 rooms, stored solid: 171, 171; 6 rooms, stored solid faces: 6, 6; 2 rooms, record box: 2, 2 (rectangles) | | Vitoria `ALD26 Before` (2018, Revit's IFC2X3 export; spent) | 73 | 72 | 72 | 71 | 73 rooms, stored solid: 73, 72 | -On Warmond, RE-316 closes 6 of the 8 rooms that kept their box (5 were off in area; one was a rectangle already equal to Revit's, whose vertices are no longer the box's: vertices within 0.01 ft 127 → 126); the 2 that keep their box are rectangles. Area within 0.1% goes from 174 to 179 of the 179 paired spaces (Measure run 41712392709, `main` at 8664b94a against this change, counts only). +On Warmond, RE-316 closes 6 of the 8 rooms that kept their box (5 were off in area; one was a rectangle already equal to Revit's, whose vertices are no longer the box's: vertices within 0.01 ft 127 → 126); the 2 that keep their box are rectangles. Area within 0.1% goes from 174 to 179 of the 179 paired spaces (Measure run 41728355227, `main` at 7fd339e2 against this change, counts only; by Revit's floor too: 177 of 177 `stored_solid` and `stored_solid_faces` rooms). A model with no rooms in Revit's export (the other R2025 models, RE1 Electrical, Mechanical and Plumbing) scores 0 of 0. One held-out model writes none of Revit's spaces: BIM Whale's 2021 project (8), whose spaces are Areas, which rvt-rs reads on Revit 2024 only. Vitoria's 2018 project exported no element, and none of its 73 spaces, before PR #1286 (#1261): its spent `ALD26 Before` now writes all 73 (run 41707934595, full output, in the row above), and its held-out sibling `ALD26 Project` writes 73 `IfcSpace` against Revit's 73 (counts only, not scored here). -The other held-out figures are Measure run 41712392709 (`main` at 8664b94a), counts only. On the 2026 house and on Gdynia, every room's area is right where it is scored, but none of their outlines is within 0.01 ft of Revit's; the files are held out, so why is not examined. +The other held-out figures are Measure run 41728355227 (`main` at 7fd339e2), counts only. On the 2026 house and on Gdynia, every room's area is right where it is scored, but none of their outlines is within 0.01 ft of Revit's; the files are held out, so why is not examined. What the misses are: -- **Rooms the exact walk did not close** (13 as built, 12 at permit; RE-316): as built 10 are `stored_solid_faces` and 3 `stored_solid_approximate`; at permit 11 are `stored_solid_faces` and 1, 3095831, keeps its box (14 ends down a 100 ft wall are not joined). One room, 1671995 (both files), is `stored_solid_faces` and 323% (350% at permit) over Revit's area, where its box was 329% (356%): its stored solid and Revit's export disagree. Before RE-316 all 13 and 12 kept their box (RE-316 §1, §3, §4). +- **Rooms the exact walk did not close** (13 as built, 12 at permit; RE-316): as built 9 are `stored_solid_faces` and 4 `stored_solid_approximate`; at permit 11 are `stored_solid_approximate` and 1, 3095831, keeps its box (14 ends down a 100 ft wall are not joined). Before RE-316 all 13 and 12 kept their box (RE-316 §1, §3, §4). +- **Room 1671995 (both files)** is `stored_solid_approximate`, 0.45% under Revit's floor area (5154.12 against 5177.37 ft² as built). The scorer's upward faces read it as 323% over (350% at permit): Revit's ceiling over it is mostly not flat, and its flat patches total 1218.33 ft² in 9 pieces (RE-316 §3). - **Family instances written as spaces (44 on the office)** are 42 "Turning Free Space" discs and two "VOLUME" families that Revit's export draws from the family's geometry (a mapped representation); rvt-rs draws their record box, a square where the family is a circle (24.22 ft² against 19.01 ft² on the 1500 mm turning spaces listed). No issue covers these yet; #1262 and PR #1279 are about the terrain office's parking bays, a different set, though its rule (a family instance written as a space is left out where its family draws no model form) may change this one too. - **The office's other 30 spaces are its Areas**: Revit's export gives them a body, rvt-rs writes them without one (RE-191). -- **One room at building permit** is read from its stored solid and is 66% off in area (172.77 against 103.90 ft²); its cause is not found (RE-313 §3). +- **The permit office's STAIRCASE room 2611271**, 66% off in area by the scorer's upward faces (172.77 against 103.90 ft²), is within 0.1% of Revit's floor area, like the other 221 `stored_solid` rooms: the scorer reads a room with a stepped or sloped ceiling as a fraction of itself (RE-316 §3, `tools/re/space_overlaps_vs_ifc.py`). ## What is not measured diff --git a/docs/status.md b/docs/status.md index 4dd1798d..ac88e3b6 100644 --- a/docs/status.md +++ b/docs/status.md @@ -113,7 +113,7 @@ walls Revit steps over a floor that covers them in part (29 of the BIM4LCA offic the rooms whose stored solid still does not close (RE-316 §4) and opening profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, resolved to a boundary, and says so: `stored_solid_faces` (10 of the BIM4LCA office's 13 such rooms as built and 11 of 12 at building permit; Revit's area to 0.1% on 19 of the 21 Revit's export pairs, and the 2 others are one room, 1671995, whose stored solid and Revit's export disagree by 323% and 350%; 6 of the 8 rooms that kept their box on a held-out Revit 2021 project, all 6 at Revit's area) or `stored_solid_approximate` (3 rooms as built, 0.68% and 0.47% under Revit's area on the two it pairs), counted in `exported.resolved_outline_spaces` and `exported.approximate_outline_spaces`; the 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, up to the solid's own end, and says so: `stored_solid_faces` where the faces' edges close as the walk reads them (9 of the BIM4LCA office's 13 such rooms as built, all at Revit's area to 0.1%) or `stored_solid_approximate` where a column overlaps a wall or the outline falls up to 0.02 ft short of the box (4 as built and 11 of the 12 at building permit: 10 of the 15 at Revit's floor area to 0.1%, the 5 others, four rooms, 0.45% to 0.68% under it); the diagnostics count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`). The scorer's upward-face area reads one of them (1671995) as 323% over and RE-313's 66% room as 66% off; by Revit's floor both are within 0.5% and 0.1%. The 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history diff --git a/docs/supported-profile.md b/docs/supported-profile.md index b4d96666..87f3fe2a 100644 --- a/docs/supported-profile.md +++ b/docs/supported-profile.md @@ -112,7 +112,7 @@ counts reported on 2026-11-02 and 2026-11-03 on 0.12.0): | Stairs | aggregates of flights, landings and supports, each stair's and flight's `PredefinedType` from its runs and landings; straight flights as their steps, monolithic ones included; riserless flights as their treads, spiral ones as their sector-shaped treads; a monolithic landing as its slab, and a landing that is one tread as that tread | Snowdon Towers 33 of 38 drawn flights equal to Revit's; on the BIM4LCA office (Revit 2024) all 35 flights have Revit's volume, its 34 straight ones and its spiral one, its 21 monolithic landings Revit's top and outline, and its tread landing Revit's body; on puzzbobb's Revit 2025 stair models every flight of models 13 to 16 has Revit's volume (RE-214, RE-217). A monolithic landing's underside is drawn flat where Revit's follows a flight's soffit, 100.4% to 104.7% of Revit's volume, and its `BodySource` says so; monolithic spiral runs and riserless runs with a nosing keep their box (#357) | | Curtain walls | aggregates of panels and mullions placed by their grids; a mullion or panel turned or tilted off the model's axes as the box along its own axes (RE-106) | every Snowdon panel and mullion relation Revit's; 1,019 turned or tilted mullions and panels with Revit's extents along their axes | | Doors, windows, openings | the element's box, turned with the instance where it is turned (RE-87); its opening is as deep as the host wall | host is Revit's on 138 of 138 (Core Interior) and 192 of 194 (Snowdon; the other 2 in another wall Revit cuts, RE-85), and on Revit 2024 and 2025 the wall its native record names, Revit's on all 463 of the BIM4LCA office's as built (RE-245); on Revit 2024 and 2025 the opening is the outline the symbol that draws it cuts, through the instance's transform: 138 of 138 on Core Interior and 4 of 4 on RE1 within 0.001 ft of Revit's (RE-215, #227; Measure run 40927543524); a window whose type stores Width, Height and Default Sill Height takes Revit's opening exactly (56 of 68 on Snowdon, RE-93), and a door whose body is its type's Rough Height tall its rough opening (57 of 126, RE-94); any other door whose type stores Width and Height carries them as its overall size (137 of 137 on Core Interior and RE1, RE-187; Measure run 40927543524); on Revit 2024 and 2025 a door's or window's overall size is the outline the symbol that draws it cuts, Revit's on every one with such an outline (138 of 138 on Core Interior, 4 of 5 on RE1, RE-215); a door or window whose symbol cuts no outline takes its own extents as its overall size, Revit's on all 269 windows and 266 of 269 doors of the BIM4LCA office (RE-218); otherwise not Revit's opening profile (#227, RE-83) | -| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); all 220 of the BIM4LCA office's scored rooms read from their stored solid by the exact walk as built (221 of 222 at building permit) at Revit's area and outline; its rooms the walk did not close take a resolved outline, `stored_solid_faces` (19 of the 21 scored at Revit's area, the other two being one room whose stored solid and Revit's export disagree) or `stored_solid_approximate` (0.68% and 0.47% under Revit's), and its 1 room (permit) that keeps its box and 44 family-instance spaces drawn as boxes almost never are Revit's (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | +| Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); all 220 of the BIM4LCA office's scored rooms read from their stored solid by the exact walk as built (221 of 222 at building permit) at Revit's area and outline; its rooms the walk did not close take a resolved outline, `stored_solid_faces` (9 of 9 as built at Revit's area) or `stored_solid_approximate` (10 of 15 room scores at Revit's floor area, the 5 others 0.45% to 0.68% under it), and its 1 room (permit) that keeps its box and 44 family-instance spaces drawn as boxes almost never are Revit's (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | | Areas | `IfcSpace` with number, name and storey; no body | all 30 of Revit's area spaces on the BIM4LCA office, as built and at building permit (RE-191); Revit 2024 only | | Furniture, fixtures, equipment, MEP devices, site | the element's bounding box, turned with the instance where it is turned (RE-87) | placement and extent only; no family geometry. MEP devices and equipment are measured on RE1 Electrical and Mechanical only (RE-79) | | Family instances from their saved meshes (doors and windows by default on Revit 2023 to 2027, every other family with `--saved-meshes`) | the mesh Revit saved for the element's display | within 0.01 ft of Revit's export: Core Interior 158 of 158, RE1 (2025) 207 of 214, Snowdon Towers 3,332 of 3,736. Revit's own shape, every vertex within 0.001 ft: Core Interior 138 of 138 doors and windows, the BIM4LCA office 1,294 of 1,709 scored (all its windows; 48 of 269 doors), RE1 148 of 214; a mesh that is not exact is part of the body (RE-78). Since RE-224 a mesh some face of which did not decode keeps its box: on the office 1,294 of the 1,352 bodies then drawn are exact (1,376 of 1,438 at permit) | diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md index 5cb980f4..a49156cf 100644 --- a/reports/element-framing/RE-316-room-solids-resolved.md +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -75,45 +75,77 @@ Every one gives its outline or none and takes under 10 ms. The review measured t ### The office, against Revit's own IFC4 export -As built, the rooms that kept their box (ft²): +As built, the rooms that kept their box (ft²). Revit's area is its floor (the faces of its mesh that point down), which is the room's plan; its upward faces are in §3 below: -| Room | Box | Revit | Outline | `SpaceOutlineSource` | Cause | +| Room | Box | Revit | Outline | `SpaceOutlineSource` | Why the edges did not close as the walk reads them | |---|---:|---:|---:|---|---| -| 1383998 | 1708.89 | 1020.46 | 1020.46 | `stored_solid_faces` | step | -| 1422302 | 362.30 | 291.54 | 291.54 | `stored_solid_faces` | record between faces | -| 1444351 | 362.31 | 291.56 | 291.56 | `stored_solid_faces` | record between faces | -| 1493531 | 362.31 | 291.56 | 291.55 | `stored_solid_faces` | step | -| 1681182 | 5963.96 | 3818.36 | 3818.36 | `stored_solid_faces` | step, long solid | -| 1700966 | 4774.18 | 3259.86 | 3259.85 | `stored_solid_faces` | step | -| 1736690 | 2846.73 | 2049.24 | 2049.25 | `stored_solid_faces` | step | -| 1736726 | 367.14 | 293.43 | 293.43 | `stored_solid_faces` | record between faces | -| 2297732 | 135.82 | 131.76 | 131.76 | `stored_solid_faces` | step | -| 1671995 | 5228.48 | 1218.33 | 5154.12 | `stored_solid_faces` | **disagrees with Revit** | -| 1832504 | 465.18 | 440.31 | 437.31 | `stored_solid_approximate` | column overlaps wall | -| 2066902 | 1542.77 | 638.23 | 635.23 | `stored_solid_approximate` | column overlaps wall | -| 1820322 | 1882.11 | not paired | 1865.97 | `stored_solid_approximate` | column overlaps wall | - -**Room 1671995 disagrees with Revit's export, as built and at permit.** Its stored solid is one region with 6 holes whose outline is 5154 ft² (the box, 5228); Revit's export gives it 9 pieces with 1 hole totalling 1218 ft² (1146 at permit), their centroids 25.8 ft apart. Its list counts 69 objects and the walk reads 67 faces, as for the rooms that agree; nothing in the file tells it from them. It was a box 329% over Revit's area (356% at permit) and is now an outline 323% over (350%), with its boundary 1.80 ft from Revit's (2.30 ft as a box). It is the one room of the office where the stored solid and Revit's export disagree; whether Revit's room is split by something the file does not say, or the stored solid is not the room Revit exports, is not found. It carries `stored_solid_faces`, so an importer can see that this reading is not the walk's. - -At building permit, 11 rooms are `stored_solid_faces`: 1436086, 1480292, 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 (all with Revit's area to 0.1%: their boxes were 2.6% to 58% over it) and 1671995. Room 3095831 keeps its box: 14 ends at y = 35.269 ft, in pairs 0.656 and 1.312 ft apart down a 100 ft wall, are not joined, since the columns along it share the wall's line. - -Rooms per outline source (the export's own counts, run 41712392709; the scorer pairs 276 of the as-built file's 308 spaces and 278 of the permit's): +| 1383998 | 1708.89 | 1020.46 | 1020.46 | `stored_solid_faces` | a step, 0 holes | +| 1422302 | 362.30 | 291.54 | 291.54 | `stored_solid_faces` | a record between faces, 1 hole | +| 1444351 | 362.31 | 291.56 | 291.56 | `stored_solid_faces` | a record between faces, 1 hole | +| 1493531 | 362.31 | 291.56 | 291.55 | `stored_solid_faces` | a step, 1 hole | +| 1681182 | 5963.96 | 3818.36 | 3818.36 | `stored_solid_faces` | a step, a long solid, 1 hole | +| 1700966 | 4774.18 | 3259.86 | 3259.85 | `stored_solid_faces` | a step, 1 hole | +| 1736690 | 2846.73 | 2049.24 | 2049.25 | `stored_solid_faces` | a step, 3 holes | +| 1736726 | 367.14 | 293.43 | 293.43 | `stored_solid_faces` | a record between faces, 1 hole | +| 2297732 | 135.82 | 131.76 | 131.76 | `stored_solid_faces` | a step | +| 1671995 | 5228.48 | 5177.37 | 5154.12 | `stored_solid_approximate` | a column overlaps a wall: 6 crossing pairs | +| 1820322 | 1882.11 | 1877.97 | 1865.97 | `stored_solid_approximate` | 9 crossing pairs | +| 1832504 | 465.18 | 440.31 | 437.31 | `stored_solid_approximate` | 2 crossing pairs | +| 2066902 | 1542.77 | 638.23 | 635.23 | `stored_solid_approximate` | 3 crossing pairs | + +The first nine have no edge crossing or overlapping another (`probe_re316_room_closure`, run 41728640203) and Revit's area to 0.1% by the upward faces, the floor and every face. The four approximate rooms are 0.45% (1671995: 23.25 ft²), 0.64% (1820322: 12.00), 0.68% (1832504: 3.00) and 0.47% (2066902: 3.00) under Revit's floor area, the 3.0 ft² being a column's 3.001. Revit's export does not take those columns off the room, where it takes off the 1.722 ft² columns of eight rooms at permit. + +At building permit 11 rooms are `stored_solid_approximate`: 1671995 (6 crossing pairs; 5153.60 against Revit's floor of 5176.85, 0.45% under); 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 (1 to 3 pairs of edges overlapping along a line, no crossing: the columns); and 1436086 and 1480292 (neither crossing nor overlap: an end lies on another edge). Ten have Revit's floor area to 0.1%: their boxes were 2.6% to 58% over it. Room 3095831 keeps its box: 14 ends at y = 35.269 ft, in pairs 0.656 and 1.312 ft apart down a 100 ft wall, are not joined, since the columns along it share the wall's line. + +Rooms per outline source (the export's own counts, [41728355227](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41728355227); the scorer pairs 276 of the as-built file's 308 spaces and 278 of the permit's): | | `stored_solid` | `stored_solid_faces` | `stored_solid_approximate` | `record_box` | family instances, `record_box` | |---|---:|---:|---:|---:|---:| -| Office as built (234 rooms) | 221 | 10 | 3 | 0 | 44 | -| Office at permit (234 rooms) | 222 | 11 | 0 | 1 | 44 | +| Office as built (234 rooms) | 221 | 9 | 4 | 0 | 44 | +| Office at permit (234 rooms) | 222 | 0 | 11 | 1 | 44 | -`space_profiles_vs_ifc`, `main` against this change (run 41712392709): +`space_profiles_vs_ifc` (the existing scorer, which compares the faces of Revit's mesh that point up), `main` against this change: | | Area within 0.1% | `stored_solid` scored | `stored_solid_faces` scored, area within 0.1% | `stored_solid_approximate` scored, area within 0.1% | Room, record box | |---|---:|---|---|---|---:| -| Office as built | 221 → 230 | 220, all | 10, 9 | 2, 0 | 12 → 0 | -| Office at permit | 222 → 232 | 222, 221 | 11, 10 | 0 | 12 → 1 | +| Office as built | 221 → 230 | 220, all | 9, 9 | 3, 0 | 12 → 0 | +| Office at permit | 222 → 232 | 222, 221 | 0 | 11, 10 | 12 → 1 | | Core Interior | 116 | 116 | 0 | 0 | 0 | | RE1 Architecture | 11 | 11 | 0 | 0 | 0 | -At permit the boundary distance is within 0.01 ft on 10 of the 11 `stored_solid_faces` rooms; the outline metric (which counts the vertices of Revit's ceiling steps, RE-313 §3) on 9: room 1436086 is 0.492 ft off on it and 0.000 ft on the boundary. The one `stored_solid` room at permit that is off (2T7gRjEFvBgRaNLZHAfExM, room 2611271, STAIRCASE, 66% in area) is RE-313's, unchanged; the walk closes a loop of 172.47 ft² on its box where Revit's area is 103.90, and the span does not change that, since the walk comes first. +### Room 1671995, and what the scorer's top surface is + +Round 3 of the review found room 1671995 at 323% over Revit's area as built and 350% at permit, by this scorer. It compares the plan union of the faces of each mesh that point up, within 25 degrees of vertical (`normal.z > 0.9 · length`). Room 1671995's ceiling is mostly not that flat: Revit's export has 9 flat patches of 1218.33 ft² (1145.89 at permit) and 1 hole in its top surface. `tools/re/space_overlaps_vs_ifc.py` measures each space's plan three ways, in Revit's export and in rvt-rs's (run 41723543204, the office): + +| Room 1671995 | upward faces | floor (downward faces) | every face projected | +|---|---:|---:|---:| +| Revit, as built | 1218.33 | 5177.37 | 5178.07 | +| rvt-rs, as built | 5154.12 | 5154.12 | 5154.12 | +| Revit, at permit | 1145.89 | 5176.85 | 5178.38 | +| rvt-rs, at permit | 5153.60 | 5153.60 | 5153.60 | + +The room is 5177 ft² in Revit's export, and rvt-rs is 0.45% under: its span's outer loop alone is 5176.72 ft² (run 41673400892) and the stored solid takes off the 6 holes and the notches of the columns that overlap its walls (22.6 ft²), which Revit's floor does not. Its box was 1.0% over. By the floor, over the whole office: + +| Area within 0.1% of Revit's | upward faces | floor | every face | +|---|---:|---:|---:| +| `stored_solid`, as built | 220 of 221 | 221 of 221 | 221 of 221 | +| `stored_solid`, at permit | 221 of 222 | 222 of 222 | 222 of 222 | +| `stored_solid_faces`, as built | 9 of 9 | 9 of 9 | 9 of 9 | +| `stored_solid_approximate`, as built | 0 of 4 | 0 of 4 | 0 of 4 | +| `stored_solid_approximate`, at permit | 10 of 11 | 10 of 11 | 10 of 11 | + +RE-313's 66% room at permit (2T7gRjEFvBgRaNLZHAfExM, room 2611271, STAIRCASE) is the one `stored_solid` room that the upward faces miss; by the floor it is within 0.1% of Revit's like the other 221. The scorer's top surface reads a room with a sloped or stepped ceiling as a fraction of itself. This change does not alter `space_profiles_vs_ifc` or its CI gates; the floor counts are in the new scorer, which Measure runs on every model, and the held-out models' floor counts are kept by the scrub. + +### A guard for 1671995: the four candidates + +The review asked for a discriminator that rejects 1671995 (both stages) and keeps every agreeing room, in four candidates. Measured on every room the span reads, and on the baseline of rooms the walk reads: + +1. **Wall overlap** (`space_overlaps_vs_ifc`, runs 41722753269 and 41723543204): the area of a room's top surface inside the top surfaces of the walls on its level, each shrunk by 0.02 ft. 1671995 has 1.65 ft² (0.03%) as built and 0.99 ft² (0.02%) at permit. The nine rooms that agree have 0.10% to 7.59% (7.59% is room 2297732, 10.01 ft²) and the 221 rooms the walk reads have 0.1% or more on 43, 1% on 34, 5% on 10 and up to 23.03%. The wall plans of rvt-rs's own export overlap the rooms that agree by more than they overlap 1671995. Not separating. The same test against the outlines of the other rooms on the level (what Revit's rooms cannot do) is 0.00% on every room of every source in both files. +2. **Shell closure**: a closed shell needs the floor and ceiling faces, and `face_at` reads one or more on 3 of the 13 rooms as built (1383998: 1, 1671995: 1, 2297732: 2; `probe_re316_solid_extent`, run 41707078214). In plan form, every end shared by two edges, it holds on all 24 resolved rooms or they would not close. Not testable, and not separating where it is. +3. **Volume**: the divergence-theorem volume needs the same faces. Not testable on 10 of the 13. +4. **Containment**: every outline vertex inside the record box to 0.01 ft holds on all 24 (an outline spans its box to 0.001 ft, or up to 0.02 ft short of it). Not separating. + +None works, so no guard is applied. The reason none can: 1671995's outline is not wrong. The residual risk the review named, a face of another element within 1,024 bytes after the last listed face, is not closed by candidate 1 or 2 either: it would need a closed loop of the foreign faces inside the box and clear of every wall to be read as `stored_solid_faces`, and any foreign face that crosses or overlaps a wall makes the room `stored_solid_approximate`. No solid measured has a face of another element inside 6,826 bytes of its last where the list counts the walk's faces exactly. ### Revit's cached room areas @@ -128,28 +160,28 @@ RE-101 §4 found a cache of each room's computed area in its partition (`u64 · | `rac_advanced` 2019 | 0 of 21 | | | | `rac_basic` 2019 | 0 of 7 | | | -For rooms 1422302, 1444351 and 1493531 the cache holds the record box's area (362.3 ft²), not the 291.5 ft² of Revit's export and of the outline. The cache is not there on every release (`rac_advanced`: none of 21 on 2019, 19 of 21 on 2024), so it is not used as evidence. +(Counted under the first version's labels; the rooms are the same.) For rooms 1422302, 1444351 and 1493531 the cache holds the record box's area (362.3 ft²), not the 291.5 ft² of Revit's export and of the outline. The cache is not there on every release (`rac_advanced`: none of 21 on 2019, 19 of 21 on 2024), so it is not used as evidence. ### The Autodesk samples (no Revit export) -`probe_re316_room_closure` on the 2019 and 2024 saves (run 41710248227): +`probe_re316_room_closure` on the 2019 and 2024 saves (runs 41726486862 and 41728640203): | Project | Rooms | Closed by RE-101 | Resolved now | Box | |---|---:|---:|---:|---:| | `rac_basic` | 14 | 14 | 0 | 0 | | `rac_advanced` | 91 | 85 | 0 | 6 | -| `rme_basic`, `rme_advanced` | 76 | 26 | 1 (`stored_solid_faces`, room 576007: 1887.2 ft² against its box's 1917.9) | 49 | +| `rme_basic`, `rme_advanced` | 76 | 26 | 1 (`stored_solid_approximate`, room 576007: 1887.2 ft² against its box's 1917.9; 2 crossing and 2 overlapping pairs) | 49 | | Snowdon Towers (architectural, 2024) | 54 | 47 | 0 | 7 | ### Everything else -Measure unfiltered, `-f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true` ([41712392709](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41712392709): this change at edd7a72a against `main` at 8664b94a; 93 models, every scorer including `space_profiles_vs_ifc`). Every scorer's output is identical on every model but three, and the exports' hashes move on exactly the models with a room that now closes: +Measure unfiltered, `-f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true` ([41728355227](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41728355227): this change at a00e705d against `main` at 7fd339e2; every scorer including `space_profiles_vs_ifc` and the new `space_overlaps_vs_ifc`). Every scorer's output is identical on every model but five files, and the exports' hashes move on exactly the models with a room that now closes: -- **Office as built and at permit**: the rows above. `exported.bounding_box_spaces` goes from 57 to 44 and from 56 to 45 (the 44 family instances, and room 3095831 at permit). -- **`rme_basic` and `rme_advanced`, 12 files over the 6 releases 2015, 2019, 2024, 2025, 2026 and 2027**: one room each (576007, 1887.2 ft² against its box's 1917.9) takes an outline, `stored_solid_faces`, and box spaces go 50 → 49; no Revit export of them exists. -- **Held out, counts only (BIM Speed's Revit 2021 project, Warmond)**: the one model outside the office whose scorer output changes. Area within 0.1%: 174 → 179 of the 179 paired spaces, off 5 → 0; outline and boundary within 0.01 ft 174 → 179; rooms on their record box 8 → 2 (both within 0.1%: rectangles), `stored_solid_faces` 6 (all within 0.1% and 0.01 ft), `stored_solid` 171 unchanged. Of the 8 rooms that kept their box, 5 were off in area and 6 now take an outline: the sixth was a rectangle already equal to Revit's. Vertices within 0.01 ft go from 127 to 126, which the scorer prints and no earlier summary did: that room's outline has vertices the box had not. This model was not read while the rule was found. -- **Core Interior, RE1 Architecture, the R2025 models and the other held-out models**: unchanged, so no witness observation moves. `rac_advanced`, `rac_basic` and Snowdon Towers' architectural sample in every release: unchanged. -- **Cost**: Measure's `rvt-ifc` export times are 0.98 times main's at the median across the 93 models, 1.03 and 1.05 on the office. +- **Office as built and at permit**: the rows above. `exported.bounding_box_spaces` goes from 57 to 44 and from 56 to 45 (the 44 family instances, and room 3095831 at permit); the export counts 9 `stored_solid_faces` and 4 `stored_solid_approximate` as built, and 0 and 11 at permit. +- **`rme_basic` and `rme_advanced`, 12 files over the 6 releases 2015, 2019, 2024, 2025, 2026 and 2027**: one room each (576007, 1887.2 ft² against its box's 1917.9) takes an outline, `stored_solid_approximate`, and box spaces go 50 → 49; no Revit export of them exists. +- **Held out, counts only (BIM Speed's Revit 2021 project, Warmond)**: the one model outside the office whose scorer output changes. Area within 0.1%: 174 → 179 of the 179 paired spaces, off 5 → 0; outline and boundary within 0.01 ft 174 → 179; rooms on their record box 8 → 2 (both within 0.1%: rectangles), `stored_solid_faces` 6 (all within 0.1% and 0.01 ft, by the upward faces and by the floor), `stored_solid` 171 unchanged. Of the 8 rooms that kept their box, 5 were off in area and 6 now take an outline: the sixth was a rectangle already equal to Revit's. Vertices within 0.01 ft go from 127 to 126: that room's outline has vertices the box had not. This model was not read while the rule was found. +- **Core Interior, RE1 Architecture, the R2025 models, Vitoria's spent pair and the other held-out models**: unchanged, so no witness observation moves. `rac_advanced`, `rac_basic` and Snowdon Towers' architectural sample in every release: unchanged. +- **Refused**: no guard is applied, so no room is refused for a guard's reason. The rooms that keep their box are in §4. ## 4. What keeps its box @@ -161,17 +193,20 @@ Measure unfiltered, `-f base=main -f nordic=true -f r2025=true -f samples=true - ## 5. Not claimed -- That a `stored_solid_faces` outline equals Revit's outline on a release other than 2024: the evidence is one building, in two states, and one held-out project. 19 of the office's 21 scored `stored_solid_faces` rooms agree and 2 do not (1671995, twice), and nothing in the file tells them apart. -- That the span holds no face of another element: no solid measured has one inside 6,826 bytes of its last face where the list counts the walk's faces exactly, and the rule admits at most the list's difference elsewhere. +- That a `stored_solid_faces` or `stored_solid_approximate` outline equals Revit's on a release other than 2024: the evidence is one building, in two states, and one held-out project. +- That the span holds no face of another element: the rule admits at most the list's difference within 1,024 bytes of the last listed face, and neither candidate 1 nor 2 of the review closes that (§3). +- That the area of an approximate room is Revit's: 5 of the 15 office room scores are 0.45% to 0.68% under Revit's floor area, and 10 are equal. Whether Revit takes a column off the room is not in the file. - That a cylinder's arc is Revit's on any room: `rac_advanced` has no export. - Any volume of a resolved room (no prism marker is written). -- That the 0.02 ft slack is Revit's: it is the 0.0164 ft the three rooms measured need, and the two paired are 0.68% and 0.47% under Revit's area. ## 6. Reproduce ```bash -gh workflow run measure.yml --ref main -f ref=7d7c098c8ede1ae8bb55d559a4bfdf270a5e70fc -f base=none -f samples=true -f sample_years="2019 2024" \ +gh workflow run measure.yml --ref feat/rooms-unclosed-solids -f ref=b714122a343e659dc2834269fd0c217569069f8d -f base=none -f samples=true -f sample_years="2019" \ + -f nordic=true -f models="nordic-office-as-built nordic-office-permit" -f probe=probe_re316_room_closure -f probe_scorer=- -f scorers=space_overlaps_vs_ifc +gh workflow run measure.yml --ref feat/rooms-unclosed-solids -f ref=7d7c098c8ede1ae8bb55d559a4bfdf270a5e70fc -f base=none -f samples=true -f sample_years="2019 2024" \ -f nordic=true -f models="nordic-office-* core-interior re1-architecture" \ -f probe="probe_re316_solid_extent probe_re316_room_closure probe_re316_hostile_solid" -f probe_scorer="- - -" -f scorers=space_profiles_vs_ifc -gh workflow run measure.yml --ref main -f ref=edd7a72a943ee0d99da416ecf86269722977c80c -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true +gh workflow run measure.yml --ref feat/rooms-unclosed-solids -f ref=a00e705d76ca728dbdbc3033df7c68aad94cd69e -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true +python3 tools/re/space_overlaps_vs_ifc.py model.ifc revit-export.ifc # the floor and every-face areas, the walls and rooms a room covers ``` From e55ff36631f62cbb9c961f6c6d8c640f0066edb9 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:17:37 -0500 Subject: [PATCH 31/31] docs(rooms): an approximate outline's third cause, an edge end on another edge, named everywhere; the span's looser join stated; the room schedule note and the held-out scrub of every-face lines corrected (#90) (RE-316) --- changelog.d/changed/1260-room-solids-resolved.md | 5 +++-- docs/export-diagnostics.md | 2 +- docs/rooms.md | 2 +- docs/schemas/export-diagnostics.schema.json | 2 +- docs/status.md | 2 +- docs/support-matrix.json | 2 +- src/research/partition_room_boundaries.rs | 8 +++++--- tools/ci/scrub-holdout-results.sh | 2 +- 8 files changed, 14 insertions(+), 11 deletions(-) diff --git a/changelog.d/changed/1260-room-solids-resolved.md b/changelog.d/changed/1260-room-solids-resolved.md index 6b5b537f..4fca28bd 100644 --- a/changelog.d/changed/1260-room-solids-resolved.md +++ b/changelog.d/changed/1260-room-solids-resolved.md @@ -8,8 +8,9 @@ is bounded (512 faces, 4,096 pieces; crafted solids take under 10 ms). `SpaceOutlineSource` is `stored_solid_faces` where the faces' edges close as the walk reads them, and `stored_solid_approximate` where a column overlaps - a wall (its area depends on whether Revit's export takes the column off) or - the outline falls up to 0.02 ft short of the record box; `stored_solid` + a wall (its area depends on whether Revit's export takes the column off), + an end of one wall edge lies on another, or the outline falls up to + 0.02 ft short of the record box; `stored_solid` stays the exact walk's. The diagnostics count both (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`) and the room schedule leaves an approximate room's area blank. On the diff --git a/docs/export-diagnostics.md b/docs/export-diagnostics.md index 0cf9d29a..2f2d2b01 100644 --- a/docs/export-diagnostics.md +++ b/docs/export-diagnostics.md @@ -204,7 +204,7 @@ Important nested fields: | `exported.layered_element_count` | integer | Elements whose compound layers and layer colours were read (RE-53). The glTF export draws each in its layers where they add up to its body's thickness; the rest are drawn whole. | | `exported.bounding_box_spaces` | integer | Exported `IfcSpace`s whose body is their record's bounding box (`RvtElementRecordGeometry.SpaceOutlineSource` is `record_box`): a room whose outline was not read from its stored solid, or a family instance written as a space and drawn as its box. The body's plan area is the box's, not the space's (RE-313, [rooms](rooms.md)). Each is also in `exported.bounding_box_bodies`, except a family instance turned or tilted off the model's axes. | | `exported.resolved_outline_spaces` | integer | Exported rooms whose outline was read from the faces their stored solid lists, up to the solid's own end, where the walk of RE-101 did not close them (`RvtElementRecordGeometry.SpaceOutlineSource` is `stored_solid_faces`, RE-316, [rooms](rooms.md)). Equal to Revit's area on every such room measured, but bounded by the solid's list of objects and a gap of 1,024 bytes, so a closed loop of another element's faces inside the room's box would be read as the room's. | -| `exported.approximate_outline_spaces` | integer | Exported rooms whose resolved outline is the boundary of a region, a column that overlaps a wall having been taken off it, or falls up to 0.02 ft short of their record box on a side (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316, [rooms](rooms.md)). The area is approximate: up to 0.68% under Revit's floor area where Revit's export does not take that column off the room, and equal to it on 10 of the 15 BIM4LCA office room scores. | +| `exported.approximate_outline_spaces` | integer | Exported rooms whose resolved outline is the boundary of a region, a column that overlaps a wall having been taken off it or an end of one wall edge lying on another, or falls up to 0.02 ft short of their record box on a side (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316, [rooms](rooms.md)). The area is approximate: up to 0.68% under Revit's floor area where Revit's export does not take that column off the room, and equal to it on 10 of the 15 BIM4LCA office room scores. | | `confidence.level` | string | `scaffold`, `typed_no_geometry`, `geometry`, `diagnostic_partial`, or `proxy_only`. | | `confidence.score` | number | Heuristic 0..1 readiness score for UI sorting and dashboards. Its element terms (elements, typed elements, geometry) are scaled by the exported share when `unexported_element_records` is non-zero. | | `confidence.unexported_element_records` | integer | Element records of a recovered category (walls, doors, windows, columns, floors, rooms and the RE-33 product categories) the partition scan found but could not export because no ElementId is attributable (RE-30). Non-zero means the model is incomplete. Zero is not a completeness claim. | diff --git a/docs/rooms.md b/docs/rooms.md index 4531bdef..a859aed1 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -21,7 +21,7 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: |---|---|---| | `stored_solid` | the outline of the room's stored solid, extruded to the room's height, read by the exact walk of RE-101 | Revit's room area: within 0.1% of Revit's export on every `stored_solid` room scored below by the scorer's upward faces except one, the permit office's STAIRCASE room 2611271 at 66%, which by Revit's floor is within 0.1% like the others (221 of 221 as built, 222 of 222 at permit; RE-313 §2, §3, RE-316 §3) | | `stored_solid_faces` | the outline of the room's stored solid, read from the faces its solid lists, up to the solid's own end, where the walk did not close them: their edges close as the walk reads them (RE-316) | Revit's room area: within 0.1% on 9 of 9 office rooms as built, by every metric. Bounded by the solid's list of objects and a gap, not by the walk, so a closed loop of another element's faces inside the room's box would be read as the room's: that is why it is labelled apart (RE-316 §2, §3) | -| `stored_solid_approximate` | the same, as the boundary of a region, a column that overlaps a wall taken off it, or up to 0.02 ft short of the room's record box on a side | approximate. Of the office's 15 room scores (4 as built, 11 at permit) 10 are within 0.1% of Revit's floor area and 5 are 0.45% to 0.68% under it (four rooms, 1671995 in both files): Revit's export does not take such a column off the room, and nothing in the file says which. The room schedule leaves the area blank (RE-316 §2, §3) | +| `stored_solid_approximate` | the same, as the boundary of a region, a column that overlaps a wall taken off it or an end of one wall edge lying on another, or up to 0.02 ft short of the room's record box on a side | approximate. Of the office's 15 room scores (4 as built, 11 at permit) 10 are within 0.1% of Revit's floor area and 5 are 0.45% to 0.68% under it (four rooms, 1671995 in both files): Revit's export does not take such a column off the room, and nothing in the file says which. The room schedule leaves the area blank (RE-316 §2, §3) | | `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's room scores that keep their box (1 room at permit, 3095831; RE-316 §4) | | `saved_mesh` | the mesh Revit saved for the room (`rvt-ifc --saved-meshes`) | the mesh's; not scored | | `none` | no body (an Area) | not available | diff --git a/docs/schemas/export-diagnostics.schema.json b/docs/schemas/export-diagnostics.schema.json index f5d54e5b..199260c6 100644 --- a/docs/schemas/export-diagnostics.schema.json +++ b/docs/schemas/export-diagnostics.schema.json @@ -293,7 +293,7 @@ "approximate_outline_spaces": { "type": "integer", "minimum": 0, - "description": "Exported IfcSpaces that are rooms whose resolved outline is the boundary of a region, a column overlapping a wall having been taken off it, or falls up to 0.02 ft short of their record box on a side (SpaceOutlineSource stored_solid_approximate, RE-316): their area is approximate, up to 0.68% under Revit's floor area where Revit's export does not take the column off." + "description": "Exported IfcSpaces that are rooms whose resolved outline is the boundary of a region, a column overlapping a wall having been taken off it or an end of one wall edge lying on another, or falls up to 0.02 ft short of their record box on a side (SpaceOutlineSource stored_solid_approximate, RE-316): their area is approximate, up to 0.68% under Revit's floor area where Revit's export does not take the column off." } } }, diff --git a/docs/status.md b/docs/status.md index 93bec479..372c36a9 100644 --- a/docs/status.md +++ b/docs/status.md @@ -113,7 +113,7 @@ walls Revit steps over a floor that covers them in part (29 of the BIM4LCA offic the rooms whose stored solid still does not close (RE-316 §4) and opening profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, up to the solid's own end, and says so: `stored_solid_faces` where the faces' edges close as the walk reads them (9 of the BIM4LCA office's 13 such rooms as built, all at Revit's area to 0.1%) or `stored_solid_approximate` where a column overlaps a wall or the outline falls up to 0.02 ft short of the box (4 as built and 11 of the 12 at building permit: 10 of the 15 at Revit's floor area to 0.1%, the 5 others, four rooms, 0.45% to 0.68% under it); the diagnostics count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`). The scorer's upward-face area reads one of them (1671995) as 323% over and RE-313's 66% room as 66% off; by Revit's floor both are within 0.5% and 0.1%. The 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, up to the solid's own end, and says so: `stored_solid_faces` where the faces' edges close as the walk reads them (9 of the BIM4LCA office's 13 such rooms as built, all at Revit's area to 0.1%) or `stored_solid_approximate` where a column overlaps a wall, an end of one wall edge lies on another, or the outline falls up to 0.02 ft short of the box (4 as built and 11 of the 12 at building permit: 10 of the 15 at Revit's floor area to 0.1%, the 5 others, four rooms, 0.45% to 0.68% under it); the diagnostics count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`). The scorer's upward-face area reads one of them (1671995) as 323% over and RE-313's 66% room as 66% off; by Revit's floor both are within 0.5% and 0.1%. The 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history diff --git a/docs/support-matrix.json b/docs/support-matrix.json index dcdb8ac4..d344e0ec 100644 --- a/docs/support-matrix.json +++ b/docs/support-matrix.json @@ -662,7 +662,7 @@ "name": "Element and room schedules as CSV (rvt-schedule, viewer, Python)", "status": "partial", "evidence": "tests/rvt_schedule_cli.rs (tier-1 shape; 2024_Core_Interior 970 elements / 360 walls / 132 hosted doors / 6 windows / 256 columns / 116 rooms); viewer Playwright download test", - "notes": "One row per decoded element or room, so coverage is exactly the decode coverage: typed rows on every release whose element records are read (Revit 2023 to 2025; 2015 to 2022, 2026 and 2027 experimentally), scaffold-only elsewhere. Unknown values are empty cells. A room's area is its outline's where the room's stored solid was read (RE-101), and empty where the room keeps its box.", + "notes": "One row per decoded element or room, so coverage is exactly the decode coverage: typed rows on every release whose element records are read (Revit 2023 to 2025; 2015 to 2022, 2026 and 2027 experimentally), scaffold-only elsewhere. Unknown values are empty cells. A room's area is its outline's where the room's stored solid was read (RE-101) or its solid's faces close as the walk reads them (RE-316), and empty where the room keeps its box or its outline is approximate (stored_solid_approximate).", "user_impact": "A spreadsheet of walls, doors, windows, columns, slabs and rooms without Revit, on files whose elements decode." }, { diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index c108f071..32cd9df1 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -40,7 +40,9 @@ //! bytes, and no more than its list of objects counts ([`solid_span`], //! [`solid_object_count`]), so the faces of the element that follows it are //! not read. Where the span's wall edges close as the walk reads them (one -//! outer loop, the loops inside it nested, no edge crossing another) that is +//! outer loop, the loops inside it nested, no edge crossing another; their +//! ends joined within [`ROOM_STEP_TOLERANCE_FEET`], where the walk joins them +//! within 1e-4 ft) that is //! the outline ([`ROOM_OUTLINE_SOURCE_FACES`]). Where a column overlaps a wall //! and its loop lies partly inside the wall's, each plan edge is directed so //! the room is on its left and the outline is the boundary of the region those @@ -53,7 +55,7 @@ //! the face's u and v, at +158, and its radius at +182 ([`RoomCylinder`]). Its //! arc closes the loops of the six `rac_advanced` rooms RE-101 left open, but //! whether Revit takes a column off a room's area is unmeasured (it does not -//! on two office rooms), so a solid with one is left unresolved. +//! on four office rooms), so a solid with one is left unresolved. //! //! Core Interior keeps a room's record and solid in more than one partition, //! and the copies can disagree: room 20832's outline spans 10.5 ft in one and @@ -685,7 +687,7 @@ pub enum ResolvedKind { Faces, /// The outline is the boundary of the region the edges wind around, /// because a column overlaps a wall and its loop lies partly inside the - /// wall's, or it falls up to [`ROOM_SLIVER_FEET`] short of the record box + /// wall's, or an end of one wall edge lies on another, or it falls up to [`ROOM_SLIVER_FEET`] short of the record box /// on a side. Revit's export does not always take such a column off the /// room's area, so the area is approximate: 0.45% to 0.7% under Revit's /// floor area on the four office rooms where it does not. diff --git a/tools/ci/scrub-holdout-results.sh b/tools/ci/scrub-holdout-results.sh index 122ce897..9ece1b10 100755 --- a/tools/ci/scrub-holdout-results.sh +++ b/tools/ci/scrub-holdout-results.sh @@ -106,7 +106,7 @@ for dir in "$RESULTS"/holdout-*/ "$RESULTS"/bimcollab-*/; do done for f in "$dir"space_overlaps_vs_ifc.txt; do [ -f "$f" ] || continue - grep -E "^space overlaps: (upward|downward|every) faces, [a-z ]+: [0-9]+ paired rooms, area within 0\.1% of Revit's on [0-9]+$" "$f" || true + grep -E "^space overlaps: (upward|downward|every) faces?, [a-z ]+: [0-9]+ paired rooms, area within 0\.1% of Revit's on [0-9]+$" "$f" || true done for f in "$dir"storeys_vs_ifc.txt; do [ -f "$f" ] || continue