Skip to main content

ifc_properties/unit/
authoring.rs

1//! Transactional authoring of the project unit context.
2//!
3//! Reading lives in [`super::assignment`]; this module is the write side. The
4//! slot layout is documented there and deliberately not repeated: both sides
5//! resolve the same constants so a schema correction cannot update one and
6//! leave the other stale.
7//!
8//! These helpers only stage records. [`ifc_model::Transaction::commit`] owns
9//! atomic application, so a rejected draft cannot leave a half-built unit in
10//! the model.
11//!
12//! # Why prefixes are validated, not defaulted
13//!
14//! An unrecognised `IfcSIPrefix` is refused rather than written through as
15//! "no prefix". Silently dropping `MILLI` would rescale every length in the
16//! file by a thousand, and the resulting model would still parse -- the worst
17//! kind of authoring bug, because nothing downstream can detect it.
18
19use ifc_model::{Entity, EntityId, Transaction, Value};
20
21use super::assignment::prefix_exponent;
22use crate::{PropertyError, PropertyResult};
23
24/// Authored fields for `IfcSIUnit`.
25#[derive(Debug, Clone, Copy)]
26pub struct SiUnitDraft<'a> {
27    /// `IfcNamedUnit.UnitType`, an `IfcUnitEnum` constant such as `LENGTHUNIT`.
28    pub unit_type: &'a str,
29    /// `IfcSIUnit.Name`, an `IfcSIUnitName` constant such as `METRE`.
30    pub name: &'a str,
31    /// `IfcSIUnit.Prefix`, an `IfcSIPrefix` constant such as `MILLI`.
32    ///
33    /// `None` writes an unprefixed unit. A prefix that is not a schema
34    /// constant is refused; see the module note.
35    pub prefix: Option<&'a str>,
36}
37
38/// Authored fields for `IfcMonetaryUnit`.
39#[derive(Debug, Clone, Copy)]
40pub struct MonetaryUnitDraft<'a> {
41    /// `IfcMonetaryUnit.Currency`, an ISO 4217 code such as `EUR`.
42    pub currency: &'a str,
43}
44
45/// Stage an `IfcSIUnit`.
46///
47/// `Dimensions` is written as `Value::Derived`, the STEP asterisk. The
48/// schema declares it DERIVE on this subtype, computed from `Name`, and
49/// a derived attribute is not an omitted one: `$` claims the value is
50/// absent, while `*` states it is computed by the schema rule.
51pub fn add_si_unit(tx: &mut Transaction, draft: SiUnitDraft<'_>) -> PropertyResult<EntityId> {
52    require_enum("IFCSIUNIT", "UnitType", draft.unit_type)?;
53    require_enum("IFCSIUNIT", "Name", draft.name)?;
54    if let Some(prefix) = draft.prefix {
55        if prefix_exponent(prefix).is_none() {
56            return Err(authoring_invalid("IFCSIUNIT", "Prefix", prefix));
57        }
58    }
59    Ok(tx.create(Entity::new(
60        "IFCSIUNIT",
61        vec![
62            Value::Derived,
63            enum_value(draft.unit_type),
64            draft.prefix.map_or(Value::Null, enum_value),
65            enum_value(draft.name),
66        ],
67    )))
68}
69
70/// Stage an `IfcMonetaryUnit`.
71pub fn add_monetary_unit(
72    tx: &mut Transaction,
73    draft: MonetaryUnitDraft<'_>,
74) -> PropertyResult<EntityId> {
75    if draft.currency.trim().is_empty() {
76        return Err(authoring_invalid(
77            "IFCMONETARYUNIT",
78            "Currency",
79            "expected a currency code",
80        ));
81    }
82    Ok(tx.create(Entity::new(
83        "IFCMONETARYUNIT",
84        vec![Value::Text(draft.currency.into())],
85    )))
86}
87
88/// Authored fields for `IfcConversionBasedUnit`.
89#[derive(Debug, Clone, Copy)]
90pub struct ConversionBasedUnitDraft<'a> {
91    /// `IfcConversionBasedUnit.UnitType`, an `IfcUnitEnum` constant.
92    pub unit_type: &'a str,
93    /// `IfcConversionBasedUnit.Name`.
94    pub name: &'a str,
95    /// `IfcConversionBasedUnit.ConversionFactor`, an `IfcMeasureWithUnit`.
96    pub conversion_factor: EntityId,
97    /// `IfcConversionBasedUnit.Dimensions`, an `IfcDimensionalExponents`.
98    pub dimensions: EntityId,
99}
100
101/// Stage an `IfcConversionBasedUnit`.
102///
103/// The conversion factor and dimensions are references the caller must have
104/// created; their types are checked by `ifc-validate`, not here, because this
105/// crate has no schema handle.
106pub fn add_conversion_based_unit(
107    tx: &mut Transaction,
108    draft: ConversionBasedUnitDraft<'_>,
109) -> PropertyResult<EntityId> {
110    require_enum("IFCCONVERSIONBASEDUNIT", "UnitType", draft.unit_type)?;
111    if draft.name.trim().is_empty() {
112        return Err(authoring_invalid(
113            "IFCCONVERSIONBASEDUNIT",
114            "Name",
115            "expected a non-empty name",
116        ));
117    }
118    Ok(tx.create(Entity::new(
119        "IFCCONVERSIONBASEDUNIT",
120        vec![
121            Value::Ref(draft.dimensions),
122            enum_value(draft.unit_type),
123            Value::Text(draft.name.into()),
124            Value::Ref(draft.conversion_factor),
125        ],
126    )))
127}
128
129/// Stage an `IfcDimensionalExponents`.
130///
131/// All seven SI base exponents are required by the schema, so they are taken
132/// positionally rather than as options: a missing exponent is not "unset", it
133/// is zero, and conflating the two silently changes the dimension.
134pub fn add_dimensional_exponents(tx: &mut Transaction, exponents: [i64; 7]) -> EntityId {
135    tx.create(Entity::new(
136        "IFCDIMENSIONALEXPONENTS",
137        exponents.iter().copied().map(Value::Integer).collect(),
138    ))
139}
140
141/// Stage an `IfcDerivedUnitElement`: one base unit raised to an exponent.
142pub fn add_derived_unit_element(
143    tx: &mut Transaction,
144    unit: EntityId,
145    exponent: i64,
146) -> PropertyResult<EntityId> {
147    if exponent == 0 {
148        return Err(authoring_invalid(
149            "IFCDERIVEDUNITELEMENT",
150            "Exponent",
151            "expected a non-zero exponent",
152        ));
153    }
154    Ok(tx.create(Entity::new(
155        "IFCDERIVEDUNITELEMENT",
156        vec![Value::Ref(unit), Value::Integer(exponent)],
157    )))
158}
159
160/// Stage an `IfcDerivedUnit` over previously staged elements.
161pub fn add_derived_unit(
162    tx: &mut Transaction,
163    elements: &[EntityId],
164    unit_type: &str,
165    user_defined_type: Option<&str>,
166) -> PropertyResult<EntityId> {
167    if elements.is_empty() {
168        return Err(authoring_invalid(
169            "IFCDERIVEDUNIT",
170            "Elements",
171            "expected at least one derived unit element",
172        ));
173    }
174    require_enum("IFCDERIVEDUNIT", "UnitType", unit_type)?;
175    Ok(tx.create(Entity::new(
176        "IFCDERIVEDUNIT",
177        vec![
178            Value::List(elements.iter().copied().map(Value::Ref).collect()),
179            enum_value(unit_type),
180            user_defined_type.map_or(Value::Null, |v| Value::Text(v.into())),
181        ],
182    )))
183}
184
185/// Stage an `IfcUnitAssignment`: the project-wide unit context.
186///
187/// Assigning no units is refused. An empty assignment parses, but leaves every
188/// measure in the file dimensionless by omission, which is never intended.
189pub fn assign_units(tx: &mut Transaction, units: &[EntityId]) -> PropertyResult<EntityId> {
190    if units.is_empty() {
191        return Err(authoring_invalid(
192            "IFCUNITASSIGNMENT",
193            "Units",
194            "expected at least one unit",
195        ));
196    }
197    Ok(tx.create(Entity::new(
198        "IFCUNITASSIGNMENT",
199        vec![Value::List(units.iter().copied().map(Value::Ref).collect())],
200    )))
201}
202
203/// Stage an `IfcMeasureWithUnit`.
204///
205/// A magnitude paired with the unit it is stated in. Both attributes
206/// are required: this is the entity `IfcConversionBasedUnit` points at
207/// for its conversion factor, and a factor missing either half states
208/// no conversion at all.
209///
210/// # Errors
211///
212/// Refuses a null value component; the schema types it as a required
213/// `IfcValue`.
214pub fn add_measure_with_unit(
215    tx: &mut Transaction,
216    value: Value,
217    unit: EntityId,
218) -> PropertyResult<EntityId> {
219    if matches!(value, Value::Null) {
220        return Err(authoring_invalid(
221            "IFCMEASUREWITHUNIT",
222            "ValueComponent",
223            "expected a value",
224        ));
225    }
226    Ok(tx.create(Entity::new(
227        "IFCMEASUREWITHUNIT",
228        vec![value, Value::Ref(unit)],
229    )))
230}
231
232/// Stage an `IfcContextDependentUnit`.
233///
234/// A unit with no SI conversion, named by the project that defines it.
235/// Unlike `IfcSIUnit`, `Dimensions` is a real attribute here rather
236/// than a derived one, so the caller supplies it.
237///
238/// # Errors
239///
240/// Refuses a blank name or a malformed `UnitType` token.
241pub fn add_context_dependent_unit(
242    tx: &mut Transaction,
243    dimensions: EntityId,
244    unit_type: &str,
245    name: &str,
246) -> PropertyResult<EntityId> {
247    require_enum("IFCCONTEXTDEPENDENTUNIT", "UnitType", unit_type)?;
248    if name.trim().is_empty() {
249        return Err(authoring_invalid(
250            "IFCCONTEXTDEPENDENTUNIT",
251            "Name",
252            "expected a non-empty name",
253        ));
254    }
255    Ok(tx.create(Entity::new(
256        "IFCCONTEXTDEPENDENTUNIT",
257        vec![
258            Value::Ref(dimensions),
259            enum_value(unit_type),
260            Value::Text(name.into()),
261        ],
262    )))
263}
264
265fn require_enum(entity: &'static str, attribute: &'static str, value: &str) -> PropertyResult<()> {
266    if value.trim().is_empty() || !value.bytes().all(|b| b.is_ascii_uppercase() || b == b'_') {
267        return Err(authoring_invalid(entity, attribute, value));
268    }
269    Ok(())
270}
271
272fn enum_value(value: &str) -> Value {
273    Value::Enum(value.into())
274}
275
276fn authoring_invalid(
277    entity: &'static str,
278    attribute: &'static str,
279    value: impl Into<String>,
280) -> PropertyError {
281    PropertyError::AuthoringInvalid {
282        entity,
283        attribute,
284        value: value.into(),
285    }
286}
287
288/// Stage an `IfcConversionBasedUnitWithOffset`.
289///
290/// The offset form exists for scales whose zero is not the SI zero:
291/// degrees Celsius convert to kelvin by a factor of one and an offset
292/// of 273.15. Writing that as a plain conversion unit would place
293/// absolute zero at the freezing point of water, so the offset is a
294/// separate entity rather than an optional slot on the base form.
295///
296/// # Errors
297///
298/// Refuses a `UnitType` outside `IfcUnitEnum`, a blank name, and a
299/// non-finite offset.
300pub fn add_conversion_based_unit_with_offset(
301    tx: &mut Transaction,
302    draft: ConversionBasedUnitDraft<'_>,
303    conversion_offset: f64,
304) -> PropertyResult<EntityId> {
305    const ENTITY: &str = "IFCCONVERSIONBASEDUNITWITHOFFSET";
306    require_enum(ENTITY, "UnitType", draft.unit_type)?;
307    if draft.name.trim().is_empty() {
308        return Err(authoring_invalid(
309            ENTITY,
310            "Name",
311            "expected a non-empty name",
312        ));
313    }
314    if !conversion_offset.is_finite() {
315        return Err(authoring_invalid(
316            ENTITY,
317            "ConversionOffset",
318            "expected a finite offset",
319        ));
320    }
321    Ok(tx.create(Entity::new(
322        ENTITY,
323        vec![
324            Value::Ref(draft.dimensions),
325            enum_value(draft.unit_type),
326            Value::Text(draft.name.into()),
327            Value::Ref(draft.conversion_factor),
328            Value::Real(conversion_offset),
329        ],
330    )))
331}