1use crate::error::{GeometryError, GeometryResult};
22use crate::slots::Slots;
23use ifc_model::{EntityId, Model};
24
25#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct UnitScale {
28 pub length_to_metres: f64,
30 pub angle_to_radians: f64,
32}
33
34impl Default for UnitScale {
35 fn default() -> Self {
40 Self {
41 length_to_metres: 1.0,
42 angle_to_radians: 1.0,
43 }
44 }
45}
46
47impl UnitScale {
48 pub fn length(&self, value: f64) -> f64 {
50 value * self.length_to_metres
51 }
52
53 pub fn angle(&self, value: f64) -> f64 {
55 value * self.angle_to_radians
56 }
57
58 pub fn is_metric_identity(&self) -> bool {
60 (self.length_to_metres - 1.0).abs() < f64::EPSILON
61 }
62}
63
64pub fn resolve(model: &Model) -> UnitScale {
72 let Some(assignment) = find_unit_assignment(model) else {
73 return UnitScale::default();
74 };
75
76 let mut scale = UnitScale::default();
77 let Some(entity) = model.get(assignment) else {
78 return scale;
79 };
80 let slots = Slots::new(assignment, entity);
81
82 for unit_id in slots.opt_ref_list(0) {
83 let Some(unit) = model.get(unit_id) else {
84 continue;
85 };
86 let unit_slots = Slots::new(unit_id, unit);
87
88 match unit_type(&unit_slots) {
89 Some("LENGTHUNIT") => {
90 if let Ok(factor) = length_factor(model, unit_id, &unit_slots) {
91 scale.length_to_metres = factor;
92 }
93 }
94 Some("PLANEANGLEUNIT") => {
95 if let Ok(factor) = angle_factor(model, unit_id, &unit_slots) {
96 scale.angle_to_radians = factor;
97 }
98 }
99 _ => {}
100 }
101 }
102 scale
103}
104
105fn find_unit_assignment(model: &Model) -> Option<EntityId> {
107 let (id, project) = model.of_type("IFCPROJECT").next()?;
108 Slots::new(id, project).opt_ref(8)
109}
110
111fn unit_type<'m>(slots: &Slots<'m>) -> Option<&'m str> {
113 match slots.type_name() {
114 "IFCSIUNIT" => slots.opt_enum(1),
116 "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => slots.opt_enum(1),
118 _ => None,
119 }
120}
121
122fn length_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
124 match slots.type_name() {
125 "IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))),
126 "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => {
127 conversion_factor(model, id, slots)
128 }
129 other => Err(GeometryError::Units(format!(
130 "{id} is {other}, which is not a length unit this build understands"
131 ))),
132 }
133}
134
135fn angle_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
141 match slots.type_name() {
142 "IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))),
143 "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => {
144 conversion_factor(model, id, slots)
145 }
146 other => Err(GeometryError::Units(format!(
147 "{id} is {other}, which is not an angle unit this build understands"
148 ))),
149 }
150}
151
152fn conversion_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
156 let measure_id = slots.req_ref(3, "ConversionFactor")?;
157 let measure = slots.resolve(model, measure_id)?;
158 let measure_slots = Slots::new(measure_id, measure);
159 measure_slots
160 .req_f64(0, "ValueComponent")
161 .map_err(|_| GeometryError::Units(format!("{id} has an unreadable conversion factor")))
162}
163
164fn prefix_factor(prefix: Option<&str>) -> f64 {
170 match prefix {
171 None => 1.0,
172 Some(p) => match p {
173 "EXA" => 1e18,
174 "PETA" => 1e15,
175 "TERA" => 1e12,
176 "GIGA" => 1e9,
177 "MEGA" => 1e6,
178 "KILO" => 1e3,
179 "HECTO" => 1e2,
180 "DECA" => 1e1,
181 "DECI" => 1e-1,
182 "CENTI" => 1e-2,
183 "MILLI" => 1e-3,
184 "MICRO" => 1e-6,
185 "NANO" => 1e-9,
186 "PICO" => 1e-12,
187 "FEMTO" => 1e-15,
188 "ATTO" => 1e-18,
189 _ => 1.0,
190 },
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use ifc_model::{Entity, Value};
198
199 fn model_with_length_unit(prefix: Option<&str>) -> Model {
200 let mut model = Model::new();
201 let prefix_value = match prefix {
202 Some(p) => Value::Enum(p.into()),
203 None => Value::Null,
204 };
205 model.insert(
206 EntityId(1),
207 Entity::new(
208 "IFCSIUNIT",
209 vec![
210 Value::Derived,
211 Value::Enum("LENGTHUNIT".into()),
212 prefix_value,
213 Value::Enum("METRE".into()),
214 ],
215 ),
216 );
217 model.insert(
218 EntityId(2),
219 Entity::new(
220 "IFCUNITASSIGNMENT",
221 vec![Value::List(vec![Value::Ref(EntityId(1))])],
222 ),
223 );
224 let mut project = vec![Value::Null; 9];
225 project[8] = Value::Ref(EntityId(2));
226 model.insert(EntityId(3), Entity::new("IFCPROJECT", project));
227 model
228 }
229
230 #[test]
231 fn metres_are_the_identity() {
232 let scale = resolve(&model_with_length_unit(None));
233 assert_eq!(scale.length_to_metres, 1.0);
234 assert!(scale.is_metric_identity());
235 }
236
237 #[test]
239 fn millimetres_scale_coordinates_down_by_a_thousand() {
240 let scale = resolve(&model_with_length_unit(Some("MILLI")));
241 assert_eq!(scale.length_to_metres, 1e-3);
242 assert_eq!(scale.length(3000.0), 3.0);
243 assert!(!scale.is_metric_identity());
244 }
245
246 #[test]
247 fn handles_the_whole_si_prefix_table() {
248 assert_eq!(prefix_factor(Some("KILO")), 1e3);
249 assert_eq!(prefix_factor(Some("CENTI")), 1e-2);
250 assert_eq!(prefix_factor(Some("MICRO")), 1e-6);
251 assert_eq!(prefix_factor(Some("UNRECOGNIZED")), 1.0);
252 }
253
254 #[test]
256 fn missing_unit_assignment_falls_back_to_si_rather_than_failing() {
257 let scale = resolve(&Model::new());
258 assert_eq!(scale, UnitScale::default());
259 }
260
261 #[test]
263 fn resolves_conversion_based_angle_units() {
264 let mut model = Model::new();
265 model.insert(
266 EntityId(1),
267 Entity::new(
268 "IFCMEASUREWITHUNIT",
269 vec![
270 Value::Typed {
271 type_name: "IFCPLANEANGLEMEASURE".into(),
272 value: Box::new(Value::Real(0.017453292519943295)),
273 },
274 Value::Null,
275 ],
276 ),
277 );
278 model.insert(
279 EntityId(2),
280 Entity::new(
281 "IFCCONVERSIONBASEDUNIT",
282 vec![
283 Value::Null,
284 Value::Enum("PLANEANGLEUNIT".into()),
285 Value::Text("DEGREE".into()),
286 Value::Ref(EntityId(1)),
287 ],
288 ),
289 );
290 model.insert(
291 EntityId(3),
292 Entity::new(
293 "IFCUNITASSIGNMENT",
294 vec![Value::List(vec![Value::Ref(EntityId(2))])],
295 ),
296 );
297 let mut project = vec![Value::Null; 9];
298 project[8] = Value::Ref(EntityId(3));
299 model.insert(EntityId(4), Entity::new("IFCPROJECT", project));
300
301 let scale = resolve(&model);
302 assert!((scale.angle(90.0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
303 }
304}