Motion animation export: a study or a joint drive as an animated GIF (#1680)
Thanks @SychPL.
This commit is contained in:
1 parent
8207f29fc0
commit
2ae21143ee
24 files changed
+902
-33
No files matched your search
@@ -0,0 +1,154 @@
|
||||
//! 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)
|
||||
}
|
||||
Reference in new issue
Block a user