Skip to main content

ifc_material/
authoring.rs

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