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
@@ -14,6 +14,8 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, reso
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "motion_animation.rs"]
|
||||
mod animation;
|
||||
#[path = "contact_sweep.rs"]
|
||||
mod contact_sweep;
|
||||
|
||||
@@ -54,6 +56,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("motion.export", "Export Motion Study", export)
|
||||
.noundo()
|
||||
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
|
||||
CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).noundo().params(
|
||||
"study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?; path: a .gif file, or a folder for PNG frames (frame-0001.png, frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, or 100); width?, height? (16…4096 pixels, default 800 × 500); camera?: {target, yaw, pitch, roll?, distance, fov?, up?} (ui.camera's reply; fields left out keep the default view: view.apply's camera, or the home view framing the model); fps? (1…50, default 20); grid?, axes? (default true: the ground grid and the origin's axes) - draws the study as it plays, or the joint as it is driven, with the headless renderer → path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the design is not changed)",
|
||||
),
|
||||
CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!(
|
||||
trace_doc!(),
|
||||
" → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \
|
||||
@@ -718,19 +723,45 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)>
|
||||
let local = solidcraft_doc::mat_inverse(&world(&t)?)
|
||||
.map(|m| solidcraft_doc::apply_point(&m, at))
|
||||
.ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?;
|
||||
let samples = |default: usize| match p.get("samples") {
|
||||
let (default, given, plan) = motion_plan(s, p, cmd)?;
|
||||
let n = sample_count(p, default.min(MAX_TRACE), MAX_TRACE, cmd)?;
|
||||
let mut points = Vec::new();
|
||||
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)?;
|
||||
points.push(solidcraft_doc::apply_point(&world(&t)?, local));
|
||||
if 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());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
|
||||
let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2});
|
||||
Ok((points, out))
|
||||
}
|
||||
|
||||
/// `samples`, 2…`max`, or `default` without it.
|
||||
fn sample_count(p: &Value, default: usize, max: usize, cmd: &str) -> Result<usize> {
|
||||
match p.get("samples") {
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && *n >= 2.0 && *n <= MAX_TRACE as f64)
|
||||
.filter(|n| n.is_finite() && *n >= 2.0 && *n <= max as f64)
|
||||
.map(|n| n.round() as usize)
|
||||
.ok_or_else(|| bad(cmd, format!("`samples` must be 2…{MAX_TRACE}"))),
|
||||
None => Ok(default),
|
||||
};
|
||||
let (n, given, plan): (usize, BTreeSet<(u64, usize)>, Plan) = match (p.get("study"), p.get("joint")) {
|
||||
.ok_or_else(|| bad(cmd, format!("`samples` must be 2…{max}"))),
|
||||
None => Ok(default.max(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The motion a trace or an animation samples: a study played from start to end, or one joint
|
||||
/// value driven from `from` to `to`. Returns the default sample count (the study's steps + 1, or
|
||||
/// 100), the values driven and what they are at sample `k` of `n`.
|
||||
fn motion_plan(s: &Session, p: &Value, cmd: &str) -> Result<(usize, BTreeSet<(u64, usize)>, Plan)> {
|
||||
Ok(match (p.get("study"), p.get("joint")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `study` or `joint`, not both")),
|
||||
(Some(_), None) => {
|
||||
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?;
|
||||
let n = samples((m.steps as usize).saturating_add(1).clamp(2, MAX_TRACE))?;
|
||||
let n = (m.steps as usize).saturating_add(1);
|
||||
(n, keyed(&m), Box::new(move |t, k, n| values_at(t, &m, f64::from(m.steps) * k as f64 / (n - 1) as f64)))
|
||||
}
|
||||
(None, Some(j)) => {
|
||||
@@ -750,7 +781,7 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)>
|
||||
};
|
||||
let end = |key: &str| match p.get(key) {
|
||||
Some(v) => value(s, v, kind.is_angle(index), cmd),
|
||||
None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the trace runs between (no `{key}`)"))),
|
||||
None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the motion runs between (no `{key}`)"))),
|
||||
};
|
||||
let (from, to) = (end("from")?, end("to")?);
|
||||
let plan: Plan = Box::new(move |t, k, n| {
|
||||
@@ -761,24 +792,10 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)>
|
||||
}
|
||||
vec![(id, q)]
|
||||
});
|
||||
(samples(100)?, BTreeSet::from([(id, index)]), plan)
|
||||
(100, BTreeSet::from([(id, index)]), plan)
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "give a `study`, or a `joint` with `from` and `to`")),
|
||||
};
|
||||
let mut points = Vec::new();
|
||||
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)?;
|
||||
points.push(solidcraft_doc::apply_point(&world(&t)?, local));
|
||||
if 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());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
|
||||
let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2});
|
||||
Ok((points, out))
|
||||
})
|
||||
}
|
||||
|
||||
fn trace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -173,3 +173,235 @@ fn exploded_views_show_without_changing_the_design() {
|
||||
);
|
||||
assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
/// Pictures drawn by the CPU rasterizer (the engine has no GPU): real views of the scene.
|
||||
fn cpu(scene: &crate::render::Scene, cam: &crate::render::Camera, w: u32, h: u32) -> Result<crate::capture::Picture, String> {
|
||||
Ok(crate::capture::Picture { rgba: crate::render::render(scene, cam, w as usize, h as usize).rgba, renderer: "cpu" })
|
||||
}
|
||||
|
||||
/// A folder of its own for one test's files.
|
||||
fn out_dir(test: &str) -> std::path::PathBuf {
|
||||
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-anim-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A GIF's logical screen size and the number of its frames (image descriptors).
|
||||
fn gif_frames(gif: &[u8]) -> (usize, usize, usize) {
|
||||
let u16_at = |i: usize| usize::from(u16::from_le_bytes([gif[i], gif[i + 1]]));
|
||||
assert_eq!(&gif[..6], b"GIF89a");
|
||||
let mut at = 13 + if gif[10] & 0x80 != 0 { 3 << ((gif[10] & 7) + 1) } else { 0 };
|
||||
let mut frames = 0;
|
||||
// Each block: a size byte and that many bytes, up to a 0.
|
||||
let skip_blocks = |mut at: usize| {
|
||||
while gif[at] != 0 {
|
||||
at += usize::from(gif[at]) + 1;
|
||||
}
|
||||
at + 1
|
||||
};
|
||||
loop {
|
||||
match gif[at] {
|
||||
0x3B => return (u16_at(6), u16_at(8), frames),
|
||||
0x21 => at = skip_blocks(at + 2),
|
||||
0x2C => {
|
||||
frames += 1;
|
||||
at = skip_blocks(at + 11);
|
||||
}
|
||||
b => panic!("unexpected GIF block {b:#x}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's width and height (its IHDR).
|
||||
fn png_size(png: &[u8]) -> (u32, u32) {
|
||||
let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]);
|
||||
(be(16), be(20))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animation_has_the_frames_and_the_size_asked_for() {
|
||||
let mut s = rail();
|
||||
run(&mut s, "motion.study", json!({"name": "Push", "steps": 10, "keys": [{"joint": "Rail", "points": [[0, 10], [10, 0]]}]}));
|
||||
let dir = out_dir("size");
|
||||
let before = s.doc.clone();
|
||||
let gif = dir.join("push.gif");
|
||||
let p = json!({"study": "Push", "path": gif.to_string_lossy(), "samples": 6, "width": 64, "height": 40, "fps": 25});
|
||||
let r = super::animation::export_with(&s, &p, Some(&cpu)).unwrap();
|
||||
assert_eq!(
|
||||
(r["format"].as_str(), r["frames"].as_u64(), r["width"].as_u64(), r["height"].as_u64()),
|
||||
(Some("gif"), Some(6), Some(64), Some(40)),
|
||||
"{r}"
|
||||
);
|
||||
assert_eq!(r["fps"], 25.0, "{r}");
|
||||
let bytes = std::fs::read(&gif).unwrap();
|
||||
assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64));
|
||||
assert_eq!(gif_frames(&bytes), (64, 40, 6));
|
||||
// Without samples: the study's steps + 1.
|
||||
let r =
|
||||
super::animation::export_with(&s, &json!({"study": "Push", "path": gif.to_string_lossy(), "width": 32, "height": 32}), Some(&cpu)).unwrap();
|
||||
assert_eq!(gif_frames(&std::fs::read(&gif).unwrap()), (32, 32, 11), "{r}");
|
||||
// A folder of PNG frames, made where missing; a joint driven instead of a study.
|
||||
let frames = dir.join("frames").join("rail");
|
||||
let p = json!({"joint": "Rail", "from": 10, "to": 0, "path": frames.to_string_lossy(), "samples": 3, "width": 48, "height": 30});
|
||||
let r = super::animation::export_with(&s, &p, Some(&cpu)).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["frames"].as_u64()), (Some("png"), Some(3)), "{r}");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&frames).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["frame-0001.png", "frame-0002.png", "frame-0003.png"]);
|
||||
assert!(names.iter().all(|n| png_size(&std::fs::read(frames.join(n)).unwrap()) == (48, 30)));
|
||||
// A shorter export into the same folder leaves no frames of a longer one behind, past a gap
|
||||
// too; other files stay.
|
||||
std::fs::write(frames.join("notes.txt"), "mine").unwrap();
|
||||
std::fs::write(frames.join("frame-0005.png"), "older").unwrap();
|
||||
super::animation::export_with(&s, &json!({"joint": "Rail", "from": 10, "to": 0, "path": frames.to_string_lossy(), "samples": 2}), Some(&cpu))
|
||||
.unwrap();
|
||||
let mut names: Vec<String> = std::fs::read_dir(&frames).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["frame-0001.png", "frame-0002.png", "notes.txt"]);
|
||||
assert_eq!(*s.doc, *before, "the design is not changed");
|
||||
// Through the registry, with the renderer the host installed.
|
||||
crate::capture::install_test_renderer();
|
||||
let r = run(&mut s, "motion.export_animation", json!({"study": "Push", "path": gif.to_string_lossy(), "samples": 2, "width": 16, "height": 16}));
|
||||
assert_eq!(r["frames"], 2, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_two_frame_drive_gives_two_different_frames() {
|
||||
let s = rail();
|
||||
let dir = out_dir("two");
|
||||
let frames = |name: &str, to: f64| {
|
||||
let folder = dir.join(name);
|
||||
let p = json!({"joint": "Rail", "from": 10, "to": to, "path": folder.to_string_lossy(), "samples": 2, "width": 120, "height": 80});
|
||||
super::animation::export_with(&s, &p, Some(&cpu)).unwrap();
|
||||
(std::fs::read(folder.join("frame-0001.png")).unwrap(), std::fs::read(folder.join("frame-0002.png")).unwrap())
|
||||
};
|
||||
let (a, b) = frames("moved", -5.0);
|
||||
assert_ne!(a, b, "B moved 15 mm between the frames");
|
||||
// Held still, the two frames are the same picture: the difference is the motion.
|
||||
let (c, d) = frames("still", 10.0);
|
||||
assert_eq!(c, d);
|
||||
assert_eq!(a, c, "both start at 10 mm");
|
||||
// Without the ground grid and the axes, the same pose is another picture.
|
||||
let bare = dir.join("bare");
|
||||
let p = json!({"joint": "Rail", "from": 10, "to": 10, "path": bare.to_string_lossy(), "samples": 2, "width": 120, "height": 80, "grid": false, "axes": false});
|
||||
super::animation::export_with(&s, &p, Some(&cpu)).unwrap();
|
||||
assert_ne!(std::fs::read(bare.join("frame-0001.png")).unwrap(), c);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animation_is_drawn_from_the_camera_given() {
|
||||
let mut s = rail();
|
||||
let dir = out_dir("camera");
|
||||
// A renderer is 'static (hosts install it for the process): it shares what it saw.
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let record = {
|
||||
let seen = seen.clone();
|
||||
move |scene: &crate::render::Scene, cam: &crate::render::Camera, w: u32, h: u32| {
|
||||
seen.lock().unwrap().push(*cam);
|
||||
cpu(scene, cam, w, h)
|
||||
}
|
||||
};
|
||||
let path = dir.join("cam.gif");
|
||||
let base = json!({"joint": "Rail", "from": 10, "to": 0, "path": path.to_string_lossy(), "samples": 3, "width": 40, "height": 30});
|
||||
let with = |camera: Value| {
|
||||
let mut p = base.clone();
|
||||
p["camera"] = camera;
|
||||
p
|
||||
};
|
||||
// ui.camera's reply, every field.
|
||||
let reply = json!({"target": [1.0, 2.0, 3.0], "yaw": 0.5, "pitch": -0.25, "roll": 0.1, "distance": 300.0, "fov": 0.6, "up": "y"});
|
||||
super::animation::export_with(&s, &with(reply.clone()), Some(&record)).unwrap();
|
||||
let cams = std::mem::take(&mut *seen.lock().unwrap());
|
||||
assert_eq!(cams.len(), 3);
|
||||
assert!(cams.iter().all(|c| json!(c) == reply), "{cams:?}");
|
||||
// Fields left out keep the default view: view.apply's camera.
|
||||
run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80}}));
|
||||
super::animation::export_with(&s, &with(json!({"yaw": 2.0})), Some(&record)).unwrap();
|
||||
let cams = std::mem::take(&mut *seen.lock().unwrap());
|
||||
assert!(cams.iter().all(|c| c.yaw == 2.0 && c.pitch == 0.2 && c.distance == 80.0), "{cams:?}");
|
||||
// Without one: view.apply's camera as it is; and another camera draws another picture.
|
||||
super::animation::export_with(&s, &base, Some(&record)).unwrap();
|
||||
assert!(seen.lock().unwrap().iter().all(|c| Some(*c) == s.camera));
|
||||
let pictures = |camera: Value| {
|
||||
let folder = dir.join(format!("p{}", camera["yaw"]));
|
||||
let mut p = with(camera);
|
||||
p["path"] = json!(folder.to_string_lossy());
|
||||
super::animation::export_with(&s, &p, Some(&cpu)).unwrap();
|
||||
std::fs::read(folder.join("frame-0001.png")).unwrap()
|
||||
};
|
||||
assert_ne!(pictures(json!({"yaw": 0.3})), pictures(json!({"yaw": 1.3})));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_animation_params_are_refused() {
|
||||
let mut s = rail();
|
||||
run(&mut s, "motion.study", json!({"name": "Push", "steps": 10, "keys": [{"joint": "Rail", "points": [[0, 10], [10, 0]]}]}));
|
||||
let dir = out_dir("hostile");
|
||||
let gif = dir.join("x.gif").to_string_lossy().to_string();
|
||||
let base = json!({"study": "Push", "path": gif, "samples": 2, "width": 16, "height": 16});
|
||||
let refused = |change: Value| {
|
||||
let mut p = base.clone();
|
||||
if let (Some(p), Some(change)) = (p.as_object_mut(), change.as_object()) {
|
||||
for (k, v) in change {
|
||||
if v.is_null() {
|
||||
p.remove(k);
|
||||
} else {
|
||||
p.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
super::animation::export_with(&s, &p, Some(&cpu)).err().map(|e| e.to_string())
|
||||
};
|
||||
for change in [
|
||||
json!({"samples": 0}),
|
||||
json!({"samples": 1}),
|
||||
json!({"samples": 1e9}),
|
||||
json!({"samples": "many"}),
|
||||
json!({"samples": 1001}),
|
||||
json!({"width": 1e9}),
|
||||
json!({"width": -16}),
|
||||
json!({"height": 3}),
|
||||
json!({"height": f64::MAX}),
|
||||
json!({"fps": 0}),
|
||||
json!({"fps": 1e9}),
|
||||
json!({"path": ""}),
|
||||
json!({"path": null}),
|
||||
json!({"path": "one.png"}),
|
||||
json!({"path": "a\u{0}b.gif"}),
|
||||
json!({"path": "x".repeat(5000)}),
|
||||
json!({"camera": "iso"}),
|
||||
json!({"camera": {"distance": -1}}),
|
||||
json!({"camera": {"distance": 1e300}}),
|
||||
json!({"camera": {"pitch": 4}}),
|
||||
json!({"camera": {"fov": 9}}),
|
||||
json!({"camera": {"target": [1, 2]}}),
|
||||
json!({"camera": {"fit": true}}),
|
||||
json!({"grid": "no"}),
|
||||
json!({"axes": 1}),
|
||||
json!({"study": null}),
|
||||
json!({"study": "Nope"}),
|
||||
json!({"study": null, "joint": "Rail"}),
|
||||
// 4096 × 4096 frames: a GIF holds four of them.
|
||||
json!({"width": 4096, "height": 4096, "samples": 5}),
|
||||
] {
|
||||
assert!(refused(change.clone()).is_some(), "{change} was accepted");
|
||||
}
|
||||
let e = refused(json!({"width": 4096, "height": 4096, "samples": 5})).unwrap();
|
||||
assert!(e.contains("2…4 for 4096 × 4096"), "{e}");
|
||||
assert!(super::animation::export_with(&s, &base, None).unwrap_err().to_string().contains("no renderer"));
|
||||
assert!(!std::path::Path::new(&gif).exists(), "nothing refused was written");
|
||||
// An agent's export lands only inside its folders.
|
||||
let root = dir.join("root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([root.clone()])), || {
|
||||
let e = refused(json!({"path": dir.join("out").to_string_lossy()})).unwrap();
|
||||
assert!(e.contains("outside the folders this agent may use"), "{e}");
|
||||
assert!(refused(json!({"path": root.join("in.gif").to_string_lossy()})).is_none());
|
||||
});
|
||||
assert!(root.join("in.gif").exists() && !dir.join("out").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user