Files
solidcraft-upstream/crates/engine/src/cmd/motion_animation.rs
T

155 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
}