Skip to main content

ifc_structural/authoring/
action.rs

1use ifc_model::{EntityId, Model, Transaction, Value};
2use ifc_schema::{Schema, TypeKind};
3
4use super::item::{root_fields, validate_root, StructuralRootDraft};
5use super::{build_named, optional_ref, validate_optional_ref, validate_ref, validate_ref_select};
6use crate::action::CoordinateSystem;
7use crate::error::{StructuralError, StructuralResult};
8
9/// Value of `IfcProjectedOrTrueLengthEnum` naming how a linear/planar action's magnitude is measured.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ProjectedOrTrue {
12    /// `PROJECTED_LENGTH`: magnitude given per unit of the projected length/area.
13    ProjectedLength,
14    /// `TRUE_LENGTH`: magnitude given per unit of the true (unprojected) length/area.
15    TrueLength,
16}
17
18impl ProjectedOrTrue {
19    fn token(self) -> &'static str {
20        match self {
21            Self::ProjectedLength => "PROJECTED_LENGTH",
22            Self::TrueLength => "TRUE_LENGTH",
23        }
24    }
25}
26
27/// Staged action subtype for [`ActionDraft::kind`].
28#[derive(Debug, Clone)]
29pub enum ActionDraftKind {
30    /// Stages an `IfcStructuralPointAction`.
31    Point,
32    /// Stages an `IfcStructuralLinearAction`.
33    Linear {
34        /// `ProjectedOrTrue`; staged only when the target schema declares the attribute.
35        projected_or_true: Option<ProjectedOrTrue>,
36    },
37    /// Stages an `IfcStructuralPlanarAction`.
38    Planar {
39        /// `ProjectedOrTrue`; staged only when the target schema declares the attribute.
40        projected_or_true: Option<ProjectedOrTrue>,
41    },
42    /// Stages an `IfcStructuralCurveAction`.
43    ///
44    /// IFC4 replaced the linear/planar pair with curve/surface forms that
45    /// additionally carry a mandatory `PredefinedType`.
46    Curve {
47        /// `ProjectedOrTrue`; staged only when the target schema declares it.
48        projected_or_true: Option<ProjectedOrTrue>,
49        /// `PredefinedType`, an `IfcStructuralCurveActivityTypeEnum` token.
50        predefined_type: &'static str,
51    },
52    /// Stages an `IfcStructuralSurfaceAction`.
53    Surface {
54        /// `ProjectedOrTrue`; staged only when the target schema declares it.
55        projected_or_true: Option<ProjectedOrTrue>,
56        /// `PredefinedType`, an `IfcStructuralSurfaceActivityTypeEnum` token.
57        predefined_type: &'static str,
58    },
59}
60
61/// Staged fields for creating an `IfcStructuralAction` via [`stage_action`].
62#[derive(Debug, Clone)]
63#[non_exhaustive]
64pub struct ActionDraft {
65    /// `IfcRoot` attributes shared with other staged structural entities.
66    pub root: StructuralRootDraft,
67    /// `AppliedLoad`; must reference a load type compatible with `kind`.
68    pub applied_load: EntityId,
69    /// `GlobalOrLocal`.
70    pub coordinate_system: CoordinateSystem,
71    /// `DestabilizingLoad`; required when the target schema (IFC2X3) declares it mandatory.
72    pub destabilizing_load: Option<bool>,
73    /// `CausedBy`; only staged when the target schema declares the attribute.
74    pub caused_by: Option<EntityId>,
75    /// Which `IfcStructuralAction` subtype to create, and its subtype-specific attributes.
76    pub kind: ActionDraftKind,
77    /// Varying-load attributes; when set, a [`ActionDraftKind::Linear`] or
78    /// [`ActionDraftKind::Planar`] kind stages the IFC2X3 varying subtype.
79    pub varying: Option<VaryingActionDraft>,
80}
81
82/// IFC2X3 varying-action attributes for [`ActionDraft::varying`].
83///
84/// With [`ActionDraftKind::Linear`] it stages an
85/// `IfcStructuralLinearActionVarying` (`SubsequentAppliedLoads : LIST [1:?]`);
86/// with [`ActionDraftKind::Planar`] an `IfcStructuralPlanarActionVarying`
87/// (`LIST [2:?]`). IFC4 and IFC4X3 declare neither entity.
88#[derive(Debug, Clone, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct VaryingActionDraft {
91    /// `VaryingAppliedLoadLocation`, an `IfcShapeAspect` reference.
92    pub varying_applied_load_location: EntityId,
93    /// `SubsequentAppliedLoads`, `IfcStructuralLoad` references in list
94    /// order after the action's `AppliedLoad`. Repeats are allowed (LIST).
95    pub subsequent_applied_loads: Vec<EntityId>,
96}
97
98impl VaryingActionDraft {
99    /// Starts a draft from its two required attributes.
100    #[must_use]
101    pub fn new(
102        varying_applied_load_location: EntityId,
103        subsequent_applied_loads: Vec<EntityId>,
104    ) -> Self {
105        Self {
106            varying_applied_load_location,
107            subsequent_applied_loads,
108        }
109    }
110}
111
112impl ActionDraft {
113    /// Starts a draft from its required fields; every other field is unset.
114    #[must_use]
115    pub fn new(
116        root: StructuralRootDraft,
117        applied_load: EntityId,
118        coordinate_system: CoordinateSystem,
119        kind: ActionDraftKind,
120    ) -> Self {
121        Self {
122            root,
123            applied_load,
124            coordinate_system,
125            destabilizing_load: None,
126            caused_by: None,
127            kind,
128            varying: None,
129        }
130    }
131
132    /// Sets `destabilizing_load`: `DestabilizingLoad`; required when the target
133    /// schema (IFC2X3) declares it mandatory.
134    #[must_use]
135    pub fn destabilizing_load(mut self, value: bool) -> Self {
136        self.destabilizing_load = Some(value);
137        self
138    }
139
140    /// Sets `caused_by`: `CausedBy`; only staged when the target schema
141    /// declares the attribute.
142    #[must_use]
143    pub fn caused_by(mut self, value: EntityId) -> Self {
144        self.caused_by = Some(value);
145        self
146    }
147
148    /// Sets `varying`: stage the IFC2X3 varying subtype of a linear or
149    /// planar action with these attributes.
150    #[must_use]
151    pub fn varying(mut self, value: VaryingActionDraft) -> Self {
152        self.varying = Some(value);
153        self
154    }
155}
156
157/// Stage an `IfcStructuralAction` create edit on `tx`.
158///
159/// Fails with [`StructuralError::WrongReferenceType`] if `draft.applied_load`
160/// is not one of the load types `kind` permits, [`StructuralError::SemanticViolation`]
161/// if `ProjectedOrTrue` is `PROJECTED_LENGTH` while `coordinate_system` is not
162/// `Global`, and [`StructuralError::MissingRequired`] if `destabilizing_load`
163/// is unset while the target schema requires it.
164///
165/// With [`ActionDraft::varying`] set, a linear kind stages
166/// `IfcStructuralLinearActionVarying` and a planar kind
167/// `IfcStructuralPlanarActionVarying`. That fails with
168/// [`StructuralError::EntityNotInSchema`] outside IFC2X3, with
169/// [`StructuralError::InvalidDraftValue`] for any other kind or a
170/// `SubsequentAppliedLoads` list below its minimum (1 linear, 2 planar),
171/// and with the reference errors when the location is not an
172/// `IfcShapeAspect` or a subsequent load not an `IfcStructuralLoad`.
173/// Every refusal stages nothing. Returns the id staged for the new entity.
174pub fn stage_action(
175    tx: &mut Transaction,
176    model: &Model,
177    schema: &Schema,
178    draft: ActionDraft,
179) -> StructuralResult<EntityId> {
180    let varying = validate_varying(tx, model, schema, &draft)?;
181    validate_root(tx, model, schema, &draft.root)?;
182    let (entity_type, projected_or_true, load_members): (&str, Option<ProjectedOrTrue>, &[&str]) =
183        match draft.kind {
184            ActionDraftKind::Point => (
185                "IfcStructuralPointAction",
186                None,
187                &[
188                    "IfcStructuralLoadSingleForce",
189                    "IfcStructuralLoadSingleDisplacement",
190                ],
191            ),
192            ActionDraftKind::Linear { projected_or_true } => (
193                "IfcStructuralLinearAction",
194                projected_or_true,
195                &[
196                    "IfcStructuralLoadLinearForce",
197                    "IfcStructuralLoadTemperature",
198                ],
199            ),
200            ActionDraftKind::Planar { projected_or_true } => (
201                "IfcStructuralPlanarAction",
202                projected_or_true,
203                &[
204                    "IfcStructuralLoadPlanarForce",
205                    "IfcStructuralLoadTemperature",
206                ],
207            ),
208            ActionDraftKind::Curve {
209                projected_or_true, ..
210            } => (
211                "IfcStructuralCurveAction",
212                projected_or_true,
213                &[
214                    "IfcStructuralLoadLinearForce",
215                    "IfcStructuralLoadTemperature",
216                ],
217            ),
218            ActionDraftKind::Surface {
219                projected_or_true, ..
220            } => (
221                "IfcStructuralSurfaceAction",
222                projected_or_true,
223                &[
224                    "IfcStructuralLoadPlanarForce",
225                    "IfcStructuralLoadTemperature",
226                ],
227            ),
228        };
229    let entity_type = varying.as_ref().map_or(entity_type, |(name, _)| *name);
230    validate_ref_select(
231        tx,
232        model,
233        schema,
234        draft.applied_load,
235        "compatible structural load",
236        load_members,
237    )?;
238    validate_optional_ref(tx, model, schema, draft.caused_by, "IfcStructuralReaction")?;
239    if projected_or_true == Some(ProjectedOrTrue::ProjectedLength)
240        && draft.coordinate_system != CoordinateSystem::Global
241    {
242        return Err(StructuralError::SemanticViolation {
243            entity: None,
244            rule: "PROJECTED_LENGTH structural action requires GLOBAL_COORDS",
245        });
246    }
247    let attributes = schema.attributes(entity_type);
248
249    let destabilizing_required = attributes
250        .iter()
251        .find(|a| a.name.eq_ignore_ascii_case("DestabilizingLoad"))
252        .is_some_and(|attribute| !attribute.optional);
253    if destabilizing_required && draft.destabilizing_load.is_none() {
254        return Err(StructuralError::MissingRequired {
255            entity_type: entity_type.into(),
256            attribute: "DestabilizingLoad".into(),
257        });
258    }
259    let has_object_type = draft.root.object_type.is_some();
260    let mut fields = root_fields(draft.root);
261    fields.push(("AppliedLoad", Value::Ref(draft.applied_load)));
262    fields.push((
263        "GlobalOrLocal",
264        Value::Enum(match draft.coordinate_system {
265            CoordinateSystem::Global => "GLOBAL_COORDS".into(),
266            CoordinateSystem::Local => "LOCAL_COORDS".into(),
267        }),
268    ));
269    if attributes
270        .iter()
271        .any(|a| a.name.eq_ignore_ascii_case("DestabilizingLoad"))
272    {
273        fields.push((
274            "DestabilizingLoad",
275            draft.destabilizing_load.map_or(Value::Null, Value::Bool),
276        ));
277    }
278    if attributes
279        .iter()
280        .any(|a| a.name.eq_ignore_ascii_case("CausedBy"))
281    {
282        fields.push(("CausedBy", optional_ref(draft.caused_by)));
283    }
284    if attributes
285        .iter()
286        .any(|a| a.name.eq_ignore_ascii_case("ProjectedOrTrue"))
287    {
288        fields.push((
289            "ProjectedOrTrue",
290            projected_or_true.map_or(Value::Null, |value| Value::Enum(value.token().into())),
291        ));
292    }
293    if attributes
294        .iter()
295        .any(|a| a.name.eq_ignore_ascii_case("PredefinedType"))
296    {
297        // IFC4 makes PredefinedType mandatory on the curve and surface
298        // forms. USERDEFINED without an ObjectType names nothing, which
299        // is the same trap the element types carry.
300        let token = match draft.kind {
301            ActionDraftKind::Curve {
302                predefined_type, ..
303            }
304            | ActionDraftKind::Surface {
305                predefined_type, ..
306            } => predefined_type,
307            _ => "NOTDEFINED",
308        };
309        if token.eq_ignore_ascii_case("USERDEFINED") && !has_object_type {
310            return Err(StructuralError::SemanticViolation {
311                entity: None,
312                rule: "USERDEFINED PredefinedType requires an ObjectType",
313            });
314        }
315        // SuitablePredefinedType: the curve form alone excludes
316        // EQUIDISTANT. The token exists in the enum because the
317        // activity-type enum is shared with curve *reactions*, where
318        // equidistant results are meaningful; an applied action
319        // cannot be equidistant.
320        if matches!(draft.kind, ActionDraftKind::Curve { .. })
321            && token.eq_ignore_ascii_case("EQUIDISTANT")
322        {
323            return Err(StructuralError::SemanticViolation {
324                entity: None,
325                rule: "IfcStructuralCurveAction.SuitablePredefinedType",
326            });
327        }
328        // The token must be one the target schema declares for this
329        // attribute: a surface token on a curve action resolves to a
330        // slot that accepts it structurally and means nothing.
331        validate_activity_token(schema, entity_type, token)?;
332        fields.push(("PredefinedType", Value::Enum(token.into())));
333    }
334    if let Some((_, varying)) = varying {
335        fields.push((
336            "VaryingAppliedLoadLocation",
337            Value::Ref(varying.varying_applied_load_location),
338        ));
339        fields.push((
340            "SubsequentAppliedLoads",
341            Value::List(
342                varying
343                    .subsequent_applied_loads
344                    .into_iter()
345                    .map(Value::Ref)
346                    .collect(),
347            ),
348        ));
349    }
350    Ok(tx.create(build_named(schema, entity_type, fields)?))
351}
352
353/// Resolve and check [`ActionDraft::varying`] before anything is staged.
354///
355/// Returns the varying entity type with its attributes, or `None` when the
356/// draft is not varying.
357fn validate_varying(
358    tx: &Transaction,
359    model: &Model,
360    schema: &Schema,
361    draft: &ActionDraft,
362) -> StructuralResult<Option<(&'static str, VaryingActionDraft)>> {
363    let Some(varying) = &draft.varying else {
364        return Ok(None);
365    };
366    let (entity_type, minimum, expected) = match draft.kind {
367        ActionDraftKind::Linear { .. } => (
368            "IfcStructuralLinearActionVarying",
369            1,
370            "LIST [1:?] of IfcStructuralLoad references",
371        ),
372        ActionDraftKind::Planar { .. } => (
373            "IfcStructuralPlanarActionVarying",
374            2,
375            "LIST [2:?] of IfcStructuralLoad references",
376        ),
377        _ => {
378            return Err(StructuralError::InvalidDraftValue {
379                entity_type: "IfcStructuralAction",
380                attribute: "SubsequentAppliedLoads",
381                expected: "a Linear or Planar action kind (only those have varying subtypes)",
382            })
383        }
384    };
385    if schema.entity(entity_type).is_none() {
386        return Err(StructuralError::EntityNotInSchema {
387            entity: entity_type,
388            schema: schema.name().to_owned(),
389        });
390    }
391    if varying.subsequent_applied_loads.len() < minimum {
392        return Err(StructuralError::InvalidDraftValue {
393            entity_type,
394            attribute: "SubsequentAppliedLoads",
395            expected,
396        });
397    }
398    validate_ref(
399        tx,
400        model,
401        schema,
402        varying.varying_applied_load_location,
403        "IfcShapeAspect",
404    )?;
405    for load in &varying.subsequent_applied_loads {
406        validate_ref(tx, model, schema, *load, "IfcStructuralLoad")?;
407    }
408    Ok(Some((entity_type, varying.clone())))
409}
410
411/// Refuse a `PredefinedType` token the schema does not declare
412/// for `entity_type`.
413///
414/// The curve and surface forms carry different activity enums
415/// with overlapping tokens (`CONST`, `DISCRETE`), so a wrong-form
416/// token is not always visibly wrong.
417pub(super) fn validate_activity_token(
418    schema: &Schema,
419    entity_type: &'static str,
420    token: &str,
421) -> StructuralResult<()> {
422    let declared = schema
423        .attributes(entity_type)
424        .iter()
425        .find(|a| a.name.eq_ignore_ascii_case("PredefinedType"))
426        .and_then(|a| schema.type_def(&a.type_name))
427        .is_some_and(|def| {
428            matches!(&def.kind, TypeKind::Enumeration(values)
429                if values.iter().any(|v| v.eq_ignore_ascii_case(token)))
430        });
431    if declared {
432        return Ok(());
433    }
434    Err(StructuralError::InvalidDraftValue {
435        entity_type,
436        attribute: "PredefinedType",
437        expected: "a token this action's activity enum declares",
438    })
439}