1use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashSet;
12
13pub const BLOCK_KEY: &str = "simulation";
15
16pub const SCHEMA: u32 = 1;
18
19pub const MAX_DURATION: f64 = 600.0;
21
22pub const FPS_RANGE: std::ops::RangeInclusive<u32> = 1..=120;
24
25pub 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 #[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 pub duration: f64,
49 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 #[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 pub enabled: bool,
68}
69
70impl Default for Interference {
71 fn default() -> Self {
72 Self { enabled: true }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct Motor {
80 pub id: String,
81 pub constraint: String,
83 pub from: f64,
85 pub to: f64,
86 pub drive: Drive,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[serde(tag = "mode", rename_all = "camelCase")]
92pub enum Drive {
93 Speed { speed: f64 },
96 Profile {
98 shape: Shape,
99 start: f64,
100 end: f64,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 accel: Option<f64>,
105 },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum Shape {
112 Linear,
114 Ease,
116 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
131pub const DEFAULT_ACCEL: f64 = 0.25;
133
134#[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 Suppress { constraint: String },
150 Camera { views: Vec<String> },
153 Visibility { occurrences: Vec<String> },
155 Transparency { occurrences: Vec<String>, opacity: [f64; 2] },
158 Section {
161 view: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 sweep: Option<[f64; 2]>,
164 },
165 Explode {
168 view: String,
169 #[serde(default)]
170 collapse: bool,
171 },
172}
173
174impl ClipBody {
175 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 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 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 pub fn next_id(&mut self, prefix: &str) -> String {
216 self.id_counter += 1;
217 format!("{prefix}{}", self.id_counter)
218 }
219
220 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 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 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 pub fn frame_count(&self) -> usize {
285 (self.duration * self.fps as f64).round() as usize + 1
286 }
287
288 pub fn frame_time(&self, index: usize) -> f64 {
290 (index as f64 / self.fps as f64).min(self.duration)
291 }
292
293 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 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