Skip to main content

ifc_schedule/
authoring.rs

1//! Transactional authoring for tasks, task times and sequence links.
2//!
3//! The crate could read a programme and not write one. These constructors
4//! reuse the slot constants the readers in [`crate::task`] and
5//! [`crate::sequence`] already declare, so an authored task is readable by
6//! construction rather than by coincidence -- `IfcTask` carries thirteen
7//! slots, seven of them inherited from `IfcRoot`, `IfcObject` and
8//! `IfcProcess`, and a constructor that counted only the six declared
9//! attributes would put Status where GlobalId belongs.
10//!
11//! # Releases (#202)
12//!
13//! The writers here take no model, so they cannot see the declared release.
14//! They write the layout IFC4 ADD2 TC1 and IFC4X3 ADD2 share, with
15//! `IfcRoot.OwnerHistory` unset, and are for those releases only. IFC2X3
16//! requires `OwnerHistory` and lays several records out differently, so
17//! each `IfcRoot` writer has a `*_with_owner_history` variant that binds the
18//! model's declared release, takes a caller-supplied `IfcOwnerHistory` and
19//! places every attribute by name from that release's table.
20//!
21//! Durations and timestamps are written as authored: ISO 8601 strings are
22//! not parsed here, because a scheduling tool owns calendar semantics and
23//! silently normalising them would lose the authored intent.
24
25use ifc_model::{Entity, EntityId, Model, Transaction, Value};
26
27use crate::calendar::{work_calendar_slot, work_time_slot};
28use crate::error::ScheduleAuthoringError;
29use crate::event::{event_slot, event_time_slot};
30use crate::query::{assigns_slot, nests_slot};
31use crate::schedule::work_control_slot as control_slot;
32use crate::schedule::WorkControlKind;
33use crate::sequence::lag_slot;
34use crate::sequence::relation::slot as sequence_slot;
35use crate::task::definition::task_slot;
36
37pub use datetime::{CalendarDate, DateTimeValue, LocalTime};
38
39/// Result of a schedule authoring call.
40pub type ScheduleAuthoringResult<T> = Result<T, ScheduleAuthoringError>;
41
42/// Authored fields for `IfcTask`.
43#[derive(Debug, Clone, Copy, Default)]
44#[non_exhaustive]
45pub struct TaskDraft<'a> {
46    /// `IfcRoot.GlobalId`. Must be a valid IFC compressed GUID.
47    pub global_id: &'a str,
48    /// `IfcRoot.Name`, if given.
49    pub name: Option<&'a str>,
50    /// `IfcRoot.Description`, if given.
51    pub description: Option<&'a str>,
52    /// `IfcProcess.Identification`, if given.
53    pub identification: Option<&'a str>,
54    /// `IfcProcess.LongDescription`, if given.
55    pub long_description: Option<&'a str>,
56    /// `IfcTask.Status`, if given.
57    pub status: Option<&'a str>,
58    /// `IfcTask.WorkMethod`, if given.
59    pub work_method: Option<&'a str>,
60    /// `IfcTask.IsMilestone`. Required by the schema.
61    pub is_milestone: bool,
62    /// `IfcTask.Priority`, if given. An `IfcInteger` in `0..=100`.
63    pub priority: Option<i64>,
64    /// `IfcTask.TaskTime`, an `IfcTaskTime` reference, if given.
65    pub task_time: Option<EntityId>,
66    /// `IfcTask.PredefinedType`, if given.
67    pub predefined_type: Option<&'a str>,
68}
69
70/// Stage an `IfcTask`.
71///
72/// Slots are filled by the reader's own constants, so the seven inherited
73/// positions are reserved even when unset rather than counted by hand.
74///
75/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
76/// shared layout. This writer takes no model, so it cannot see the declared
77/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
78/// `OwnerHistory` and lays the record out differently; use
79/// [`create_task_with_owner_history`] there, which binds the release (#202).
80pub fn create_task(
81    tx: &mut Transaction,
82    draft: TaskDraft<'_>,
83) -> ScheduleAuthoringResult<EntityId> {
84    checks::task(&draft)?;
85    let mut attributes = vec![Value::Null; task_slot::PREDEFINED_TYPE + 1];
86    attributes[task_slot::GLOBAL_ID] = Value::Text(draft.global_id.into());
87    attributes[task_slot::NAME] = optional_text(draft.name);
88    attributes[task_slot::DESCRIPTION] = optional_text(draft.description);
89    attributes[task_slot::IDENTIFICATION] = optional_text(draft.identification);
90    attributes[task_slot::LONG_DESCRIPTION] = optional_text(draft.long_description);
91    attributes[task_slot::STATUS] = optional_text(draft.status);
92    attributes[task_slot::WORK_METHOD] = optional_text(draft.work_method);
93    attributes[task_slot::IS_MILESTONE] = Value::Bool(draft.is_milestone);
94    attributes[task_slot::PRIORITY] = draft.priority.map_or(Value::Null, Value::Integer);
95    attributes[task_slot::TASK_TIME] = draft.task_time.map_or(Value::Null, Value::Ref);
96    attributes[task_slot::PREDEFINED_TYPE] = draft
97        .predefined_type
98        .map_or(Value::Null, |t| Value::Enum(t.into()));
99    Ok(tx.create(Entity::new("IFCTASK", attributes)))
100}
101
102pub(crate) fn optional_text(value: Option<&str>) -> Value {
103    value.map_or(Value::Null, |text| Value::Text(text.into()))
104}
105/// Stage an `IfcRelSequence` linking a predecessor to a successor.
106///
107/// A task may not precede itself: a self-loop is an unsatisfiable
108/// constraint that the traversal in [`crate::query`] would otherwise have
109/// to detect as a cycle at read time.
110///
111/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
112/// shared layout. This writer takes no model, so it cannot see the declared
113/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
114/// `OwnerHistory` and lays the record out differently; use
115/// [`create_sequence_with_owner_history`] there, which binds the release (#202).
116pub fn create_sequence(
117    tx: &mut Transaction,
118    global_id: &str,
119    predecessor: EntityId,
120    successor: EntityId,
121    sequence_type: Option<&str>,
122    time_lag: Option<EntityId>,
123) -> ScheduleAuthoringResult<EntityId> {
124    checks::sequence(global_id, predecessor, successor)?;
125    let mut attributes = vec![Value::Null; sequence_slot::SEQUENCE_TYPE + 2];
126    attributes[0] = Value::Text(global_id.into());
127    attributes[sequence_slot::RELATING] = Value::Ref(predecessor);
128    attributes[sequence_slot::RELATED] = Value::Ref(successor);
129    attributes[sequence_slot::TIME_LAG] = time_lag.map_or(Value::Null, Value::Ref);
130    attributes[sequence_slot::SEQUENCE_TYPE] =
131        sequence_type.map_or(Value::Null, |t| Value::Enum(t.into()));
132    Ok(tx.create(Entity::new("IFCRELSEQUENCE", attributes)))
133}
134
135/// Authored fields for `IfcWorkPlan` and `IfcWorkSchedule`.
136///
137/// Both are `IfcWorkControl` subtypes with identical slots, so one draft
138/// serves both and the kind picks the entity type. In IFC4 and IFC4X3
139/// timestamps and durations are ISO 8601 strings written exactly as given,
140/// for the same reason `IfcTaskTime` does not parse them; IFC2X3 dates are
141/// [`DateTimeValue`] records, which only
142/// [`create_work_control_with_owner_history`] writes.
143#[derive(Debug, Clone, Copy, Default)]
144#[non_exhaustive]
145pub struct WorkControlDraft<'a> {
146    /// `IfcRoot.GlobalId`. Must be a valid IFC compressed GUID.
147    pub global_id: &'a str,
148    /// `IfcRoot.Name`, if given.
149    pub name: Option<&'a str>,
150    /// `IfcRoot.Description`, if given.
151    pub description: Option<&'a str>,
152    /// `IfcWorkControl.Identification`, if given.
153    pub identification: Option<&'a str>,
154    /// `IfcWorkControl.CreationDate`. Required by the schema: ISO 8601
155    /// text in IFC4 and IFC4X3, an `IfcDateTimeSelect` record in IFC2X3.
156    pub creation_date: DateTimeValue<'a>,
157    /// `IfcWorkControl.Purpose`, if given.
158    pub purpose: Option<&'a str>,
159    /// `IfcWorkControl.Duration`, an ISO 8601 duration, if given.
160    pub duration: Option<&'a str>,
161    /// `IfcWorkControl.TotalFloat`, an ISO 8601 duration, if given.
162    pub total_float: Option<&'a str>,
163    /// `IfcWorkControl.StartTime`. Required by the schema, in the same
164    /// form as `creation_date`.
165    pub start_time: DateTimeValue<'a>,
166    /// `IfcWorkControl.FinishTime`, if given, in the same form as
167    /// `creation_date`.
168    pub finish_time: Option<DateTimeValue<'a>>,
169    /// `IfcWorkControl.PredefinedType`, if given.
170    pub predefined_type: Option<&'a str>,
171}
172
173/// Stage an `IfcWorkPlan` or `IfcWorkSchedule`.
174///
175/// `CreationDate` and `StartTime` are required by the schema, so they are
176/// plain fields rather than options: a work control without them parses
177/// but does not say when the work happens.
178///
179/// # Errors
180///
181/// Refuses a malformed GUID, an empty required timestamp -- a blank
182/// `StartTime` writes a schedule that validates and schedules nothing --
183/// and a date that is not ISO 8601 text: the IFC2X3 `IfcDateTimeSelect`
184/// records are not IFC4 or IFC4X3 values.
185///
186/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
187/// shared layout. This writer takes no model, so it cannot see the declared
188/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
189/// `OwnerHistory` and lays the record out differently; use
190/// [`create_work_control_with_owner_history`] there, which binds the release (#202).
191pub fn create_work_control(
192    tx: &mut Transaction,
193    kind: WorkControlKind,
194    draft: WorkControlDraft<'_>,
195) -> ScheduleAuthoringResult<EntityId> {
196    let type_name = checks::work_control(kind, &draft)?;
197    let text = |attribute: &'static str, value: DateTimeValue<'_>| {
198        value.text().map(|text| Value::Text(text.into())).ok_or(
199            ScheduleAuthoringError::InvalidValue {
200                entity: type_name,
201                attribute,
202                expected: "ISO 8601 text: IfcDateTimeSelect records are IFC2X3 only",
203            },
204        )
205    };
206    let creation_date = text("CreationDate", draft.creation_date)?;
207    let start_time = text("StartTime", draft.start_time)?;
208    let finish_time = match draft.finish_time {
209        Some(value) => text("FinishTime", value)?,
210        None => Value::Null,
211    };
212    let mut attributes = vec![Value::Null; control_slot::PREDEFINED_TYPE + 1];
213    attributes[control_slot::GLOBAL_ID] = Value::Text(draft.global_id.into());
214    attributes[control_slot::NAME] = optional_text(draft.name);
215    attributes[control_slot::DESCRIPTION] = optional_text(draft.description);
216    attributes[control_slot::IDENTIFICATION] = optional_text(draft.identification);
217    attributes[control_slot::CREATION_DATE] = creation_date;
218    attributes[control_slot::PURPOSE] = optional_text(draft.purpose);
219    attributes[control_slot::DURATION] = optional_text(draft.duration);
220    attributes[control_slot::TOTAL_FLOAT] = optional_text(draft.total_float);
221    attributes[control_slot::START_TIME] = start_time;
222    attributes[control_slot::FINISH_TIME] = finish_time;
223    attributes[control_slot::PREDEFINED_TYPE] = draft
224        .predefined_type
225        .map_or(Value::Null, |t| Value::Enum(t.into()));
226    Ok(tx.create(Entity::new(type_name, attributes)))
227}
228
229/// Stage an `IfcRelAssignsToControl` binding tasks to a work control.
230///
231/// This is the link `tasks_of_schedule` reads back: a schedule with no
232/// assignment owns nothing, however many tasks the file contains.
233///
234/// # Errors
235///
236/// Refuses a malformed GUID, and an empty task list -- an assignment
237/// relating no objects parses and assigns nothing.
238///
239/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
240/// shared layout. This writer takes no model, so it cannot see the declared
241/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
242/// `OwnerHistory` and lays the record out differently; use
243/// [`assign_tasks_to_control_with_owner_history`] there, which binds the release (#202).
244pub fn assign_tasks_to_control(
245    tx: &mut Transaction,
246    global_id: &str,
247    control: EntityId,
248    tasks: &[EntityId],
249) -> ScheduleAuthoringResult<EntityId> {
250    checks::assignment(global_id, tasks)?;
251    let mut attributes = vec![Value::Null; assigns_slot::RELATING + 1];
252    attributes[assigns_slot::GLOBAL_ID] = Value::Text(global_id.into());
253    attributes[assigns_slot::RELATED] =
254        Value::List(tasks.iter().copied().map(Value::Ref).collect());
255    attributes[assigns_slot::RELATING] = Value::Ref(control);
256    Ok(tx.create(Entity::new("IFCRELASSIGNSTOCONTROL", attributes)))
257}
258
259/// Stage an `IfcRelNests` nesting child tasks under a parent.
260///
261/// Task breakdown structure: `IfcRelNests` is the ordered parent-child
262/// link `subtasks_of` reads, not `IfcRelAggregates`, which nests physical
263/// decomposition instead.
264///
265/// # Errors
266///
267/// Refuses a malformed GUID, an empty child list, and a parent that also
268/// appears among its own children -- a self-nesting task is a cycle the
269/// timeline walk cannot terminate on.
270///
271/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
272/// shared layout. This writer takes no model, so it cannot see the declared
273/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
274/// `OwnerHistory` and lays the record out differently; use
275/// [`nest_tasks_with_owner_history`] there, which binds the release (#202).
276pub fn nest_tasks(
277    tx: &mut Transaction,
278    global_id: &str,
279    parent: EntityId,
280    children: &[EntityId],
281) -> ScheduleAuthoringResult<EntityId> {
282    checks::nesting(global_id, parent, children)?;
283    let mut attributes = vec![Value::Null; nests_slot::RELATED + 1];
284    attributes[nests_slot::GLOBAL_ID] = Value::Text(global_id.into());
285    attributes[nests_slot::RELATING] = Value::Ref(parent);
286    attributes[nests_slot::RELATED] =
287        Value::List(children.iter().copied().map(Value::Ref).collect());
288    Ok(tx.create(Entity::new("IFCRELNESTS", attributes)))
289}
290
291/// Stage an `IfcWorkTime`.
292///
293/// One working or exception period inside a calendar. The recurrence
294/// pattern is optional: a period with explicit start and finish dates and
295/// no pattern is a single block, which is what a one-off shutdown is.
296///
297/// # Errors
298///
299/// Refuses an empty name when one is given, since a named period that
300/// carries no name reads back as unnamed rather than as authored.
301pub fn create_work_time(
302    tx: &mut Transaction,
303    name: Option<&str>,
304    recurrence: Option<EntityId>,
305    start: Option<&str>,
306    finish: Option<&str>,
307) -> ScheduleAuthoringResult<EntityId> {
308    if name.is_some_and(|value| value.trim().is_empty()) {
309        return Err(ScheduleAuthoringError::InvalidValue {
310            entity: "IFCWORKTIME",
311            attribute: "Name",
312            expected: "a non-empty name when one is given",
313        });
314    }
315    let mut attributes = vec![Value::Null; work_time_slot::FINISH + 1];
316    attributes[work_time_slot::NAME] = optional_text(name);
317    attributes[work_time_slot::RECURRENCE_PATTERN] = recurrence.map_or(Value::Null, Value::Ref);
318    attributes[work_time_slot::START] = optional_text(start);
319    attributes[work_time_slot::FINISH] = optional_text(finish);
320    Ok(tx.create(Entity::new("IFCWORKTIME", attributes)))
321}
322
323/// Stage an `IfcWorkCalendar`.
324///
325/// Working times say when work happens; exception times carve holidays
326/// out of them. Both are `IfcWorkTime` lists, so the two roles are
327/// separate slots rather than a flag on the period.
328///
329/// # Errors
330///
331/// Refuses a malformed GUID, and a calendar with neither working nor
332/// exception times -- it constrains nothing but reads as a real calendar.
333///
334/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
335/// shared layout. This writer takes no model, so it cannot see the declared
336/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
337/// `OwnerHistory` and lays the record out differently; use
338/// [`create_work_calendar_with_owner_history`] there, which binds the release (#202).
339pub fn create_work_calendar(
340    tx: &mut Transaction,
341    global_id: &str,
342    name: Option<&str>,
343    working_times: &[EntityId],
344    exception_times: &[EntityId],
345    predefined_type: Option<&str>,
346) -> ScheduleAuthoringResult<EntityId> {
347    checks::calendar(global_id, working_times, exception_times)?;
348    let mut attributes = vec![Value::Null; work_calendar_slot::PREDEFINED_TYPE + 1];
349    attributes[work_calendar_slot::GLOBAL_ID] = Value::Text(global_id.into());
350    attributes[work_calendar_slot::NAME] = optional_text(name);
351    attributes[work_calendar_slot::WORKING_TIMES] = reference_list(working_times);
352    attributes[work_calendar_slot::EXCEPTION_TIMES] = reference_list(exception_times);
353    attributes[work_calendar_slot::PREDEFINED_TYPE] =
354        predefined_type.map_or(Value::Null, |t| Value::Enum(t.into()));
355    Ok(tx.create(Entity::new("IFCWORKCALENDAR", attributes)))
356}
357
358/// A reference list, or `Null` when empty.
359///
360/// An empty IFC set is not the same as an absent one: writing `()` where
361/// the file means "not stated" reads back as an authored empty set.
362pub(super) fn reference_list(ids: &[EntityId]) -> Value {
363    if ids.is_empty() {
364        return Value::Null;
365    }
366    Value::List(ids.iter().copied().map(Value::Ref).collect())
367}
368
369/// Authored fields for `IfcEvent`.
370///
371/// Two schema WHERE rules are enforced here rather than left to a
372/// downstream validator: both produce a file that parses cleanly and reads
373/// back as a different event than the author meant.
374#[derive(Debug, Clone, Copy, Default)]
375#[non_exhaustive]
376pub struct EventDraft<'a> {
377    /// `IfcRoot.GlobalId`. Must be a valid IFC compressed GUID.
378    pub global_id: &'a str,
379    /// `IfcRoot.Name`, if given.
380    pub name: Option<&'a str>,
381    /// `IfcObject.ObjectType`. Required when `predefined_type` is
382    /// `USERDEFINED`.
383    pub object_type: Option<&'a str>,
384    /// `IfcProcess.Identification`, if given.
385    pub identification: Option<&'a str>,
386    /// `IfcProcess.LongDescription`, if given.
387    pub long_description: Option<&'a str>,
388    /// `IfcEvent.PredefinedType`, if given.
389    pub predefined_type: Option<&'a str>,
390    /// `IfcEvent.EventTriggerType`, if given.
391    pub trigger_type: Option<&'a str>,
392    /// `IfcEvent.UserDefinedEventTriggerType`. Required when `trigger_type`
393    /// is `USERDEFINED`.
394    pub user_defined_trigger_type: Option<&'a str>,
395    /// `IfcEvent.EventOccurenceTime`, an `IfcEventTime` reference.
396    pub occurence_time: Option<EntityId>,
397}
398
399/// Stage an `IfcEvent`.
400///
401/// Refuses a `USERDEFINED` discriminator whose accompanying label is
402/// missing. The schema states both as WHERE rules, and a reader that meets
403/// one has no way to recover what the author meant: the event reads back
404/// as user-defined with nothing saying what it is.
405///
406/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
407/// shared layout. This writer takes no model, so it cannot see the declared
408/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
409/// `OwnerHistory` and lays the record out differently; use
410/// [`create_event_with_owner_history`] there, which binds the release (#202).
411pub fn create_event(
412    tx: &mut Transaction,
413    draft: EventDraft<'_>,
414) -> ScheduleAuthoringResult<EntityId> {
415    checks::event(&draft)?;
416    let mut attributes = vec![Value::Null; event_slot::EVENT_OCCURENCE_TIME + 1];
417    attributes[event_slot::GLOBAL_ID] = Value::Text(draft.global_id.into());
418    attributes[event_slot::NAME] = optional_text(draft.name);
419    attributes[event_slot::OBJECT_TYPE] = optional_text(draft.object_type);
420    attributes[event_slot::IDENTIFICATION] = optional_text(draft.identification);
421    attributes[event_slot::LONG_DESCRIPTION] = optional_text(draft.long_description);
422    attributes[event_slot::PREDEFINED_TYPE] = optional_enum(draft.predefined_type);
423    attributes[event_slot::EVENT_TRIGGER_TYPE] = optional_enum(draft.trigger_type);
424    attributes[event_slot::USER_DEFINED_EVENT_TRIGGER_TYPE] =
425        optional_text(draft.user_defined_trigger_type);
426    attributes[event_slot::EVENT_OCCURENCE_TIME] =
427        draft.occurence_time.map_or(Value::Null, Value::Ref);
428    Ok(tx.create(Entity::new("IFCEVENT", attributes)))
429}
430
431/// Whether a discriminator names the USERDEFINED case.
432pub(super) fn is_user_defined(value: Option<&str>) -> bool {
433    value.is_some_and(|v| v.eq_ignore_ascii_case("USERDEFINED"))
434}
435
436/// Whether an accompanying label is absent or only whitespace.
437///
438/// A blank string satisfies EXISTS in the schema but carries no meaning, so
439/// it is treated as absent here.
440pub(super) fn blank(value: Option<&str>) -> bool {
441    value.is_none_or(|v| v.trim().is_empty())
442}
443
444/// An optional enumeration value.
445pub(super) fn optional_enum(value: Option<&str>) -> Value {
446    value.map_or(Value::Null, |v| Value::Enum(v.into()))
447}
448
449/// Authored fields for `IfcEventTime`.
450///
451/// Dates are ISO 8601 strings written exactly as given, matching how
452/// [`create_task_time`] treats its timestamps.
453#[derive(Debug, Clone, Copy, Default)]
454#[non_exhaustive]
455pub struct EventTimeDraft<'a> {
456    /// `IfcSchedulingTime.Name`, if given.
457    pub name: Option<&'a str>,
458    /// `IfcEventTime.ActualDate`, if given.
459    pub actual: Option<&'a str>,
460    /// `IfcEventTime.EarlyDate`, if given.
461    pub early: Option<&'a str>,
462    /// `IfcEventTime.LateDate`, if given.
463    pub late: Option<&'a str>,
464    /// `IfcEventTime.ScheduleDate`, if given.
465    pub schedule: Option<&'a str>,
466}
467
468/// Stage an `IfcEventTime`.
469///
470/// Every slot is optional in the schema, so an all-empty record is legal
471/// and not refused here: it says "the dates are not yet known", which is a
472/// meaningful state for a planned event.
473pub fn create_event_time(
474    tx: &mut Transaction,
475    draft: EventTimeDraft<'_>,
476) -> ScheduleAuthoringResult<EntityId> {
477    let mut attributes = vec![Value::Null; event_time_slot::SCHEDULE_DATE + 1];
478    attributes[event_time_slot::NAME] = optional_text(draft.name);
479    attributes[event_time_slot::ACTUAL_DATE] = optional_text(draft.actual);
480    attributes[event_time_slot::EARLY_DATE] = optional_text(draft.early);
481    attributes[event_time_slot::LATE_DATE] = optional_text(draft.late);
482    attributes[event_time_slot::SCHEDULE_DATE] = optional_text(draft.schedule);
483    Ok(tx.create(Entity::new("IFCEVENTTIME", attributes)))
484}
485
486/// Stage an `IfcLagTime`.
487///
488/// `LagValue` and `DurationType` are both REQUIRED by the schema, unlike
489/// every other scheduling-time field, so neither is an `Option` here: a lag
490/// that states neither how long nor in what units is not a lag.
491///
492/// `lag_value` is an `IfcTimeOrRatioSelect`. A duration is an ISO 8601
493/// string; a ratio is a plain number. The caller picks, because the two
494/// mean different things and this crate will not guess.
495///
496/// `LagValue` is declared `IfcTimeOrRatioSelect = SELECT (IfcDuration,
497/// IfcRatioMeasure)` in IFC4 and IFC4X3, so the value is written as the
498/// typed parameter of the member it is (#201): a string as
499/// `IFCDURATION('P5D')`, a number as `IFCRATIOMEASURE(0.5)` (an integer is
500/// written as that REAL). A value already typed as one of those two members
501/// is accepted as is.
502///
503/// Takes no model, so it cannot see the model's release and is for IFC4 and
504/// IFC4X3 only. IFC2X3 declares no `IfcLagTime`: there the lag is
505/// `IfcRelSequence.TimeLag` itself, an `IfcTimeMeasure`
506/// ([`TimeLag::Seconds`]). To have the header's release checked, use
507/// [`create_lag_time_in`].
508pub fn create_lag_time(
509    tx: &mut Transaction,
510    name: Option<&str>,
511    lag_value: Value,
512    duration_type: &str,
513) -> ScheduleAuthoringResult<EntityId> {
514    if duration_type.trim().is_empty() {
515        return Err(ScheduleAuthoringError::InvalidValue {
516            entity: "IFCLAGTIME",
517            attribute: "DurationType",
518            expected: "a non-empty IfcTaskDurationEnum value",
519        });
520    }
521    // (member, payload): the SELECT member the value is, as a bare value.
522    let (member, payload) = match lag_value {
523        Value::Typed { type_name, value } => (Some(type_name), *value),
524        bare => (None, bare),
525    };
526    let is = |name: &str| member.as_ref().is_none_or(|m| m.eq_ignore_ascii_case(name));
527    #[allow(clippy::cast_precision_loss)]
528    let (member, payload) = match payload {
529        Value::Text(text) if is("IFCDURATION") => ("IFCDURATION", Value::Text(text)),
530        Value::Real(ratio) if is("IFCRATIOMEASURE") => ("IFCRATIOMEASURE", Value::Real(ratio)),
531        Value::Integer(ratio) if is("IFCRATIOMEASURE") => {
532            ("IFCRATIOMEASURE", Value::Real(ratio as f64))
533        }
534        _ => {
535            return Err(ScheduleAuthoringError::InvalidValue {
536                entity: "IFCLAGTIME",
537                attribute: "LagValue",
538                expected: "a duration string or a ratio number",
539            })
540        }
541    };
542    let lag_value = Value::Typed {
543        type_name: member.into(),
544        value: Box::new(payload),
545    };
546    let mut attributes = vec![Value::Null; lag_slot::DURATION_TYPE + 1];
547    attributes[event_time_slot::NAME] = optional_text(name);
548    attributes[lag_slot::LAG_VALUE] = lag_value;
549    attributes[lag_slot::DURATION_TYPE] = Value::Enum(duration_type.into());
550    Ok(tx.create(Entity::new("IFCLAGTIME", attributes)))
551}
552
553/// [`create_lag_time`] bound to `model`'s declared release (#211).
554///
555/// IFC4 and IFC4X3 are staged exactly as [`create_lag_time`] stages them.
556/// IFC2X3 declares no `IfcLagTime` and is refused with `EntityNotInSchema`;
557/// author its lag as [`TimeLag::Seconds`] on the sequence instead.
558///
559/// # Errors
560///
561/// `EntityNotInSchema` for a release without `IfcLagTime`,
562/// `MultipleSchemas` or `UnsupportedSchema` when the header binds no single
563/// known release, and those of [`create_lag_time`]. Nothing is staged on an
564/// error.
565pub fn create_lag_time_in(
566    tx: &mut Transaction,
567    model: &Model,
568    name: Option<&str>,
569    lag_value: Value,
570    duration_type: &str,
571) -> ScheduleAuthoringResult<EntityId> {
572    crate::release::bind(model)?.require_entity("IFCLAGTIME")?;
573    create_lag_time(tx, name, lag_value, duration_type)
574}
575
576mod builders;
577mod checks;
578mod datetime;
579mod owned;
580mod procedure;
581mod recurrence;
582mod timing;
583
584pub use owned::{
585    assign_tasks_to_control_with_owner_history, create_event_with_owner_history,
586    create_procedure_with_owner_history, create_sequence_with_owner_history,
587    create_task_with_owner_history, create_work_calendar_with_owner_history,
588    create_work_control_with_owner_history, nest_tasks_with_owner_history, TimeLag,
589};
590pub use procedure::{create_procedure, ProcedureDraft};
591pub use recurrence::{create_recurrence_pattern, create_recurrence_pattern_in, RecurrenceDraft};
592pub use timing::{
593    create_task_time, create_task_time_recurring, create_time_period, create_time_period_in,
594    TaskTimeDraft,
595};