Skip to main content

ifc_structural/authoring/
load_group.rs

1//! Staging `IfcStructuralLoadGroup` and `IfcStructuralLoadCase`.
2//!
3//! # A load case is a load group that says so
4//!
5//! `IfcStructuralLoadCase` adds one attribute to its supertype and
6//! one rule:
7//!
8//! ```text
9//! IsLoadCasePredefinedType :
10//!   SELF\\IfcStructuralLoadGroup.PredefinedType = IfcLoadGroupTypeEnum.LOAD_CASE;
11//! ```
12//!
13//! The rule pins an *inherited* attribute. A load case whose
14//! `PredefinedType` says `LOAD_COMBINATION` is a contradiction the
15//! schema forbids, so the caller does not choose that token for a
16//! case: the writer sets it.
17//!
18//! # Three enums, one rule
19//!
20//! `HasObjectType` fires when *any* of `PredefinedType`,
21//! `ActionType` or `ActionSource` is `USERDEFINED`. One
22//! `ObjectType` covers all three, so the check is on the
23//! disjunction rather than per attribute.
24
25use ifc_model::guid::Guid;
26use ifc_model::{EntityId, Model, Transaction, Value};
27use ifc_schema::{Schema, TypeKind};
28
29use super::{build_named, optional_text, validate_optional_ref};
30use crate::error::{StructuralError, StructuralResult};
31
32/// Which of the two group forms to stage.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub enum LoadGroupKind {
35    /// `IfcStructuralLoadGroup`, whose `PredefinedType` the caller chooses.
36    Group {
37        /// `PredefinedType`, an `IfcLoadGroupTypeEnum` token.
38        predefined_type: &'static str,
39    },
40    /// `IfcStructuralLoadCase`, whose `PredefinedType` is pinned to
41    /// `LOAD_CASE` by `IsLoadCasePredefinedType`.
42    Case {
43        /// `SelfWeightCoefficients`, a `LIST [3:3]` of ratios when given.
44        ///
45        /// Exactly three: one per global axis. Any other length is
46        /// refused rather than padded, because a two-entry list
47        /// leaves an axis with no stated self-weight.
48        self_weight_coefficients: Option<[f64; 3]>,
49    },
50}
51
52/// Staged fields for [`stage_load_group`].
53///
54/// A load group is an `IfcGroup`, not an `IfcProduct`: it declares
55/// no `ObjectPlacement` or `Representation`, so this draft has no
56/// fields for them rather than accepting and dropping them.
57#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub struct LoadGroupDraft {
60    /// `GlobalId`; must parse as a 22-character IFC GUID.
61    pub global_id: String,
62    /// `OwnerHistory`, validated against the model/transaction if present.
63    pub owner_history: Option<EntityId>,
64    /// `Name`.
65    pub name: Option<String>,
66    /// `Description`.
67    pub description: Option<String>,
68    /// `ObjectType`; required non-blank when any of the three
69    /// enum attributes is `USERDEFINED`.
70    pub object_type: Option<String>,
71    /// `ActionType`, an `IfcActionTypeEnum` token.
72    pub action_type: &'static str,
73    /// `ActionSource`, an `IfcActionSourceTypeEnum` token.
74    pub action_source: &'static str,
75    /// `Coefficient`, a ratio applied to every load in the group.
76    pub coefficient: Option<f64>,
77    /// `Purpose`.
78    pub purpose: Option<String>,
79    /// Which group form, and its form-specific attributes.
80    pub kind: LoadGroupKind,
81}
82
83impl LoadGroupDraft {
84    /// Starts a draft from its required fields; every other field is unset.
85    #[must_use]
86    pub fn new(
87        global_id: impl Into<String>,
88        action_type: &'static str,
89        action_source: &'static str,
90        kind: LoadGroupKind,
91    ) -> Self {
92        Self {
93            global_id: global_id.into(),
94            owner_history: None,
95            name: None,
96            description: None,
97            object_type: None,
98            action_type,
99            action_source,
100            coefficient: None,
101            purpose: None,
102            kind,
103        }
104    }
105
106    /// Sets `owner_history`: `OwnerHistory`, validated against the
107    /// model/transaction if present.
108    #[must_use]
109    pub fn owner_history(mut self, value: EntityId) -> Self {
110        self.owner_history = Some(value);
111        self
112    }
113
114    /// Sets `name`: `Name`.
115    #[must_use]
116    pub fn name(mut self, value: impl Into<String>) -> Self {
117        self.name = Some(value.into());
118        self
119    }
120
121    /// Sets `description`: `Description`.
122    #[must_use]
123    pub fn description(mut self, value: impl Into<String>) -> Self {
124        self.description = Some(value.into());
125        self
126    }
127
128    /// Sets `object_type`: `ObjectType`; required non-blank when any of the
129    /// three enum attributes is `USERDEFINED`.
130    #[must_use]
131    pub fn object_type(mut self, value: impl Into<String>) -> Self {
132        self.object_type = Some(value.into());
133        self
134    }
135
136    /// Sets `coefficient`: `Coefficient`, a ratio applied to every load in the
137    /// group.
138    #[must_use]
139    pub fn coefficient(mut self, value: f64) -> Self {
140        self.coefficient = Some(value);
141        self
142    }
143
144    /// Sets `purpose`: `Purpose`.
145    #[must_use]
146    pub fn purpose(mut self, value: impl Into<String>) -> Self {
147        self.purpose = Some(value.into());
148        self
149    }
150}
151
152/// Stage an `IfcStructuralLoadGroup` or `IfcStructuralLoadCase`.
153///
154/// # Errors
155///
156/// Refuses a malformed `GlobalId`; an enum token the target schema
157/// does not declare for that attribute; a `USERDEFINED` token with
158/// no non-blank `ObjectType` (`HasObjectType`); a non-finite
159/// `Coefficient` or self-weight ratio; and a `LOAD_CASE`
160/// `PredefinedType` requested for the plain group form, which
161/// would duplicate the case form with the rule unenforced.
162pub fn stage_load_group(
163    tx: &mut Transaction,
164    model: &Model,
165    schema: &Schema,
166    draft: LoadGroupDraft,
167) -> StructuralResult<EntityId> {
168    if Guid::parse(&draft.global_id).is_none() {
169        return Err(StructuralError::InvalidGlobalId);
170    }
171    let entity_type = match draft.kind {
172        LoadGroupKind::Group { .. } => "IfcStructuralLoadGroup",
173        LoadGroupKind::Case { .. } => "IfcStructuralLoadCase",
174    };
175    // IsLoadCasePredefinedType: the case form's inherited
176    // PredefinedType is not the caller's to choose.
177    let predefined_type = match draft.kind {
178        LoadGroupKind::Group { predefined_type } => {
179            if predefined_type.eq_ignore_ascii_case("LOAD_CASE") {
180                return Err(StructuralError::SemanticViolation {
181                    entity: None,
182                    rule: "LOAD_CASE PredefinedType requires IfcStructuralLoadCase",
183                });
184            }
185            predefined_type
186        }
187        LoadGroupKind::Case { .. } => "LOAD_CASE",
188    };
189
190    for (attribute, token) in [
191        ("PredefinedType", predefined_type),
192        ("ActionType", draft.action_type),
193        ("ActionSource", draft.action_source),
194    ] {
195        validate_enum_token(schema, entity_type, attribute, token)?;
196    }
197
198    // HasObjectType fires on the disjunction: one USERDEFINED
199    // anywhere among the three makes ObjectType the only place
200    // the intended kind is stated.
201    let user_defined = [predefined_type, draft.action_type, draft.action_source]
202        .iter()
203        .any(|token| token.eq_ignore_ascii_case("USERDEFINED"));
204    if user_defined
205        && draft
206            .object_type
207            .as_deref()
208            .is_none_or(|value| value.trim().is_empty())
209    {
210        return Err(StructuralError::SemanticViolation {
211            entity: None,
212            rule: "USERDEFINED load group requires an ObjectType",
213        });
214    }
215
216    validate_finite(draft.coefficient, entity_type, "Coefficient")?;
217    let self_weight = match draft.kind {
218        LoadGroupKind::Case {
219            self_weight_coefficients: Some(ratios),
220        } => {
221            for ratio in ratios {
222                validate_finite(Some(ratio), entity_type, "SelfWeightCoefficients")?;
223            }
224            Some(Value::List(
225                ratios.iter().copied().map(Value::Real).collect(),
226            ))
227        }
228        _ => None,
229    };
230
231    validate_root_refs(tx, model, schema, draft.owner_history)?;
232
233    let mut fields = vec![
234        ("GlobalId", Value::Text(draft.global_id.into())),
235        ("Name", optional_text(draft.name)),
236        ("Description", optional_text(draft.description)),
237        ("ObjectType", optional_text(draft.object_type)),
238        ("PredefinedType", Value::Enum(predefined_type.into())),
239        ("ActionType", Value::Enum(draft.action_type.into())),
240        ("ActionSource", Value::Enum(draft.action_source.into())),
241        ("Purpose", optional_text(draft.purpose)),
242        (
243            "Coefficient",
244            draft.coefficient.map_or(Value::Null, Value::Real),
245        ),
246    ];
247    if let Some(owner_history) = draft.owner_history {
248        fields.push(("OwnerHistory", Value::Ref(owner_history)));
249    }
250    // SelfWeightCoefficients belongs to the case form only;
251    // build_named refuses it on the plain group rather than
252    // dropping it, so it is pushed only when present.
253    if let Some(values) = self_weight {
254        fields.push(("SelfWeightCoefficients", values));
255    }
256    Ok(tx.create(build_named(schema, entity_type, fields)?))
257}
258
259/// Refuse an enum token the target schema does not declare.
260///
261/// Tokens are read from the schema rather than a local list, so a
262/// token added or withdrawn between schemas needs no edit here.
263pub(super) fn validate_enum_token(
264    schema: &Schema,
265    entity_type: &'static str,
266    attribute: &'static str,
267    token: &str,
268) -> StructuralResult<()> {
269    let declared = schema
270        .attributes(entity_type)
271        .iter()
272        .find(|candidate| candidate.name.eq_ignore_ascii_case(attribute))
273        .and_then(|candidate| schema.type_def(&candidate.type_name))
274        .is_some_and(|definition| match &definition.kind {
275            TypeKind::Enumeration(values) => values.iter().any(|member| member == token),
276            _ => false,
277        });
278    if declared {
279        return Ok(());
280    }
281    Err(StructuralError::InvalidDraftValue {
282        entity_type,
283        attribute,
284        expected: "a token the schema declares for this attribute",
285    })
286}
287
288/// Refuse a non-finite ratio.
289///
290/// NaN and the infinities all survive a `f64` slot and reach the
291/// file as text no reader can act on.
292fn validate_finite(
293    value: Option<f64>,
294    entity_type: &'static str,
295    attribute: &'static str,
296) -> StructuralResult<()> {
297    if value.is_some_and(|number| !number.is_finite()) {
298        return Err(StructuralError::InvalidDraftValue {
299            entity_type,
300            attribute,
301            expected: "a finite ratio",
302        });
303    }
304    Ok(())
305}
306
307/// Validate the one reference an `IfcGroup`-rooted record carries.
308fn validate_root_refs(
309    tx: &Transaction,
310    model: &Model,
311    schema: &Schema,
312    owner_history: Option<EntityId>,
313) -> StructuralResult<()> {
314    validate_optional_ref(tx, model, schema, owner_history, "IfcOwnerHistory")
315}