Skip to main content

brep_simulation/
model.rs

1//! The `simulation` block: the motion studies of an assembly document.
2//!
3//! The block sits at the top level of the assembly document it animates, so a
4//! study drives that document's own mates. Each study has a duration and a
5//! frame rate (every output samples the same frames), motors and clips.
6//! Constraints and PMI views are named by id; occurrences by path
7//! (`ACOMP1:ACOMP3`).
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashSet;
12
13/// The document key the block is stored under.
14pub const BLOCK_KEY: &str = "simulation";
15
16/// The block's schema version.
17pub const SCHEMA: u32 = 1;
18
19/// The longest study, in seconds.
20pub const MAX_DURATION: f64 = 600.0;
21
22/// The frame rates a study may run at.
23pub const FPS_RANGE: std::ops::RangeInclusive<u32> = 1..=120;
24
25/// A new study's duration (s) and frame rate.
26pub const DEFAULT_DURATION: f64 = 4.0;
27pub const DEFAULT_FPS: u32 = 30;
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct Simulation {
32    pub schema: u32,
33    /// The study the workbench shows.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub active: Option<String>,
36    #[serde(default)]
37    pub studies: Vec<Study>,
38    #[serde(default)]
39    pub id_counter: u64,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct Study {
45    pub id: String,
46    pub name: String,
47    /// Seconds.
48    pub duration: f64,
49    /// Frames per second: playback, scrubbing and every output use these frames.
50    pub fps: u32,
51    #[serde(default)]
52    pub motors: Vec<Motor>,
53    #[serde(default)]
54    pub clips: Vec<Clip>,
55    #[serde(default)]
56    pub interference: Interference,
57    /// The published motion (the baked frames of moving occurrences), for a
58    /// manufacturing-instructions step to attach. Written by the workbench.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub motion: Option<Value>,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct Interference {
66    /// Check every frame for parts that collide.
67    pub enabled: bool,
68}
69
70impl Default for Interference {
71    fn default() -> Self {
72        Self { enabled: true }
73    }
74}
75
76/// A mate driven over time. Its kind (distance or angle) is the constraint's.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct Motor {
80    pub id: String,
81    /// The distance or angle constraint it drives.
82    pub constraint: String,
83    /// Seconds: the motor runs inside `[from, to]` and holds its last value after.
84    pub from: f64,
85    pub to: f64,
86    pub drive: Drive,
87}
88
89/// How a motor's value moves.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[serde(tag = "mode", rename_all = "camelCase")]
92pub enum Drive {
93    /// From the mate's own value at `from`, at a constant rate (model units or
94    /// degrees per second; negative runs backwards).
95    Speed { speed: f64 },
96    /// From `start` to `end` over the motor's span, along `shape`.
97    Profile {
98        shape: Shape,
99        start: f64,
100        end: f64,
101        /// Trapezoid only: the fraction of the span spent accelerating (and
102        /// again decelerating), `0 < accel ≤ 0.5`.
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        accel: Option<f64>,
105    },
106}
107
108/// The motion profiles a motor can follow.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum Shape {
112    /// Constant rate.
113    Linear,
114    /// Starts and stops smoothly (smoothstep).
115    Ease,
116    /// Accelerate, cruise, decelerate.
117    Trapezoid,
118}
119
120impl Shape {
121    pub fn parse(text: &str) -> Option<Self> {
122        match text {
123            "linear" => Some(Self::Linear),
124            "ease" => Some(Self::Ease),
125            "trapezoid" => Some(Self::Trapezoid),
126            _ => None,
127        }
128    }
129}
130
131/// The default trapezoid acceleration fraction.
132pub const DEFAULT_ACCEL: f64 = 0.25;
133
134/// One timeline row other than a motor, active over `[from, to]`.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct Clip {
138    pub id: String,
139    pub from: f64,
140    pub to: f64,
141    #[serde(flatten)]
142    pub body: ClipBody,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "camelCase")]
147pub enum ClipBody {
148    /// The constraint is off (suppressed) inside the span.
149    Suppress { constraint: String },
150    /// The camera moves through these PMI views' cameras, evenly over the
151    /// span, and holds the last one after it.
152    Camera { views: Vec<String> },
153    /// The occurrences are hidden inside the span.
154    Visibility { occurrences: Vec<String> },
155    /// The occurrences' opacity ramps from `opacity[0]` to `opacity[1]` over
156    /// the span.
157    Transparency { occurrences: Vec<String>, opacity: [f64; 2] },
158    /// The PMI view's section is on inside the span; with `sweep`, its planes
159    /// move along their normals from `sweep[0]` to `sweep[1]` (model units).
160    Section {
161        view: String,
162        #[serde(default, skip_serializing_if = "Option::is_none")]
163        sweep: Option<[f64; 2]>,
164    },
165    /// The exploded view's steps play over the span (collapse: backwards),
166    /// and the last state holds after it.
167    Explode {
168        view: String,
169        #[serde(default)]
170        collapse: bool,
171    },
172}
173
174impl ClipBody {
175    /// The `kind` string.
176    pub fn kind(&self) -> &'static str {
177        match self {
178            Self::Suppress { .. } => "suppress",
179            Self::Camera { .. } => "camera",
180            Self::Visibility { .. } => "visibility",
181            Self::Transparency { .. } => "transparency",
182            Self::Section { .. } => "section",
183            Self::Explode { .. } => "explode",
184        }
185    }
186}
187
188impl Default for Simulation {
189    fn default() -> Self {
190        Self { schema: SCHEMA, active: None, studies: Vec::new(), id_counter: 0 }
191    }
192}
193
194impl Simulation {
195    /// Read the block from its JSON, validated.
196    pub fn from_value(value: &Value) -> Result<Self, String> {
197        let block: Self = serde_json::from_value(value.clone()).map_err(|e| format!("simulation block: {e}"))?;
198        block.validate()?;
199        Ok(block)
200    }
201
202    /// Read the block out of a whole document, if it has one.
203    pub fn from_document(document: &Value) -> Result<Option<Self>, String> {
204        match document.get(BLOCK_KEY) {
205            None | Some(Value::Null) => Ok(None),
206            Some(block) => Self::from_value(block).map(Some),
207        }
208    }
209
210    pub fn to_value(&self) -> Value {
211        serde_json::to_value(self).expect("simulation block serializes")
212    }
213
214    /// A fresh id with `prefix` (`SIM` study, `MOT` motor, `CLP` clip).
215    pub fn next_id(&mut self, prefix: &str) -> String {
216        self.id_counter += 1;
217        format!("{prefix}{}", self.id_counter)
218    }
219
220    /// Add an empty study named `name` (numbered when empty), make it active
221    /// and return its id.
222    pub fn add_study(&mut self, name: &str) -> String {
223        let id = self.next_id("SIM");
224        let name = if name.trim().is_empty() { format!("Study {}", self.studies.len() + 1) } else { name.trim().to_string() };
225        self.studies.push(Study::new(&id, &name));
226        self.active = Some(id.clone());
227        id
228    }
229
230    pub fn study(&self, id: &str) -> Option<&Study> {
231        self.studies.iter().find(|s| s.id == id)
232    }
233
234    pub fn study_mut(&mut self, id: &str) -> Option<&mut Study> {
235        self.studies.iter_mut().find(|s| s.id == id)
236    }
237
238    /// The active study, else the first.
239    pub fn active_study(&self) -> Option<&Study> {
240        self.active.as_deref().and_then(|id| self.study(id)).or(self.studies.first())
241    }
242
243    /// The structural rules: a known schema, unique ids, and every study valid.
244    pub fn validate(&self) -> Result<(), String> {
245        if self.schema == 0 || self.schema > SCHEMA {
246            return Err(format!("simulation schema {} is not supported (this build reads up to {SCHEMA})", self.schema));
247        }
248        let mut ids = HashSet::new();
249        for study in &self.studies {
250            for id in std::iter::once(&study.id).chain(study.motors.iter().map(|m| &m.id)).chain(study.clips.iter().map(|c| &c.id)) {
251                if id.is_empty() {
252                    return Err(format!("study '{}': an empty id", study.name));
253                }
254                if !ids.insert(id.as_str()) {
255                    return Err(format!("duplicate id '{id}'"));
256                }
257            }
258            study.validate()?;
259        }
260        if let Some(active) = &self.active {
261            if self.study(active).is_none() {
262                return Err(format!("the active study '{active}' does not exist"));
263            }
264        }
265        Ok(())
266    }
267}
268
269impl Study {
270    pub fn new(id: &str, name: &str) -> Self {
271        Self {
272            id: id.into(),
273            name: name.into(),
274            duration: DEFAULT_DURATION,
275            fps: DEFAULT_FPS,
276            motors: Vec::new(),
277            clips: Vec::new(),
278            interference: Interference::default(),
279            motion: None,
280        }
281    }
282
283    /// Frames from 0 to the duration inclusive.
284    pub fn frame_count(&self) -> usize {
285        (self.duration * self.fps as f64).round() as usize + 1
286    }
287
288    /// The time of frame `index`, seconds.
289    pub fn frame_time(&self, index: usize) -> f64 {
290        (index as f64 / self.fps as f64).min(self.duration)
291    }
292
293    /// The frame nearest `time`.
294    pub fn frame_at(&self, time: f64) -> usize {
295        ((time.clamp(0.0, self.duration) * self.fps as f64).round() as usize).min(self.frame_count() - 1)
296    }
297
298    pub fn motor(&self, id: &str) -> Option<&Motor> {
299        self.motors.iter().find(|m| m.id == id)
300    }
301
302    pub fn clip(&self, id: &str) -> Option<&Clip> {
303        self.clips.iter().find(|c| c.id == id)
304    }
305
306    /// A study the sampler and every output can run: a sane duration and rate,
307    /// spans inside the study, motors on distinct mates where they overlap,
308    /// and clips that name what they act on.
309    pub fn validate(&self) -> Result<(), String> {
310        let name = &self.name;
311        if !(self.duration.is_finite() && self.duration > 0.0 && self.duration <= MAX_DURATION) {
312            return Err(format!("study '{name}': the duration must be over 0 and at most {MAX_DURATION} s, not {}", self.duration));
313        }
314        if !FPS_RANGE.contains(&self.fps) {
315            return Err(format!("study '{name}': the frame rate must be {} to {} fps, not {}", FPS_RANGE.start(), FPS_RANGE.end(), self.fps));
316        }
317        let span = |what: &str, from: f64, to: f64| -> Result<(), String> {
318            if !(from.is_finite() && to.is_finite() && from >= 0.0 && from < to && to <= self.duration + 1e-9) {
319                return Err(format!("study '{name}': {what} must run inside 0–{} s with its start before its end, not {from}–{to}", self.duration));
320            }
321            Ok(())
322        };
323        for (index, motor) in self.motors.iter().enumerate() {
324            let what = format!("motor '{}'", motor.id);
325            span(&what, motor.from, motor.to)?;
326            if motor.constraint.is_empty() {
327                return Err(format!("study '{name}': {what} drives no mate"));
328            }
329            match &motor.drive {
330                Drive::Speed { speed } if !speed.is_finite() => return Err(format!("study '{name}': {what} has no speed")),
331                Drive::Profile { start, end, .. } if !(start.is_finite() && end.is_finite()) => {
332                    return Err(format!("study '{name}': {what} needs a start and an end value"))
333                }
334                Drive::Profile { shape, accel: Some(accel), .. } if *shape == Shape::Trapezoid && !(*accel > 0.0 && *accel <= 0.5) => {
335                    return Err(format!("study '{name}': {what} accelerates over 0–0.5 of its span, not {accel}"))
336                }
337                _ => {}
338            }
339            for other in &self.motors[..index] {
340                if other.constraint == motor.constraint && other.from < motor.to && motor.from < other.to {
341                    return Err(format!("study '{name}': motors '{}' and '{}' drive the same mate at the same time", other.id, motor.id));
342                }
343            }
344        }
345        for clip in &self.clips {
346            let what = format!("{} clip '{}'", clip.body.kind(), clip.id);
347            span(&what, clip.from, clip.to)?;
348            let missing = match &clip.body {
349                ClipBody::Suppress { constraint } => constraint.is_empty(),
350                ClipBody::Camera { views } => views.is_empty() || views.iter().any(String::is_empty),
351                ClipBody::Visibility { occurrences } | ClipBody::Transparency { occurrences, .. } => {
352                    occurrences.is_empty() || occurrences.iter().any(String::is_empty)
353                }
354                ClipBody::Section { view, sweep } => view.is_empty() || sweep.is_some_and(|s| !(s[0].is_finite() && s[1].is_finite())),
355                ClipBody::Explode { view, .. } => view.is_empty(),
356            };
357            if missing {
358                return Err(format!("study '{name}': {what} names nothing to act on"));
359            }
360            if let ClipBody::Transparency { opacity, .. } = &clip.body {
361                if opacity.iter().any(|o| !(0.0..=1.0).contains(o)) {
362                    return Err(format!("study '{name}': {what} opacity must be 0–1, not {}–{}", opacity[0], opacity[1]));
363                }
364            }
365        }
366        Ok(())
367    }
368}
369