ifc_lite_export/step_log/
wire.rs

1// SPDX-License-Identifier: MPL-2.0
2//! The mutation log, as `MutablePropertyView.exportMutations()` writes it.
3//!
4//! ONE definition of the wire shape, serde-compatible with the TypeScript
5//! `{ modelId, mutations, exportedAt }` object (`@ifc-lite/mutations`'
6//! `Mutation` interface, field for field). Two optional members extend it,
7//! because the history alone cannot carry them:
8//!
9//! - `newEntities`: the `NewEntity` payloads (`getNewEntities()`). A
10//!   `CREATE_ENTITY` record carries only the express id, which is why
11//!   `importMutations` cannot rebuild the entity either; a host that wants its
12//!   created entities written sends their payloads here, the same thing
13//!   `restoreNewEntity()` receives on the TypeScript side.
14//! - `georefMutations`: `StepExportOptions.georefMutations`, which never was a
15//!   mutation record at all.
16//!
17//! Unknown MEMBERS are ignored, so a newer producer's extra fields do not
18//! break an older writer. An unknown mutation `type` is different: it may be
19//! an edit, so the export refuses the log rather than write a file without it
20//! (`applyMutationsBatch` warns and skips; a native save must not).
21
22use serde::{Deserialize, Deserializer, Serialize};
23use serde_json::Value;
24
25/// `Some(Value::Null)` for an explicit JSON `null`, `None` only when the key
26/// is absent. The TypeScript replay distinguishes the two (`newValue !==
27/// undefined` admits `null`), and plain `Option<Value>` would collapse them.
28fn present<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
29    Value::deserialize(d).map(Some)
30}
31
32/// What `exportMutations()` returns, plus the two members above.
33#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35#[non_exhaustive]
36pub struct MutationLog {
37    /// The model the history was recorded against. Informational only.
38    #[serde(default)]
39    pub model_id: String,
40    /// The history, in the order it was recorded.
41    #[serde(default)]
42    pub mutations: Vec<LogMutation>,
43    /// `Date.now()` at export. Informational only.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub exported_at: Option<f64>,
46    /// Payloads for the entities the session created (see the module doc).
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub new_entities: Vec<LogNewEntity>,
49    /// Georeferencing edits (`StepExportOptions.georefMutations`).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub georef_mutations: Option<GeorefMutations>,
52}
53
54impl MutationLog {
55    /// Parse the JSON `exportMutations()` produced, with or without the two
56    /// extension members. Non-finite numbers arrive as
57    /// `{ "__nonFiniteNumber": "NaN" }` markers and are read as such where a
58    /// number is expected.
59    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
60        serde_json::from_str(json)
61    }
62
63    /// A log holding exactly `mutations`, for callers building one in Rust.
64    pub fn new(mutations: Vec<LogMutation>) -> Self {
65        MutationLog { mutations, ..MutationLog::default() }
66    }
67
68    /// Attach the created-entity payloads.
69    pub fn with_new_entities(mut self, entities: Vec<LogNewEntity>) -> Self {
70        self.new_entities = entities;
71        self
72    }
73
74    /// Attach georeferencing edits.
75    pub fn with_georef_mutations(mut self, georef: GeorefMutations) -> Self {
76        self.georef_mutations = Some(georef);
77        self
78    }
79
80    /// Whether the log asks for nothing at all.
81    pub fn is_empty(&self) -> bool {
82        self.mutations.is_empty() && self.new_entities.is_empty() && self.georef_mutations.is_none()
83    }
84}
85
86/// `Mutation['type']`.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
89#[non_exhaustive]
90pub enum MutationKind {
91    CreateProperty,
92    UpdateProperty,
93    DeleteProperty,
94    CreatePropertySet,
95    DeletePropertySet,
96    CreateQuantity,
97    UpdateQuantity,
98    DeleteQuantity,
99    DeleteQuantitySet,
100    UpdateAttribute,
101    UpdatePositionalAttribute,
102    UpdateEntityType,
103    CreateEntity,
104    DeleteEntity,
105    /// A type this writer does not know. The export refuses the log.
106    #[serde(other)]
107    Unknown,
108}
109
110/// One `Mutation` record. Values are kept as JSON because that is what they
111/// are on the wire: `PropertyValue` is `string | number | boolean | null |
112/// PropertyValue[]`, and a positional value may carry the `{ real }` /
113/// `{ typed }` write markers.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116#[non_exhaustive]
117pub struct LogMutation {
118    #[serde(default)]
119    pub id: String,
120    #[serde(rename = "type")]
121    pub kind: MutationKind,
122    #[serde(default)]
123    pub timestamp: f64,
124    #[serde(default)]
125    pub model_id: String,
126    pub entity_id: u32,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub pset_name: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub prop_name: Option<String>,
131    #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
132    pub old_value: Option<Value>,
133    #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
134    pub new_value: Option<Value>,
135    /// `PropertyValueType` (0 = String … 10 = List).
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub value_type: Option<f64>,
138    /// `QuantityType` (0 = Length … 6 = Number).
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub quantity_type: Option<f64>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub unit: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub attribute_name: Option<String>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub entity_type: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub predefined_type: Option<String>,
149}
150
151impl LogMutation {
152    /// A record of `kind` against `entity_id`, every optional member unset.
153    pub fn new(kind: MutationKind, entity_id: u32) -> Self {
154        LogMutation {
155            id: String::new(),
156            kind,
157            timestamp: 0.0,
158            model_id: String::new(),
159            entity_id,
160            pset_name: None,
161            prop_name: None,
162            old_value: None,
163            new_value: None,
164            value_type: None,
165            quantity_type: None,
166            unit: None,
167            attribute_name: None,
168            entity_type: None,
169            predefined_type: None,
170        }
171    }
172}
173
174/// `NewEntity`: an entity the session created, as `getNewEntities()` returns
175/// it. `attributes` is the positional STEP argument list in the
176/// `IfcAttributeValue` shape (`"#42"` for a reference, `".AREA."` for an
177/// enum, `"$"` for unset, `{ "real": 5 }` for a forced REAL).
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180#[non_exhaustive]
181pub struct LogNewEntity {
182    pub express_id: u32,
183    #[serde(rename = "type")]
184    pub entity_type: String,
185    #[serde(default)]
186    pub attributes: Vec<Value>,
187}
188
189impl LogNewEntity {
190    pub fn new(express_id: u32, entity_type: impl Into<String>, attributes: Vec<Value>) -> Self {
191        LogNewEntity { express_id, entity_type: entity_type.into(), attributes }
192    }
193}
194
195/// `StepExportOptions.georefMutations`: partial `ProjectedCRS` /
196/// `MapConversion` edits. Each member is kept as its JSON value so an explicit
197/// `null` stays distinguishable from an absent key, which the TypeScript
198/// exporter tests with `!== undefined`.
199#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201#[non_exhaustive]
202pub struct GeorefMutations {
203    #[serde(default, rename = "projectedCRS", skip_serializing_if = "Option::is_none")]
204    pub projected_crs: Option<serde_json::Map<String, Value>>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub map_conversion: Option<serde_json::Map<String, Value>>,
207}
208
209impl GeorefMutations {
210    pub fn new(
211        projected_crs: Option<serde_json::Map<String, Value>>,
212        map_conversion: Option<serde_json::Map<String, Value>>,
213    ) -> Self {
214        GeorefMutations { projected_crs, map_conversion }
215    }
216}