use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
pub const BLOCK_KEY: &str = "simulation";
pub const SCHEMA: u32 = 1;
pub const MAX_DURATION: f64 = 600.0;
pub const FPS_RANGE: std::ops::RangeInclusive<u32> = 1..=120;
pub const DEFAULT_DURATION: f64 = 4.0;
pub const DEFAULT_FPS: u32 = 30;
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Simulation {
pub schema: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
#[serde(default)]
pub studies: Vec<Study>,
#[serde(default)]
pub id_counter: u64,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Study {
pub id: String,
pub name: String,
pub duration: f64,
pub fps: u32,
#[serde(default)]
pub motors: Vec<Motor>,
#[serde(default)]
pub clips: Vec<Clip>,
#[serde(default)]
pub interference: Interference,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub motion: Option<Value>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Interference {
pub enabled: bool,
}
impl Default for Interference {
fn default() -> Self {
Self { enabled: true }
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Motor {
pub id: String,
pub constraint: String,
pub from: f64,
pub to: f64,
pub drive: Drive,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "camelCase")]
pub enum Drive {
Speed { speed: f64 },
Profile {
shape: Shape,
start: f64,
end: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
accel: Option<f64>,
},
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Shape {
Linear,
Ease,
Trapezoid,
}
impl Shape {
pub fn parse(text: &str) -> Option<Self> {
match text {
"linear" => Some(Self::Linear),
"ease" => Some(Self::Ease),
"trapezoid" => Some(Self::Trapezoid),
_ => None,
}
}
}
pub const DEFAULT_ACCEL: f64 = 0.25;
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Clip {
pub id: String,
pub from: f64,
pub to: f64,
#[serde(flatten)]
pub body: ClipBody,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ClipBody {
Suppress { constraint: String },
Camera { views: Vec<String> },
Visibility { occurrences: Vec<String> },
Transparency { occurrences: Vec<String>, opacity: [f64; 2] },
Section {
view: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sweep: Option<[f64; 2]>,
},
Explode {
view: String,
#[serde(default)]
collapse: bool,
},
}
impl ClipBody {
pub fn kind(&self) -> &'static str {
match self {
Self::Suppress { .. } => "suppress",
Self::Camera { .. } => "camera",
Self::Visibility { .. } => "visibility",
Self::Transparency { .. } => "transparency",
Self::Section { .. } => "section",
Self::Explode { .. } => "explode",
}
}
}
impl Default for Simulation {
fn default() -> Self {
Self { schema: SCHEMA, active: None, studies: Vec::new(), id_counter: 0 }
}
}
impl Simulation {
pub fn from_value(value: &Value) -> Result<Self, String> {
let block: Self = serde_json::from_value(value.clone()).map_err(|e| format!("simulation block: {e}"))?;
block.validate()?;
Ok(block)
}
pub fn from_document(document: &Value) -> Result<Option<Self>, String> {
match document.get(BLOCK_KEY) {
None | Some(Value::Null) => Ok(None),
Some(block) => Self::from_value(block).map(Some),
}
}
pub fn to_value(&self) -> Value {
serde_json::to_value(self).expect("simulation block serializes")
}
pub fn next_id(&mut self, prefix: &str) -> String {
self.id_counter += 1;
format!("{prefix}{}", self.id_counter)
}
pub fn add_study(&mut self, name: &str) -> String {
let id = self.next_id("SIM");
let name = if name.trim().is_empty() { format!("Study {}", self.studies.len() + 1) } else { name.trim().to_string() };
self.studies.push(Study::new(&id, &name));
self.active = Some(id.clone());
id
}
pub fn study(&self, id: &str) -> Option<&Study> {
self.studies.iter().find(|s| s.id == id)
}
pub fn study_mut(&mut self, id: &str) -> Option<&mut Study> {
self.studies.iter_mut().find(|s| s.id == id)
}
pub fn active_study(&self) -> Option<&Study> {
self.active.as_deref().and_then(|id| self.study(id)).or(self.studies.first())
}
pub fn validate(&self) -> Result<(), String> {
if self.schema == 0 || self.schema > SCHEMA {
return Err(format!("simulation schema {} is not supported (this build reads up to {SCHEMA})", self.schema));
}
let mut ids = HashSet::new();
for study in &self.studies {
for id in std::iter::once(&study.id).chain(study.motors.iter().map(|m| &m.id)).chain(study.clips.iter().map(|c| &c.id)) {
if id.is_empty() {
return Err(format!("study '{}': an empty id", study.name));
}
if !ids.insert(id.as_str()) {
return Err(format!("duplicate id '{id}'"));
}
}
study.validate()?;
}
if let Some(active) = &self.active {
if self.study(active).is_none() {
return Err(format!("the active study '{active}' does not exist"));
}
}
Ok(())
}
}
impl Study {
pub fn new(id: &str, name: &str) -> Self {
Self {
id: id.into(),
name: name.into(),
duration: DEFAULT_DURATION,
fps: DEFAULT_FPS,
motors: Vec::new(),
clips: Vec::new(),
interference: Interference::default(),
motion: None,
}
}
pub fn frame_count(&self) -> usize {
(self.duration * self.fps as f64).round() as usize + 1
}
pub fn frame_time(&self, index: usize) -> f64 {
(index as f64 / self.fps as f64).min(self.duration)
}
pub fn frame_at(&self, time: f64) -> usize {
((time.clamp(0.0, self.duration) * self.fps as f64).round() as usize).min(self.frame_count() - 1)
}
pub fn motor(&self, id: &str) -> Option<&Motor> {
self.motors.iter().find(|m| m.id == id)
}
pub fn clip(&self, id: &str) -> Option<&Clip> {
self.clips.iter().find(|c| c.id == id)
}
pub fn validate(&self) -> Result<(), String> {
let name = &self.name;
if !(self.duration.is_finite() && self.duration > 0.0 && self.duration <= MAX_DURATION) {
return Err(format!("study '{name}': the duration must be over 0 and at most {MAX_DURATION} s, not {}", self.duration));
}
if !FPS_RANGE.contains(&self.fps) {
return Err(format!("study '{name}': the frame rate must be {} to {} fps, not {}", FPS_RANGE.start(), FPS_RANGE.end(), self.fps));
}
let span = |what: &str, from: f64, to: f64| -> Result<(), String> {
if !(from.is_finite() && to.is_finite() && from >= 0.0 && from < to && to <= self.duration + 1e-9) {
return Err(format!("study '{name}': {what} must run inside 0–{} s with its start before its end, not {from}–{to}", self.duration));
}
Ok(())
};
for (index, motor) in self.motors.iter().enumerate() {
let what = format!("motor '{}'", motor.id);
span(&what, motor.from, motor.to)?;
if motor.constraint.is_empty() {
return Err(format!("study '{name}': {what} drives no mate"));
}
match &motor.drive {
Drive::Speed { speed } if !speed.is_finite() => return Err(format!("study '{name}': {what} has no speed")),
Drive::Profile { start, end, .. } if !(start.is_finite() && end.is_finite()) => {
return Err(format!("study '{name}': {what} needs a start and an end value"))
}
Drive::Profile { shape, accel: Some(accel), .. } if *shape == Shape::Trapezoid && !(*accel > 0.0 && *accel <= 0.5) => {
return Err(format!("study '{name}': {what} accelerates over 0–0.5 of its span, not {accel}"))
}
_ => {}
}
for other in &self.motors[..index] {
if other.constraint == motor.constraint && other.from < motor.to && motor.from < other.to {
return Err(format!("study '{name}': motors '{}' and '{}' drive the same mate at the same time", other.id, motor.id));
}
}
}
for clip in &self.clips {
let what = format!("{} clip '{}'", clip.body.kind(), clip.id);
span(&what, clip.from, clip.to)?;
let missing = match &clip.body {
ClipBody::Suppress { constraint } => constraint.is_empty(),
ClipBody::Camera { views } => views.is_empty() || views.iter().any(String::is_empty),
ClipBody::Visibility { occurrences } | ClipBody::Transparency { occurrences, .. } => {
occurrences.is_empty() || occurrences.iter().any(String::is_empty)
}
ClipBody::Section { view, sweep } => view.is_empty() || sweep.is_some_and(|s| !(s[0].is_finite() && s[1].is_finite())),
ClipBody::Explode { view, .. } => view.is_empty(),
};
if missing {
return Err(format!("study '{name}': {what} names nothing to act on"));
}
if let ClipBody::Transparency { opacity, .. } = &clip.body {
if opacity.iter().any(|o| !(0.0..=1.0).contains(o)) {
return Err(format!("study '{name}': {what} opacity must be 0–1, not {}–{}", opacity[0], opacity[1]));
}
}
}
Ok(())
}
}