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)]
58pub struct LoadGroupDraft {
59    /// `GlobalId`; must parse as a 22-character IFC GUID.
60    pub global_id: String,
61    /// `OwnerHistory`, validated against the model/transaction if present.
62    pub owner_history: Option<EntityId>,
63    /// `Name`.
64    pub name: Option<String>,
65    /// `Description`.
66    pub description: Option<String>,
67    /// `ObjectType`; required non-blank when any of the three
68    /// enum attributes is `USERDEFINED`.
69    pub object_type: Option<String>,
70    /// `ActionType`, an `IfcActionTypeEnum` token.
71    pub action_type: &'static str,
72    /// `ActionSource`, an `IfcActionSourceTypeEnum` token.
73    pub action_source: &'static str,
74    /// `Coefficient`, a ratio applied to every load in the group.
75    pub coefficient: Option<f64>,
76    /// `Purpose`.
77    pub purpose: Option<String>,
78    /// Which group form, and its form-specific attributes.
79    pub kind: LoadGroupKind,
80}
81
82/// Stage an `IfcStructuralLoadGroup` or `IfcStructuralLoadCase`.
83///
84/// # Errors
85///
86/// Refuses a malformed `GlobalId`; an enum token the target schema
87/// does not declare for that attribute; a `USERDEFINED` token with
88/// no non-blank `ObjectType` (`HasObjectType`); a non-finite
89/// `Coefficient` or self-weight ratio; and a `LOAD_CASE`
90/// `PredefinedType` requested for the plain group form, which
91/// would duplicate the case form with the rule unenforced.
92pub fn stage_load_group(
93    tx: &mut Transaction,
94    model: &Model,
95    schema: &Schema,
96    draft: LoadGroupDraft,
97) -> StructuralResult<EntityId> {
98    if Guid::parse(&draft.global_id).is_none() {
99        return Err(StructuralError::InvalidGlobalId);
100    }
101    let entity_type = match draft.kind {
102        LoadGroupKind::Group { .. } => "IfcStructuralLoadGroup",
103        LoadGroupKind::Case { .. } => "IfcStructuralLoadCase",
104    };
105    // IsLoadCasePredefinedType: the case form's inherited
106    // PredefinedType is not the caller's to choose.
107    let predefined_type = match draft.kind {
108        LoadGroupKind::Group { predefined_type } => {
109            if predefined_type.eq_ignore_ascii_case("LOAD_CASE") {
110                return Err(StructuralError::SemanticViolation {
111                    entity: None,
112                    rule: "LOAD_CASE PredefinedType requires IfcStructuralLoadCase",
113                });
114            }
115            predefined_type
116        }
117        LoadGroupKind::Case { .. } => "LOAD_CASE",
118    };
119
120    for (attribute, token) in [
121        ("PredefinedType", predefined_type),
122        ("ActionType", draft.action_type),
123        ("ActionSource", draft.action_source),
124    ] {
125        validate_enum_token(schema, entity_type, attribute, token)?;
126    }
127
128    // HasObjectType fires on the disjunction: one USERDEFINED
129    // anywhere among the three makes ObjectType the only place
130    // the intended kind is stated.
131    let user_defined = [predefined_type, draft.action_type, draft.action_source]
132        .iter()
133        .any(|token| token.eq_ignore_ascii_case("USERDEFINED"));
134    if user_defined
135        && draft
136            .object_type
137            .as_deref()
138            .is_none_or(|value| value.trim().is_empty())
139    {
140        return Err(StructuralError::SemanticViolation {
141            entity: None,
142            rule: "USERDEFINED load group requires an ObjectType",
143        });
144    }
145
146    validate_finite(draft.coefficient, entity_type, "Coefficient")?;
147    let self_weight = match draft.kind {
148        LoadGroupKind::Case {
149            self_weight_coefficients: Some(ratios),
150        } => {
151            for ratio in ratios {
152                validate_finite(Some(ratio), entity_type, "SelfWeightCoefficients")?;
153            }
154            Some(Value::List(
155                ratios.iter().copied().map(Value::Real).collect(),
156            ))
157        }
158        _ => None,
159    };
160
161    validate_root_refs(tx, model, schema, draft.owner_history)?;
162
163    let mut fields = vec![
164        ("GlobalId", Value::Text(draft.global_id.into())),
165        ("Name", optional_text(draft.name)),
166        ("Description", optional_text(draft.description)),
167        ("ObjectType", optional_text(draft.object_type)),
168        ("PredefinedType", Value::Enum(predefined_type.into())),
169        ("ActionType", Value::Enum(draft.action_type.into())),
170        ("ActionSource", Value::Enum(draft.action_source.into())),
171        ("Purpose", optional_text(draft.purpose)),
172        (
173            "Coefficient",
174            draft.coefficient.map_or(Value::Null, Value::Real),
175        ),
176    ];
177    if let Some(owner_history) = draft.owner_history {
178        fields.push(("OwnerHistory", Value::Ref(owner_history)));
179    }
180    // SelfWeightCoefficients belongs to the case form only;
181    // build_named refuses it on the plain group rather than
182    // dropping it, so it is pushed only when present.
183    if let Some(values) = self_weight {
184        fields.push(("SelfWeightCoefficients", values));
185    }
186    Ok(tx.create(build_named(schema, entity_type, fields)?))
187}
188
189/// Refuse an enum token the target schema does not declare.
190///
191/// Tokens are read from the schema rather than a local list, so a
192/// token added or withdrawn between schemas needs no edit here.
193pub(super) fn validate_enum_token(
194    schema: &Schema,
195    entity_type: &'static str,
196    attribute: &'static str,
197    token: &str,
198) -> StructuralResult<()> {
199    let declared = schema
200        .attributes(entity_type)
201        .iter()
202        .find(|candidate| candidate.name.eq_ignore_ascii_case(attribute))
203        .and_then(|candidate| schema.type_def(&candidate.type_name))
204        .is_some_and(|definition| match &definition.kind {
205            TypeKind::Enumeration(values) => values.iter().any(|member| member == token),
206            _ => false,
207        });
208    if declared {
209        return Ok(());
210    }
211    Err(StructuralError::InvalidDraftValue {
212        entity_type,
213        attribute,
214        expected: "a token the schema declares for this attribute",
215    })
216}
217
218/// Refuse a non-finite ratio.
219///
220/// NaN and the infinities all survive a `f64` slot and reach the
221/// file as text no reader can act on.
222fn validate_finite(
223    value: Option<f64>,
224    entity_type: &'static str,
225    attribute: &'static str,
226) -> StructuralResult<()> {
227    if value.is_some_and(|number| !number.is_finite()) {
228        return Err(StructuralError::InvalidDraftValue {
229            entity_type,
230            attribute,
231            expected: "a finite ratio",
232        });
233    }
234    Ok(())
235}
236
237/// Validate the one reference an `IfcGroup`-rooted record carries.
238fn validate_root_refs(
239    tx: &Transaction,
240    model: &Model,
241    schema: &Schema,
242    owner_history: Option<EntityId>,
243) -> StructuralResult<()> {
244    validate_optional_ref(tx, model, schema, owner_history, "IfcOwnerHistory")
245}