use crate::partition_beam_axes::BoundedLine;
use crate::{Result, RevitFile};
use std::collections::{BTreeMap, BTreeSet};
pub const STAIR_DIMENSIONS_SUPPORTED_REVIT_VERSIONS: &[u32] = &[2024];
pub const STAIR_DIMENSIONS_ANCHOR: [u8; 16] = [
0xff, 0xff, 0xff, 0xff, 0xfb, 0x03, 0xff, 0xff, 0xff, 0xff, 0xfb, 0x03, 0x00, 0x00, 0x00, 0x00,
];
pub const RISER_HEIGHT_OFFSET: usize = 16;
pub const TREAD_DEPTH_OFFSET: usize = 24;
pub const RISER_COUNT_OFFSET: usize = 88;
pub const STAIR_RUN_RISERS_ANCHOR: [u8; 15] = [
0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
];
pub const ANCHOR_WINDOW: usize = 0x100;
pub const MAX_RISERS: u32 = 400;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StairDimensions {
pub riser_height_feet: f64,
pub tread_depth_feet: f64,
pub riser_count: u32,
}
pub fn supports_revit_version(revit_version: u32) -> bool {
STAIR_DIMENSIONS_SUPPORTED_REVIT_VERSIONS.contains(&revit_version)
}
fn read_u32(buf: &[u8], at: usize) -> Option<u32> {
buf.get(at..at.checked_add(4)?)
.map(|s| u32::from_le_bytes(s.try_into().expect("4 bytes")))
}
fn read_f64(buf: &[u8], at: usize) -> Option<f64> {
buf.get(at..at.checked_add(8)?)
.map(|s| f64::from_le_bytes(s.try_into().expect("8 bytes")))
}
fn anchor_at(buf: &[u8], data_offset: usize, anchor: &[u8]) -> Option<usize> {
let end = data_offset
.checked_add(ANCHOR_WINDOW + anchor.len())?
.min(buf.len());
let window = buf.get(data_offset..end)?;
memchr::memmem::find(window, anchor).map(|at| data_offset + at)
}
fn plausible(value: f64, limit_feet: f64) -> Option<f64> {
(value.is_finite() && value > 0.0 && value < limit_feet).then_some(value)
}
pub fn stair_dimensions_at(buf: &[u8], data_offset: usize) -> Option<StairDimensions> {
let anchor = anchor_at(buf, data_offset, &STAIR_DIMENSIONS_ANCHOR)?;
let riser_height_feet = plausible(read_f64(buf, anchor + RISER_HEIGHT_OFFSET)?, 2.0)?;
let tread_depth_feet = plausible(read_f64(buf, anchor + TREAD_DEPTH_OFFSET)?, 10.0)?;
let riser_count = read_u32(buf, anchor + RISER_COUNT_OFFSET)?;
if !(1..=MAX_RISERS).contains(&riser_count) {
return None;
}
Some(StairDimensions {
riser_height_feet,
tread_depth_feet,
riser_count,
})
}
pub fn run_riser_count_at(buf: &[u8], data_offset: usize) -> Option<u32> {
let anchor = anchor_at(buf, data_offset, &STAIR_RUN_RISERS_ANCHOR)?;
let count = read_u32(buf, anchor + STAIR_RUN_RISERS_ANCHOR.len())?;
(1..=MAX_RISERS).contains(&count).then_some(count)
}
fn data_offsets(buf: &[u8], header: &[u8; 10], ids: &[u32]) -> BTreeMap<u32, usize> {
let mut out = BTreeMap::new();
for hit in memchr::memmem::find_iter(buf, header) {
let Some(id) = buf
.get(hit + header.len()..hit + header.len() + 8)
.map(|s| u64::from_le_bytes(s.try_into().expect("8 bytes")))
.and_then(|id| u32::try_from(id).ok())
else {
continue;
};
if ids.contains(&id) {
out.entry(id).or_insert(hit);
}
}
out
}
pub fn scan_stair_dimensions(
rf: &mut RevitFile,
revit_version: u32,
stairs: &[u32],
runs: &[u32],
) -> Result<(BTreeMap<u32, StairDimensions>, BTreeMap<u32, u32>)> {
let mut stair_out: BTreeMap<u32, Option<StairDimensions>> = BTreeMap::new();
let mut run_out: BTreeMap<u32, Option<u32>> = BTreeMap::new();
let header = match crate::partition_names::element_data_header(revit_version) {
Some(header) if supports_revit_version(revit_version) => header,
_ => return Ok((BTreeMap::new(), BTreeMap::new())),
};
let wanted: Vec<u32> = stairs.iter().chain(runs).copied().collect();
for stream in rf.partition_stream_names() {
let Ok(inflated) = rf.inflated_partition(&stream) else {
continue;
};
let buf = inflated.bytes();
for (id, offset) in data_offsets(buf, &header, &wanted) {
if stairs.contains(&id) {
if let Some(found) = stair_dimensions_at(buf, offset) {
merge(&mut stair_out, id, found);
}
}
if runs.contains(&id) {
if let Some(found) = run_riser_count_at(buf, offset) {
merge(&mut run_out, id, found);
}
}
}
}
Ok((settled(stair_out), settled(run_out)))
}
fn merge<T: PartialEq>(out: &mut BTreeMap<u32, Option<T>>, id: u32, found: T) {
match out.get_mut(&id) {
None => {
out.insert(id, Some(found));
}
Some(held) => {
if held.as_ref() != Some(&found) {
*held = None;
}
}
}
}
fn settled<T>(map: BTreeMap<u32, Option<T>>) -> BTreeMap<u32, T> {
map.into_iter()
.filter_map(|(id, value)| value.map(|value| (id, value)))
.collect()
}
pub fn flight_riser_counts(stair_risers: u32, runs: &[(u32, Option<u32>)]) -> BTreeMap<u32, u32> {
if let [(run, _)] = runs {
return BTreeMap::from([(*run, stair_risers)]);
}
let counts: Option<Vec<u32>> = runs.iter().map(|(_, count)| *count).collect();
match counts {
Some(counts) if counts.iter().sum::<u32>() == stair_risers => runs
.iter()
.zip(counts)
.map(|((run, _), count)| (*run, count))
.collect(),
_ => BTreeMap::new(),
}
}
pub const SKETCH_TOLERANCE_FEET: f64 = 1e-6;
#[derive(Debug, Clone, PartialEq)]
pub struct RunSketch {
pub origin: [f64; 3],
pub climb: [f64; 2],
pub across: [f64; 2],
pub width_feet: f64,
pub risers: Vec<f64>,
}
fn plan(a: [f64; 3], b: [f64; 3]) -> [f64; 2] {
[b[0] - a[0], b[1] - a[1]]
}
fn dot2(a: [f64; 2], b: [f64; 2]) -> f64 {
a[0] * b[0] + a[1] * b[1]
}
pub fn run_sketch(lines: &[BoundedLine]) -> Option<RunSketch> {
let first = lines.first()?;
let (origin, end) = (first.start(), first.end());
let level = |line: &BoundedLine| {
(line.start()[2] - origin[2]).abs() < SKETCH_TOLERANCE_FEET
&& (line.end()[2] - origin[2]).abs() < SKETCH_TOLERANCE_FEET
};
if !level(first) {
return None;
}
let run = plan(origin, end);
let length = run[0].hypot(run[1]);
if !length.is_finite() || length <= SKETCH_TOLERANCE_FEET {
return None;
}
let climb = [run[0] / length, run[1] / length];
let normal = [-climb[1], climb[0]];
let offset = |p: [f64; 3]| dot2(plan(origin, p), normal);
let along = |p: [f64; 3]| dot2(plan(origin, p), climb);
let direction = |line: &BoundedLine| [line.direction[0], line.direction[1]];
let signed_width = lines[1..]
.iter()
.filter(|line| level(line))
.find_map(|line| {
let parallel = (1.0 - dot2(direction(line), climb).abs()) < SKETCH_TOLERANCE_FEET;
let w = offset(line.start());
(parallel && w.abs() > SKETCH_TOLERANCE_FEET).then_some(w)
})?;
let width_feet = signed_width.abs();
let across = [
normal[0] * signed_width.signum(),
normal[1] * signed_width.signum(),
];
let mut risers: Vec<f64> = lines[1..]
.iter()
.filter(|line| level(line) && dot2(direction(line), climb).abs() < SKETCH_TOLERANCE_FEET)
.filter_map(|line| {
let (a, b) = (line.start(), line.end());
let (u, ua, ub) = (along(a), offset(a), offset(b));
let spans = |x: f64, y: f64| {
(x.abs() < SKETCH_TOLERANCE_FEET
&& (y - signed_width).abs() < SKETCH_TOLERANCE_FEET)
|| (y.abs() < SKETCH_TOLERANCE_FEET
&& (x - signed_width).abs() < SKETCH_TOLERANCE_FEET)
};
((u - along(b)).abs() < SKETCH_TOLERANCE_FEET && spans(ua, ub)).then_some(u)
})
.collect();
risers.sort_by(f64::total_cmp);
risers.dedup_by(|a, b| (*a - *b).abs() < SKETCH_TOLERANCE_FEET);
let (&first_riser, &last_riser) = (risers.first()?, risers.last()?);
if risers.len() < 2
|| first_riser.abs() > SKETCH_TOLERANCE_FEET
|| (last_riser - length).abs() > SKETCH_TOLERANCE_FEET
{
return None;
}
Some(RunSketch {
origin,
climb,
across,
width_feet,
risers,
})
}
pub const RUN_TYPE_OBJECT_TAG: [u8; 2] = [0xdb, 0x0f];
pub const RUN_TYPE_TAG_OFFSET: usize = 0x47;
pub const RUN_TYPE_STRUCTURAL_DEPTH_OFFSET: usize = 0x59;
pub const RUN_TYPE_TREAD_THICKNESS_OFFSET: usize = 0x69;
pub const RUN_TYPE_RISER_THICKNESS_OFFSET: usize = 0x71;
pub const RUN_TYPE_NOSING_LENGTH_OFFSET: usize = 0x79;
pub const RUN_TYPE_TREAD_UNDER_RISER_OFFSET: usize = 0xb5;
pub const RUN_TYPE_FLAGS_OFFSET: usize = 0xbd;
#[derive(Debug, Clone, PartialEq)]
pub struct RunType {
pub structural_depth_feet: f64,
pub tread_thickness_feet: f64,
pub riser_thickness_feet: f64,
pub nosing_length_feet: f64,
pub monolithic: bool,
pub treads: bool,
pub risers: bool,
pub slanted_risers: bool,
pub tread_under_riser: bool,
pub name: Option<String>,
}
fn flag(buf: &[u8], at: usize) -> Option<bool> {
match buf.get(at)? {
0 => Some(false),
1 => Some(true),
_ => None,
}
}
fn utf16_at(buf: &[u8], at: usize) -> Option<String> {
let length = usize::try_from(read_u32(buf, at)?).ok()?;
if length == 0 || length > 256 {
return None;
}
let raw = buf.get(at + 4..at + 4 + 2 * length)?;
let units: Vec<u16> = raw
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();
String::from_utf16(&units).ok()
}
pub fn run_type_at(buf: &[u8], id_at: usize) -> Option<RunType> {
let tag_at = id_at.checked_add(RUN_TYPE_TAG_OFFSET)?;
if buf.get(tag_at..tag_at + RUN_TYPE_OBJECT_TAG.len())? != RUN_TYPE_OBJECT_TAG {
return None;
}
let length = |offset: usize, limit_feet: f64| {
let value = read_f64(buf, id_at + offset)?;
(value.is_finite() && (0.0..limit_feet).contains(&value)).then_some(value)
};
let flags = id_at + RUN_TYPE_FLAGS_OFFSET;
Some(RunType {
structural_depth_feet: length(RUN_TYPE_STRUCTURAL_DEPTH_OFFSET, 10.0)?,
tread_thickness_feet: length(RUN_TYPE_TREAD_THICKNESS_OFFSET, 2.0)?,
riser_thickness_feet: length(RUN_TYPE_RISER_THICKNESS_OFFSET, 2.0)?,
nosing_length_feet: length(RUN_TYPE_NOSING_LENGTH_OFFSET, 2.0)?,
tread_under_riser: flag(buf, id_at + RUN_TYPE_TREAD_UNDER_RISER_OFFSET)?,
monolithic: flag(buf, flags)?,
treads: flag(buf, flags + 1)?,
risers: flag(buf, flags + 2)?,
slanted_risers: flag(buf, flags + 3)?,
name: utf16_at(buf, flags + 4),
})
}
pub fn scan_run_types(
rf: &mut RevitFile,
revit_version: u32,
ids: &BTreeSet<u32>,
) -> Result<BTreeMap<u32, RunType>> {
let mut out: BTreeMap<u32, Option<RunType>> = BTreeMap::new();
if !supports_revit_version(revit_version) || ids.is_empty() {
return Ok(BTreeMap::new());
}
for stream in rf.partition_stream_names() {
let Ok(inflated) = rf.inflated_partition(&stream) else {
continue;
};
let buf = inflated.bytes();
for (id, id_at) in crate::partition_id_objects::find_id_objects(buf, ids) {
if let Some(found) = run_type_at(buf, id_at) {
merge(&mut out, id, found);
}
}
}
Ok(settled(out))
}
pub const STAIR_TYPE_CONSTRUCTION_OFFSET: usize = 0x129;
pub const STAIR_TYPE_COMPONENTS_OFFSET: usize = 0xc9;
pub const STAIR_TYPE_COMPONENT_SLOTS: usize = 7;
pub const STAIR_TYPE_PARAMETERS_OFFSET: usize = 0x13f;
pub const LANDING_TYPE_MONOLITHIC_OFFSET: usize = 0x95;
pub const LANDING_TYPE_NAME_OFFSET: usize = 0x98;
pub const SUPPORT_TYPE_CARRIAGE_OFFSET: usize = 0x89;
pub const SUPPORT_TYPE_NAME_OFFSET: usize = 0x92;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComponentKind {
Stair,
Landing,
Support,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ComponentType {
pub family: Option<&'static str>,
pub name: String,
pub components: BTreeSet<u32>,
}
fn type_name_text(name: String) -> Option<String> {
(!name.trim().is_empty() && !name.chars().any(char::is_control)).then_some(name)
}
fn stair_type_name(buf: &[u8], at: usize) -> Option<String> {
let count = read_u32(buf, at)?;
if count > 16 {
return None;
}
let mut at = at + 4;
for _ in 0..count {
let parameter = i64::from_le_bytes(buf.get(at..at + 8)?.try_into().ok()?);
if parameter >= 0 {
return None;
}
let length = usize::try_from(read_u32(buf, at + 8)?).ok()?;
if length > 256 {
return None;
}
at += 12 + 2 * length;
}
utf16_at(buf, at)
}
pub fn component_type_at(buf: &[u8], id_at: usize, kind: ComponentKind) -> Option<ComponentType> {
let tag_at = id_at.checked_add(RUN_TYPE_TAG_OFFSET)?;
if buf.get(tag_at..tag_at + RUN_TYPE_OBJECT_TAG.len())? != RUN_TYPE_OBJECT_TAG {
return None;
}
let mut components = BTreeSet::new();
let (family, name) = match kind {
ComponentKind::Stair => {
for slot in 0..STAIR_TYPE_COMPONENT_SLOTS {
let at = id_at + STAIR_TYPE_COMPONENTS_OFFSET + 8 * slot;
let id = u64::from_le_bytes(buf.get(at..at + 8)?.try_into().ok()?);
if id == u64::MAX {
continue;
}
components.insert(u32::try_from(id).ok()?);
}
let family = match read_u32(buf, id_at + STAIR_TYPE_CONSTRUCTION_OFFSET)? {
0 => Some("Assembled Stair"),
1 => Some("Cast-In-Place Stair"),
2 => None,
_ => return None,
};
(
family,
stair_type_name(buf, id_at + STAIR_TYPE_PARAMETERS_OFFSET)?,
)
}
ComponentKind::Landing => {
let family = if flag(buf, id_at + LANDING_TYPE_MONOLITHIC_OFFSET)? {
None
} else {
Some("Non-Monolithic Landing")
};
(family, utf16_at(buf, id_at + LANDING_TYPE_NAME_OFFSET)?)
}
ComponentKind::Support => {
let family = if flag(buf, id_at + SUPPORT_TYPE_CARRIAGE_OFFSET)? {
"Carriage"
} else {
"Stringer"
};
(
Some(family),
utf16_at(buf, id_at + SUPPORT_TYPE_NAME_OFFSET)?,
)
}
};
Some(ComponentType {
family,
name: type_name_text(name)?,
components,
})
}
pub fn scan_component_types(
rf: &mut RevitFile,
revit_version: u32,
kind: ComponentKind,
ids: &BTreeSet<u32>,
) -> Result<BTreeMap<u32, ComponentType>> {
let mut out: BTreeMap<u32, Option<ComponentType>> = BTreeMap::new();
if !supports_revit_version(revit_version) || ids.is_empty() {
return Ok(BTreeMap::new());
}
for stream in rf.partition_stream_names() {
let Ok(inflated) = rf.inflated_partition(&stream) else {
continue;
};
let buf = inflated.bytes();
for (id, id_at) in crate::partition_id_objects::find_id_objects(buf, ids) {
if let Some(found) = component_type_at(buf, id_at, kind) {
merge(&mut out, id, found);
}
}
}
Ok(settled(out))
}
pub const RUN_ENDS_WINDOW: usize = 0x1000;
pub const RUN_ENDS_FLAGS_OFFSET: usize = 60;
pub const RUN_TOP_RISER_INDEX_LIMIT: u32 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RunEnds {
pub begin_with_riser: bool,
pub end_with_riser: bool,
}
pub fn run_ends_at(data: &[u8], base_elevation_feet: f64, width_feet: f64) -> Option<RunEnds> {
let end = data.len().min(RUN_ENDS_WINDOW);
(0..end.saturating_sub(RUN_ENDS_FLAGS_OFFSET + 2)).find_map(|at| {
let value = |index: usize| read_f64(data, at + 8 * index);
let (bottom, top, width) = (value(0)?, value(1)?, value(4)?);
if (bottom - base_elevation_feet).abs() >= SKETCH_TOLERANCE_FEET
|| (width - width_feet).abs() >= SKETCH_TOLERANCE_FEET
|| !(top.is_finite() && top > bottom)
|| read_u32(data, at + 56)? >= RUN_TOP_RISER_INDEX_LIMIT
{
return None;
}
let flags = at + RUN_ENDS_FLAGS_OFFSET;
flag(data, flags)?;
Some(RunEnds {
begin_with_riser: flag(data, flags + 1)?,
end_with_riser: flag(data, flags + 2)?,
})
})
}
pub fn scan_run_ends(
rf: &mut RevitFile,
revit_version: u32,
sketches: &BTreeMap<u32, RunSketch>,
) -> Result<BTreeMap<u32, RunEnds>> {
let runs: BTreeSet<u32> = sketches.keys().copied().collect();
scan_run_data(rf, revit_version, &runs, |id, data| {
let sketch = sketches.get(&id)?;
run_ends_at(data, sketch.origin[2], sketch.width_feet)
})
}
pub fn scan_run_lines(
rf: &mut RevitFile,
revit_version: u32,
runs: &BTreeSet<u32>,
) -> Result<BTreeMap<u32, Vec<BoundedLine>>> {
use crate::partition_beam_axes::{BOUNDED_LINE_TAG, bounded_line_at};
scan_run_data(rf, revit_version, runs, |_, data| {
let lines: Vec<BoundedLine> = memchr::memmem::find_iter(data, &BOUNDED_LINE_TAG)
.filter_map(|at| bounded_line_at(data, at))
.collect();
(!lines.is_empty()).then_some(lines)
})
}
fn scan_run_data<T: PartialEq>(
rf: &mut RevitFile,
revit_version: u32,
runs: &BTreeSet<u32>,
mut read: impl FnMut(u32, &[u8]) -> Option<T>,
) -> Result<BTreeMap<u32, T>> {
use crate::partition_beam_axes::BEAM_DATA_WINDOW;
let header = match crate::partition_names::element_data_header(revit_version) {
Some(header) if supports_revit_version(revit_version) => header,
_ => return Ok(BTreeMap::new()),
};
let mut out: BTreeMap<u32, Option<T>> = BTreeMap::new();
for stream in rf.partition_stream_names() {
let Ok(inflated) = rf.inflated_partition(&stream) else {
continue;
};
let buf = inflated.bytes();
let hits: Vec<usize> = memchr::memmem::find_iter(buf, &header).collect();
for (index, &hit) in hits.iter().enumerate() {
let id_at = hit + header.len();
let Some(id) = buf
.get(id_at..id_at + 8)
.map(|s| u64::from_le_bytes(s.try_into().expect("8 bytes")))
.and_then(|id| u32::try_from(id).ok())
.filter(|id| runs.contains(id))
else {
continue;
};
let end = hits
.get(index + 1)
.copied()
.unwrap_or(buf.len())
.min(hit.saturating_add(BEAM_DATA_WINDOW))
.min(buf.len());
let Some(data) = buf.get(id_at + 8..end) else {
continue;
};
if let Some(found) = read(id, data) {
merge(&mut out, id, found);
}
}
}
Ok(settled(out))
}
pub fn run_side_profile(
sketch: &RunSketch,
run_type: &RunType,
riser_height_feet: f64,
end_with_riser: Option<bool>,
) -> Option<Vec<[f64; 2]>> {
if run_type.monolithic {
return end_with_riser
.and_then(|_| monolithic_side_profile(sketch, run_type, riser_height_feet));
}
let end_with_riser = end_with_riser.unwrap_or(true);
let (h, t, rt, n) = (
riser_height_feet,
run_type.tread_thickness_feet,
run_type.riser_thickness_feet,
run_type.nosing_length_feet,
);
let construction = (run_type.slanted_risers, run_type.tread_under_riser);
if run_type.monolithic
|| !run_type.treads
|| !run_type.risers
|| !matches!(construction, (true, true) | (false, false))
|| !(h.is_finite() && h > 0.0)
|| !(t > 0.0 && t < h)
|| rt <= 0.0
|| n < 0.0
{
return None;
}
let slanted = run_type.slanted_risers;
if slanted && !end_with_riser {
return None;
}
let (top_front, rh) = if slanted {
(n * t / h, rt * h.hypot(n) / h)
} else {
(n, rt)
};
let u = &sketch.risers;
if u.windows(2).any(|pair| pair[1] - pair[0] <= n + rh) {
return None;
}
let count = if end_with_riser { u.len() } else { u.len() - 1 };
let rise = |k: usize| k as f64 * h;
let mut ring: Vec<[f64; 2]> = Vec::with_capacity(6 * count + 3);
for k in 1..=count {
let a = u[k - 1];
ring.push([a + n, rise(k - 1)]);
if k < count || !end_with_riser {
if !slanted {
ring.push([a + top_front, rise(k) - t]);
ring.push([a, rise(k) - t]);
}
ring.push([a, rise(k)]);
} else {
ring.push([a + top_front, rise(k) - t]);
ring.push([a + top_front + rh, rise(k) - t]);
}
}
if !end_with_riser {
ring.push([u[count], rise(count)]);
ring.push([u[count], rise(count) - t]);
ring.push([u[count - 1] + n + rh, rise(count) - t]);
}
for k in (1..=count).rev() {
let back = u[k - 1] + n + rh;
if k == 1 {
ring.push([back, 0.0]);
break;
}
let through = if slanted { top_front } else { 0.0 };
ring.push([back + through, rise(k - 1) - t]);
ring.push([u[k - 2] + top_front + rh, rise(k - 1) - t]);
}
ring.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-12 && (a[1] - b[1]).abs() < 1e-12);
let area: f64 = ring
.iter()
.zip(ring.iter().cycle().skip(1))
.map(|(p, q)| p[0] * q[1] - q[0] * p[1])
.sum();
if area < 0.0 {
ring.reverse();
}
Some(ring)
}
pub fn monolithic_side_profile(
sketch: &RunSketch,
run_type: &RunType,
riser_height_feet: f64,
) -> Option<Vec<[f64; 2]>> {
let (h, n, depth) = (
riser_height_feet,
run_type.nosing_length_feet,
run_type.structural_depth_feet,
);
if !run_type.monolithic
|| !run_type.slanted_risers
|| run_type.treads
|| run_type.risers
|| !(h.is_finite() && h > 0.0)
|| !(depth.is_finite() && depth > 0.0)
|| n < 0.0
{
return None;
}
let u = &sketch.risers;
if u.windows(2).any(|pair| pair[1] - pair[0] <= n) {
return None;
}
let count = u.len() - 1;
let rise = |k: usize| k as f64 * h;
let pitch = rise(count) / (u[count] - u[0]);
let drop = depth * pitch.hypot(1.0);
let mut ring: Vec<[f64; 2]> = Vec::with_capacity(2 * count + 3);
for k in 1..=count {
ring.push([u[k - 1] + n, rise(k - 1)]);
ring.push([u[k - 1], rise(k)]);
}
ring.push([u[count], rise(count)]);
let end = (u[count] - u[0] - n) * pitch - drop;
if end > 0.0 {
ring.push([u[count], end]);
ring.push([u[0] + n + drop / pitch, 0.0]);
} else {
ring.push([u[count], 0.0]);
}
let area: f64 = ring
.iter()
.zip(ring.iter().cycle().skip(1))
.map(|(p, q)| p[0] * q[1] - q[0] * p[1])
.sum();
if area < 0.0 {
ring.reverse();
}
Some(ring)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::partition_names::ELEMENT_DATA_HEADER;
fn stair_620883(shift: usize) -> Vec<u8> {
let mut out = ELEMENT_DATA_HEADER.to_vec();
out.extend_from_slice(&620_883u64.to_le_bytes());
out.extend(vec![0xff; 0x45 + shift]);
out.extend_from_slice(&STAIR_DIMENSIONS_ANCHOR);
out.extend_from_slice(&(11.0f64 / 19.0).to_le_bytes());
out.extend_from_slice(&(11.0f64 / 12.0).to_le_bytes());
out.extend(vec![0u8; 24]);
out.extend_from_slice(&11.0f64.to_le_bytes());
out.extend(vec![0u8; 24]);
out.extend_from_slice(&19u32.to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes());
out
}
#[test]
fn stair_dimensions_follow_their_anchor() {
for shift in [0, 8] {
let buf = stair_620883(shift);
let found = stair_dimensions_at(&buf, 0).expect("dimensions");
assert_eq!(found.riser_count, 19);
assert!((found.riser_height_feet * 0.3048 - 0.176_463_157_894_736_85).abs() < 1e-12);
assert!((found.tread_depth_feet * 0.3048 - 0.2794).abs() < 1e-12);
}
let mut buf = stair_620883(0);
let at = memchr::memmem::find(&buf, &STAIR_DIMENSIONS_ANCHOR).expect("anchor");
buf[at + 4] = 0;
assert_eq!(stair_dimensions_at(&buf, 0), None);
assert_eq!(stair_dimensions_at(&stair_620883(ANCHOR_WINDOW), 0), None);
}
#[test]
fn a_run_records_its_risers() {
let mut buf = ELEMENT_DATA_HEADER.to_vec();
buf.extend_from_slice(&621_141u64.to_le_bytes());
buf.extend(vec![0xff; 0x2a]);
buf.extend_from_slice(&STAIR_RUN_RISERS_ANCHOR);
buf.extend_from_slice(&10u32.to_le_bytes());
assert_eq!(run_riser_count_at(&buf, 0), Some(10));
let offsets = data_offsets(&buf, &ELEMENT_DATA_HEADER, &[621_141]);
assert_eq!(offsets.get(&621_141), Some(&0));
}
#[test]
fn flight_counts_add_up_to_the_stair_or_are_not_claimed() {
assert_eq!(
flight_riser_counts(19, &[(621_141, Some(10)), (621_208, Some(9))]),
BTreeMap::from([(621_141, 10), (621_208, 9)])
);
assert_eq!(
flight_riser_counts(2, &[(1_563_804, Some(3))]),
BTreeMap::from([(1_563_804, 2)])
);
assert!(flight_riser_counts(19, &[(1, Some(10)), (2, Some(10))]).is_empty());
assert!(flight_riser_counts(19, &[(1, Some(10)), (2, None)]).is_empty());
}
fn line(a: [f64; 3], b: [f64; 3]) -> BoundedLine {
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
BoundedLine {
start_parameter: 0.0,
end_parameter: length,
origin: a,
direction: [d[0] / length, d[1] / length, d[2] / length],
}
}
fn run_621141_lines() -> Vec<BoundedLine> {
let (x0, x1, y0, z) = (-108.979_167, -105.3125, 25.6875, -16.916_667);
let tread = 11.0 / 12.0;
let y1 = y0 + 9.0 * tread;
let mut lines = vec![
line([x0, y0, z], [x0, y1, z]),
line([x1, y0, z], [x1, y1, z]),
];
for _ in 0..2 {
for k in 0..10 {
let y = y0 + k as f64 * tread;
lines.push(line([x0, y, z], [x1, y, z]));
}
}
lines
}
#[test]
fn a_run_sketch_reads_its_boundary_and_riser_lines() {
let sketch = run_sketch(&run_621141_lines()).expect("sketch");
assert_eq!(sketch.risers.len(), 10);
assert!(sketch.risers[0].abs() < 1e-12);
assert!((sketch.risers[9] - 8.25).abs() < 1e-9);
assert!((sketch.width_feet - 3.666_667).abs() < 1e-6);
assert!((sketch.climb[1] - 1.0).abs() < 1e-12);
assert!((sketch.across[0] - 1.0).abs() < 1e-12);
let mut lines = run_621141_lines();
lines[0] = line([0.0, 0.0, 0.0], [0.0, 8.25, 1.0]);
assert_eq!(run_sketch(&lines), None);
let mut lines = run_621141_lines();
lines.retain(|l| (l.start()[1] - (25.6875 + 8.25)).abs() > 1e-6 || l.direction[0] == 0.0);
assert_eq!(run_sketch(&lines), None);
let mut lines = run_621141_lines();
lines.remove(1);
assert_eq!(run_sketch(&lines), None);
}
fn run_type_613941() -> Vec<u8> {
let mut buf = vec![1u8, 0, 0, 0];
buf.extend_from_slice(&613_941u64.to_le_bytes());
buf.resize(4 + 0x160, 0xff);
let id_at = 4;
buf[id_at + RUN_TYPE_TAG_OFFSET..id_at + RUN_TYPE_TAG_OFFSET + 2]
.copy_from_slice(&RUN_TYPE_OBJECT_TAG);
for (offset, value) in [
(RUN_TYPE_STRUCTURAL_DEPTH_OFFSET, 2.5 / 12.0),
(RUN_TYPE_STRUCTURAL_DEPTH_OFFSET + 8, 0.0),
(RUN_TYPE_TREAD_THICKNESS_OFFSET, 0.25 / 12.0),
(RUN_TYPE_RISER_THICKNESS_OFFSET, 0.25 / 12.0),
(RUN_TYPE_NOSING_LENGTH_OFFSET, 1.0 / 12.0),
] {
buf[id_at + offset..id_at + offset + 8].copy_from_slice(&f64::to_le_bytes(value));
}
buf[id_at + RUN_TYPE_TREAD_UNDER_RISER_OFFSET] = 1;
let flags = id_at + RUN_TYPE_FLAGS_OFFSET;
buf[flags..flags + 4].copy_from_slice(&[0, 1, 1, 1]);
let name = "1/4\" Tread 1\" Nosing 1/4\" Riser";
buf[flags + 4..flags + 8].copy_from_slice(&(name.len() as u32).to_le_bytes());
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
buf[flags + 8..flags + 8 + units.len()].copy_from_slice(&units);
buf
}
#[test]
fn a_run_type_reads_its_construction() {
let buf = run_type_613941();
let found = run_type_at(&buf, 4).expect("run type");
assert!((found.tread_thickness_feet * 12.0 - 0.25).abs() < 1e-12);
assert!((found.riser_thickness_feet * 12.0 - 0.25).abs() < 1e-12);
assert!((found.nosing_length_feet * 12.0 - 1.0).abs() < 1e-12);
assert!((found.structural_depth_feet * 12.0 - 2.5).abs() < 1e-12);
assert!(!found.monolithic && found.treads && found.risers);
assert!(found.slanted_risers && found.tread_under_riser);
assert_eq!(
found.name.as_deref(),
Some("1/4\" Tread 1\" Nosing 1/4\" Riser")
);
let mut untagged = buf.clone();
untagged[4 + RUN_TYPE_TAG_OFFSET] = 0;
assert_eq!(run_type_at(&untagged, 4), None);
let mut odd = buf;
odd[4 + RUN_TYPE_FLAGS_OFFSET + 2] = 3;
assert_eq!(run_type_at(&odd, 4), None);
}
fn upright_type() -> RunType {
RunType {
structural_depth_feet: 2.5 / 12.0,
tread_thickness_feet: 2.0 / 12.0,
riser_thickness_feet: 0.25 / 12.0,
nosing_length_feet: 1.0 / 12.0,
monolithic: false,
treads: true,
risers: true,
slanted_risers: false,
tread_under_riser: false,
name: None,
}
}
fn area(ring: &[[f64; 2]]) -> f64 {
ring.iter()
.zip(ring.iter().cycle().skip(1))
.map(|(p, q)| p[0] * q[1] - q[0] * p[1])
.sum::<f64>()
/ 2.0
}
#[test]
fn an_upright_riser_run_is_its_treads_and_risers() {
let sketch = run_sketch(&run_621141_lines()).expect("sketch");
let run_type = upright_type();
let h = 0.570_833;
let ring = run_side_profile(&sketch, &run_type, h, Some(true)).expect("profile");
let (t, rt, n) = (2.0 / 12.0, 0.25 / 12.0, 1.0 / 12.0);
let expected = 9.0 * (11.0 / 12.0 + n) * t + rt * ((h - t) + 9.0 * h);
assert!((area(&ring) - expected).abs() < 1e-9, "{}", area(&ring));
assert!(ring.contains(&[0.0, h]));
assert!(ring.contains(&[n, 0.0]));
}
#[test]
fn a_slanted_riser_run_is_one_folded_plate() {
let sketch = run_sketch(&run_621141_lines()).expect("sketch");
let buf = run_type_613941();
let run_type = run_type_at(&buf, 4).expect("run type");
let h = 11.0 / 19.0;
let ring = run_side_profile(&sketch, &run_type, h, Some(true)).expect("profile");
let (t, n) = (0.25 / 12.0, 1.0 / 12.0);
let rh = (0.25 / 12.0) * h.hypot(n) / h;
let near = |p: [f64; 2]| {
ring.iter()
.any(|q| (q[0] - p[0]).abs() < 1e-9 && (q[1] - p[1]).abs() < 1e-9)
};
assert!(near([n, 0.0]) && near([0.0, h]));
assert!(!near([0.0, h - t]));
let top = 10.0 * h - t;
assert!(near([8.25 + n * t / h, top]) && near([8.25 + n * t / h + rh, top]));
assert!(
ring.iter()
.all(|p| p[0] >= -1e-12 && p[1] >= -1e-12 && p[1] <= 10.0 * h)
);
}
#[test]
fn runs_rvt_rs_has_not_measured_are_not_drawn() {
let sketch = run_sketch(&run_621141_lines()).expect("sketch");
let h = 0.57;
let mut monolithic = upright_type();
monolithic.monolithic = true;
assert_eq!(run_side_profile(&sketch, &monolithic, h, Some(true)), None);
let mut no_risers = upright_type();
no_risers.risers = false;
assert_eq!(run_side_profile(&sketch, &no_risers, h, Some(true)), None);
let mut unmeasured = upright_type();
unmeasured.slanted_risers = true;
assert_eq!(run_side_profile(&sketch, &unmeasured, h, Some(true)), None);
assert_eq!(
run_side_profile(&sketch, &upright_type(), 0.1, Some(true)),
None
);
}
#[test]
fn only_2024_is_read() {
assert!(supports_revit_version(2024));
assert!(!supports_revit_version(2025));
}
}