Skip to main content

ifc_resource/author/
editor.rs

1//! Schema-checked, transaction-staged resource authoring.
2
3use std::collections::HashSet;
4
5use ifc_model::{Budget, Entity, EntityId, Model, Transaction, Value};
6use ifc_schema::{ifc4, Schema, TypeKind};
7
8use crate::author::draft::{AllocationDraft, NestingDraft, ResourceDraft, ResourceTimeDraft};
9use crate::error::{ResourceError, ResourceResult};
10use crate::view::validate_object_assignment;
11use crate::{ResourceKind, ResourceView};
12
13/// Transaction-staged writer for the bounded IFC4 resource slice.
14///
15/// Every `create_*` method validates the draft against the bundled schema
16/// and cross-attribute IFC rules before staging it, then commits through a
17/// single-entity `ifc_model::Transaction`.
18pub struct ResourceEditor<'m> {
19    model: &'m mut Model,
20    pub(crate) schema: &'static Schema,
21}
22
23impl<'m> ResourceEditor<'m> {
24    /// Opens an editor for `model`, verifying it declares a supported
25    /// resource schema (IFC4 ADD2 TC1 or IFC4X3 ADD2).
26    pub fn for_model(model: &'m mut Model) -> ResourceResult<Self> {
27        ResourceView::for_model(model)?;
28        Ok(Self {
29            model,
30            schema: ifc4(),
31        })
32    }
33
34    pub(crate) fn validate_new_global_id(&self, value: &str) -> ResourceResult<()> {
35        validate_global_id(value)?;
36        for (id, entity) in self.model.iter() {
37            let Some(slot) = self
38                .schema
39                .attribute_names(&entity.type_name)
40                .iter()
41                .position(|name| name.eq_ignore_ascii_case("GlobalId"))
42            else {
43                continue;
44            };
45            if entity
46                .attributes
47                .get(slot)
48                .is_some_and(|stored| matches!(stored.unwrap_typed(), Value::Text(stored) if stored.as_ref() == value))
49            {
50                return Err(ResourceError::SemanticViolation {
51                    entity: Some(id),
52                    rule: "IfcRoot.GlobalId must be unique in the model",
53                });
54            }
55        }
56        Ok(())
57    }
58
59    /// Commits a `ResourceDraft` as a new concrete `IfcConstructionResource`
60    /// occurrence, failing if `USERDEFINED` is authored without
61    /// `ObjectType`, or if `Usage`/`BaseCosts`/`BaseQuantity` reference
62    /// entities of the wrong type.
63    pub fn create_resource(&mut self, draft: ResourceDraft<'_>) -> ResourceResult<EntityId> {
64        self.validate_new_global_id(draft.global_id)?;
65        let entity_type = resource_entity_type(draft.kind);
66        if let Some(value) = draft.predefined_type {
67            validate_enum(self.schema, entity_type, "PredefinedType", value)?;
68            if value == "USERDEFINED"
69                && draft
70                    .object_type
71                    .is_none_or(|value| value.trim().is_empty())
72            {
73                return Err(ResourceError::SemanticViolation {
74                    entity: None,
75                    rule: "USERDEFINED_REQUIRES_OBJECT_TYPE",
76                });
77            }
78        }
79        if let Some(usage) = draft.usage {
80            self.check_reference(usage, "Usage", "IfcResourceTime", usage)?;
81        }
82        for cost in &draft.base_costs {
83            self.check_reference(*cost, "BaseCosts", "IfcAppliedValue", *cost)?;
84        }
85        if let Some(quantity) = draft.base_quantity {
86            self.check_reference(quantity, "BaseQuantity", "IfcPhysicalQuantity", quantity)?;
87        }
88
89        let base_costs = (!draft.base_costs.is_empty()).then(|| refs(&draft.base_costs));
90        let entity = build_entity(
91            self.schema,
92            entity_type,
93            &[
94                ("GlobalId", Some(text(draft.global_id))),
95                ("Name", draft.name.map(text)),
96                ("ObjectType", draft.object_type.map(text)),
97                ("Identification", draft.identification.map(text)),
98                ("LongDescription", draft.long_description.map(text)),
99                ("Usage", draft.usage.map(Value::Ref)),
100                ("BaseCosts", base_costs),
101                ("BaseQuantity", draft.base_quantity.map(Value::Ref)),
102                (
103                    "PredefinedType",
104                    draft.predefined_type.map(|value| Value::Enum(value.into())),
105                ),
106            ],
107        )?;
108        self.commit_create(entity)
109    }
110
111    /// Commits a `ResourceDraft` as an `IfcTypeResource` subtype.
112    ///
113    /// The type counterpart of [`ResourceEditor::create_resource`]:
114    /// the shared definition several occurrences point at. The draft is
115    /// reused because the two differ by one attribute, `Usage`, which
116    /// belongs to the occurrence and is refused here rather than
117    /// silently dropped.
118    ///
119    /// `PredefinedType` is required on every resource type in the
120    /// schema, unlike on the occurrence where it is optional.
121    ///
122    /// # Errors
123    ///
124    /// Refuses a duplicate or malformed `GlobalId`, a missing or
125    /// unknown `PredefinedType`, a `USERDEFINED` type with no
126    /// `ObjectType` to name it, a `Usage` reference, and any
127    /// `BaseCosts` or `BaseQuantity` reference of the wrong type.
128    pub fn create_resource_type(&mut self, draft: ResourceDraft<'_>) -> ResourceResult<EntityId> {
129        self.validate_new_global_id(draft.global_id)?;
130        let entity_type = resource_type_entity_type(draft.kind);
131        let Some(predefined_type) = draft.predefined_type else {
132            return Err(ResourceError::SemanticViolation {
133                entity: None,
134                rule: "RESOURCE_TYPE_REQUIRES_PREDEFINED_TYPE",
135            });
136        };
137        validate_enum(self.schema, entity_type, "PredefinedType", predefined_type)?;
138        if predefined_type == "USERDEFINED"
139            && draft
140                .object_type
141                .is_none_or(|value| value.trim().is_empty())
142        {
143            return Err(ResourceError::SemanticViolation {
144                entity: None,
145                rule: "USERDEFINED_REQUIRES_OBJECT_TYPE",
146            });
147        }
148        if draft.usage.is_some() {
149            return Err(ResourceError::SemanticViolation {
150                entity: None,
151                rule: "RESOURCE_TYPE_HAS_NO_USAGE",
152            });
153        }
154        for cost in &draft.base_costs {
155            self.check_reference(*cost, "BaseCosts", "IfcAppliedValue", *cost)?;
156        }
157        if let Some(quantity) = draft.base_quantity {
158            self.check_reference(quantity, "BaseQuantity", "IfcPhysicalQuantity", quantity)?;
159        }
160
161        let base_costs = (!draft.base_costs.is_empty()).then(|| refs(&draft.base_costs));
162        let entity = build_entity(
163            self.schema,
164            entity_type,
165            &[
166                ("GlobalId", Some(text(draft.global_id))),
167                ("Name", draft.name.map(text)),
168                ("ApplicableOccurrence", draft.object_type.map(text)),
169                ("Identification", draft.identification.map(text)),
170                ("LongDescription", draft.long_description.map(text)),
171                ("BaseCosts", base_costs),
172                ("BaseQuantity", draft.base_quantity.map(Value::Ref)),
173                ("PredefinedType", Some(Value::Enum(predefined_type.into()))),
174            ],
175        )?;
176        self.commit_create(entity)
177    }
178
179    /// Commits a `ResourceTimeDraft` as a new `IfcResourceTime`, failing if
180    /// any ratio attribute is non-finite or not strictly positive.
181    pub fn create_time(&mut self, draft: ResourceTimeDraft<'_>) -> ResourceResult<EntityId> {
182        for (attribute, value) in [
183            ("ScheduleUsage", draft.schedule_usage),
184            ("ActualUsage", draft.actual_usage),
185            ("RemainingUsage", draft.remaining_usage),
186            ("Completion", draft.completion),
187        ] {
188            if value.is_some_and(|value| !value.is_finite() || value <= 0.0) {
189                return Err(ResourceError::InvalidDraft {
190                    entity_type: "IfcResourceTime",
191                    attribute,
192                    expected: "finite positive ratio",
193                });
194            }
195        }
196        let entity = build_entity(
197            self.schema,
198            "IfcResourceTime",
199            &[
200                ("Name", draft.name.map(text)),
201                ("ScheduleWork", draft.schedule_work.map(text)),
202                ("ScheduleUsage", draft.schedule_usage.map(Value::Real)),
203                ("ScheduleStart", draft.schedule_start.map(text)),
204                ("ScheduleFinish", draft.schedule_finish.map(text)),
205                ("IsOverAllocated", draft.is_over_allocated.map(Value::Bool)),
206                ("StatusTime", draft.status_time.map(text)),
207                ("ActualWork", draft.actual_work.map(text)),
208                ("ActualUsage", draft.actual_usage.map(Value::Real)),
209                ("ActualStart", draft.actual_start.map(text)),
210                ("ActualFinish", draft.actual_finish.map(text)),
211                ("RemainingWork", draft.remaining_work.map(text)),
212                ("RemainingUsage", draft.remaining_usage.map(Value::Real)),
213                ("Completion", draft.completion.map(Value::Real)),
214            ],
215        )?;
216        self.commit_create(entity)
217    }
218
219    /// Commits an `AllocationDraft` as a new `IfcRelAssignsToResource`,
220    /// failing if `RelatedObjects` is empty, repeats a target, includes the
221    /// resource itself, or references entities the `RelatedObjectsType`
222    /// does not permit.
223    pub fn create_allocation(&mut self, draft: AllocationDraft<'_>) -> ResourceResult<EntityId> {
224        self.validate_new_global_id(draft.global_id)?;
225        self.check_reference_select(
226            draft.resource,
227            "RelatingResource",
228            "IfcResourceSelect",
229            &["IfcResource", "IfcTypeResource"],
230            draft.resource,
231        )?;
232        validate_required_unique(draft.resource, "RelatedObjects", &draft.related_objects)?;
233        if let Some(category) = draft.related_objects_type {
234            validate_enum(
235                self.schema,
236                "IfcRelAssignsToResource",
237                "RelatedObjectsType",
238                category,
239            )?;
240        }
241        for target in &draft.related_objects {
242            self.check_reference(
243                draft.resource,
244                "RelatedObjects",
245                "IfcObjectDefinition",
246                *target,
247            )?;
248            if *target == draft.resource {
249                return Err(ResourceError::SemanticViolation {
250                    entity: None,
251                    rule: "RESOURCE_ASSIGNMENT_NO_SELF_REFERENCE",
252                });
253            }
254        }
255        validate_object_assignment(
256            self.model,
257            self.schema,
258            None,
259            draft.related_objects_type,
260            &draft.related_objects,
261        )?;
262        let entity = build_entity(
263            self.schema,
264            "IfcRelAssignsToResource",
265            &[
266                ("GlobalId", Some(text(draft.global_id))),
267                ("Name", draft.name.map(text)),
268                ("Description", draft.description.map(text)),
269                ("RelatedObjects", Some(refs(&draft.related_objects))),
270                (
271                    "RelatedObjectsType",
272                    draft
273                        .related_objects_type
274                        .map(|value| Value::Enum(value.into())),
275                ),
276                ("RelatingResource", Some(Value::Ref(draft.resource))),
277            ],
278        )?;
279        self.commit_create(entity)
280    }
281
282    /// Commits a `NestingDraft` as a new `IfcRelNests`, failing if
283    /// `RelatedObjects` is empty, repeats a target, includes the parent
284    /// itself, names a child that already has a resource parent, or would
285    /// introduce a nesting cycle.
286    pub fn create_nesting(&mut self, draft: NestingDraft<'_>) -> ResourceResult<EntityId> {
287        self.validate_new_global_id(draft.global_id)?;
288        self.check_reference(
289            draft.parent,
290            "RelatingObject",
291            "IfcConstructionResource",
292            draft.parent,
293        )?;
294        validate_required_unique(draft.parent, "RelatedObjects", &draft.children)?;
295        for child in &draft.children {
296            self.check_reference(
297                draft.parent,
298                "RelatedObjects",
299                "IfcConstructionResource",
300                *child,
301            )?;
302            if *child == draft.parent {
303                return Err(ResourceError::SemanticViolation {
304                    entity: None,
305                    rule: "RESOURCE_NESTING_NO_SELF_REFERENCE",
306                });
307            }
308        }
309        {
310            let view = ResourceView::new(self.model, self.schema)?;
311            for child in &draft.children {
312                if view.parent_resource(*child)?.is_some() {
313                    return Err(ResourceError::SemanticViolation {
314                        entity: Some(*child),
315                        rule: "IfcObject.Nests permits at most one resource parent",
316                    });
317                }
318                if view
319                    .descendants(*child, Budget::DEFAULT)?
320                    .contains(&draft.parent)
321                {
322                    return Err(ResourceError::SemanticViolation {
323                        entity: Some(*child),
324                        rule: "resource nesting must remain acyclic",
325                    });
326                }
327            }
328        }
329        let entity = build_entity(
330            self.schema,
331            "IfcRelNests",
332            &[
333                ("GlobalId", Some(text(draft.global_id))),
334                ("Name", draft.name.map(text)),
335                ("Description", draft.description.map(text)),
336                ("RelatingObject", Some(Value::Ref(draft.parent))),
337                ("RelatedObjects", Some(refs(&draft.children))),
338            ],
339        )?;
340        self.commit_create(entity)
341    }
342
343    pub(crate) fn check_reference(
344        &self,
345        owner: EntityId,
346        attribute: &'static str,
347        expected: &'static str,
348        target: EntityId,
349    ) -> ResourceResult<()> {
350        self.check_reference_select(owner, attribute, expected, &[expected], target)
351    }
352
353    pub(crate) fn check_reference_select(
354        &self,
355        owner: EntityId,
356        attribute: &'static str,
357        expected: &'static str,
358        members: &[&str],
359        target: EntityId,
360    ) -> ResourceResult<()> {
361        let entity = self
362            .model
363            .get(target)
364            .ok_or(ResourceError::DanglingReference {
365                entity: owner,
366                attribute,
367                target,
368            })?;
369        if !members
370            .iter()
371            .any(|member| self.schema.is_a(&entity.type_name, member))
372        {
373            return Err(ResourceError::WrongReferenceType {
374                entity: owner,
375                attribute,
376                target,
377                expected,
378                actual: entity.type_name.to_string(),
379            });
380        }
381        Ok(())
382    }
383
384    pub(crate) fn commit_create(&mut self, entity: Entity) -> ResourceResult<EntityId> {
385        let mut transaction = Transaction::new(self.model);
386        let expected = transaction.revision();
387        let id = transaction.create(entity);
388        transaction
389            .commit(self.model)
390            .map_err(|_| ResourceError::TransactionConflict {
391                expected,
392                actual: self.model.revision(),
393            })?;
394        Ok(id)
395    }
396}
397
398fn resource_entity_type(kind: ResourceKind) -> &'static str {
399    match kind {
400        ResourceKind::Labor => "IfcLaborResource",
401        ResourceKind::Equipment => "IfcConstructionEquipmentResource",
402        ResourceKind::Crew => "IfcCrewResource",
403        ResourceKind::Material => "IfcConstructionMaterialResource",
404        ResourceKind::Product => "IfcConstructionProductResource",
405        ResourceKind::Subcontract => "IfcSubContractResource",
406    }
407}
408
409/// The `IfcTypeResource` subtype paired with each occurrence kind.
410///
411/// Each occurrence resource has exactly one type counterpart, so the
412/// mapping lives next to [`resource_entity_type`] rather than being
413/// derived by string surgery on the occurrence name.
414fn resource_type_entity_type(kind: ResourceKind) -> &'static str {
415    match kind {
416        ResourceKind::Labor => "IfcLaborResourceType",
417        ResourceKind::Equipment => "IfcConstructionEquipmentResourceType",
418        ResourceKind::Crew => "IfcCrewResourceType",
419        ResourceKind::Material => "IfcConstructionMaterialResourceType",
420        ResourceKind::Product => "IfcConstructionProductResourceType",
421        ResourceKind::Subcontract => "IfcSubContractResourceType",
422    }
423}
424
425pub(crate) fn build_entity(
426    schema: &Schema,
427    entity_type: &'static str,
428    values: &[(&'static str, Option<Value>)],
429) -> ResourceResult<Entity> {
430    if schema.entity(entity_type).is_none() {
431        return Err(ResourceError::InvalidDraft {
432            entity_type,
433            attribute: "<entity>",
434            expected: "entity declared by IFC4",
435        });
436    }
437    let attribute_names = schema.attribute_names(entity_type);
438    let mut attributes = vec![Value::Null; attribute_names.len()];
439    for (name, value) in values {
440        if let Some(value) = value {
441            let slot = attribute_names
442                .iter()
443                .position(|candidate| candidate.eq_ignore_ascii_case(name))
444                .ok_or(ResourceError::InvalidDraft {
445                    entity_type,
446                    attribute: name,
447                    expected: "attribute declared by IFC4",
448                })?;
449            attributes[slot] = value.clone();
450        }
451    }
452    Ok(Entity::new(entity_type, attributes))
453}
454
455pub(crate) fn validate_enum(
456    schema: &Schema,
457    entity_type: &'static str,
458    attribute: &'static str,
459    value: &str,
460) -> ResourceResult<()> {
461    let declared = schema
462        .attributes(entity_type)
463        .iter()
464        .find(|candidate| candidate.name.eq_ignore_ascii_case(attribute))
465        .and_then(|attribute| schema.type_def(&attribute.type_name))
466        .is_some_and(|definition| {
467            matches!(&definition.kind, TypeKind::Enumeration(values) if values.iter().any(|member| member == value))
468        });
469    if !declared {
470        return Err(ResourceError::InvalidEnumeration {
471            entity: None,
472            attribute,
473            value: value.to_owned(),
474        });
475    }
476    Ok(())
477}
478
479fn validate_required_unique(
480    owner: EntityId,
481    attribute: &'static str,
482    values: &[EntityId],
483) -> ResourceResult<()> {
484    if values.is_empty() {
485        return Err(ResourceError::InvalidCardinality {
486            entity: owner,
487            attribute,
488            minimum: 1,
489            actual: 0,
490        });
491    }
492    let mut unique = HashSet::with_capacity(values.len());
493    for value in values {
494        if !unique.insert(*value) {
495            return Err(ResourceError::DuplicateReference {
496                entity: owner,
497                attribute,
498                target: *value,
499            });
500        }
501    }
502    Ok(())
503}
504
505fn validate_global_id(value: &str) -> ResourceResult<()> {
506    if value.len() != 22
507        || !matches!(value.as_bytes().first(), Some(b'0'..=b'3'))
508        || !value
509            .bytes()
510            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$')
511    {
512        return Err(ResourceError::InvalidGlobalId);
513    }
514    Ok(())
515}
516
517pub(crate) fn text(value: &str) -> Value {
518    Value::Text(value.into())
519}
520
521pub(crate) fn refs(values: &[EntityId]) -> Value {
522    Value::List(values.iter().copied().map(Value::Ref).collect())
523}
524
525/// Draft for one `IfcAppliedValue`.
526#[derive(Debug, Clone, Copy, Default)]
527pub struct AppliedValueDraft<'a> {
528    /// `Name`.
529    pub name: Option<&'a str>,
530    /// `Description`.
531    pub description: Option<&'a str>,
532    /// `AppliedValue`, an `IfcAppliedValueSelect` reference.
533    pub applied_value: Option<EntityId>,
534    /// `UnitBasis`, the `IfcMeasureWithUnit` the value is quoted per.
535    pub unit_basis: Option<EntityId>,
536    /// `ApplicableDate`, an ISO 8601 date written as given.
537    pub applicable_date: Option<&'a str>,
538    /// `FixedUntilDate`, an ISO 8601 date written as given.
539    pub fixed_until_date: Option<&'a str>,
540    /// `Category`.
541    pub category: Option<&'a str>,
542    /// `Condition`.
543    pub condition: Option<&'a str>,
544    /// `ArithmeticOperator`, an `IfcArithmeticOperatorEnum` token.
545    pub arithmetic_operator: Option<&'a str>,
546}
547
548impl ResourceEditor<'_> {
549    /// Create an `IfcAppliedValue`, optionally composed from components.
550    ///
551    /// An applied value is a cost-like quantity: a value, the unit basis
552    /// it is quoted per, and the dates it applies between. It may instead
553    /// be *composed*, combining `components` with an arithmetic operator.
554    ///
555    /// The schema states no WHERE rule tying the two together, but a
556    /// composition without an operator does not say how to combine its
557    /// parts, and an operator without components has nothing to combine;
558    /// both are refused rather than written for a reader to guess at.
559    ///
560    /// `Components` is `LIST [1:?] OF IfcAppliedValue`, so an empty list
561    /// is left absent rather than written as `()`, which would satisfy
562    /// the type and break the bound.
563    ///
564    /// # Errors
565    ///
566    /// Refuses an `arithmetic_operator` the target schema does not
567    /// declare, an operator/components pair where only one side is
568    /// present, a component that is not an `IfcAppliedValue`, and an
569    /// `applied_value` outside `IfcAppliedValueSelect`.
570    pub fn create_applied_value(
571        &mut self,
572        draft: AppliedValueDraft<'_>,
573        components: &[EntityId],
574    ) -> ResourceResult<EntityId> {
575        const ENTITY: &str = "IfcAppliedValue";
576        if let Some(operator) = draft.arithmetic_operator {
577            validate_enum(self.schema, ENTITY, "ArithmeticOperator", operator)?;
578        }
579        if draft.arithmetic_operator.is_some() == components.is_empty() {
580            return Err(ResourceError::SemanticViolation {
581                entity: None,
582                rule: "ArithmeticOperator and Components are stated together",
583            });
584        }
585        if let Some(value) = draft.applied_value {
586            self.check_reference_select(
587                value,
588                "AppliedValue",
589                "IfcAppliedValueSelect",
590                &[
591                    "IfcMeasureWithUnit",
592                    "IfcMonetaryMeasure",
593                    "IfcRatioMeasure",
594                    "IfcReference",
595                ],
596                value,
597            )?;
598        }
599        if let Some(basis) = draft.unit_basis {
600            self.check_reference(basis, "UnitBasis", "IfcMeasureWithUnit", basis)?;
601        }
602        for component in components {
603            self.check_reference(*component, "Components", ENTITY, *component)?;
604        }
605
606        let entity = build_entity(
607            self.schema,
608            ENTITY,
609            &[
610                ("Name", draft.name.map(text)),
611                ("Description", draft.description.map(text)),
612                ("AppliedValue", draft.applied_value.map(Value::Ref)),
613                ("UnitBasis", draft.unit_basis.map(Value::Ref)),
614                ("ApplicableDate", draft.applicable_date.map(text)),
615                ("FixedUntilDate", draft.fixed_until_date.map(text)),
616                ("Category", draft.category.map(text)),
617                ("Condition", draft.condition.map(text)),
618                ("ArithmeticOperator", draft.arithmetic_operator.map(enum_of)),
619                (
620                    "Components",
621                    (!components.is_empty()).then(|| refs(components)),
622                ),
623            ],
624        )?;
625        self.commit_create(entity)
626    }
627}
628
629/// An enum token as a value.
630fn enum_of(token: &str) -> Value {
631    Value::Enum(std::sync::Arc::from(token.to_ascii_uppercase()))
632}