use crate::error::{GeometryError, GeometryResult};
use crate::slots::Slots;
use ifc_model::{EntityId, Model};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UnitScale {
pub length_to_metres: f64,
pub angle_to_radians: f64,
}
impl Default for UnitScale {
fn default() -> Self {
Self {
length_to_metres: 1.0,
angle_to_radians: 1.0,
}
}
}
impl UnitScale {
pub fn length(&self, value: f64) -> f64 {
value * self.length_to_metres
}
pub fn angle(&self, value: f64) -> f64 {
value * self.angle_to_radians
}
pub fn is_metric_identity(&self) -> bool {
(self.length_to_metres - 1.0).abs() < f64::EPSILON
}
}
pub fn resolve(model: &Model) -> UnitScale {
let Some(assignment) = find_unit_assignment(model) else {
return UnitScale::default();
};
let mut scale = UnitScale::default();
let Some(entity) = model.get(assignment) else {
return scale;
};
let slots = Slots::new(assignment, entity);
for unit_id in slots.opt_ref_list(0) {
let Some(unit) = model.get(unit_id) else {
continue;
};
let unit_slots = Slots::new(unit_id, unit);
match unit_type(&unit_slots) {
Some("LENGTHUNIT") => {
if let Ok(factor) = length_factor(model, unit_id, &unit_slots) {
scale.length_to_metres = factor;
}
}
Some("PLANEANGLEUNIT") => {
if let Ok(factor) = angle_factor(model, unit_id, &unit_slots) {
scale.angle_to_radians = factor;
}
}
_ => {}
}
}
scale
}
fn find_unit_assignment(model: &Model) -> Option<EntityId> {
let (id, project) = model.of_type("IFCPROJECT").next()?;
Slots::new(id, project).opt_ref(8)
}
fn unit_type<'m>(slots: &Slots<'m>) -> Option<&'m str> {
match slots.type_name() {
"IFCSIUNIT" => slots.opt_enum(1),
"IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => slots.opt_enum(1),
_ => None,
}
}
fn length_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
match slots.type_name() {
"IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))),
"IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => {
conversion_factor(model, id, slots)
}
other => Err(GeometryError::Units(format!(
"{id} is {other}, which is not a length unit this build understands"
))),
}
}
fn angle_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
match slots.type_name() {
"IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))),
"IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => {
conversion_factor(model, id, slots)
}
other => Err(GeometryError::Units(format!(
"{id} is {other}, which is not an angle unit this build understands"
))),
}
}
fn conversion_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
let measure_id = slots.req_ref(3, "ConversionFactor")?;
let measure = slots.resolve(model, measure_id)?;
let measure_slots = Slots::new(measure_id, measure);
measure_slots
.req_f64(0, "ValueComponent")
.map_err(|_| GeometryError::Units(format!("{id} has an unreadable conversion factor")))
}
fn prefix_factor(prefix: Option<&str>) -> f64 {
match prefix {
None => 1.0,
Some(p) => match p {
"EXA" => 1e18,
"PETA" => 1e15,
"TERA" => 1e12,
"GIGA" => 1e9,
"MEGA" => 1e6,
"KILO" => 1e3,
"HECTO" => 1e2,
"DECA" => 1e1,
"DECI" => 1e-1,
"CENTI" => 1e-2,
"MILLI" => 1e-3,
"MICRO" => 1e-6,
"NANO" => 1e-9,
"PICO" => 1e-12,
"FEMTO" => 1e-15,
"ATTO" => 1e-18,
_ => 1.0,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use ifc_model::{Entity, Value};
fn model_with_length_unit(prefix: Option<&str>) -> Model {
let mut model = Model::new();
let prefix_value = match prefix {
Some(p) => Value::Enum(p.into()),
None => Value::Null,
};
model.insert(
EntityId(1),
Entity::new(
"IFCSIUNIT",
vec![
Value::Derived,
Value::Enum("LENGTHUNIT".into()),
prefix_value,
Value::Enum("METRE".into()),
],
),
);
model.insert(
EntityId(2),
Entity::new(
"IFCUNITASSIGNMENT",
vec![Value::List(vec![Value::Ref(EntityId(1))])],
),
);
let mut project = vec![Value::Null; 9];
project[8] = Value::Ref(EntityId(2));
model.insert(EntityId(3), Entity::new("IFCPROJECT", project));
model
}
#[test]
fn metres_are_the_identity() {
let scale = resolve(&model_with_length_unit(None));
assert_eq!(scale.length_to_metres, 1.0);
assert!(scale.is_metric_identity());
}
#[test]
fn millimetres_scale_coordinates_down_by_a_thousand() {
let scale = resolve(&model_with_length_unit(Some("MILLI")));
assert_eq!(scale.length_to_metres, 1e-3);
assert_eq!(scale.length(3000.0), 3.0);
assert!(!scale.is_metric_identity());
}
#[test]
fn handles_the_whole_si_prefix_table() {
assert_eq!(prefix_factor(Some("KILO")), 1e3);
assert_eq!(prefix_factor(Some("CENTI")), 1e-2);
assert_eq!(prefix_factor(Some("MICRO")), 1e-6);
assert_eq!(prefix_factor(Some("UNRECOGNIZED")), 1.0);
}
#[test]
fn missing_unit_assignment_falls_back_to_si_rather_than_failing() {
let scale = resolve(&Model::new());
assert_eq!(scale, UnitScale::default());
}
#[test]
fn resolves_conversion_based_angle_units() {
let mut model = Model::new();
model.insert(
EntityId(1),
Entity::new(
"IFCMEASUREWITHUNIT",
vec![
Value::Typed {
type_name: "IFCPLANEANGLEMEASURE".into(),
value: Box::new(Value::Real(0.017453292519943295)),
},
Value::Null,
],
),
);
model.insert(
EntityId(2),
Entity::new(
"IFCCONVERSIONBASEDUNIT",
vec![
Value::Null,
Value::Enum("PLANEANGLEUNIT".into()),
Value::Text("DEGREE".into()),
Value::Ref(EntityId(1)),
],
),
);
model.insert(
EntityId(3),
Entity::new(
"IFCUNITASSIGNMENT",
vec![Value::List(vec![Value::Ref(EntityId(2))])],
),
);
let mut project = vec![Value::Null; 9];
project[8] = Value::Ref(EntityId(3));
model.insert(EntityId(4), Entity::new("IFCPROJECT", project));
let scale = resolve(&model);
assert!((scale.angle(90.0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
}
}