diff --git a/Cargo.lock b/Cargo.lock index 1fa23852..684cb308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4215,6 +4215,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "solidcraft-geom", + "weezl", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d48e8a61..080e0184 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,8 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] } # images image = { version = "0.25", default-features = false, features = ["png", "hdr"] } png = "0.18.1" +# LZW for GIF animations (MIT OR Apache-2.0, pure Rust; already in the tree through image's tiff). +weezl = "0.1.12" # ui egui = "0.36" diff --git a/assets/locales/de.json b/assets/locales/de.json index c74bd78c..a9b927c9 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -24,6 +24,7 @@ "Drive Joint": "Gelenk antreiben", "Edges": "Kanten", "Edit Profile Sketch": "Profile-Skizze bearbeiten", + "Export Animation…": "Animation exportieren…", "Export Current View as DXF": "Aktuelle Ansicht als DXF exportieren", "Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…", "Export DXF…": "DXF exportieren…", @@ -59,6 +60,7 @@ "Restore outer boundary": "Äußere Begrenzung wiederherstellen", "Revert Position": "Position zurücksetzen", "Round": "Rund", + "Save the study as an animated GIF, seen as the viewport shows it": "Die Studie als animiertes GIF speichern, so wie der Ansichtsbereich sie zeigt", "Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)", "Selection filter": "Auswahlfilter", "Send your report on GitHub": "Bericht auf GitHub senden", @@ -225,6 +227,7 @@ "cmd.model_threads": "Gewinde auflisten", "cmd.motion_delete": "Bewegungsuntersuchung löschen", "cmd.motion_export": "Bewegungsuntersuchung exportieren", + "cmd.motion_export_animation": "Bewegungsanimation exportieren", "cmd.motion_list": "Bewegungsuntersuchungen auflisten", "cmd.motion_play": "Bewegungsuntersuchung abspielen", "cmd.motion_study": "Bewegungsuntersuchung", diff --git a/assets/locales/en.json b/assets/locales/en.json index ffbe52a3..b2238047 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -92,6 +92,7 @@ "Enable rule": "Enable rule", "Enabled": "Enabled", "Explode": "Explode", + "Export Animation…": "Export Animation…", "Export Current View as DXF": "Export Current View as DXF", "Export Current View as DXF…": "Export Current View as DXF…", "Export DXF…": "Export DXF…", @@ -211,6 +212,7 @@ "Round": "Round", "Rule": "Rule", "Save Rule": "Save Rule", + "Save the study as an animated GIF, seen as the viewport shows it": "Save the study as an animated GIF, seen as the viewport shows it", "Saved preview. Geometry is being checked before editing.": "Saved preview. Geometry is being checked before editing.", "Scale factor": "Scale factor", "Search": "Search", @@ -426,6 +428,7 @@ "cmd.model_threads": "List Threads", "cmd.motion_delete": "Delete Motion Study", "cmd.motion_export": "Export Motion Study", + "cmd.motion_export_animation": "Export Motion Animation", "cmd.motion_list": "List Motion Studies", "cmd.motion_play": "Play Motion Study", "cmd.motion_study": "Motion Study", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 8e52631c..bd86bf26 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -85,6 +85,7 @@ "Enable rule": "Activer la règle", "Enabled": "Activé", "Explode": "Éclater", + "Export Animation…": "Exporter l'animation…", "Extents": "Étendues", "Face": "Face", "Face attachment lost": "Rattachement à la face perdu", @@ -194,6 +195,7 @@ "Round": "Arrondi", "Rule": "Règle", "Save Rule": "Enregistrer la règle", + "Save the study as an animated GIF, seen as the viewport shows it": "Enregistrer l'étude en GIF animé, telle que la fenêtre d'affichage la montre", "Saved preview. Geometry is being checked before editing.": "Aperçu enregistré. La géométrie est en cours de vérification avant modification.", "Scale factor": "Facteur d'échelle", "Search": "Rechercher", @@ -406,6 +408,7 @@ "cmd.model_threads": "Lister les filetages", "cmd.motion_delete": "Supprimer l'étude de mouvement", "cmd.motion_export": "Exporter l'étude de mouvement", + "cmd.motion_export_animation": "Exporter l'animation du mouvement", "cmd.motion_list": "Lister les études de mouvement", "cmd.motion_play": "Lire l'étude de mouvement", "cmd.motion_study": "Étude de mouvement", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 4bb4502f..9086717a 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -92,6 +92,7 @@ "Enable rule": "ルールを有効化", "Enabled": "有効", "Explode": "分解", + "Export Animation…": "アニメーションをエクスポート…", "Export Current View as DXF": "現在の表示をDXFでエクスポート", "Export Current View as DXF…": "現在の表示をDXFでエクスポート…", "Export DXF…": "DXFをエクスポート…", @@ -211,6 +212,7 @@ "Round": "丸め", "Rule": "ルール", "Save Rule": "ルールを保存", + "Save the study as an animated GIF, seen as the viewport shows it": "ビューポートの表示どおりにスタディをアニメーション GIF として保存", "Saved preview. Geometry is being checked before editing.": "保存済みのプレビューです。編集前にジオメトリを検証しています。", "Scale factor": "尺度係数", "Search": "検索", @@ -426,6 +428,7 @@ "cmd.model_threads": "ねじを一覧表示", "cmd.motion_delete": "モーション スタディを削除", "cmd.motion_export": "モーション スタディをエクスポート", + "cmd.motion_export_animation": "モーション アニメーションをエクスポート", "cmd.motion_list": "モーション スタディを一覧表示", "cmd.motion_play": "モーション スタディを再生", "cmd.motion_study": "モーション スタディ", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index a274a358..5d2e3538 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -24,6 +24,7 @@ "Drive Joint": "조인트 구동", "Edges": "모서리", "Edit Profile Sketch": "윤곽 스케치 편집", + "Export Animation…": "애니메이션 내보내기…", "Export Current View as DXF": "현재 뷰를 DXF로 내보내기", "Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…", "Export DXF…": "DXF 내보내기…", @@ -59,6 +60,7 @@ "Restore outer boundary": "외부 경계 복원", "Revert Position": "위치 복원", "Round": "둥글게", + "Save the study as an animated GIF, seen as the viewport shows it": "뷰포트에 보이는 대로 스터디를 애니메이션 GIF로 저장", "Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)", "Selection filter": "선택 필터", "Send your report on GitHub": "GitHub에서 보고서 보내기", @@ -225,6 +227,7 @@ "cmd.model_threads": "나사산 목록", "cmd.motion_delete": "운동 연구 삭제", "cmd.motion_export": "운동 연구 내보내기", + "cmd.motion_export_animation": "모션 애니메이션 내보내기", "cmd.motion_list": "운동 연구 목록", "cmd.motion_play": "운동 연구 재생", "cmd.motion_study": "운동 연구", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 30f1ab3b..5df35119 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -81,6 +81,7 @@ "Enable rule": "Включить правило", "Enabled": "Включено", "Explode": "Разобрать", + "Export Animation…": "Экспортировать анимацию…", "Extents": "Пределы", "Face attachment lost": "Привязка к грани потеряна", "Faces": "Грани", @@ -177,6 +178,7 @@ "Round": "Круглая", "Rule": "Правило", "Save Rule": "Сохранить правило", + "Save the study as an animated GIF, seen as the viewport shows it": "Сохранить исследование как анимированный GIF, как его показывает видовой экран", "Saved preview. Geometry is being checked before editing.": "Сохранённый предпросмотр. Геометрия проверяется перед редактированием.", "Scale factor": "Коэффициент масштаба", "Search": "Поиск", @@ -378,6 +380,7 @@ "cmd.model_threads": "Список резьб", "cmd.motion_delete": "Удалить исследование движения", "cmd.motion_export": "Экспортировать исследование движения", + "cmd.motion_export_animation": "Экспортировать анимацию движения", "cmd.motion_list": "Список исследований движения", "cmd.motion_play": "Воспроизвести исследование движения", "cmd.motion_study": "Исследование движения", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index e916335a..eb75ca65 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -85,6 +85,7 @@ "Enable rule": "Kuralı etkinleştir", "Enabled": "Etkin", "Explode": "Patlat", + "Export Animation…": "Animasyonu dışa aktar…", "Extents": "Kapsamlar", "Face": "Yüz", "Face attachment lost": "Yüze bağlama kayboldu", @@ -193,6 +194,7 @@ "Round": "Yuvarlak", "Rule": "Kural", "Save Rule": "Kuralı kaydet", + "Save the study as an animated GIF, seen as the viewport shows it": "Etüdü görünüm alanında göründüğü gibi animasyonlu GIF olarak kaydet", "Saved preview. Geometry is being checked before editing.": "Kaydedilmiş önizleme. Düzenlemeden önce geometri kontrol ediliyor.", "Scale factor": "Ölçek faktörü", "Search": "Ara", @@ -400,6 +402,7 @@ "cmd.model_threads": "Dişleri listele", "cmd.motion_delete": "Hareket etüdünü sil", "cmd.motion_export": "Hareket etüdünü dışa aktar", + "cmd.motion_export_animation": "Hareket animasyonunu dışa aktar", "cmd.motion_list": "Hareket etütlerini listele", "cmd.motion_play": "Hareket etüdünü oynat", "cmd.motion_study": "Hareket etüdü", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 86c162c3..d906673d 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -92,6 +92,7 @@ "Enable rule": "Увімкнути правило", "Enabled": "Увімкнено", "Explode": "Рознести", + "Export Animation…": "Експортувати анімацію…", "Export Current View as DXF": "Експортувати поточний вид як DXF", "Export Current View as DXF…": "Експортувати поточний вид як DXF…", "Export DXF…": "Експорт DXF…", @@ -211,6 +212,7 @@ "Round": "Круглий", "Rule": "Правило", "Save Rule": "Зберегти правило", + "Save the study as an animated GIF, seen as the viewport shows it": "Зберегти дослідження як анімований GIF, як його показує вікно перегляду", "Saved preview. Geometry is being checked before editing.": "Збережений перегляд. Геометрію перевіряють перед редагуванням.", "Scale factor": "Коефіцієнт масштабу", "Search": "Пошук", @@ -426,6 +428,7 @@ "cmd.model_threads": "Список різьб", "cmd.motion_delete": "Видалити дослідження руху", "cmd.motion_export": "Експортувати дослідження руху", + "cmd.motion_export_animation": "Експортувати анімацію руху", "cmd.motion_list": "Список досліджень руху", "cmd.motion_play": "Відтворити дослідження руху", "cmd.motion_study": "Дослідження руху", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index b310f839..e9acd94e 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -92,6 +92,7 @@ "Enable rule": "启用规则", "Enabled": "已启用", "Explode": "爆炸", + "Export Animation…": "导出动画…", "Export Current View as DXF": "将当前视图导出为DXF", "Export Current View as DXF…": "将当前视图导出为DXF…", "Export DXF…": "导出DXF…", @@ -211,6 +212,7 @@ "Round": "圆角", "Rule": "规则", "Save Rule": "保存规则", + "Save the study as an animated GIF, seen as the viewport shows it": "按视口中的显示将研究保存为动画 GIF", "Saved preview. Geometry is being checked before editing.": "已保存预览。编辑前正在检查几何。", "Scale factor": "比例因子", "Search": "搜索", @@ -426,6 +428,7 @@ "cmd.model_threads": "列出螺纹", "cmd.motion_delete": "删除运动研究", "cmd.motion_export": "导出运动研究", + "cmd.motion_export_animation": "导出运动动画", "cmd.motion_list": "列出运动研究", "cmd.motion_play": "播放运动研究", "cmd.motion_study": "运动研究", diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 2e575911..cc42c151 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -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, 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 { + 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, 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, 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 { diff --git a/crates/engine/src/cmd/motion_animation.rs b/crates/engine/src/cmd/motion_animation.rs new file mode 100644 index 00000000..53bd0795 --- /dev/null +++ b/crates/engine/src/cmd/motion_animation.rs @@ -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 { + 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 { + 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 { + 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) +} diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs index 6ee3215a..44974d82 100644 --- a/crates/engine/src/cmd/motion_tests.rs +++ b/crates/engine/src/cmd/motion_tests.rs @@ -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 { + 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 = 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 = 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); +} diff --git a/crates/io/src/vfs.rs b/crates/io/src/vfs.rs index 5749f2af..05b6ebae 100644 --- a/crates/io/src/vfs.rs +++ b/crates/io/src/vfs.rs @@ -50,6 +50,11 @@ mod imp { check(path)?; std::fs::remove_file(path) } + /// A folder and the folders above it, where missing. + pub fn create_dir_all(path: &str) -> Result<()> { + check(path)?; + std::fs::create_dir_all(path) + } } #[cfg(target_arch = "wasm32")] @@ -94,6 +99,10 @@ mod imp { pub fn remove(path: &str) -> Result<()> { FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path)) } + /// Files are keyed by their whole path here: a folder needs no making. + pub fn create_dir_all(_path: &str) -> Result<()> { + Ok(()) + } pub fn insert(path: &str, bytes: Vec) { FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes)); } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index b988a4ad..c31f0b8a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -15,6 +15,7 @@ image = { workspace = true, features = ["jpeg"] } moxcms = { workspace = true } png = { workspace = true } sha2 = { workspace = true } +weezl = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/gif.rs b/crates/render/src/gif.rs new file mode 100644 index 00000000..278708a8 --- /dev/null +++ b/crates/render/src/gif.rs @@ -0,0 +1,321 @@ +//! Animated GIF (GIF89a) from RGBA frames, for motion animations. Every frame shares one palette, +//! so a colour keeps its value from frame to frame, and each frame after the first stores only +//! the rectangle that changed. The palette splits the frames' colours where that removes the +//! most squared colour error, so a small, strongly coloured part (a red part, a thin trace line) +//! keeps its colour instead of being averaged into the shading around it. No dithering: what +//! stands still stays still. + +use std::collections::HashMap; + +/// Colours a GIF palette holds. +const PALETTE: usize = 256; + +/// Frames (straight RGBA, `width × height`, rows top to bottom; alpha is ignored) as a GIF that +/// loops forever and shows each frame for `delay` hundredths of a second. `None` without frames, +/// for a frame of the wrong size, or a side GIF can't store (above 65535). +pub fn gif(width: usize, height: usize, frames: &[Vec], delay: u16) -> Option> { + let (w, h) = (u16::try_from(width).ok().filter(|w| *w > 0)?, u16::try_from(height).ok().filter(|h| *h > 0)?); + let bytes = width.checked_mul(height)?.checked_mul(4)?; + if frames.is_empty() || frames.iter().any(|f| f.len() != bytes) { + return None; + } + let (palette, index) = palette(frames); + let mut out = Vec::with_capacity(1024); + out.extend_from_slice(b"GIF89a"); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + // A global table of 256 colours (8 bits each), background colour 0, square pixels. + out.extend_from_slice(&[0xF7, 0, 0]); + for k in 0..PALETTE { + out.extend_from_slice(palette.get(k).unwrap_or(&[0, 0, 0])); + } + // Loop forever (the NETSCAPE2.0 extension every viewer reads). + out.extend_from_slice(&[0x21, 0xFF, 0x0B]); + out.extend_from_slice(b"NETSCAPE2.0"); + out.extend_from_slice(&[0x03, 0x01, 0x00, 0x00, 0x00]); + let mut previous: Option> = None; + for frame in frames { + let now = indices(frame, &index); + let (x0, y0, x1, y1) = previous.as_deref().map_or(Some((0, 0, width - 1, height - 1)), |p| changed(p, &now, width)).unwrap_or((0, 0, 0, 0)); + let mut rect = Vec::with_capacity((x1 - x0 + 1) * (y1 - y0 + 1)); + for y in y0..=y1 { + rect.extend_from_slice(now.get(y * width + x0..=y * width + x1)?); + } + // Graphic control: kept on screen under the next frame (disposal 1), shown `delay`. + out.extend_from_slice(&[0x21, 0xF9, 0x04, 0x04]); + out.extend_from_slice(&delay.to_le_bytes()); + out.extend_from_slice(&[0, 0]); + out.push(0x2C); + for v in [x0, y0, x1 - x0 + 1, y1 - y0 + 1] { + out.extend_from_slice(&u16::try_from(v).ok()?.to_le_bytes()); + } + out.push(0); + out.push(8); + let data = weezl::encode::Encoder::new(weezl::BitOrder::Lsb, 8).encode(&rect).ok()?; + for block in data.chunks(255) { + out.push(block.len() as u8); + out.extend_from_slice(block); + } + out.push(0); + previous = Some(now); + } + out.push(0x3B); + Some(out) +} + +/// The pixels' colours, `0xRRGGBB`. +fn colours(frame: &[u8]) -> impl Iterator + '_ { + frame.as_chunks::<4>().0.iter().map(|&[r, g, b, _]| (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)) +} + +/// A frame's palette indices. +fn indices(frame: &[u8], index: &HashMap) -> Vec { + let mut last = (u32::MAX, 0); + colours(frame) + .map(|k| { + if k != last.0 { + last = (k, index.get(&k).copied().unwrap_or(0)); + } + last.1 + }) + .collect() +} + +/// The smallest rectangle (x0, y0, x1, y1, inclusive) holding every pixel that differs, or `None` +/// when none does. +fn changed(before: &[u8], now: &[u8], width: usize) -> Option<(usize, usize, usize, usize)> { + let mut r: Option<(usize, usize, usize, usize)> = None; + for (i, _) in before.iter().zip(now).enumerate().filter(|(_, (a, b))| a != b) { + let (x, y) = (i % width, i / width); + r = Some(r.map_or((x, y, x, y), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x), y1.max(y)))); + } + r +} + +/// How many pixels have a colour, and the sums its mean and squared error follow from. +#[derive(Clone, Copy, Default)] +struct Sums { + n: f64, + s: [f64; 3], + q: f64, +} + +impl Sums { + fn of(rgb: u32, n: u64) -> Sums { + let n = n as f64; + let c = [f64::from((rgb >> 16) as u8), f64::from((rgb >> 8) as u8), f64::from(rgb as u8)]; + Sums { n, s: c.map(|v| n * v), q: n * c.iter().map(|v| v * v).sum::() } + } + fn add(&mut self, o: &Sums) { + self.n += o.n; + self.q += o.q; + self.s.iter_mut().zip(o.s).for_each(|(a, b)| *a += b); + } + fn minus(&self, o: &Sums) -> Sums { + let mut s = *self; + s.n -= o.n; + s.q -= o.q; + s.s.iter_mut().zip(o.s).for_each(|(a, b)| *a -= b); + s + } + /// The squared distance of the pixels from their mean colour. + fn error(&self) -> f64 { + if self.n <= 0.0 { 0.0 } else { self.q - self.s.iter().map(|s| s * s).sum::() / self.n } + } + fn mean(&self) -> [u8; 3] { + self.s.map(|s| if self.n > 0.0 { (s / self.n).round().clamp(0.0, 255.0) as u8 } else { 0 }) + } +} + +/// A set of the frames' colours that becomes one palette entry, and its best split: (error +/// removed, channel, the largest value of that channel on the low side). +struct Group { + colours: Vec<(u32, Sums)>, + sums: Sums, + split: Option<(f64, u32, u8)>, +} + +impl Group { + fn new(colours: Vec<(u32, Sums)>) -> Group { + let mut sums = Sums::default(); + colours.iter().for_each(|(_, s)| sums.add(s)); + let mut best: Option<(f64, u32, u8)> = None; + for channel in [16u32, 8, 0] { + let mut bins = [Sums::default(); 256]; + for (k, s) in &colours { + if let Some(b) = bins.get_mut(usize::from((k >> channel) as u8)) { + b.add(s); + } + } + let mut low = Sums::default(); + for (v, b) in bins.iter().enumerate() { + low.add(b); + if low.n <= 0.0 || low.n >= sums.n { + continue; + } + let gain = sums.error() - low.error() - sums.minus(&low).error(); + if best.is_none_or(|(g, _, _)| gain > g) { + best = Some((gain, channel, v as u8)); + } + } + } + Group { colours, sums, split: best.filter(|(g, _, _)| *g > 0.0) } + } +} + +/// One palette for all frames (up to 256 colours) and each colour's index in it. Every colour +/// gets its own entry while there are 256 or fewer. +fn palette(frames: &[Vec]) -> (Vec<[u8; 3]>, HashMap) { + let mut counts: HashMap = HashMap::new(); + for frame in frames { + // Runs of one colour (the background) are counted once per run. + let mut run = (u32::MAX, 0u64); + for k in colours(frame) { + if k != run.0 { + if run.1 > 0 { + *counts.entry(run.0).or_default() += run.1; + } + run = (k, 0); + } + run.1 += 1; + } + if run.1 > 0 { + *counts.entry(run.0).or_default() += run.1; + } + } + let mut groups = vec![Group::new(counts.into_iter().map(|(k, n)| (k, Sums::of(k, n))).collect())]; + while groups.len() < PALETTE { + let Some(i) = groups.iter().enumerate().filter_map(|(i, g)| g.split.map(|s| (i, s.0))).max_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) + else { + break; + }; + let g = groups.swap_remove(i); + let Some((_, channel, at)) = g.split else { break }; + let (low, high) = g.colours.into_iter().partition(|(k, _)| (k >> channel) as u8 <= at); + groups.push(Group::new(low)); + groups.push(Group::new(high)); + } + let mut index = HashMap::new(); + for (i, g) in groups.iter().enumerate() { + for (k, _) in &g.colours { + index.insert(*k, i as u8); + } + } + (groups.iter().map(|g| g.sums.mean()).collect(), index) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The frames of a GIF this module wrote, as RGBA (each frame drawn over the one before). + fn decode(gif: &[u8]) -> (usize, usize, Vec>, Vec) { + let u16_at = |i: usize| usize::from(u16::from_le_bytes([gif[i], gif[i + 1]])); + assert_eq!(&gif[..6], b"GIF89a"); + let (w, h) = (u16_at(6), u16_at(8)); + let table = &gif[13..13 + 768]; + let mut at = 13 + 768; + let (mut frames, mut delays) = (Vec::new(), Vec::new()); + let mut canvas = vec![0u8; w * h * 4]; + loop { + match gif[at] { + 0x3B => break, + 0x21 => { + if gif[at + 1] == 0xF9 { + delays.push(u16_at(at + 4) as u16); + } + at += 2; + while gif[at] != 0 { + at += usize::from(gif[at]) + 1; + } + at += 1; + } + 0x2C => { + let (x0, y0, fw, fh) = (u16_at(at + 1), u16_at(at + 3), u16_at(at + 5), u16_at(at + 7)); + at += 11; + let mut data = Vec::new(); + while gif[at] != 0 { + data.extend_from_slice(&gif[at + 1..at + 1 + usize::from(gif[at])]); + at += usize::from(gif[at]) + 1; + } + at += 1; + let pixels = weezl::decode::Decoder::new(weezl::BitOrder::Lsb, 8).decode(&data).unwrap(); + assert_eq!(pixels.len(), fw * fh); + for (i, p) in pixels.iter().enumerate() { + let o = ((y0 + i / fw) * w + x0 + i % fw) * 4; + let c = usize::from(*p) * 3; + canvas[o..o + 4].copy_from_slice(&[table[c], table[c + 1], table[c + 2], 255]); + } + frames.push(canvas.clone()); + } + b => panic!("unexpected block {b:#x} at {at}"), + } + } + (w, h, frames, delays) + } + + fn frame(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 3]) -> Vec { + (0..w * h).flat_map(|i| f(i % w, i / w).into_iter().chain([255])).collect() + } + + #[test] + fn few_colours_come_back_exactly_and_frames_keep_their_size() { + let (w, h) = (37, 23); + let a = frame(w, h, |x, y| { + if x < 10 { + [250, 30, 30] + } else if y > 15 { + [20, 200, 60] + } else { + [220, 224, 230] + } + }); + // The second frame moves the red band: only that part changes. + let b = frame(w, h, |x, y| { + if (5..15).contains(&x) { + [250, 30, 30] + } else if y > 15 { + [20, 200, 60] + } else { + [220, 224, 230] + } + }); + let bytes = gif(w, h, &[a.clone(), b.clone(), b.clone()], 5).unwrap(); + let (dw, dh, frames, delays) = decode(&bytes); + assert_eq!((dw, dh, delays), (w, h, vec![5, 5, 5])); + assert_eq!(frames, vec![a, b.clone(), b]); + } + + #[test] + fn a_thin_saturated_line_keeps_its_colour_among_many_shades() { + let (w, h) = (300, 200); + // A grey shading with far more than 256 shades, a red part and a 1-pixel green line. + let f = frame(w, h, |x, y| { + if y == 100 { + [30, 220, 40] + } else if (40..80).contains(&x) && (40..80).contains(&y) { + [200 + (y as u8 % 40), 30, 30] + } else { + let g = 60 + ((x * 180) / w) as u8; + [g, g.saturating_add((y / 4) as u8), g.saturating_add(10)] + } + }); + let (_, _, frames, _) = decode(&gif(w, h, std::slice::from_ref(&f), 4).unwrap()); + let px = |img: &[u8], x: usize, y: usize| img[(y * w + x) * 4..(y * w + x) * 4 + 3].to_vec(); + assert_eq!(px(&frames[0], 150, 100), vec![30, 220, 40], "the line keeps its colour"); + let red = px(&frames[0], 60, 60); + assert!(red[0] > 180 && red[1] < 60 && red[2] < 60, "the part stays red: {red:?}"); + // Elsewhere each channel is close to the original. + let worst = f.chunks(4).zip(frames[0].chunks(4)).map(|(a, b)| (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap()).max().unwrap(); + assert!(worst <= 12, "worst channel error {worst}"); + } + + #[test] + fn bad_input_is_none_not_a_crash() { + assert!(gif(4, 4, &[], 5).is_none()); + assert!(gif(4, 4, &[vec![0; 63]], 5).is_none()); + assert!(gif(0, 4, &[vec![]], 5).is_none()); + assert!(gif(70_000, 1, &[vec![0; 280_000]], 5).is_none()); + assert!(gif(usize::MAX, 2, &[vec![0; 8]], 5).is_none()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0e12a95a..4ccf3315 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ mod analysis; mod backend; mod camera; mod curve_display; +mod gif; pub mod job; pub mod lighting; mod product; @@ -28,6 +29,7 @@ pub use backend::{ }; pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; +pub use gif::gif; pub use product::{FaceAppearance, ProductMesh, ProductScene}; pub use raster::{Canvas, render, render_png, render_transparent}; pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png}; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f7cb7ebe..8601a3bb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -187,7 +187,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides } /// The grid and the origin's axes as the window shows them (the Grid and Origin switches). -fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { +pub(crate) fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() } } diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 523d8c68..3bb4f9c2 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -326,6 +326,34 @@ fn study_pose(app: &SolveApp, params: &Value, step: u32) -> Result app.set_status(crate::i18n::format("ui.saved_to", &[("path", &path)]), false), + Err(e) => app.set_status(e.to_string(), true), + } + }); +} + /// The explosion at `scale`, computed on a scratch copy. fn explode_pose(app: &SolveApp, scale: f64) -> Result, String> { let mut sc = app.session.scratch(); @@ -532,15 +560,28 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel tracks.remove(i); } row_label(ui, ""); - if ui.button(crate::i18n::tr_label("Add Key")).on_hover_text(crate::i18n::tr_label("Key a joint's value at this step")).clicked() - && let Some((ji, _, kind)) = moving.first() - { - let unit = if kind.is_angle(0) { "deg" } else { "mm" }; - tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); - } + let mut export = false; + ui.horizontal(|ui| { + if ui.button(crate::i18n::tr_label("Add Key")).on_hover_text(crate::i18n::tr_label("Key a joint's value at this step")).clicked() + && let Some((ji, _, kind)) = moving.first() + { + let unit = if kind.is_angle(0) { "deg" } else { "mm" }; + tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); + } + export = ui + .button(crate::i18n::tr_label("Export Animation…")) + .on_hover_text(crate::i18n::tr_label("Save the study as an animated GIF, seen as the viewport shows it")) + .clicked(); + }); ui.end_row(); // Show the pose at this step (again when the step or the keys change). let params = study_params(&app.session, name, study.as_deref(), steps, tracks); + if export { + match ¶ms { + Ok(p) => export_animation(app, p.clone(), name), + Err(e) => *error = Some(e.clone()), + } + } let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -1276,6 +1317,48 @@ mod tests { } } + /// Export Animation saves the study the dialog shows (its keys need not be saved) as a GIF, + /// also when the name is typed without `.gif`, and the design keeps no study. + #[test] + fn export_animation_saves_the_dialogs_study_as_a_gif() { + // Drawn as the CLI draws (the headless device), as scenario shots are. + #[cfg(not(target_arch = "wasm32"))] + if solidcraft_engine::capture::installed().is_none() { + solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| { + let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?; + Ok(solidcraft_engine::capture::Picture { rgba, renderer }) + })); + } + let mut h = crate::scenario::Harness::new(); + for (name, x) in [("A", 0), ("B", 30)] { + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("component.create", json!({ "name": name })).unwrap(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + let a = json!({"occurrence": "A:1", "face": [10, 10, 10]}); + h.app.run("joint.create", json!({"type": "revolute", "a": a, "b": {"occurrence": "B:1", "face": [40, 10, 0]}})).unwrap(); + h.app.start("motion.study"); + h.frames(3); + let Some(Kind::Motion(Mo::MotionStudy { name, steps, tracks, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { + panic!("no Motion Study dialog"); + }; + *name = "Swing".into(); + *steps = "4".into(); + *tracks = vec![Track { joint: 0, index: 0, points: vec![(0, "0 deg".into()), (4, "90 deg".into())] }]; + let dir = h.dir.clone(); + h.app.services.pick_save = Some(Box::new(move |name, extensions| { + assert_eq!((name, extensions), ("Swing.gif", &["gif"][..])); + Box::pin(std::future::ready(Some(dir.join("swing").to_string_lossy().into_owned()))) + })); + h.frames(2); + h.step(&json!({"widget": "Export Animation…"})).unwrap(); + h.frames(3); + let gif = std::fs::read(h.dir.join("swing.gif")).unwrap(); + assert!(gif.starts_with(b"GIF89a"), "{:?}", h.app.status); + assert!(h.app.status.as_ref().is_some_and(|s| !s.2), "{:?}", h.app.status); + assert!(h.app.session.doc.assembly.studies.is_empty(), "exporting saves no study"); + } + #[test] fn study_keys_by_joint_id() { let mut s = Session::default(); diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index f3a1de7f..743a30ce 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -122,6 +122,23 @@ study, samples?}`) answers the world points without changing the design, and `motion.trace.keep` with the same parameters (and `name?`) keeps them. A trace takes 2 to 500 samples: 100 by default for a joint, one per step for a study. +**Export Animation…** in the **Motion Study** dialog saves the study as an animated GIF, drawn +from the viewport's camera as the study plays (the keys in the dialog count, even before OK). +`motion.export_animation` does the same for scripts, the CLI and agents, without a window: give a +`study`, or a `joint` with `from` and `to` (and `index?`) to animate a drive, and a `path`. A path +ending in `.gif` gets an animated GIF that loops; any other path is a folder that gets the frames +as `frame-0001.png`, `frame-0002.png`, and so on (made if missing; frames of a longer earlier +export there are removed), for a video tool. The web app writes GIFs only. `samples` is +the number of frames (one per step for a study by default, 100 for a joint), `width` and `height` +their size in pixels (800 × 500 by default, at most 4096 a side), and `fps` the GIF's speed (20 +by default); `grid: false` and `axes: false` leave out the ground grid and the origin's axes. +`camera` takes the reply of the control channel's `ui.camera` (or any of its fields; +the rest come from the view `view.apply` chose, or the home view framing the model). The frames +are drawn by the same renderer as `solidcraft-cli snapshot`, on a copy of the design, which is not +changed. A GIF keeps one palette for all its frames, so colours don't flicker, and stores only +what changed from one frame to the next. Frames are capped: at most 1000, and a GIF holds at most +2^26 pixels of frames (about 160 frames of 800 × 500). + **Insert Design** (`component.insert_design {path, translate?}`, on the **ASSEMBLE** panel and in the **File** menu) adds a component linked to another saved design, so each part can live in its own document. A STEP or mesh file picked there is inserted as **Insert STEP** or **Insert diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index e98a158c..b11b283e 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -171,6 +171,7 @@ cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket cargo run --release -p solidcraft-cli -- eval examples/bracket.json # volume, area, faces… cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png cargo run --release -p solidcraft-cli -- exec solid.box '{"length": 20, "width": 10, "height": 5}' +cargo run --release -p solidcraft-cli -- run mechanism.json --in arm.solidcraft # with a motion.export_animation step: a GIF ``` A script is a list of commands: diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 14562cbd..26d72590 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -63,7 +63,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free - **Each stop:** solve for where the limit is reached, and compare with the value the drive stopped at. Stops are found by bisection, so they land within about 0.01 degrees. - **Links:** b must equal r·a + o exactly, unless that is outside b's limits; then b sits at the limit and the mirror stops following. - **The whole motion:** `motion.play` at a few steps, or one `motion.export`, and compare the poses with the values you planned. -- **Look:** when the server is bridged, `screenshot` after `motion.play` shows the app window at that step. +- **Look:** when the server is bridged, `screenshot` after `motion.play` shows the app window at that step. `motion.export_animation {"study":..., "path":"tour.gif"}` (or `joint`, `from`, `to`) writes the whole motion as an animated GIF without changing the design. ## Save - Drive the joints back to the pose you planned to keep (`motion.play` at step 0, or `joint.drive`), then `save {path}`. diff --git a/skills/solidcraft-mechanisms/reference.md b/skills/solidcraft-mechanisms/reference.md index aee1fa9b..8ae5dfd4 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -151,4 +151,6 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft ## Showing it moving -Bridged to the app, `motion.play {"study":..., "step":s}` moves the user's viewport, and `screenshot` captures the window at each step. To record outside MCP, drive the app's control channel (`docs/control-protocol.md`): `engine.execute` with `motion.play`, then `ui.screenshot` with a `path`, once per frame. +`motion.export_animation {"study":..., "path":"tour.gif"}` draws the whole motion as an animated GIF in one call, headless or bridged, and changes nothing: `{"joint":..., "from":0, "to":90, "path":...}` animates a drive instead. A path that does not end in `.gif` is a folder for PNG frames (`frame-0001.png`, ...). Pass `camera` (the reply of `ui.camera`, or any of its fields), `width`/`height` (default 800 x 500), `samples` (frames; default the study's steps + 1, or 100), `fps` (default 20), and `grid: false` / `axes: false` for a clean background. The reply is small: `frames`, `bytes`, and `stopped_at` with `stopped_by_loop`/`stopped_by_contact` where the motion first stopped short. + +Bridged to the app, `motion.play {"study":..., "step":s}` moves the user's viewport, and `screenshot` captures the window at that step.