Motion animation export: a study or a joint drive as an animated GIF (#1680)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 07:26:15 -04:00
1 parent 8207f29fc0
commit 2ae21143ee
24 files changed
+902 -33

No files matched your search

+41 -24
View File
@@ -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> {
+154
View File
@@ -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)
}
+232
View File
@@ -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);
}