155 lines
8.0 KiB
Rust
155 lines
8.0 KiB
Rust
//! Motion animations (`motion.export_animation`): a study played, or one joint driven, drawn
|
||
//! frame by frame by the headless renderer the host installed ([`crate::capture`]) and written as
|
||
//! an animated GIF or a folder of PNG frames. Sampled on a copy, as a trace is: the design is not
|
||
//! changed.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{drive_with_contact, motion_plan, sample_count};
|
||
use crate::capture::{self, Renderer};
|
||
use crate::params::{bad, str_};
|
||
use crate::render::Camera;
|
||
use crate::{Result, Session};
|
||
|
||
/// Most frames of one animation.
|
||
const MAX_FRAMES: usize = 1000;
|
||
/// Largest frame side, in pixels.
|
||
const MAX_SIDE: u32 = 4096;
|
||
/// Most pixels of a GIF's frames together: they are held in memory (4 bytes each) until the
|
||
/// palette is chosen, so 256 MB at most.
|
||
const MAX_GIF_PIXELS: usize = 1 << 26;
|
||
/// Most pixels of a PNG folder's frames together (each frame is written as it is drawn): about
|
||
/// 500 frames of 1920 × 1080.
|
||
const MAX_PNG_PIXELS: usize = 1 << 30;
|
||
/// The camera fields `camera` may set: those of `ui.camera`'s reply.
|
||
const FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"];
|
||
|
||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||
export_with(s, p, capture::installed().as_deref())
|
||
}
|
||
|
||
/// [`export`] drawn by `renderer`.
|
||
pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -> Result<Value> {
|
||
let cmd = "motion.export_animation";
|
||
let path = str_(p, "path")
|
||
.filter(|x| !x.trim().is_empty() && x.len() <= 4096 && !x.chars().any(char::is_control))
|
||
.ok_or_else(|| bad(cmd, "`path` must name a .gif file, or a folder for PNG frames"))?;
|
||
let lower = path.to_ascii_lowercase();
|
||
if lower.ends_with(".png") {
|
||
return Err(bad(cmd, "`path` is a .gif file or a folder for the PNG frames (frame-0001.png, …), not one .png"));
|
||
}
|
||
let gif = lower.ends_with(".gif");
|
||
// In the browser written files stay in memory until the host takes them, and the host is not
|
||
// told of removed ones: a folder of frames would pile up there.
|
||
if !gif && cfg!(target_arch = "wasm32") {
|
||
return Err(bad(cmd, "a folder of PNG frames is not available in the browser: give a .gif path"));
|
||
}
|
||
let side = |key: &str, default: u32| match p.get(key) {
|
||
None => Ok(default),
|
||
Some(v) => v
|
||
.as_f64()
|
||
.filter(|n| n.is_finite() && (16.0..=f64::from(MAX_SIDE)).contains(n))
|
||
.map(|n| n.round() as u32)
|
||
.ok_or_else(|| bad(cmd, format!("`{key}` must be 16…{MAX_SIDE} pixels"))),
|
||
};
|
||
let (width, height) = (side("width", 800)?, side("height", 500)?);
|
||
let fps = match p.get("fps") {
|
||
None => 20.0,
|
||
Some(v) => v.as_f64().filter(|n| n.is_finite() && (1.0..=50.0).contains(n)).ok_or_else(|| bad(cmd, "`fps` must be 1…50"))?,
|
||
};
|
||
let cam = camera(s, p.get("camera"), cmd)?;
|
||
let shown = |key: &str| match p.get(key) {
|
||
None => Ok(true),
|
||
Some(Value::Bool(b)) => Ok(*b),
|
||
Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))),
|
||
};
|
||
let guides = crate::view::Guides { grid: shown("grid")?, axes: shown("axes")? };
|
||
let (default, given, plan) = motion_plan(s, p, cmd)?;
|
||
// At least 4 frames fit even at the largest size.
|
||
let max = MAX_FRAMES.min(if gif { MAX_GIF_PIXELS } else { MAX_PNG_PIXELS } / (width as usize * height as usize));
|
||
let n = sample_count(p, default.min(max), max, cmd).map_err(|_| bad(cmd, format!("`samples` must be 2…{max} for {width} × {height} frames")))?;
|
||
let renderer = renderer.ok_or_else(|| bad(cmd, "no renderer is installed to draw pictures in this process"))?;
|
||
// A destination outside an agent's folders is refused before anything is drawn.
|
||
let io = |e: std::io::Error| bad(cmd, format!("{path}: {e}"));
|
||
solidcraft_io::vfs::check(path).map_err(io)?;
|
||
let frame_path = |k: usize| std::path::Path::new(path).join(format!("frame-{k:04}.png")).to_string_lossy().to_string();
|
||
if !gif {
|
||
solidcraft_io::vfs::create_dir_all(path).map_err(io)?;
|
||
// The frames of an earlier export here go first, so the folder never mixes two
|
||
// animations: not after a shorter one, nor when this one fails part way.
|
||
for stale in (1..=MAX_FRAMES).map(frame_path).filter(|f| solidcraft_io::vfs::exists(f)) {
|
||
solidcraft_io::vfs::remove(&stale).map_err(|e| bad(cmd, format!("{stale}: {e}")))?;
|
||
}
|
||
}
|
||
let mut t = s.scratch();
|
||
// From where the parts are placed: moves only shown (an exploded view, the pose an open
|
||
// Motion Study dialog shows) are not where the joints are solved.
|
||
t.pending_moves.clear();
|
||
let (mut frames, mut bytes, mut drawn_by) = (Vec::new(), 0, "");
|
||
let mut stop = (Value::Null, Value::Null, Value::Null);
|
||
for k in 0..n {
|
||
let want = plan(&t, k, n);
|
||
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
|
||
if stop.0.is_null() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) {
|
||
stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone());
|
||
}
|
||
let scene = crate::view::scene_with_guides(&t, &cam, s.section, guides);
|
||
let picture = capture::rgba_with(Some(renderer), &scene, &cam, width, height).map_err(|e| bad(cmd, e))?;
|
||
drawn_by = picture.renderer;
|
||
if gif {
|
||
frames.push(picture.rgba);
|
||
} else {
|
||
let png = crate::render::png(width as usize, height as usize, picture.rgba).ok_or_else(|| bad(cmd, "PNG encoding failed"))?;
|
||
let file = frame_path(k + 1);
|
||
solidcraft_io::vfs::write(&file, &png).map_err(|e| bad(cmd, format!("{file}: {e}")))?;
|
||
bytes += png.len();
|
||
}
|
||
}
|
||
let mut out = json!({"path": path, "format": if gif { "gif" } else { "png" }, "frames": n, "width": width, "height": height});
|
||
if gif {
|
||
// GIF counts in hundredths of a second, and viewers slow anything under 2 down.
|
||
let delay = (100.0 / fps).round().clamp(2.0, 100.0) as u16;
|
||
let gif = crate::render::gif(width as usize, height as usize, &frames, delay).ok_or_else(|| bad(cmd, "GIF encoding failed"))?;
|
||
solidcraft_io::write_atomic(std::path::Path::new(path), &gif, false).map_err(io)?;
|
||
bytes = gif.len();
|
||
out["fps"] = json!(100.0 / f64::from(delay));
|
||
}
|
||
out["bytes"] = json!(bytes);
|
||
out["renderer"] = json!(drawn_by);
|
||
if let Some(w) = capture::warning(drawn_by) {
|
||
out["warning"] = json!(w);
|
||
}
|
||
out["stopped_at"] = stop.0;
|
||
out["stopped_by_loop"] = stop.1;
|
||
out["stopped_by_contact"] = stop.2;
|
||
Ok(out)
|
||
}
|
||
|
||
/// The camera the frames are drawn from: the session's (`view.apply`), or the home view framing
|
||
/// the model, with the fields `given` sets (`ui.camera`'s reply sets them all).
|
||
fn camera(s: &Session, given: Option<&Value>, cmd: &str) -> Result<Camera> {
|
||
let base = s.camera.unwrap_or_else(|| crate::view::home_camera(s));
|
||
let Some(given) = given else { return Ok(base) };
|
||
let fields = given.as_object().ok_or_else(|| bad(cmd, "`camera` must be an object, such as ui.camera's reply"))?;
|
||
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
|
||
for (k, x) in fields {
|
||
if !FIELDS.contains(&k.as_str()) {
|
||
return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", "))));
|
||
}
|
||
if let Some(o) = v.as_object_mut() {
|
||
o.insert(k.clone(), x.clone());
|
||
}
|
||
}
|
||
let mut cam: Camera = serde_json::from_value(v).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||
if !cam.is_valid() || cam.distance > 1e15 || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 || !(0.0..3.0).contains(&cam.fov) {
|
||
return Err(bad(
|
||
cmd,
|
||
"the camera must be finite, with a distance up to 1e15 mm, a pitch within ±π/2 and a fov of 0 (orthographic) to 3 radians",
|
||
));
|
||
}
|
||
// Straight up or down keeps a heading, as ui.camera does.
|
||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||
cam.pitch = cam.pitch.clamp(-lim, lim);
|
||
Ok(cam)
|
||
}
|