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

Generated
+1
View File
@@ -4215,6 +4215,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"solidcraft-geom", "solidcraft-geom",
"weezl",
] ]
[[package]] [[package]]
+2
View File
@@ -71,6 +71,8 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# images # images
image = { version = "0.25", default-features = false, features = ["png", "hdr"] } image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
png = "0.18.1" 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 # ui
egui = "0.36" egui = "0.36"
+3
View File
@@ -24,6 +24,7 @@
"Drive Joint": "Gelenk antreiben", "Drive Joint": "Gelenk antreiben",
"Edges": "Kanten", "Edges": "Kanten",
"Edit Profile Sketch": "Profile-Skizze bearbeiten", "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 Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…", "Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…",
"Export DXF…": "DXF exportieren…", "Export DXF…": "DXF exportieren…",
@@ -59,6 +60,7 @@
"Restore outer boundary": "Äußere Begrenzung wiederherstellen", "Restore outer boundary": "Äußere Begrenzung wiederherstellen",
"Revert Position": "Position zurücksetzen", "Revert Position": "Position zurücksetzen",
"Round": "Rund", "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)", "Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)",
"Selection filter": "Auswahlfilter", "Selection filter": "Auswahlfilter",
"Send your report on GitHub": "Bericht auf GitHub senden", "Send your report on GitHub": "Bericht auf GitHub senden",
@@ -225,6 +227,7 @@
"cmd.model_threads": "Gewinde auflisten", "cmd.model_threads": "Gewinde auflisten",
"cmd.motion_delete": "Bewegungsuntersuchung löschen", "cmd.motion_delete": "Bewegungsuntersuchung löschen",
"cmd.motion_export": "Bewegungsuntersuchung exportieren", "cmd.motion_export": "Bewegungsuntersuchung exportieren",
"cmd.motion_export_animation": "Bewegungsanimation exportieren",
"cmd.motion_list": "Bewegungsuntersuchungen auflisten", "cmd.motion_list": "Bewegungsuntersuchungen auflisten",
"cmd.motion_play": "Bewegungsuntersuchung abspielen", "cmd.motion_play": "Bewegungsuntersuchung abspielen",
"cmd.motion_study": "Bewegungsuntersuchung", "cmd.motion_study": "Bewegungsuntersuchung",
+3
View File
@@ -92,6 +92,7 @@
"Enable rule": "Enable rule", "Enable rule": "Enable rule",
"Enabled": "Enabled", "Enabled": "Enabled",
"Explode": "Explode", "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 Current View as DXF…": "Export Current View as DXF…", "Export Current View as DXF…": "Export Current View as DXF…",
"Export DXF…": "Export DXF…", "Export DXF…": "Export DXF…",
@@ -211,6 +212,7 @@
"Round": "Round", "Round": "Round",
"Rule": "Rule", "Rule": "Rule",
"Save Rule": "Save 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.", "Saved preview. Geometry is being checked before editing.": "Saved preview. Geometry is being checked before editing.",
"Scale factor": "Scale factor", "Scale factor": "Scale factor",
"Search": "Search", "Search": "Search",
@@ -426,6 +428,7 @@
"cmd.model_threads": "List Threads", "cmd.model_threads": "List Threads",
"cmd.motion_delete": "Delete Motion Study", "cmd.motion_delete": "Delete Motion Study",
"cmd.motion_export": "Export Motion Study", "cmd.motion_export": "Export Motion Study",
"cmd.motion_export_animation": "Export Motion Animation",
"cmd.motion_list": "List Motion Studies", "cmd.motion_list": "List Motion Studies",
"cmd.motion_play": "Play Motion Study", "cmd.motion_play": "Play Motion Study",
"cmd.motion_study": "Motion Study", "cmd.motion_study": "Motion Study",
+3
View File
@@ -85,6 +85,7 @@
"Enable rule": "Activer la règle", "Enable rule": "Activer la règle",
"Enabled": "Activé", "Enabled": "Activé",
"Explode": "Éclater", "Explode": "Éclater",
"Export Animation…": "Exporter l'animation…",
"Extents": "Étendues", "Extents": "Étendues",
"Face": "Face", "Face": "Face",
"Face attachment lost": "Rattachement à la face perdu", "Face attachment lost": "Rattachement à la face perdu",
@@ -194,6 +195,7 @@
"Round": "Arrondi", "Round": "Arrondi",
"Rule": "Règle", "Rule": "Règle",
"Save Rule": "Enregistrer la 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.", "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", "Scale factor": "Facteur d'échelle",
"Search": "Rechercher", "Search": "Rechercher",
@@ -406,6 +408,7 @@
"cmd.model_threads": "Lister les filetages", "cmd.model_threads": "Lister les filetages",
"cmd.motion_delete": "Supprimer l'étude de mouvement", "cmd.motion_delete": "Supprimer l'étude de mouvement",
"cmd.motion_export": "Exporter 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_list": "Lister les études de mouvement",
"cmd.motion_play": "Lire l'étude de mouvement", "cmd.motion_play": "Lire l'étude de mouvement",
"cmd.motion_study": "Étude de mouvement", "cmd.motion_study": "Étude de mouvement",
+3
View File
@@ -92,6 +92,7 @@
"Enable rule": "ルールを有効化", "Enable rule": "ルールを有効化",
"Enabled": "有効", "Enabled": "有効",
"Explode": "分解", "Explode": "分解",
"Export Animation…": "アニメーションをエクスポート…",
"Export Current View as DXF": "現在の表示をDXFでエクスポート", "Export Current View as DXF": "現在の表示をDXFでエクスポート",
"Export Current View as DXF…": "現在の表示をDXFでエクスポート…", "Export Current View as DXF…": "現在の表示をDXFでエクスポート…",
"Export DXF…": "DXFをエクスポート…", "Export DXF…": "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": "ビューポートの表示どおりにスタディをアニメーション GIF として保存",
"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": "ねじを一覧表示", "cmd.model_threads": "ねじを一覧表示",
"cmd.motion_delete": "モーション スタディを削除", "cmd.motion_delete": "モーション スタディを削除",
"cmd.motion_export": "モーション スタディをエクスポート", "cmd.motion_export": "モーション スタディをエクスポート",
"cmd.motion_export_animation": "モーション アニメーションをエクスポート",
"cmd.motion_list": "モーション スタディを一覧表示", "cmd.motion_list": "モーション スタディを一覧表示",
"cmd.motion_play": "モーション スタディを再生", "cmd.motion_play": "モーション スタディを再生",
"cmd.motion_study": "モーション スタディ", "cmd.motion_study": "モーション スタディ",
+3
View File
@@ -24,6 +24,7 @@
"Drive Joint": "조인트 구동", "Drive Joint": "조인트 구동",
"Edges": "모서리", "Edges": "모서리",
"Edit Profile Sketch": "윤곽 스케치 편집", "Edit Profile Sketch": "윤곽 스케치 편집",
"Export Animation…": "애니메이션 내보내기…",
"Export Current View as DXF": "현재 뷰를 DXF로 내보내기", "Export Current View as DXF": "현재 뷰를 DXF로 내보내기",
"Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…", "Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…",
"Export DXF…": "DXF 내보내기…", "Export DXF…": "DXF 내보내기…",
@@ -59,6 +60,7 @@
"Restore outer boundary": "외부 경계 복원", "Restore outer boundary": "외부 경계 복원",
"Revert Position": "위치 복원", "Revert Position": "위치 복원",
"Round": "둥글게", "Round": "둥글게",
"Save the study as an animated GIF, seen as the viewport shows it": "뷰포트에 보이는 대로 스터디를 애니메이션 GIF로 저장",
"Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)", "Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)",
"Selection filter": "선택 필터", "Selection filter": "선택 필터",
"Send your report on GitHub": "GitHub에서 보고서 보내기", "Send your report on GitHub": "GitHub에서 보고서 보내기",
@@ -225,6 +227,7 @@
"cmd.model_threads": "나사산 목록", "cmd.model_threads": "나사산 목록",
"cmd.motion_delete": "운동 연구 삭제", "cmd.motion_delete": "운동 연구 삭제",
"cmd.motion_export": "운동 연구 내보내기", "cmd.motion_export": "운동 연구 내보내기",
"cmd.motion_export_animation": "모션 애니메이션 내보내기",
"cmd.motion_list": "운동 연구 목록", "cmd.motion_list": "운동 연구 목록",
"cmd.motion_play": "운동 연구 재생", "cmd.motion_play": "운동 연구 재생",
"cmd.motion_study": "운동 연구", "cmd.motion_study": "운동 연구",
+3
View File
@@ -81,6 +81,7 @@
"Enable rule": "Включить правило", "Enable rule": "Включить правило",
"Enabled": "Включено", "Enabled": "Включено",
"Explode": "Разобрать", "Explode": "Разобрать",
"Export Animation…": "Экспортировать анимацию…",
"Extents": "Пределы", "Extents": "Пределы",
"Face attachment lost": "Привязка к грани потеряна", "Face attachment lost": "Привязка к грани потеряна",
"Faces": "Грани", "Faces": "Грани",
@@ -177,6 +178,7 @@
"Round": "Круглая", "Round": "Круглая",
"Rule": "Правило", "Rule": "Правило",
"Save 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.": "Сохранённый предпросмотр. Геометрия проверяется перед редактированием.", "Saved preview. Geometry is being checked before editing.": "Сохранённый предпросмотр. Геометрия проверяется перед редактированием.",
"Scale factor": "Коэффициент масштаба", "Scale factor": "Коэффициент масштаба",
"Search": "Поиск", "Search": "Поиск",
@@ -378,6 +380,7 @@
"cmd.model_threads": "Список резьб", "cmd.model_threads": "Список резьб",
"cmd.motion_delete": "Удалить исследование движения", "cmd.motion_delete": "Удалить исследование движения",
"cmd.motion_export": "Экспортировать исследование движения", "cmd.motion_export": "Экспортировать исследование движения",
"cmd.motion_export_animation": "Экспортировать анимацию движения",
"cmd.motion_list": "Список исследований движения", "cmd.motion_list": "Список исследований движения",
"cmd.motion_play": "Воспроизвести исследование движения", "cmd.motion_play": "Воспроизвести исследование движения",
"cmd.motion_study": "Исследование движения", "cmd.motion_study": "Исследование движения",
+3
View File
@@ -85,6 +85,7 @@
"Enable rule": "Kuralı etkinleştir", "Enable rule": "Kuralı etkinleştir",
"Enabled": "Etkin", "Enabled": "Etkin",
"Explode": "Patlat", "Explode": "Patlat",
"Export Animation…": "Animasyonu dışa aktar…",
"Extents": "Kapsamlar", "Extents": "Kapsamlar",
"Face": "Yüz", "Face": "Yüz",
"Face attachment lost": "Yüze bağlama kayboldu", "Face attachment lost": "Yüze bağlama kayboldu",
@@ -193,6 +194,7 @@
"Round": "Yuvarlak", "Round": "Yuvarlak",
"Rule": "Kural", "Rule": "Kural",
"Save Rule": "Kuralı kaydet", "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.", "Saved preview. Geometry is being checked before editing.": "Kaydedilmiş önizleme. Düzenlemeden önce geometri kontrol ediliyor.",
"Scale factor": "Ölçek faktörü", "Scale factor": "Ölçek faktörü",
"Search": "Ara", "Search": "Ara",
@@ -400,6 +402,7 @@
"cmd.model_threads": "Dişleri listele", "cmd.model_threads": "Dişleri listele",
"cmd.motion_delete": "Hareket etüdünü sil", "cmd.motion_delete": "Hareket etüdünü sil",
"cmd.motion_export": "Hareket etüdünü dışa aktar", "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_list": "Hareket etütlerini listele",
"cmd.motion_play": "Hareket etüdünü oynat", "cmd.motion_play": "Hareket etüdünü oynat",
"cmd.motion_study": "Hareket etüdü", "cmd.motion_study": "Hareket etüdü",
+3
View File
@@ -92,6 +92,7 @@
"Enable rule": "Увімкнути правило", "Enable rule": "Увімкнути правило",
"Enabled": "Увімкнено", "Enabled": "Увімкнено",
"Explode": "Рознести", "Explode": "Рознести",
"Export Animation…": "Експортувати анімацію…",
"Export Current View as DXF": "Експортувати поточний вид як DXF", "Export Current View as DXF": "Експортувати поточний вид як DXF",
"Export Current View as DXF…": "Експортувати поточний вид як DXF…", "Export Current View as DXF…": "Експортувати поточний вид як DXF…",
"Export DXF…": "Експорт DXF…", "Export DXF…": "Експорт 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": "Зберегти дослідження як анімований GIF, як його показує вікно перегляду",
"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": "Список різьб", "cmd.model_threads": "Список різьб",
"cmd.motion_delete": "Видалити дослідження руху", "cmd.motion_delete": "Видалити дослідження руху",
"cmd.motion_export": "Експортувати дослідження руху", "cmd.motion_export": "Експортувати дослідження руху",
"cmd.motion_export_animation": "Експортувати анімацію руху",
"cmd.motion_list": "Список досліджень руху", "cmd.motion_list": "Список досліджень руху",
"cmd.motion_play": "Відтворити дослідження руху", "cmd.motion_play": "Відтворити дослідження руху",
"cmd.motion_study": "Дослідження руху", "cmd.motion_study": "Дослідження руху",
+3
View File
@@ -92,6 +92,7 @@
"Enable rule": "启用规则", "Enable rule": "启用规则",
"Enabled": "已启用", "Enabled": "已启用",
"Explode": "爆炸", "Explode": "爆炸",
"Export Animation…": "导出动画…",
"Export Current View as DXF": "将当前视图导出为DXF", "Export Current View as DXF": "将当前视图导出为DXF",
"Export Current View as DXF…": "将当前视图导出为DXF…", "Export Current View as DXF…": "将当前视图导出为DXF…",
"Export DXF…": "导出DXF…", "Export DXF…": "导出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": "按视口中的显示将研究保存为动画 GIF",
"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": "列出螺纹", "cmd.model_threads": "列出螺纹",
"cmd.motion_delete": "删除运动研究", "cmd.motion_delete": "删除运动研究",
"cmd.motion_export": "导出运动研究", "cmd.motion_export": "导出运动研究",
"cmd.motion_export_animation": "导出运动动画",
"cmd.motion_list": "列出运动研究", "cmd.motion_list": "列出运动研究",
"cmd.motion_play": "播放运动研究", "cmd.motion_play": "播放运动研究",
"cmd.motion_study": "运动研究", "cmd.motion_study": "运动研究",
+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::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session}; use crate::{EngineError, Result, Session};
#[path = "motion_animation.rs"]
mod animation;
#[path = "contact_sweep.rs"] #[path = "contact_sweep.rs"]
mod contact_sweep; mod contact_sweep;
@@ -54,6 +56,9 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("motion.export", "Export Motion Study", export) CommandSpec::new("motion.export", "Export Motion Study", export)
.noundo() .noundo()
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"), .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!( CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!(
trace_doc!(), trace_doc!(),
" → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \ " → 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)?) let local = solidcraft_doc::mat_inverse(&world(&t)?)
.map(|m| solidcraft_doc::apply_point(&m, at)) .map(|m| solidcraft_doc::apply_point(&m, at))
.ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?; .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 Some(v) => v
.as_f64() .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) .map(|n| n.round() as usize)
.ok_or_else(|| bad(cmd, format!("`samples` must be 2…{MAX_TRACE}"))), .ok_or_else(|| bad(cmd, format!("`samples` must be 2…{max}"))),
None => Ok(default), None => Ok(default.max(2)),
}; }
let (n, given, plan): (usize, BTreeSet<(u64, usize)>, Plan) = match (p.get("study"), p.get("joint")) { }
/// 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(_), Some(_)) => return Err(bad(cmd, "give `study` or `joint`, not both")),
(Some(_), None) => { (Some(_), None) => {
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?; 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))) (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)) => { (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) { let end = |key: &str| match p.get(key) {
Some(v) => value(s, v, kind.is_angle(index), cmd), 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 (from, to) = (end("from")?, end("to")?);
let plan: Plan = Box::new(move |t, k, n| { 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)] 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`")), (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> { 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)); 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);
}
+9
View File
@@ -50,6 +50,11 @@ mod imp {
check(path)?; check(path)?;
std::fs::remove_file(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")] #[cfg(target_arch = "wasm32")]
@@ -94,6 +99,10 @@ mod imp {
pub fn remove(path: &str) -> Result<()> { pub fn remove(path: &str) -> Result<()> {
FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path)) 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<u8>) { pub fn insert(path: &str, bytes: Vec<u8>) {
FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes)); FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes));
} }
+1
View File
@@ -15,6 +15,7 @@ image = { workspace = true, features = ["jpeg"] }
moxcms = { workspace = true } moxcms = { workspace = true }
png = { workspace = true } png = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
weezl = { workspace = true }
[dev-dependencies] [dev-dependencies]
serde_json = { workspace = true } serde_json = { workspace = true }
+321
View File
@@ -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<u8>], delay: u16) -> Option<Vec<u8>> {
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<Vec<u8>> = 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<Item = u32> + '_ {
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<u32, u8>) -> Vec<u8> {
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::<f64>() }
}
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::<f64>() / 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<u8>]) -> (Vec<[u8; 3]>, HashMap<u32, u8>) {
let mut counts: HashMap<u32, u64> = 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<u8>>, Vec<u16>) {
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<u8> {
(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());
}
}
+2
View File
@@ -8,6 +8,7 @@ mod analysis;
mod backend; mod backend;
mod camera; mod camera;
mod curve_display; mod curve_display;
mod gif;
pub mod job; pub mod job;
pub mod lighting; pub mod lighting;
mod product; mod product;
@@ -28,6 +29,7 @@ pub use backend::{
}; };
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; 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 curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
pub use gif::gif;
pub use product::{FaceAppearance, ProductMesh, ProductScene}; pub use product::{FaceAppearance, ProductMesh, ProductScene};
pub use raster::{Canvas, render, render_png, render_transparent}; pub use raster::{Canvas, render, render_png, render_transparent};
pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png}; pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png};
+1 -1
View File
@@ -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). /// 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() } solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }
} }
+89 -6
View File
@@ -326,6 +326,34 @@ fn study_pose(app: &SolveApp, params: &Value, step: u32) -> Result<BTreeMap<u64,
Ok(moved(&app.session, &sc)) Ok(moved(&app.session, &sc))
} }
/// Save the study being edited as an animated GIF, seen from the viewport's camera. Drawn on a
/// scratch copy, so the dialog's keys need not be saved first and the design is not changed.
fn export_animation(app: &mut SolveApp, params: Value, name: &str) {
// The view as the viewport shows it: its camera, and its grid and axes on or off.
let (camera, guides) = (app.cam, crate::control::window_guides(app));
let file = format!("{}.gif", Some(name.trim()).filter(|n| !n.is_empty()).unwrap_or("Motion Study"));
app.pick_save(&file, &["gif"], move |app, path| {
let Some(mut path) = path else { return };
// A name typed without the extension is still a GIF (not a folder of frames).
if !path.to_ascii_lowercase().ends_with(".gif") {
path.push_str(".gif");
}
let mut sc = app.session.scratch();
// Cut as the viewport is (a copy leaves the section out).
sc.section = app.session.section;
let made = sc.execute("motion.study", &params).and_then(|r| {
sc.execute(
"motion.export_animation",
&json!({"study": r["study"], "path": path, "camera": camera, "grid": guides.grid, "axes": guides.axes}),
)
});
match made {
Ok(_) => 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. /// The explosion at `scale`, computed on a scratch copy.
fn explode_pose(app: &SolveApp, scale: f64) -> Result<BTreeMap<u64, Mat>, String> { fn explode_pose(app: &SolveApp, scale: f64) -> Result<BTreeMap<u64, Mat>, String> {
let mut sc = app.session.scratch(); 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); tracks.remove(i);
} }
row_label(ui, ""); 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 mut export = false;
&& let Some((ji, _, kind)) = moving.first() 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 unit = if kind.is_angle(0) { "deg" } else { "mm" }; && let Some((ji, _, kind)) = moving.first()
tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); {
} 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(); ui.end_row();
// Show the pose at this step (again when the step or the keys change). // 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); let params = study_params(&app.session, name, study.as_deref(), steps, tracks);
if export {
match &params {
Ok(p) => export_animation(app, p.clone(), name),
Err(e) => *error = Some(e.clone()),
}
}
let key = { let key = {
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new(); 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] #[test]
fn study_keys_by_joint_id() { fn study_keys_by_joint_id() {
let mut s = Session::default(); let mut s = Session::default();
+17
View File
@@ -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 `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. 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 **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 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 its own document. A STEP or mesh file picked there is inserted as **Insert STEP** or **Insert
+1
View File
@@ -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 -- 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 -- 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 -- 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: A script is a list of commands:
+1 -1
View File
@@ -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. - **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. - **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. - **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 ## Save
- Drive the joints back to the pose you planned to keep (`motion.play` at step 0, or `joint.drive`), then `save {path}`. - Drive the joints back to the pose you planned to keep (`motion.play` at step 0, or `joint.drive`), then `save {path}`.
+3 -1
View File
@@ -151,4 +151,6 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft
## Showing it moving ## 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.