Skip to main content

ifc_material/
authoring.rs

1//! Transactional material authoring, bound to the model's release.
2//!
3//! These helpers only stage records. [`ifc_model::Transaction::commit`] owns
4//! atomic graph/index application, so a failed batch cannot leave part of a
5//! material graph in the model.
6//!
7//! Every record is laid out from the bundled table of the release the
8//! model's header declares (see [`crate::material_schema`]), never from IFC4
9//! by assumption. A record type the release lacks (a constituent or profile
10//! set for IFC2X3) is refused with [`MaterialError::EntityNotInSchema`]; a
11//! draft value for an attribute it lacks (an IFC2X3 layer `Name`) with
12//! [`MaterialError::AuthoringNotInSchema`]; and an attribute it requires
13//! that the call leaves unset (the IFC2X3 `OwnerHistory`) with
14//! [`MaterialError::AuthoringRequired`]. Nothing is staged on refusal.
15mod composites;
16mod relationships;
17
18pub use composites::{
19    create_constituent, create_constituent_set, create_profile, create_profile_set,
20    create_profile_set_usage, create_profile_set_usage_tapering, create_profile_with_offsets,
21    ConstituentDraft, ProfileDraft,
22};
23pub use relationships::{
24    create_material_classification_relationship, create_material_definition_representation,
25    create_material_properties, create_material_relationship,
26};
27
28use std::collections::HashSet;
29
30use ifc_model::{Edit, EntityId, Model, Transaction, Value};
31
32use crate::release::Release;
33use crate::{DirectionSense, LayerSetDirection, LogicalValue, MaterialError, MaterialResult};
34
35/// Authored identity fields for `IfcMaterial`.
36#[derive(Debug, Clone, Copy)]
37#[non_exhaustive]
38pub struct MaterialDraft<'a> {
39    /// `IfcMaterial.Name`.
40    pub name: &'a str,
41    /// `IfcMaterial.Description`, if given. IFC4 onwards.
42    pub description: Option<&'a str>,
43    /// `IfcMaterial.Category`, if given. IFC4 onwards.
44    pub category: Option<&'a str>,
45}
46
47impl<'a> MaterialDraft<'a> {
48    /// Starts a draft for a material called `name`.
49    #[must_use]
50    pub const fn new(name: &'a str) -> Self {
51        Self {
52            name,
53            description: None,
54            category: None,
55        }
56    }
57
58    /// Sets `Description`.
59    #[must_use]
60    pub const fn description(mut self, value: &'a str) -> Self {
61        self.description = Some(value);
62        self
63    }
64
65    /// Sets `Category`.
66    #[must_use]
67    pub const fn category(mut self, value: &'a str) -> Self {
68        self.category = Some(value);
69        self
70    }
71}
72
73/// Authored fields for `IfcMaterialLayer`.
74#[derive(Debug, Clone, Copy)]
75#[non_exhaustive]
76pub struct LayerDraft<'a> {
77    /// `IfcMaterialLayer.Material`, an `IfcMaterial` reference, if given.
78    pub material: Option<EntityId>,
79    /// `IfcMaterialLayer.LayerThickness`. Must be finite and non-negative;
80    /// strictly positive for IFC2X3.
81    pub thickness: f64,
82    /// `IfcMaterialLayer.IsVentilated`, if given.
83    pub is_ventilated: Option<LogicalValue>,
84    /// `IfcMaterialLayer.Name`, if given. IFC4 onwards.
85    pub name: Option<&'a str>,
86    /// `IfcMaterialLayer.Description`, if given. IFC4 onwards.
87    pub description: Option<&'a str>,
88    /// `IfcMaterialLayer.Category`, if given. IFC4 onwards.
89    pub category: Option<&'a str>,
90    /// `IfcMaterialLayer.Priority`, if given. Must be in `0..=100`. IFC4
91    /// onwards.
92    pub priority: Option<i64>,
93}
94
95impl<'a> LayerDraft<'a> {
96    /// Starts a draft for a layer `thickness` thick.
97    #[must_use]
98    pub const fn new(thickness: f64) -> Self {
99        Self {
100            material: None,
101            thickness,
102            is_ventilated: None,
103            name: None,
104            description: None,
105            category: None,
106            priority: None,
107        }
108    }
109
110    /// Sets `Material`, an `IfcMaterial` reference.
111    #[must_use]
112    pub const fn material(mut self, value: EntityId) -> Self {
113        self.material = Some(value);
114        self
115    }
116
117    /// Sets `IsVentilated`.
118    #[must_use]
119    pub const fn is_ventilated(mut self, value: LogicalValue) -> Self {
120        self.is_ventilated = Some(value);
121        self
122    }
123
124    /// Sets `Name`.
125    #[must_use]
126    pub const fn name(mut self, value: &'a str) -> Self {
127        self.name = Some(value);
128        self
129    }
130
131    /// Sets `Description`.
132    #[must_use]
133    pub const fn description(mut self, value: &'a str) -> Self {
134        self.description = Some(value);
135        self
136    }
137
138    /// Sets `Category`.
139    #[must_use]
140    pub const fn category(mut self, value: &'a str) -> Self {
141        self.category = Some(value);
142        self
143    }
144
145    /// Sets `Priority`.
146    #[must_use]
147    pub const fn priority(mut self, value: i64) -> Self {
148        self.priority = Some(value);
149        self
150    }
151}
152
153/// Ordered composition fields for `IfcMaterialLayerSet`.
154#[derive(Debug, Clone, Copy)]
155#[non_exhaustive]
156pub struct LayerSetDraft<'a> {
157    /// `IfcMaterialLayerSet.MaterialLayers`, in set order. Must be non-empty.
158    pub layers: &'a [EntityId],
159    /// `IfcMaterialLayerSet.LayerSetName`, if given.
160    pub name: Option<&'a str>,
161    /// `IfcMaterialLayerSet.Description`, if given. IFC4 onwards.
162    pub description: Option<&'a str>,
163}
164
165impl<'a> LayerSetDraft<'a> {
166    /// Starts a draft for a set of `layers`, in set order.
167    #[must_use]
168    pub const fn new(layers: &'a [EntityId]) -> Self {
169        Self {
170            layers,
171            name: None,
172            description: None,
173        }
174    }
175
176    /// Sets `LayerSetName`.
177    #[must_use]
178    pub const fn name(mut self, value: &'a str) -> Self {
179        self.name = Some(value);
180        self
181    }
182
183    /// Sets `Description`.
184    #[must_use]
185    pub const fn description(mut self, value: &'a str) -> Self {
186        self.description = Some(value);
187        self
188    }
189}
190
191/// Authored fields for `IfcRelAssociatesMaterial`.
192#[derive(Debug, Clone, Copy)]
193#[non_exhaustive]
194pub struct MaterialAssignmentDraft<'a> {
195    /// `IfcRelAssociatesMaterial.GlobalId`. Must be a valid IFC compressed
196    /// GUID.
197    pub global_id: &'a str,
198    /// `IfcRelAssociatesMaterial.Name`, if given.
199    pub name: Option<&'a str>,
200    /// `IfcRelAssociatesMaterial.Description`, if given.
201    pub description: Option<&'a str>,
202    /// `IfcRelAssociatesMaterial.RelatedObjects`. Must be non-empty and
203    /// contain no duplicate references.
204    pub related_objects: &'a [EntityId],
205    /// `IfcRelAssociatesMaterial.RelatingMaterial`, an `IfcMaterialSelect`
206    /// branch reference.
207    pub relating_material: EntityId,
208}
209
210impl<'a> MaterialAssignmentDraft<'a> {
211    /// Starts a draft associating `relating_material` with
212    /// `related_objects`.
213    #[must_use]
214    pub const fn new(
215        global_id: &'a str,
216        related_objects: &'a [EntityId],
217        relating_material: EntityId,
218    ) -> Self {
219        Self {
220            global_id,
221            name: None,
222            description: None,
223            related_objects,
224            relating_material,
225        }
226    }
227
228    /// Sets `Name`.
229    #[must_use]
230    pub const fn name(mut self, value: &'a str) -> Self {
231        self.name = Some(value);
232        self
233    }
234
235    /// Sets `Description`.
236    #[must_use]
237    pub const fn description(mut self, value: &'a str) -> Self {
238        self.description = Some(value);
239        self
240    }
241}
242
243/// Stage a material identity record in the model's release layout.
244///
245/// # Errors
246///
247/// [`MaterialError::AuthoringNotInSchema`] for a `description` or
248/// `category` in an IFC2X3 model, whose `IfcMaterial` declares `Name` only,
249/// and the release-binding errors.
250pub fn create_material(
251    tx: &mut Transaction,
252    model: &Model,
253    draft: MaterialDraft<'_>,
254) -> MaterialResult<EntityId> {
255    let record = Release::of(model).record(
256        "IFCMATERIAL",
257        vec![
258            ("Name", text(draft.name)),
259            ("Description", optional_text(draft.description)),
260            ("Category", optional_text(draft.category)),
261        ],
262    )?;
263    Ok(tx.create(record))
264}
265
266/// The layer attributes [`create_layer`] and [`create_layer_with_offsets`]
267/// share, after checking the thickness against the release's measure.
268fn layer_values(
269    release: Release<'_>,
270    entity: &'static str,
271    draft: &LayerDraft<'_>,
272) -> MaterialResult<Vec<(&'static str, Value)>> {
273    let declared = release.declared(entity, "LayerThickness")?;
274    let positive = declared
275        .type_name
276        .eq_ignore_ascii_case("IfcPositiveLengthMeasure");
277    if !draft.thickness.is_finite() || draft.thickness < 0.0 {
278        return Err(invalid(
279            entity,
280            "LayerThickness",
281            "expected a finite non-negative length",
282        ));
283    }
284    if positive && draft.thickness == 0.0 {
285        return Err(invalid(
286            entity,
287            "LayerThickness",
288            "expected a positive length (IfcPositiveLengthMeasure)",
289        ));
290    }
291    if let Some(priority) = draft.priority.filter(|value| !(0..=100).contains(value)) {
292        return Err(invalid(entity, "Priority", priority.to_string()));
293    }
294    Ok(vec![
295        ("Material", draft.material.map_or(Value::Null, Value::Ref)),
296        ("LayerThickness", Value::Real(draft.thickness)),
297        (
298            "IsVentilated",
299            draft.is_ventilated.map_or(Value::Null, logical),
300        ),
301        ("Name", optional_text(draft.name)),
302        ("Description", optional_text(draft.description)),
303        ("Category", optional_text(draft.category)),
304        (
305            "Priority",
306            draft.priority.map_or(Value::Null, Value::Integer),
307        ),
308    ])
309}
310
311/// Stage a layer after checking its scalar and material-reference invariants.
312///
313/// # Errors
314///
315/// Refuses a non-finite or negative thickness (zero too for IFC2X3), a
316/// priority outside `0..=100`, a material that is not an `IfcMaterial`, and
317/// for IFC2X3 any `name`, `description`, `category` or `priority`
318/// ([`MaterialError::AuthoringNotInSchema`]).
319pub fn create_layer(
320    tx: &mut Transaction,
321    model: &Model,
322    draft: LayerDraft<'_>,
323) -> MaterialResult<EntityId> {
324    const ENTITY: &str = "IFCMATERIALLAYER";
325    let release = Release::of(model);
326    let values = layer_values(release, ENTITY, &draft)?;
327    if let Some(material) = draft.material {
328        require_type(tx, model, release, material, &["IFCMATERIAL"])?;
329    }
330    Ok(tx.create(release.record(ENTITY, values)?))
331}
332
333/// Stage a non-empty ordered layer set. Layers may have been created earlier
334/// in this transaction; their staged type is checked just like stored records.
335///
336/// # Errors
337///
338/// Refuses an empty layer list, a member that is not a layer of the model's
339/// release, and a `description` for IFC2X3.
340pub fn create_layer_set(
341    tx: &mut Transaction,
342    model: &Model,
343    draft: LayerSetDraft<'_>,
344) -> MaterialResult<EntityId> {
345    const ENTITY: &str = "IFCMATERIALLAYERSET";
346    let release = Release::of(model);
347    release.require_entity(ENTITY, None)?;
348    if draft.layers.is_empty() {
349        return Err(invalid(
350            ENTITY,
351            "MaterialLayers",
352            "expected at least one layer",
353        ));
354    }
355    for &layer in draft.layers {
356        require_type(
357            tx,
358            model,
359            release,
360            layer,
361            &["IFCMATERIALLAYER", "IFCMATERIALLAYERWITHOFFSETS"],
362        )?;
363    }
364    let record = release.record(
365        ENTITY,
366        vec![
367            ("MaterialLayers", refs(draft.layers)),
368            ("LayerSetName", optional_text(draft.name)),
369            ("Description", optional_text(draft.description)),
370        ],
371    )?;
372    Ok(tx.create(record))
373}
374
375/// Stage a product/type material association after validating the IFC GlobalId,
376/// non-empty relation end, and `IfcMaterialSelect` branch.
377///
378/// `OwnerHistory` is left unset. IFC2X3 requires it, so an IFC2X3 model is
379/// refused with [`MaterialError::AuthoringRequired`]; use
380/// [`associate_material_with_owner_history`] there.
381///
382/// # Errors
383///
384/// Refuses a malformed GlobalId, an empty or duplicated `RelatedObjects`,
385/// and a `RelatingMaterial` that is no `IfcMaterialSelect` member of the
386/// model's release.
387pub fn associate_material(
388    tx: &mut Transaction,
389    model: &Model,
390    draft: MaterialAssignmentDraft<'_>,
391) -> MaterialResult<EntityId> {
392    associate(tx, model, draft, None)
393}
394
395/// [`associate_material`] with a caller-supplied `IfcOwnerHistory`, which
396/// IFC2X3 requires. The owner history is never invented here (build one with
397/// `ifc-author`).
398///
399/// # Errors
400///
401/// Those of [`associate_material`], and an `owner_history` that is not an
402/// `IfcOwnerHistory`.
403pub fn associate_material_with_owner_history(
404    tx: &mut Transaction,
405    model: &Model,
406    draft: MaterialAssignmentDraft<'_>,
407    owner_history: EntityId,
408) -> MaterialResult<EntityId> {
409    associate(tx, model, draft, Some(owner_history))
410}
411
412fn associate(
413    tx: &mut Transaction,
414    model: &Model,
415    draft: MaterialAssignmentDraft<'_>,
416    owner_history: Option<EntityId>,
417) -> MaterialResult<EntityId> {
418    const ENTITY: &str = "IFCRELASSOCIATESMATERIAL";
419    let release = Release::of(model);
420    release.require_entity(ENTITY, None)?;
421    if ifc_model::guid::Guid::parse(draft.global_id).is_none() {
422        return Err(invalid(ENTITY, "GlobalId", "expected IFC compressed GUID"));
423    }
424    if draft.related_objects.is_empty() {
425        return Err(invalid(
426            ENTITY,
427            "RelatedObjects",
428            "expected at least one object",
429        ));
430    }
431    let mut unique = HashSet::new();
432    for &object in draft.related_objects {
433        if !unique.insert(object) {
434            return Err(invalid(
435                ENTITY,
436                "RelatedObjects",
437                "duplicate object reference",
438            ));
439        }
440        require_exists(tx, model, object)?;
441    }
442    require_accepts(
443        tx,
444        model,
445        release,
446        ENTITY,
447        "RelatingMaterial",
448        draft.relating_material,
449    )?;
450    if let Some(owner_history) = owner_history {
451        require_type(tx, model, release, owner_history, &["IFCOWNERHISTORY"])?;
452    }
453    let record = release.record(
454        ENTITY,
455        vec![
456            ("GlobalId", text(draft.global_id)),
457            (
458                "OwnerHistory",
459                owner_history.map_or(Value::Null, Value::Ref),
460            ),
461            ("Name", optional_text(draft.name)),
462            ("Description", optional_text(draft.description)),
463            ("RelatedObjects", refs(draft.related_objects)),
464            ("RelatingMaterial", Value::Ref(draft.relating_material)),
465        ],
466    )?;
467    Ok(tx.create(record))
468}
469
470/// Stage an `IfcMaterialList`. Must name at least one material.
471pub fn create_material_list(
472    tx: &mut Transaction,
473    model: &Model,
474    materials: &[EntityId],
475) -> MaterialResult<EntityId> {
476    const ENTITY: &str = "IFCMATERIALLIST";
477    let release = Release::of(model);
478    release.require_entity(ENTITY, None)?;
479    if materials.is_empty() {
480        return Err(invalid(
481            ENTITY,
482            "Materials",
483            "expected at least one material",
484        ));
485    }
486    for &material in materials {
487        require_type(tx, model, release, material, &["IFCMATERIAL"])?;
488    }
489    Ok(tx.create(release.record(ENTITY, vec![("Materials", refs(materials))])?))
490}
491
492/// Stage an `IfcMaterialLayerSetUsage`.
493///
494/// Direction and sense are typed enums rather than strings: an invalid
495/// token cannot be constructed, so no runtime check is needed for them.
496///
497/// # Errors
498///
499/// Refuses a set that is not an `IfcMaterialLayerSet`, a non-finite offset,
500/// and a `reference_extent` for IFC2X3, which has no `ReferenceExtent`.
501pub fn create_layer_set_usage(
502    tx: &mut Transaction,
503    model: &Model,
504    for_layer_set: EntityId,
505    direction: LayerSetDirection,
506    sense: DirectionSense,
507    offset_from_reference_line: f64,
508    reference_extent: Option<f64>,
509) -> MaterialResult<EntityId> {
510    const ENTITY: &str = "IFCMATERIALLAYERSETUSAGE";
511    let release = Release::of(model);
512    release.require_entity(ENTITY, None)?;
513    require_type(tx, model, release, for_layer_set, &["IFCMATERIALLAYERSET"])?;
514    if !offset_from_reference_line.is_finite() {
515        return Err(invalid(
516            ENTITY,
517            "OffsetFromReferenceLine",
518            "expected a finite length",
519        ));
520    }
521    let record = release.record(
522        ENTITY,
523        vec![
524            ("ForLayerSet", Value::Ref(for_layer_set)),
525            (
526                "LayerSetDirection",
527                Value::Enum(direction.as_token().into()),
528            ),
529            ("DirectionSense", Value::Enum(sense.as_token().into())),
530            (
531                "OffsetFromReferenceLine",
532                Value::Real(offset_from_reference_line),
533            ),
534            (
535                "ReferenceExtent",
536                reference_extent.map_or(Value::Null, Value::Real),
537            ),
538        ],
539    )?;
540    Ok(tx.create(record))
541}
542
543/// Stage an `IfcMaterialLayerWithOffsets`. IFC4 onwards.
544///
545/// The record carries nine slots: the seven it inherits from
546/// `IfcMaterialLayer` followed by its own two. Writing only the subtype
547/// attributes would shift every inherited value into the wrong slot, which
548/// is why this is a separate constructor rather than a flag on
549/// [`create_layer`].
550///
551/// # Errors
552///
553/// Those of [`create_layer`], non-finite offsets, and
554/// [`MaterialError::EntityNotInSchema`] for IFC2X3.
555pub fn create_layer_with_offsets(
556    tx: &mut Transaction,
557    model: &Model,
558    draft: LayerDraft<'_>,
559    offset_direction: LayerSetDirection,
560    offset_values: [f64; 2],
561) -> MaterialResult<EntityId> {
562    const ENTITY: &str = "IFCMATERIALLAYERWITHOFFSETS";
563    let release = Release::of(model);
564    let mut values = layer_values(release, ENTITY, &draft)?;
565    if offset_values.iter().any(|value| !value.is_finite()) {
566        return Err(invalid(ENTITY, "OffsetValues", "expected finite lengths"));
567    }
568    if let Some(material) = draft.material {
569        require_type(tx, model, release, material, &["IFCMATERIAL"])?;
570    }
571    values.push((
572        "OffsetDirection",
573        Value::Enum(offset_direction.as_token().into()),
574    ));
575    values.push(("OffsetValues", reals(&offset_values)));
576    Ok(tx.create(release.record(ENTITY, values)?))
577}
578
579/// Fail unless `id` exists, staged or committed.
580pub(crate) fn require_exists(tx: &Transaction, model: &Model, id: EntityId) -> MaterialResult<()> {
581    if type_name(tx, model, id).is_some() {
582        Ok(())
583    } else {
584        Err(MaterialError::UnknownEntity { id })
585    }
586}
587
588/// Fail unless `id` has one of `expected`'s types that the model's release
589/// can instantiate: an `IfcMaterialLayerWithOffsets` is no IFC2X3 layer.
590pub(crate) fn require_type(
591    tx: &Transaction,
592    model: &Model,
593    release: Release<'_>,
594    id: EntityId,
595    expected: &[&'static str],
596) -> MaterialResult<()> {
597    let actual = type_name(tx, model, id).ok_or(MaterialError::UnknownEntity { id })?;
598    if expected
599        .iter()
600        .any(|kind| actual.eq_ignore_ascii_case(kind) && release.instantiates(kind))
601    {
602        Ok(())
603    } else {
604        Err(MaterialError::AuthoringReferenceType {
605            target: id,
606            expected: expected[0],
607            actual: actual.to_owned(),
608        })
609    }
610}
611
612/// Fail unless `id`'s type is a legal value of `entity.attribute` in the
613/// model's release, as its bundled table declares it (inheritance and
614/// SELECT membership included).
615pub(crate) fn require_accepts(
616    tx: &Transaction,
617    model: &Model,
618    release: Release<'_>,
619    entity: &'static str,
620    attribute: &'static str,
621    id: EntityId,
622) -> MaterialResult<()> {
623    let actual = type_name(tx, model, id).ok_or(MaterialError::UnknownEntity { id })?;
624    let (accepted, declared) = release.accepts(entity, attribute, actual)?;
625    if accepted {
626        Ok(())
627    } else {
628        Err(MaterialError::AuthoringReferenceType {
629            target: id,
630            expected: declared,
631            actual: actual.to_owned(),
632        })
633    }
634}
635
636fn type_name<'a>(tx: &'a Transaction, model: &'a Model, id: EntityId) -> Option<&'a str> {
637    for edit in tx.edits().iter().rev() {
638        match edit {
639            Edit::Create {
640                id: candidate,
641                entity,
642            } if *candidate == id => return Some(&entity.type_name),
643            Edit::Retype {
644                id: candidate,
645                type_name,
646            } if *candidate == id => return Some(type_name),
647            Edit::Remove { id: candidate } if *candidate == id => return None,
648            _ => {}
649        }
650    }
651    model.get(id).map(|entity| entity.type_name.as_ref())
652}
653
654pub(crate) fn invalid(
655    entity: &'static str,
656    attribute: &'static str,
657    value: impl Into<String>,
658) -> MaterialError {
659    MaterialError::AuthoringInvalid {
660        entity,
661        attribute,
662        value: value.into(),
663    }
664}
665
666fn text(value: &str) -> Value {
667    Value::Text(value.into())
668}
669
670pub(crate) fn optional_text(value: Option<&str>) -> Value {
671    value.map_or(Value::Null, text)
672}
673
674pub(crate) fn refs(ids: &[EntityId]) -> Value {
675    Value::List(ids.iter().copied().map(Value::Ref).collect())
676}
677
678pub(crate) fn reals(values: &[f64]) -> Value {
679    Value::List(values.iter().copied().map(Value::Real).collect())
680}
681
682fn logical(value: LogicalValue) -> Value {
683    match value {
684        LogicalValue::False => Value::Bool(false),
685        LogicalValue::True => Value::Bool(true),
686        LogicalValue::Unknown => Value::LogicalUnknown,
687    }
688}