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)]
37pub struct MaterialDraft<'a> {
38    /// `IfcMaterial.Name`.
39    pub name: &'a str,
40    /// `IfcMaterial.Description`, if given. IFC4 onwards.
41    pub description: Option<&'a str>,
42    /// `IfcMaterial.Category`, if given. IFC4 onwards.
43    pub category: Option<&'a str>,
44}
45
46/// Authored fields for `IfcMaterialLayer`.
47#[derive(Debug, Clone, Copy)]
48pub struct LayerDraft<'a> {
49    /// `IfcMaterialLayer.Material`, an `IfcMaterial` reference, if given.
50    pub material: Option<EntityId>,
51    /// `IfcMaterialLayer.LayerThickness`. Must be finite and non-negative;
52    /// strictly positive for IFC2X3.
53    pub thickness: f64,
54    /// `IfcMaterialLayer.IsVentilated`, if given.
55    pub is_ventilated: Option<LogicalValue>,
56    /// `IfcMaterialLayer.Name`, if given. IFC4 onwards.
57    pub name: Option<&'a str>,
58    /// `IfcMaterialLayer.Description`, if given. IFC4 onwards.
59    pub description: Option<&'a str>,
60    /// `IfcMaterialLayer.Category`, if given. IFC4 onwards.
61    pub category: Option<&'a str>,
62    /// `IfcMaterialLayer.Priority`, if given. Must be in `0..=100`. IFC4
63    /// onwards.
64    pub priority: Option<i64>,
65}
66
67/// Ordered composition fields for `IfcMaterialLayerSet`.
68#[derive(Debug, Clone, Copy)]
69pub struct LayerSetDraft<'a> {
70    /// `IfcMaterialLayerSet.MaterialLayers`, in set order. Must be non-empty.
71    pub layers: &'a [EntityId],
72    /// `IfcMaterialLayerSet.LayerSetName`, if given.
73    pub name: Option<&'a str>,
74    /// `IfcMaterialLayerSet.Description`, if given. IFC4 onwards.
75    pub description: Option<&'a str>,
76}
77
78/// Authored fields for `IfcRelAssociatesMaterial`.
79#[derive(Debug, Clone, Copy)]
80pub struct MaterialAssignmentDraft<'a> {
81    /// `IfcRelAssociatesMaterial.GlobalId`. Must be a valid IFC compressed
82    /// GUID.
83    pub global_id: &'a str,
84    /// `IfcRelAssociatesMaterial.Name`, if given.
85    pub name: Option<&'a str>,
86    /// `IfcRelAssociatesMaterial.Description`, if given.
87    pub description: Option<&'a str>,
88    /// `IfcRelAssociatesMaterial.RelatedObjects`. Must be non-empty and
89    /// contain no duplicate references.
90    pub related_objects: &'a [EntityId],
91    /// `IfcRelAssociatesMaterial.RelatingMaterial`, an `IfcMaterialSelect`
92    /// branch reference.
93    pub relating_material: EntityId,
94}
95
96/// Stage a material identity record in the model's release layout.
97///
98/// # Errors
99///
100/// [`MaterialError::AuthoringNotInSchema`] for a `description` or
101/// `category` in an IFC2X3 model, whose `IfcMaterial` declares `Name` only,
102/// and the release-binding errors.
103pub fn create_material(
104    tx: &mut Transaction,
105    model: &Model,
106    draft: MaterialDraft<'_>,
107) -> MaterialResult<EntityId> {
108    let record = Release::of(model).record(
109        "IFCMATERIAL",
110        vec![
111            ("Name", text(draft.name)),
112            ("Description", optional_text(draft.description)),
113            ("Category", optional_text(draft.category)),
114        ],
115    )?;
116    Ok(tx.create(record))
117}
118
119/// The layer attributes [`create_layer`] and [`create_layer_with_offsets`]
120/// share, after checking the thickness against the release's measure.
121fn layer_values(
122    release: Release<'_>,
123    entity: &'static str,
124    draft: &LayerDraft<'_>,
125) -> MaterialResult<Vec<(&'static str, Value)>> {
126    let declared = release.declared(entity, "LayerThickness")?;
127    let positive = declared
128        .type_name
129        .eq_ignore_ascii_case("IfcPositiveLengthMeasure");
130    if !draft.thickness.is_finite() || draft.thickness < 0.0 {
131        return Err(invalid(
132            entity,
133            "LayerThickness",
134            "expected a finite non-negative length",
135        ));
136    }
137    if positive && draft.thickness == 0.0 {
138        return Err(invalid(
139            entity,
140            "LayerThickness",
141            "expected a positive length (IfcPositiveLengthMeasure)",
142        ));
143    }
144    if let Some(priority) = draft.priority.filter(|value| !(0..=100).contains(value)) {
145        return Err(invalid(entity, "Priority", priority.to_string()));
146    }
147    Ok(vec![
148        ("Material", draft.material.map_or(Value::Null, Value::Ref)),
149        ("LayerThickness", Value::Real(draft.thickness)),
150        (
151            "IsVentilated",
152            draft.is_ventilated.map_or(Value::Null, logical),
153        ),
154        ("Name", optional_text(draft.name)),
155        ("Description", optional_text(draft.description)),
156        ("Category", optional_text(draft.category)),
157        (
158            "Priority",
159            draft.priority.map_or(Value::Null, Value::Integer),
160        ),
161    ])
162}
163
164/// Stage a layer after checking its scalar and material-reference invariants.
165///
166/// # Errors
167///
168/// Refuses a non-finite or negative thickness (zero too for IFC2X3), a
169/// priority outside `0..=100`, a material that is not an `IfcMaterial`, and
170/// for IFC2X3 any `name`, `description`, `category` or `priority`
171/// ([`MaterialError::AuthoringNotInSchema`]).
172pub fn create_layer(
173    tx: &mut Transaction,
174    model: &Model,
175    draft: LayerDraft<'_>,
176) -> MaterialResult<EntityId> {
177    const ENTITY: &str = "IFCMATERIALLAYER";
178    let release = Release::of(model);
179    let values = layer_values(release, ENTITY, &draft)?;
180    if let Some(material) = draft.material {
181        require_type(tx, model, release, material, &["IFCMATERIAL"])?;
182    }
183    Ok(tx.create(release.record(ENTITY, values)?))
184}
185
186/// Stage a non-empty ordered layer set. Layers may have been created earlier
187/// in this transaction; their staged type is checked just like stored records.
188///
189/// # Errors
190///
191/// Refuses an empty layer list, a member that is not a layer of the model's
192/// release, and a `description` for IFC2X3.
193pub fn create_layer_set(
194    tx: &mut Transaction,
195    model: &Model,
196    draft: LayerSetDraft<'_>,
197) -> MaterialResult<EntityId> {
198    const ENTITY: &str = "IFCMATERIALLAYERSET";
199    let release = Release::of(model);
200    release.require_entity(ENTITY, None)?;
201    if draft.layers.is_empty() {
202        return Err(invalid(
203            ENTITY,
204            "MaterialLayers",
205            "expected at least one layer",
206        ));
207    }
208    for &layer in draft.layers {
209        require_type(
210            tx,
211            model,
212            release,
213            layer,
214            &["IFCMATERIALLAYER", "IFCMATERIALLAYERWITHOFFSETS"],
215        )?;
216    }
217    let record = release.record(
218        ENTITY,
219        vec![
220            ("MaterialLayers", refs(draft.layers)),
221            ("LayerSetName", optional_text(draft.name)),
222            ("Description", optional_text(draft.description)),
223        ],
224    )?;
225    Ok(tx.create(record))
226}
227
228/// Stage a product/type material association after validating the IFC GlobalId,
229/// non-empty relation end, and `IfcMaterialSelect` branch.
230///
231/// `OwnerHistory` is left unset. IFC2X3 requires it, so an IFC2X3 model is
232/// refused with [`MaterialError::AuthoringRequired`]; use
233/// [`associate_material_with_owner_history`] there.
234///
235/// # Errors
236///
237/// Refuses a malformed GlobalId, an empty or duplicated `RelatedObjects`,
238/// and a `RelatingMaterial` that is no `IfcMaterialSelect` member of the
239/// model's release.
240pub fn associate_material(
241    tx: &mut Transaction,
242    model: &Model,
243    draft: MaterialAssignmentDraft<'_>,
244) -> MaterialResult<EntityId> {
245    associate(tx, model, draft, None)
246}
247
248/// [`associate_material`] with a caller-supplied `IfcOwnerHistory`, which
249/// IFC2X3 requires. The owner history is never invented here (build one with
250/// `ifc-author`).
251///
252/// # Errors
253///
254/// Those of [`associate_material`], and an `owner_history` that is not an
255/// `IfcOwnerHistory`.
256pub fn associate_material_with_owner_history(
257    tx: &mut Transaction,
258    model: &Model,
259    draft: MaterialAssignmentDraft<'_>,
260    owner_history: EntityId,
261) -> MaterialResult<EntityId> {
262    associate(tx, model, draft, Some(owner_history))
263}
264
265fn associate(
266    tx: &mut Transaction,
267    model: &Model,
268    draft: MaterialAssignmentDraft<'_>,
269    owner_history: Option<EntityId>,
270) -> MaterialResult<EntityId> {
271    const ENTITY: &str = "IFCRELASSOCIATESMATERIAL";
272    let release = Release::of(model);
273    release.require_entity(ENTITY, None)?;
274    if ifc_model::guid::Guid::parse(draft.global_id).is_none() {
275        return Err(invalid(ENTITY, "GlobalId", "expected IFC compressed GUID"));
276    }
277    if draft.related_objects.is_empty() {
278        return Err(invalid(
279            ENTITY,
280            "RelatedObjects",
281            "expected at least one object",
282        ));
283    }
284    let mut unique = HashSet::new();
285    for &object in draft.related_objects {
286        if !unique.insert(object) {
287            return Err(invalid(
288                ENTITY,
289                "RelatedObjects",
290                "duplicate object reference",
291            ));
292        }
293        require_exists(tx, model, object)?;
294    }
295    require_accepts(
296        tx,
297        model,
298        release,
299        ENTITY,
300        "RelatingMaterial",
301        draft.relating_material,
302    )?;
303    if let Some(owner_history) = owner_history {
304        require_type(tx, model, release, owner_history, &["IFCOWNERHISTORY"])?;
305    }
306    let record = release.record(
307        ENTITY,
308        vec![
309            ("GlobalId", text(draft.global_id)),
310            (
311                "OwnerHistory",
312                owner_history.map_or(Value::Null, Value::Ref),
313            ),
314            ("Name", optional_text(draft.name)),
315            ("Description", optional_text(draft.description)),
316            ("RelatedObjects", refs(draft.related_objects)),
317            ("RelatingMaterial", Value::Ref(draft.relating_material)),
318        ],
319    )?;
320    Ok(tx.create(record))
321}
322
323/// Stage an `IfcMaterialList`. Must name at least one material.
324pub fn create_material_list(
325    tx: &mut Transaction,
326    model: &Model,
327    materials: &[EntityId],
328) -> MaterialResult<EntityId> {
329    const ENTITY: &str = "IFCMATERIALLIST";
330    let release = Release::of(model);
331    release.require_entity(ENTITY, None)?;
332    if materials.is_empty() {
333        return Err(invalid(
334            ENTITY,
335            "Materials",
336            "expected at least one material",
337        ));
338    }
339    for &material in materials {
340        require_type(tx, model, release, material, &["IFCMATERIAL"])?;
341    }
342    Ok(tx.create(release.record(ENTITY, vec![("Materials", refs(materials))])?))
343}
344
345/// Stage an `IfcMaterialLayerSetUsage`.
346///
347/// Direction and sense are typed enums rather than strings: an invalid
348/// token cannot be constructed, so no runtime check is needed for them.
349///
350/// # Errors
351///
352/// Refuses a set that is not an `IfcMaterialLayerSet`, a non-finite offset,
353/// and a `reference_extent` for IFC2X3, which has no `ReferenceExtent`.
354pub fn create_layer_set_usage(
355    tx: &mut Transaction,
356    model: &Model,
357    for_layer_set: EntityId,
358    direction: LayerSetDirection,
359    sense: DirectionSense,
360    offset_from_reference_line: f64,
361    reference_extent: Option<f64>,
362) -> MaterialResult<EntityId> {
363    const ENTITY: &str = "IFCMATERIALLAYERSETUSAGE";
364    let release = Release::of(model);
365    release.require_entity(ENTITY, None)?;
366    require_type(tx, model, release, for_layer_set, &["IFCMATERIALLAYERSET"])?;
367    if !offset_from_reference_line.is_finite() {
368        return Err(invalid(
369            ENTITY,
370            "OffsetFromReferenceLine",
371            "expected a finite length",
372        ));
373    }
374    let record = release.record(
375        ENTITY,
376        vec![
377            ("ForLayerSet", Value::Ref(for_layer_set)),
378            (
379                "LayerSetDirection",
380                Value::Enum(direction.as_token().into()),
381            ),
382            ("DirectionSense", Value::Enum(sense.as_token().into())),
383            (
384                "OffsetFromReferenceLine",
385                Value::Real(offset_from_reference_line),
386            ),
387            (
388                "ReferenceExtent",
389                reference_extent.map_or(Value::Null, Value::Real),
390            ),
391        ],
392    )?;
393    Ok(tx.create(record))
394}
395
396/// Stage an `IfcMaterialLayerWithOffsets`. IFC4 onwards.
397///
398/// The record carries nine slots: the seven it inherits from
399/// `IfcMaterialLayer` followed by its own two. Writing only the subtype
400/// attributes would shift every inherited value into the wrong slot, which
401/// is why this is a separate constructor rather than a flag on
402/// [`create_layer`].
403///
404/// # Errors
405///
406/// Those of [`create_layer`], non-finite offsets, and
407/// [`MaterialError::EntityNotInSchema`] for IFC2X3.
408pub fn create_layer_with_offsets(
409    tx: &mut Transaction,
410    model: &Model,
411    draft: LayerDraft<'_>,
412    offset_direction: LayerSetDirection,
413    offset_values: [f64; 2],
414) -> MaterialResult<EntityId> {
415    const ENTITY: &str = "IFCMATERIALLAYERWITHOFFSETS";
416    let release = Release::of(model);
417    let mut values = layer_values(release, ENTITY, &draft)?;
418    if offset_values.iter().any(|value| !value.is_finite()) {
419        return Err(invalid(ENTITY, "OffsetValues", "expected finite lengths"));
420    }
421    if let Some(material) = draft.material {
422        require_type(tx, model, release, material, &["IFCMATERIAL"])?;
423    }
424    values.push((
425        "OffsetDirection",
426        Value::Enum(offset_direction.as_token().into()),
427    ));
428    values.push(("OffsetValues", reals(&offset_values)));
429    Ok(tx.create(release.record(ENTITY, values)?))
430}
431
432/// Fail unless `id` exists, staged or committed.
433pub(crate) fn require_exists(tx: &Transaction, model: &Model, id: EntityId) -> MaterialResult<()> {
434    if type_name(tx, model, id).is_some() {
435        Ok(())
436    } else {
437        Err(MaterialError::UnknownEntity { id })
438    }
439}
440
441/// Fail unless `id` has one of `expected`'s types that the model's release
442/// can instantiate: an `IfcMaterialLayerWithOffsets` is no IFC2X3 layer.
443pub(crate) fn require_type(
444    tx: &Transaction,
445    model: &Model,
446    release: Release<'_>,
447    id: EntityId,
448    expected: &[&'static str],
449) -> MaterialResult<()> {
450    let actual = type_name(tx, model, id).ok_or(MaterialError::UnknownEntity { id })?;
451    if expected
452        .iter()
453        .any(|kind| actual.eq_ignore_ascii_case(kind) && release.instantiates(kind))
454    {
455        Ok(())
456    } else {
457        Err(MaterialError::AuthoringReferenceType {
458            target: id,
459            expected: expected[0],
460            actual: actual.to_owned(),
461        })
462    }
463}
464
465/// Fail unless `id`'s type is a legal value of `entity.attribute` in the
466/// model's release, as its bundled table declares it (inheritance and
467/// SELECT membership included).
468pub(crate) fn require_accepts(
469    tx: &Transaction,
470    model: &Model,
471    release: Release<'_>,
472    entity: &'static str,
473    attribute: &'static str,
474    id: EntityId,
475) -> MaterialResult<()> {
476    let actual = type_name(tx, model, id).ok_or(MaterialError::UnknownEntity { id })?;
477    let (accepted, declared) = release.accepts(entity, attribute, actual)?;
478    if accepted {
479        Ok(())
480    } else {
481        Err(MaterialError::AuthoringReferenceType {
482            target: id,
483            expected: declared,
484            actual: actual.to_owned(),
485        })
486    }
487}
488
489fn type_name<'a>(tx: &'a Transaction, model: &'a Model, id: EntityId) -> Option<&'a str> {
490    for edit in tx.edits().iter().rev() {
491        match edit {
492            Edit::Create {
493                id: candidate,
494                entity,
495            } if *candidate == id => return Some(&entity.type_name),
496            Edit::Retype {
497                id: candidate,
498                type_name,
499            } if *candidate == id => return Some(type_name),
500            Edit::Remove { id: candidate } if *candidate == id => return None,
501            _ => {}
502        }
503    }
504    model.get(id).map(|entity| entity.type_name.as_ref())
505}
506
507pub(crate) fn invalid(
508    entity: &'static str,
509    attribute: &'static str,
510    value: impl Into<String>,
511) -> MaterialError {
512    MaterialError::AuthoringInvalid {
513        entity,
514        attribute,
515        value: value.into(),
516    }
517}
518
519fn text(value: &str) -> Value {
520    Value::Text(value.into())
521}
522
523pub(crate) fn optional_text(value: Option<&str>) -> Value {
524    value.map_or(Value::Null, text)
525}
526
527pub(crate) fn refs(ids: &[EntityId]) -> Value {
528    Value::List(ids.iter().copied().map(Value::Ref).collect())
529}
530
531pub(crate) fn reals(values: &[f64]) -> Value {
532    Value::List(values.iter().copied().map(Value::Real).collect())
533}
534
535fn logical(value: LogicalValue) -> Value {
536    match value {
537        LogicalValue::False => Value::Bool(false),
538        LogicalValue::True => Value::Bool(true),
539        LogicalValue::Unknown => Value::LogicalUnknown,
540    }
541}