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