Skip to main content

ifc_constraint/
authoring.rs

1//! Transaction-staged authoring for bounded IFC4 constraints.
2//!
3//! The rooted `IfcRelAssociatesConstraint` lives in `association.rs`.
4
5use std::collections::HashSet;
6use std::sync::Arc;
7
8use ifc_model::{Edit, Entity, EntityId, Model, Transaction, Value};
9use ifc_schema::Schema;
10
11use crate::types::{
12    Benchmark, ConstraintGrade, LogicalOperator, MetricValueDraft, ObjectiveQualifier,
13};
14use crate::{ConstraintError, ConstraintResult};
15
16const METRIC: &str = "IFCMETRIC";
17const OBJECTIVE: &str = "IFCOBJECTIVE";
18const RESOURCE_REL: &str = "IFCRESOURCECONSTRAINTRELATIONSHIP";
19
20/// Common inherited `IfcConstraint` fields.
21#[derive(Debug, Clone, Copy)]
22pub struct ConstraintBaseDraft<'a> {
23    /// Required constraint name.
24    pub name: &'a str,
25    /// Optional description.
26    pub description: Option<&'a str>,
27    /// Typed constraint grade.
28    pub grade: ConstraintGrade,
29    /// Optional source label.
30    pub source: Option<&'a str>,
31    /// Optional existing or earlier-staged actor-select target.
32    pub creating_actor: Option<EntityId>,
33    /// Optional IFC date-time lexical value.
34    pub creation_time: Option<&'a str>,
35    /// Required when `grade` is user-defined.
36    pub user_defined_grade: Option<&'a str>,
37}
38
39/// Draft for one `IfcMetric`.
40#[derive(Debug, Clone, Copy)]
41pub struct MetricDraft<'a> {
42    /// Inherited constraint fields.
43    pub base: ConstraintBaseDraft<'a>,
44    /// Comparison benchmark.
45    pub benchmark: Benchmark,
46    /// Optional source label for the data value.
47    pub value_source: Option<&'a str>,
48    /// Optional preserved metric SELECT value.
49    pub data_value: Option<MetricValueDraft<'a>>,
50    /// Optional existing or earlier-staged `IfcReference`.
51    pub reference_path: Option<EntityId>,
52}
53
54/// Draft for one `IfcObjective`.
55#[derive(Debug, Clone, Copy)]
56pub struct ObjectiveDraft<'a> {
57    /// Inherited constraint fields.
58    pub base: ConstraintBaseDraft<'a>,
59    /// Optional non-empty ordered constraints.
60    pub benchmark_values: Option<&'a [EntityId]>,
61    /// Optional logical operator.
62    pub logical_aggregator: Option<LogicalOperator>,
63    /// Objective purpose qualifier.
64    pub qualifier: ObjectiveQualifier,
65    /// Required when `qualifier` is user-defined.
66    pub user_defined_qualifier: Option<&'a str>,
67}
68
69/// Draft for one resource-level constraint relationship.
70#[derive(Debug, Clone, Copy)]
71pub struct ResourceConstraintDraft<'a> {
72    /// Optional relationship name.
73    pub name: Option<&'a str>,
74    /// Optional relationship description.
75    pub description: Option<&'a str>,
76    /// Existing or earlier-staged metric/objective.
77    pub relating_constraint: EntityId,
78    /// Non-empty unique resource-select targets.
79    pub related_resources: &'a [EntityId],
80}
81
82/// Validate and stage one metric.
83pub fn create_metric(
84    tx: &mut Transaction,
85    model: &Model,
86    draft: MetricDraft<'_>,
87) -> ConstraintResult<EntityId> {
88    validate_base(tx, model, METRIC, draft.base)?;
89    if let Some(path) = draft.reference_path {
90        validate_target(tx, model, path, "IfcReference")?;
91    }
92    let data_value = match draft.data_value {
93        None => Value::Null,
94        Some(MetricValueDraft::Entity(target)) => {
95            validate_target(tx, model, target, "IfcMetricValueSelect")?;
96            Value::Ref(target)
97        }
98        Some(MetricValueDraft::Typed { type_name, value }) => {
99            if !ifc_schema::ifc4().accepts_type("IfcMetricValueSelect", type_name) {
100                return Err(ConstraintError::AuthoringInvalid {
101                    entity: METRIC,
102                    attribute: "DataValue",
103                    value: format!("type {type_name} is outside IfcMetricValueSelect"),
104                });
105            }
106            Value::Typed {
107                type_name: Arc::from(type_name.to_ascii_uppercase()),
108                value: Box::new(value.clone()),
109            }
110        }
111    };
112    Ok(tx.create(Entity::new(
113        METRIC,
114        vec![
115            text(draft.base.name),
116            optional_text(draft.base.description),
117            enumeration(draft.base.grade.token()),
118            optional_text(draft.base.source),
119            optional_ref(draft.base.creating_actor),
120            optional_text(draft.base.creation_time),
121            optional_text(draft.base.user_defined_grade),
122            enumeration(draft.benchmark.token()),
123            optional_text(draft.value_source),
124            data_value,
125            optional_ref(draft.reference_path),
126        ],
127    )))
128}
129
130/// Validate and stage one objective.
131pub fn create_objective(
132    tx: &mut Transaction,
133    model: &Model,
134    draft: ObjectiveDraft<'_>,
135) -> ConstraintResult<EntityId> {
136    validate_base(tx, model, OBJECTIVE, draft.base)?;
137    if draft.qualifier == ObjectiveQualifier::UserDefined && draft.user_defined_qualifier.is_none()
138    {
139        return Err(ConstraintError::AuthoringInvalid {
140            entity: OBJECTIVE,
141            attribute: "WR21",
142            value: "USERDEFINED qualifier requires UserDefinedQualifier".into(),
143        });
144    }
145    let benchmarks = match draft.benchmark_values {
146        None => Value::Null,
147        Some([]) => {
148            return Err(ConstraintError::AuthoringInvalid {
149                entity: OBJECTIVE,
150                attribute: "BenchmarkValues",
151                value: "empty LIST [1:?]".into(),
152            });
153        }
154        Some(values) => {
155            for &target in values {
156                validate_target(tx, model, target, "IfcConstraint")?;
157            }
158            refs(values)
159        }
160    };
161    Ok(tx.create(Entity::new(
162        OBJECTIVE,
163        vec![
164            text(draft.base.name),
165            optional_text(draft.base.description),
166            enumeration(draft.base.grade.token()),
167            optional_text(draft.base.source),
168            optional_ref(draft.base.creating_actor),
169            optional_text(draft.base.creation_time),
170            optional_text(draft.base.user_defined_grade),
171            benchmarks,
172            draft
173                .logical_aggregator
174                .map_or(Value::Null, |value| enumeration(value.token())),
175            enumeration(draft.qualifier.token()),
176            optional_text(draft.user_defined_qualifier),
177        ],
178    )))
179}
180
181/// Validate and stage one resource-level constraint relationship.
182pub fn relate_resource_constraint(
183    tx: &mut Transaction,
184    model: &Model,
185    draft: ResourceConstraintDraft<'_>,
186) -> ConstraintResult<EntityId> {
187    validate_target(tx, model, draft.relating_constraint, "IfcConstraint")?;
188    validate_set(
189        tx,
190        model,
191        RESOURCE_REL,
192        "RelatedResourceObjects",
193        draft.related_resources,
194        "IfcResourceObjectSelect",
195    )?;
196    Ok(tx.create(Entity::new(
197        RESOURCE_REL,
198        vec![
199            optional_text(draft.name),
200            optional_text(draft.description),
201            Value::Ref(draft.relating_constraint),
202            refs(draft.related_resources),
203        ],
204    )))
205}
206
207fn validate_base(
208    tx: &Transaction,
209    model: &Model,
210    kind: &'static str,
211    draft: ConstraintBaseDraft<'_>,
212) -> ConstraintResult<()> {
213    if draft.grade == ConstraintGrade::UserDefined && draft.user_defined_grade.is_none() {
214        return Err(ConstraintError::AuthoringInvalid {
215            entity: kind,
216            attribute: "WR11",
217            value: "USERDEFINED grade requires UserDefinedGrade".into(),
218        });
219    }
220    if let Some(actor) = draft.creating_actor {
221        validate_target(tx, model, actor, "IfcActorSelect")?;
222    }
223    Ok(())
224}
225
226fn validate_set(
227    tx: &Transaction,
228    model: &Model,
229    kind: &'static str,
230    attribute: &'static str,
231    targets: &[EntityId],
232    expected: &'static str,
233) -> ConstraintResult<()> {
234    if targets.is_empty() {
235        return Err(ConstraintError::AuthoringInvalid {
236            entity: kind,
237            attribute,
238            value: "empty SET [1:?]".into(),
239        });
240    }
241    let mut seen = HashSet::new();
242    for &target in targets {
243        if !seen.insert(target) {
244            return Err(ConstraintError::AuthoringInvalid {
245                entity: kind,
246                attribute,
247                value: format!("duplicate {target}"),
248            });
249        }
250        validate_target(tx, model, target, expected)?;
251    }
252    Ok(())
253}
254
255/// Draft for one `IfcReference`: a path into another entity's attributes.
256#[derive(Debug, Clone, Copy)]
257pub struct ReferenceDraft<'a> {
258    /// `TypeIdentifier`, the referenced entity's type name.
259    pub type_identifier: Option<&'a str>,
260    /// `AttributeIdentifier`, the attribute being addressed.
261    pub attribute_identifier: Option<&'a str>,
262    /// `InstanceName`, naming the addressed instance.
263    pub instance_name: Option<&'a str>,
264    /// `ListPositions`, 1-based indices into list-valued attributes.
265    pub list_positions: &'a [i64],
266    /// `InnerReference`, the next step along the path.
267    pub inner_reference: Option<EntityId>,
268}
269
270/// Stage an `IfcReference`.
271///
272/// A reference is a path expression: it names a type, an attribute, and
273/// optionally positions within a list, chaining through `InnerReference`
274/// to address something nested. Every slot is OPTIONAL, so the schema
275/// permits a reference that addresses nothing; that is a silently useless
276/// record, so at least one slot must be set here.
277///
278/// `ListPositions` is `LIST [1:?] OF IfcInteger` and the positions are
279/// 1-based: an empty list fails the bound, and a zero or negative index
280/// addresses no element. Both are refused rather than written.
281///
282/// # Errors
283///
284/// Refuses a fully empty draft, an empty or non-positive `ListPositions`,
285/// and an `inner_reference` that is not itself an `IfcReference`.
286pub fn create_reference(
287    tx: &mut Transaction,
288    model: &Model,
289    draft: ReferenceDraft<'_>,
290) -> ConstraintResult<EntityId> {
291    const ENTITY: &str = "IfcReference";
292    let empty = draft.type_identifier.is_none()
293        && draft.attribute_identifier.is_none()
294        && draft.instance_name.is_none()
295        && draft.list_positions.is_empty()
296        && draft.inner_reference.is_none();
297    if empty {
298        return Err(ConstraintError::AuthoringInvalid {
299            entity: ENTITY,
300            attribute: "TypeIdentifier",
301            value: "a reference with every slot unset addresses nothing".to_owned(),
302        });
303    }
304    for position in draft.list_positions {
305        if *position < 1 {
306            return Err(ConstraintError::AuthoringInvalid {
307                entity: ENTITY,
308                attribute: "ListPositions",
309                value: format!("{position} is not a 1-based list index"),
310            });
311        }
312    }
313    if let Some(inner) = draft.inner_reference {
314        validate_target(tx, model, inner, ENTITY)?;
315    }
316
317    let positions = if draft.list_positions.is_empty() {
318        // LIST [1:?]: absent stays null rather than becoming an
319        // empty list, which would satisfy the type and break the bound.
320        Value::Null
321    } else {
322        Value::List(
323            draft
324                .list_positions
325                .iter()
326                .copied()
327                .map(Value::Integer)
328                .collect(),
329        )
330    };
331    Ok(tx.create(Entity::new(
332        "IFCREFERENCE",
333        vec![
334            optional_text(draft.type_identifier),
335            optional_text(draft.attribute_identifier),
336            optional_text(draft.instance_name),
337            positions,
338            draft.inner_reference.map_or(Value::Null, Value::Ref),
339        ],
340    )))
341}
342
343fn validate_target(
344    tx: &Transaction,
345    model: &Model,
346    target: EntityId,
347    expected: &'static str,
348) -> ConstraintResult<()> {
349    validate_target_in(ifc_schema::ifc4(), tx, model, target, expected)
350}
351
352/// [`validate_target`] against `schema` instead of the IFC4 table.
353pub(crate) fn validate_target_in(
354    schema: &Schema,
355    tx: &Transaction,
356    model: &Model,
357    target: EntityId,
358    expected: &'static str,
359) -> ConstraintResult<()> {
360    let actual =
361        final_type(tx, model, target).ok_or(ConstraintError::UnknownEntity { id: target })?;
362    if schema.accepts_type(expected, actual) {
363        Ok(())
364    } else {
365        Err(ConstraintError::AuthoringReferenceType {
366            target,
367            expected,
368            actual: actual.into(),
369        })
370    }
371}
372
373pub(crate) fn final_type<'a>(
374    tx: &'a Transaction,
375    model: &'a Model,
376    id: EntityId,
377) -> Option<&'a str> {
378    for edit in tx.edits().iter().rev() {
379        match edit {
380            Edit::Create {
381                id: edit_id,
382                entity,
383            } if *edit_id == id => return Some(&entity.type_name),
384            Edit::Remove { id: edit_id } if *edit_id == id => return None,
385            Edit::Retype {
386                id: edit_id,
387                type_name,
388            } if *edit_id == id => return Some(type_name),
389            _ => {}
390        }
391    }
392    model.get(id).map(|entity| entity.type_name.as_ref())
393}
394
395pub(crate) fn text(value: &str) -> Value {
396    Value::Text(Arc::from(value))
397}
398pub(crate) fn optional_text(value: Option<&str>) -> Value {
399    value.map_or(Value::Null, text)
400}
401pub(crate) fn optional_ref(value: Option<EntityId>) -> Value {
402    value.map_or(Value::Null, Value::Ref)
403}
404fn enumeration(value: &str) -> Value {
405    Value::Enum(Arc::from(value))
406}
407pub(crate) fn refs(values: &[EntityId]) -> Value {
408    Value::List(values.iter().copied().map(Value::Ref).collect())
409}