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, Model, Transaction, Value};
20use ifc_schema::TypeKind;
21
22use super::assignment::prefix_exponent;
23use crate::quantity::release::bind;
24use crate::{PropertyError, PropertyResult};
25
26/// Authored fields for `IfcSIUnit`.
27#[derive(Debug, Clone, Copy)]
28#[non_exhaustive]
29pub struct SiUnitDraft<'a> {
30    /// `IfcNamedUnit.UnitType`, an `IfcUnitEnum` constant such as `LENGTHUNIT`.
31    pub unit_type: &'a str,
32    /// `IfcSIUnit.Name`, an `IfcSIUnitName` constant such as `METRE`.
33    pub name: &'a str,
34    /// `IfcSIUnit.Prefix`, an `IfcSIPrefix` constant such as `MILLI`.
35    ///
36    /// `None` writes an unprefixed unit. A prefix that is not a schema
37    /// constant is refused; see the module note.
38    pub prefix: Option<&'a str>,
39}
40
41impl<'a> SiUnitDraft<'a> {
42    /// Starts a draft from its required `unit_type`, `name`; every other field
43    /// is unset.
44    #[must_use]
45    pub fn new(unit_type: &'a str, name: &'a str) -> Self {
46        Self {
47            unit_type,
48            name,
49            prefix: None,
50        }
51    }
52
53    /// Sets `prefix`.
54    ///
55    /// `IfcSIUnit.Prefix`, an `IfcSIPrefix` constant such as `MILLI`.
56    ///
57    /// `None` writes an unprefixed unit. A prefix that is not a schema
58    /// constant is refused; see the module note.
59    #[must_use]
60    pub fn prefix(mut self, value: &'a str) -> Self {
61        self.prefix = Some(value);
62        self
63    }
64}
65
66/// Authored fields for `IfcMonetaryUnit`.
67#[derive(Debug, Clone, Copy)]
68#[non_exhaustive]
69pub struct MonetaryUnitDraft<'a> {
70    /// `IfcMonetaryUnit.Currency`, an ISO 4217 code such as `EUR`.
71    ///
72    /// An `IfcLabel` from IFC4 on, an `IfcCurrencyEnum` enumerator in
73    /// IFC2X3; [`create_monetary_unit`] writes the declared form.
74    pub currency: &'a str,
75}
76
77impl<'a> MonetaryUnitDraft<'a> {
78    /// Starts a draft from its required `currency`; every other field is unset.
79    #[must_use]
80    pub fn new(currency: &'a str) -> Self {
81        Self { currency }
82    }
83}
84
85/// Stage an `IfcSIUnit`.
86///
87/// `Dimensions` is written as `Value::Derived`, the STEP asterisk. The
88/// schema declares it DERIVE on this subtype, computed from `Name`, and
89/// a derived attribute is not an omitted one: `$` claims the value is
90/// absent, while `*` states it is computed by the schema rule.
91pub fn add_si_unit(tx: &mut Transaction, draft: SiUnitDraft<'_>) -> PropertyResult<EntityId> {
92    require_enum("IFCSIUNIT", "UnitType", draft.unit_type)?;
93    require_enum("IFCSIUNIT", "Name", draft.name)?;
94    if let Some(prefix) = draft.prefix {
95        if prefix_exponent(prefix).is_none() {
96            return Err(authoring_invalid("IFCSIUNIT", "Prefix", prefix));
97        }
98    }
99    Ok(tx.create(Entity::new(
100        "IFCSIUNIT",
101        vec![
102            Value::Derived,
103            enum_value(draft.unit_type),
104            draft.prefix.map_or(Value::Null, enum_value),
105            enum_value(draft.name),
106        ],
107    )))
108}
109
110/// Stage an `IfcMonetaryUnit` as IFC4 text, whatever the model's release.
111///
112/// Takes no model, so it cannot know the release, and always writes
113/// `Currency` as an `IfcLabel`. That is the IFC4, IFC4X1, IFC4X2 and
114/// IFC4X3 form; in IFC2X3 `Currency` is an `IfcCurrencyEnum`, so the
115/// record this writes there is schema-invalid.
116///
117/// # Errors
118///
119/// [`PropertyError::AuthoringInvalid`] for a blank currency.
120#[deprecated(
121    note = "writes IFC4 text even in IFC2X3; use `create_monetary_unit`, which binds the model's release (#232)"
122)]
123pub fn add_monetary_unit(
124    tx: &mut Transaction,
125    draft: MonetaryUnitDraft<'_>,
126) -> PropertyResult<EntityId> {
127    require_currency(draft.currency)?;
128    Ok(tx.create(Entity::new(
129        MONETARY_UNIT,
130        vec![Value::Text(draft.currency.into())],
131    )))
132}
133
134/// Stage an `IfcMonetaryUnit` in `model`'s declared release.
135///
136/// `Currency` changed type between releases:
137///
138/// ```text
139/// IFC2X3 TC1                         Currency : IfcCurrencyEnum
140/// IFC4, IFC4X1, IFC4X2, IFC4X3 ADD2  Currency : IfcLabel
141/// ```
142///
143/// In IFC2X3 the currency must name an `IfcCurrencyEnum` enumerator (matched
144/// ignoring ASCII case) and is written as that token, `.EUR.`; elsewhere it
145/// is written as the text given, `'EUR'`. The release binds as for
146/// [`create_quantity`](crate::create_quantity): IFC2X3, IFC4 and IFC4X3,
147/// with an empty header binding IFC4.
148///
149/// # Errors
150///
151/// Refused before anything is staged:
152/// - [`PropertyError::AuthoringInvalid`]: a blank currency, or in IFC2X3 a
153///   currency `IfcCurrencyEnum` does not list. It is never written as text
154///   there, nor mapped to another enumerator.
155/// - [`PropertyError::MultipleSchemas`] and
156///   [`PropertyError::UnsupportedSchema`]: the header binds no single
157///   release this writer is verified against.
158pub fn create_monetary_unit(
159    tx: &mut Transaction,
160    model: &Model,
161    draft: MonetaryUnitDraft<'_>,
162) -> PropertyResult<EntityId> {
163    let layout = bind(model)?;
164    require_currency(draft.currency)?;
165    let schema = layout.schema();
166    let declared = schema
167        .attributes(MONETARY_UNIT)
168        .into_iter()
169        .find(|attribute| attribute.name.eq_ignore_ascii_case("Currency"))
170        .ok_or(PropertyError::AuthoringNotInSchema {
171            entity: MONETARY_UNIT,
172            attribute: "Currency",
173            schema: layout.version(),
174        })?;
175    let currency = draft.currency.trim();
176    let value = match schema.type_def(&declared.type_name).map(|t| &t.kind) {
177        Some(TypeKind::Enumeration(members)) => {
178            let token = members
179                .iter()
180                .find(|member| member.eq_ignore_ascii_case(currency))
181                .ok_or_else(|| {
182                    authoring_invalid(
183                        MONETARY_UNIT,
184                        "Currency",
185                        format!(
186                            "{currency:?} is not an {} member in {:?}",
187                            declared.type_name,
188                            layout.version()
189                        ),
190                    )
191                })?;
192            Value::Enum(token.as_str().into())
193        }
194        _ => Value::Text(draft.currency.into()),
195    };
196    let record = layout.named_record(MONETARY_UNIT, vec![("Currency", value)])?;
197    Ok(tx.create(record))
198}
199
200const MONETARY_UNIT: &str = "IFCMONETARYUNIT";
201
202fn require_currency(currency: &str) -> PropertyResult<()> {
203    if currency.trim().is_empty() {
204        return Err(authoring_invalid(
205            MONETARY_UNIT,
206            "Currency",
207            "expected a currency code",
208        ));
209    }
210    Ok(())
211}
212
213/// Authored fields for `IfcConversionBasedUnit`.
214#[derive(Debug, Clone, Copy)]
215#[non_exhaustive]
216pub struct ConversionBasedUnitDraft<'a> {
217    /// `IfcConversionBasedUnit.UnitType`, an `IfcUnitEnum` constant.
218    pub unit_type: &'a str,
219    /// `IfcConversionBasedUnit.Name`.
220    pub name: &'a str,
221    /// `IfcConversionBasedUnit.ConversionFactor`, an `IfcMeasureWithUnit`.
222    pub conversion_factor: EntityId,
223    /// `IfcConversionBasedUnit.Dimensions`, an `IfcDimensionalExponents`.
224    pub dimensions: EntityId,
225}
226
227impl<'a> ConversionBasedUnitDraft<'a> {
228    /// Starts a draft from its required `unit_type`, `name`,
229    /// `conversion_factor`, `dimensions`; every other field is unset.
230    #[must_use]
231    pub fn new(
232        unit_type: &'a str,
233        name: &'a str,
234        conversion_factor: EntityId,
235        dimensions: EntityId,
236    ) -> Self {
237        Self {
238            unit_type,
239            name,
240            conversion_factor,
241            dimensions,
242        }
243    }
244}
245
246/// Stage an `IfcConversionBasedUnit`.
247///
248/// The conversion factor and dimensions are references the caller must have
249/// created; their types are checked by `ifc-validate`, not here, because this
250/// crate has no schema handle.
251pub fn add_conversion_based_unit(
252    tx: &mut Transaction,
253    draft: ConversionBasedUnitDraft<'_>,
254) -> PropertyResult<EntityId> {
255    require_enum("IFCCONVERSIONBASEDUNIT", "UnitType", draft.unit_type)?;
256    if draft.name.trim().is_empty() {
257        return Err(authoring_invalid(
258            "IFCCONVERSIONBASEDUNIT",
259            "Name",
260            "expected a non-empty name",
261        ));
262    }
263    Ok(tx.create(Entity::new(
264        "IFCCONVERSIONBASEDUNIT",
265        vec![
266            Value::Ref(draft.dimensions),
267            enum_value(draft.unit_type),
268            Value::Text(draft.name.into()),
269            Value::Ref(draft.conversion_factor),
270        ],
271    )))
272}
273
274/// Stage an `IfcDimensionalExponents`.
275///
276/// All seven SI base exponents are required by the schema, so they are taken
277/// positionally rather than as options: a missing exponent is not "unset", it
278/// is zero, and conflating the two silently changes the dimension.
279pub fn add_dimensional_exponents(tx: &mut Transaction, exponents: [i64; 7]) -> EntityId {
280    tx.create(Entity::new(
281        "IFCDIMENSIONALEXPONENTS",
282        exponents.iter().copied().map(Value::Integer).collect(),
283    ))
284}
285
286/// Stage an `IfcDerivedUnitElement`: one base unit raised to an exponent.
287pub fn add_derived_unit_element(
288    tx: &mut Transaction,
289    unit: EntityId,
290    exponent: i64,
291) -> PropertyResult<EntityId> {
292    if exponent == 0 {
293        return Err(authoring_invalid(
294            "IFCDERIVEDUNITELEMENT",
295            "Exponent",
296            "expected a non-zero exponent",
297        ));
298    }
299    Ok(tx.create(Entity::new(
300        "IFCDERIVEDUNITELEMENT",
301        vec![Value::Ref(unit), Value::Integer(exponent)],
302    )))
303}
304
305/// Stage an `IfcDerivedUnit` over previously staged elements.
306pub fn add_derived_unit(
307    tx: &mut Transaction,
308    elements: &[EntityId],
309    unit_type: &str,
310    user_defined_type: Option<&str>,
311) -> PropertyResult<EntityId> {
312    if elements.is_empty() {
313        return Err(authoring_invalid(
314            "IFCDERIVEDUNIT",
315            "Elements",
316            "expected at least one derived unit element",
317        ));
318    }
319    require_enum("IFCDERIVEDUNIT", "UnitType", unit_type)?;
320    Ok(tx.create(Entity::new(
321        "IFCDERIVEDUNIT",
322        vec![
323            Value::List(elements.iter().copied().map(Value::Ref).collect()),
324            enum_value(unit_type),
325            user_defined_type.map_or(Value::Null, |v| Value::Text(v.into())),
326        ],
327    )))
328}
329
330/// Stage an `IfcUnitAssignment`: the project-wide unit context.
331///
332/// Assigning no units is refused. An empty assignment parses, but leaves every
333/// measure in the file dimensionless by omission, which is never intended.
334pub fn assign_units(tx: &mut Transaction, units: &[EntityId]) -> PropertyResult<EntityId> {
335    if units.is_empty() {
336        return Err(authoring_invalid(
337            "IFCUNITASSIGNMENT",
338            "Units",
339            "expected at least one unit",
340        ));
341    }
342    Ok(tx.create(Entity::new(
343        "IFCUNITASSIGNMENT",
344        vec![Value::List(units.iter().copied().map(Value::Ref).collect())],
345    )))
346}
347
348/// Stage an `IfcMeasureWithUnit`.
349///
350/// A magnitude paired with the unit it is stated in. Both attributes
351/// are required: this is the entity `IfcConversionBasedUnit` points at
352/// for its conversion factor, and a factor missing either half states
353/// no conversion at all.
354///
355/// # Errors
356///
357/// Refuses a null value component; the schema types it as a required
358/// `IfcValue`. A bare literal is refused with
359/// [`PropertyError::ValueForm`](crate::PropertyError::ValueForm): `IfcValue`
360/// is a SELECT, so the value names its measure as a typed parameter.
361pub fn add_measure_with_unit(
362    tx: &mut Transaction,
363    value: Value,
364    unit: EntityId,
365) -> PropertyResult<EntityId> {
366    if matches!(value, Value::Null) {
367        return Err(authoring_invalid(
368            "IFCMEASUREWITHUNIT",
369            "ValueComponent",
370            "expected a value",
371        ));
372    }
373    crate::pset::value_form::require_ifc_value("IFCMEASUREWITHUNIT", "ValueComponent", &value)?;
374    Ok(tx.create(Entity::new(
375        "IFCMEASUREWITHUNIT",
376        vec![value, Value::Ref(unit)],
377    )))
378}
379
380/// Stage an `IfcContextDependentUnit`.
381///
382/// A unit with no SI conversion, named by the project that defines it.
383/// Unlike `IfcSIUnit`, `Dimensions` is a real attribute here rather
384/// than a derived one, so the caller supplies it.
385///
386/// # Errors
387///
388/// Refuses a blank name or a malformed `UnitType` token.
389pub fn add_context_dependent_unit(
390    tx: &mut Transaction,
391    dimensions: EntityId,
392    unit_type: &str,
393    name: &str,
394) -> PropertyResult<EntityId> {
395    require_enum("IFCCONTEXTDEPENDENTUNIT", "UnitType", unit_type)?;
396    if name.trim().is_empty() {
397        return Err(authoring_invalid(
398            "IFCCONTEXTDEPENDENTUNIT",
399            "Name",
400            "expected a non-empty name",
401        ));
402    }
403    Ok(tx.create(Entity::new(
404        "IFCCONTEXTDEPENDENTUNIT",
405        vec![
406            Value::Ref(dimensions),
407            enum_value(unit_type),
408            Value::Text(name.into()),
409        ],
410    )))
411}
412
413fn require_enum(entity: &'static str, attribute: &'static str, value: &str) -> PropertyResult<()> {
414    if value.trim().is_empty() || !value.bytes().all(|b| b.is_ascii_uppercase() || b == b'_') {
415        return Err(authoring_invalid(entity, attribute, value));
416    }
417    Ok(())
418}
419
420fn enum_value(value: &str) -> Value {
421    Value::Enum(value.into())
422}
423
424fn authoring_invalid(
425    entity: &'static str,
426    attribute: &'static str,
427    value: impl Into<String>,
428) -> PropertyError {
429    PropertyError::AuthoringInvalid {
430        entity,
431        attribute,
432        value: value.into(),
433    }
434}
435
436/// Stage an `IfcConversionBasedUnitWithOffset`.
437///
438/// The offset form exists for scales whose zero is not the SI zero:
439/// degrees Celsius convert to kelvin by a factor of one and an offset
440/// of 273.15. Writing that as a plain conversion unit would place
441/// absolute zero at the freezing point of water, so the offset is a
442/// separate entity rather than an optional slot on the base form.
443///
444/// # Errors
445///
446/// Refuses a `UnitType` outside `IfcUnitEnum`, a blank name, and a
447/// non-finite offset.
448pub fn add_conversion_based_unit_with_offset(
449    tx: &mut Transaction,
450    draft: ConversionBasedUnitDraft<'_>,
451    conversion_offset: f64,
452) -> PropertyResult<EntityId> {
453    const ENTITY: &str = "IFCCONVERSIONBASEDUNITWITHOFFSET";
454    require_enum(ENTITY, "UnitType", draft.unit_type)?;
455    if draft.name.trim().is_empty() {
456        return Err(authoring_invalid(
457            ENTITY,
458            "Name",
459            "expected a non-empty name",
460        ));
461    }
462    if !conversion_offset.is_finite() {
463        return Err(authoring_invalid(
464            ENTITY,
465            "ConversionOffset",
466            "expected a finite offset",
467        ));
468    }
469    Ok(tx.create(Entity::new(
470        ENTITY,
471        vec![
472            Value::Ref(draft.dimensions),
473            enum_value(draft.unit_type),
474            Value::Text(draft.name.into()),
475            Value::Ref(draft.conversion_factor),
476            Value::Real(conversion_offset),
477        ],
478    )))
479}