Skip to main content

cadforge_core/
command.rs

1//! The command model.
2//!
3//! Every mutation to a [`Model`](crate::model::Model) is a `ModelCommand`. Nothing else may
4//! touch the store. That single rule is what buys undo, audit, replay, deterministic export,
5//! and — later — multi-user sync, none of which can be retrofitted onto ad-hoc mutation
6//! (`docs/ifc-semantics.md` §5).
7//!
8//! **Every variant has an exact inverse.** `CreateElement` carries the whole record, so
9//! deleting and undoing restores the element byte-for-byte rather than approximately.
10//! `SetName` and `SetProperty` take `Option`, so setting and clearing are the same operation
11//! in opposite directions.
12
13use crate::element::{ElementRecord, Placement};
14use crate::id::GlobalId;
15use crate::property::PropertyValue;
16use crate::representation::Representation;
17use glam::DVec3;
18use serde::{Deserialize, Serialize};
19
20/// A single durable edit.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub enum ModelCommand {
23    /// Insert an element. Boxed because this variant is far larger than the others and would
24    /// otherwise inflate every command in the log.
25    CreateElement {
26        element: Box<ElementRecord>,
27    },
28    DeleteElement {
29        global_id: GlobalId,
30    },
31    SetName {
32        global_id: GlobalId,
33        name: Option<String>,
34    },
35    SetProperty {
36        global_id: GlobalId,
37        set: String,
38        name: String,
39        /// `None` clears the property.
40        value: Option<PropertyValue>,
41    },
42    /// Translate an element. Kept distinct from `SetPlacement` because its inverse is exact
43    /// without reading the current state.
44    MoveElement {
45        global_id: GlobalId,
46        delta: DVec3,
47    },
48    SetPlacement {
49        global_id: GlobalId,
50        placement: Placement,
51    },
52    /// `IfcRelContainedInSpatialStructure`.
53    AssignContainer {
54        global_id: GlobalId,
55        container: Option<GlobalId>,
56    },
57    /// Attach evaluated geometry. `None` clears it.
58    ///
59    /// A command rather than a cache write, unlike bounds: the representation is what gets
60    /// exported to IFC, so it is part of the model and belongs in the audit trail.
61    SetRepresentation {
62        global_id: GlobalId,
63        representation: Option<Box<Representation>>,
64    },
65    /// `IfcRelDefinesByType` — bind an instance to a family type.
66    AssignType {
67        global_id: GlobalId,
68        type_ref: Option<GlobalId>,
69    },
70    /// `IfcRelVoidsElement` — an opening cuts its host.
71    AddVoid {
72        host: GlobalId,
73        opening: GlobalId,
74    },
75    RemoveVoid {
76        host: GlobalId,
77        opening: GlobalId,
78    },
79    /// `IfcRelFillsElement` — a door or window fills an opening.
80    AddFill {
81        opening: GlobalId,
82        filler: GlobalId,
83    },
84    RemoveFill {
85        opening: GlobalId,
86        filler: GlobalId,
87    },
88}
89
90impl ModelCommand {
91    /// The element this command is about, for permission checks and change reporting.
92    pub fn primary_target(&self) -> &GlobalId {
93        match self {
94            Self::CreateElement { element } => &element.global_id,
95            Self::DeleteElement { global_id }
96            | Self::SetName { global_id, .. }
97            | Self::SetProperty { global_id, .. }
98            | Self::MoveElement { global_id, .. }
99            | Self::SetPlacement { global_id, .. }
100            | Self::AssignContainer { global_id, .. }
101            | Self::SetRepresentation { global_id, .. }
102            | Self::AssignType { global_id, .. } => global_id,
103            Self::AddVoid { host, .. } | Self::RemoveVoid { host, .. } => host,
104            Self::AddFill { opening, .. } | Self::RemoveFill { opening, .. } => opening,
105        }
106    }
107
108    /// Whether applying this command requires geometry to be rebuilt.
109    ///
110    /// A rename must not invalidate a mesh — that distinction is the whole point of tracking
111    /// `semantic_revision` separately from `representation_revision`.
112    pub fn invalidates_geometry(&self) -> bool {
113        match self {
114            Self::CreateElement { .. }
115            | Self::DeleteElement { .. }
116            | Self::MoveElement { .. }
117            | Self::SetPlacement { .. }
118            | Self::AddVoid { .. }
119            | Self::RemoveVoid { .. }
120            | Self::AddFill { .. }
121            | Self::RemoveFill { .. }
122            | Self::SetRepresentation { .. } => true,
123            // Type assignment can change geometry via the family, but the family evaluator
124            // re-issues explicit geometry commands rather than relying on a side effect.
125            Self::SetName { .. }
126            | Self::SetProperty { .. }
127            | Self::AssignType { .. }
128            | Self::AssignContainer { .. } => false,
129        }
130    }
131}
132
133/// What happened when a command was applied.
134#[derive(Debug, Clone, PartialEq)]
135pub struct CommandOutcome {
136    /// The revision the model is at after applying.
137    pub revision: u64,
138    /// The command that undoes this one exactly.
139    pub inverse: ModelCommand,
140    /// Elements whose state changed.
141    pub changed: Vec<GlobalId>,
142    /// Elements whose cached geometry is now stale.
143    pub geometry_invalidated: Vec<GlobalId>,
144}
145
146/// Why a command was rejected.
147///
148/// Commands are validated before they mutate anything: a rejected command leaves the model
149/// exactly as it was.
150#[derive(Debug, Clone, PartialEq, thiserror::Error)]
151pub enum CommandError {
152    #[error("no element {0}")]
153    UnknownElement(GlobalId),
154
155    #[error("element {0} already exists")]
156    DuplicateElement(GlobalId),
157
158    #[error("{host} is a {class} and cannot host openings")]
159    NotAHost { host: GlobalId, class: String },
160
161    #[error("{0} is not an IfcOpeningElement")]
162    NotAnOpening(GlobalId),
163
164    #[error("an element cannot reference itself ({0})")]
165    SelfReference(GlobalId),
166
167    #[error("{global_id} is still referenced by {relationship}; remove the relationship first")]
168    StillReferenced {
169        global_id: GlobalId,
170        relationship: &'static str,
171    },
172
173    #[error("relationship {relationship} between {a} and {b} does not exist")]
174    NoSuchRelationship {
175        relationship: &'static str,
176        a: GlobalId,
177        b: GlobalId,
178    },
179
180    #[error("relationship {relationship} between {a} and {b} already exists")]
181    RelationshipExists {
182        relationship: &'static str,
183        a: GlobalId,
184        b: GlobalId,
185    },
186
187    #[error("a spatial container must be spatial; {0} is not")]
188    NotSpatial(GlobalId),
189
190    #[error("the representation for {0} is malformed and would produce an unreadable file")]
191    InvalidRepresentation(GlobalId),
192
193    #[error("nothing to undo")]
194    NothingToUndo,
195
196    #[error("nothing to redo")]
197    NothingToRedo,
198}