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