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#[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 #[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 pub duration: f64,
51 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 #[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 pub enabled: bool,
71}
72
73impl Default for Interference {
74 fn default() -> Self {
75 Self { enabled: true }
76 }
77}
78
79#[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 pub constraint: String,
87 pub from: f64,
89 pub to: f64,
90 pub drive: Drive,
91}
92
93#[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 Speed { speed: f64 },
101 Profile {
103 shape: Shape,
104 start: f64,
105 end: f64,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 accel: Option<f64>,
110 },
111}
112
113#[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 Linear,
120 Ease,
122 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
137pub const DEFAULT_ACCEL: f64 = 0.25;
139
140#[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 Suppress { constraint: String },
158 Camera { views: Vec<String> },
161 Visibility { occurrences: Vec<String> },
163 Transparency { occurrences: Vec<String>, opacity: [f64; 2] },
166 Section {
169 view: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 sweep: Option<[f64; 2]>,
172 },
173 Explode {
176 view: String,
177 #[serde(default)]
178 collapse: bool,
179 },
180}
181
182impl ClipBody {
183 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 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 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 pub fn next_id(&mut self, prefix: &str) -> String {
224 self.id_counter += 1;
225 format!("{prefix}{}", self.id_counter)
226 }
227
228 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 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 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 pub fn frame_count(&self) -> usize {
293 (self.duration * self.fps as f64).round() as usize + 1
294 }
295
296 pub fn frame_time(&self, index: usize) -> f64 {
298 (index as f64 / self.fps as f64).min(self.duration)
299 }
300
301 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 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