Motion animation export: a study or a joint drive as an animated GIF (#1680)
Thanks @SychPL.
This commit is contained in:
1 parent
8207f29fc0
commit
2ae21143ee
24 files changed
+902
-33
No files matched your search
Generated
+1
@@ -4215,6 +4215,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"solidcraft-geom",
|
"solidcraft-geom",
|
||||||
|
"weezl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "モーション スタディ",
|
||||||
|
|||||||
@@ -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": "운동 연구",
|
||||||
|
|||||||
@@ -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": "Исследование движения",
|
||||||
|
|||||||
@@ -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ü",
|
||||||
|
|||||||
@@ -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": "Дослідження руху",
|
||||||
|
|||||||
@@ -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": "运动研究",
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//! Motion animations (`motion.export_animation`): a study played, or one joint driven, drawn
|
||||||
|
//! frame by frame by the headless renderer the host installed ([`crate::capture`]) and written as
|
||||||
|
//! an animated GIF or a folder of PNG frames. Sampled on a copy, as a trace is: the design is not
|
||||||
|
//! changed.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::{drive_with_contact, motion_plan, sample_count};
|
||||||
|
use crate::capture::{self, Renderer};
|
||||||
|
use crate::params::{bad, str_};
|
||||||
|
use crate::render::Camera;
|
||||||
|
use crate::{Result, Session};
|
||||||
|
|
||||||
|
/// Most frames of one animation.
|
||||||
|
const MAX_FRAMES: usize = 1000;
|
||||||
|
/// Largest frame side, in pixels.
|
||||||
|
const MAX_SIDE: u32 = 4096;
|
||||||
|
/// Most pixels of a GIF's frames together: they are held in memory (4 bytes each) until the
|
||||||
|
/// palette is chosen, so 256 MB at most.
|
||||||
|
const MAX_GIF_PIXELS: usize = 1 << 26;
|
||||||
|
/// Most pixels of a PNG folder's frames together (each frame is written as it is drawn): about
|
||||||
|
/// 500 frames of 1920 × 1080.
|
||||||
|
const MAX_PNG_PIXELS: usize = 1 << 30;
|
||||||
|
/// The camera fields `camera` may set: those of `ui.camera`'s reply.
|
||||||
|
const FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"];
|
||||||
|
|
||||||
|
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
export_with(s, p, capture::installed().as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`export`] drawn by `renderer`.
|
||||||
|
pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -> Result<Value> {
|
||||||
|
let cmd = "motion.export_animation";
|
||||||
|
let path = str_(p, "path")
|
||||||
|
.filter(|x| !x.trim().is_empty() && x.len() <= 4096 && !x.chars().any(char::is_control))
|
||||||
|
.ok_or_else(|| bad(cmd, "`path` must name a .gif file, or a folder for PNG frames"))?;
|
||||||
|
let lower = path.to_ascii_lowercase();
|
||||||
|
if lower.ends_with(".png") {
|
||||||
|
return Err(bad(cmd, "`path` is a .gif file or a folder for the PNG frames (frame-0001.png, …), not one .png"));
|
||||||
|
}
|
||||||
|
let gif = lower.ends_with(".gif");
|
||||||
|
// In the browser written files stay in memory until the host takes them, and the host is not
|
||||||
|
// told of removed ones: a folder of frames would pile up there.
|
||||||
|
if !gif && cfg!(target_arch = "wasm32") {
|
||||||
|
return Err(bad(cmd, "a folder of PNG frames is not available in the browser: give a .gif path"));
|
||||||
|
}
|
||||||
|
let side = |key: &str, default: u32| match p.get(key) {
|
||||||
|
None => Ok(default),
|
||||||
|
Some(v) => v
|
||||||
|
.as_f64()
|
||||||
|
.filter(|n| n.is_finite() && (16.0..=f64::from(MAX_SIDE)).contains(n))
|
||||||
|
.map(|n| n.round() as u32)
|
||||||
|
.ok_or_else(|| bad(cmd, format!("`{key}` must be 16…{MAX_SIDE} pixels"))),
|
||||||
|
};
|
||||||
|
let (width, height) = (side("width", 800)?, side("height", 500)?);
|
||||||
|
let fps = match p.get("fps") {
|
||||||
|
None => 20.0,
|
||||||
|
Some(v) => v.as_f64().filter(|n| n.is_finite() && (1.0..=50.0).contains(n)).ok_or_else(|| bad(cmd, "`fps` must be 1…50"))?,
|
||||||
|
};
|
||||||
|
let cam = camera(s, p.get("camera"), cmd)?;
|
||||||
|
let shown = |key: &str| match p.get(key) {
|
||||||
|
None => Ok(true),
|
||||||
|
Some(Value::Bool(b)) => Ok(*b),
|
||||||
|
Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))),
|
||||||
|
};
|
||||||
|
let guides = crate::view::Guides { grid: shown("grid")?, axes: shown("axes")? };
|
||||||
|
let (default, given, plan) = motion_plan(s, p, cmd)?;
|
||||||
|
// At least 4 frames fit even at the largest size.
|
||||||
|
let max = MAX_FRAMES.min(if gif { MAX_GIF_PIXELS } else { MAX_PNG_PIXELS } / (width as usize * height as usize));
|
||||||
|
let n = sample_count(p, default.min(max), max, cmd).map_err(|_| bad(cmd, format!("`samples` must be 2…{max} for {width} × {height} frames")))?;
|
||||||
|
let renderer = renderer.ok_or_else(|| bad(cmd, "no renderer is installed to draw pictures in this process"))?;
|
||||||
|
// A destination outside an agent's folders is refused before anything is drawn.
|
||||||
|
let io = |e: std::io::Error| bad(cmd, format!("{path}: {e}"));
|
||||||
|
solidcraft_io::vfs::check(path).map_err(io)?;
|
||||||
|
let frame_path = |k: usize| std::path::Path::new(path).join(format!("frame-{k:04}.png")).to_string_lossy().to_string();
|
||||||
|
if !gif {
|
||||||
|
solidcraft_io::vfs::create_dir_all(path).map_err(io)?;
|
||||||
|
// The frames of an earlier export here go first, so the folder never mixes two
|
||||||
|
// animations: not after a shorter one, nor when this one fails part way.
|
||||||
|
for stale in (1..=MAX_FRAMES).map(frame_path).filter(|f| solidcraft_io::vfs::exists(f)) {
|
||||||
|
solidcraft_io::vfs::remove(&stale).map_err(|e| bad(cmd, format!("{stale}: {e}")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut t = s.scratch();
|
||||||
|
// From where the parts are placed: moves only shown (an exploded view, the pose an open
|
||||||
|
// Motion Study dialog shows) are not where the joints are solved.
|
||||||
|
t.pending_moves.clear();
|
||||||
|
let (mut frames, mut bytes, mut drawn_by) = (Vec::new(), 0, "");
|
||||||
|
let mut stop = (Value::Null, Value::Null, Value::Null);
|
||||||
|
for k in 0..n {
|
||||||
|
let want = plan(&t, k, n);
|
||||||
|
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
|
||||||
|
if stop.0.is_null() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) {
|
||||||
|
stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone());
|
||||||
|
}
|
||||||
|
let scene = crate::view::scene_with_guides(&t, &cam, s.section, guides);
|
||||||
|
let picture = capture::rgba_with(Some(renderer), &scene, &cam, width, height).map_err(|e| bad(cmd, e))?;
|
||||||
|
drawn_by = picture.renderer;
|
||||||
|
if gif {
|
||||||
|
frames.push(picture.rgba);
|
||||||
|
} else {
|
||||||
|
let png = crate::render::png(width as usize, height as usize, picture.rgba).ok_or_else(|| bad(cmd, "PNG encoding failed"))?;
|
||||||
|
let file = frame_path(k + 1);
|
||||||
|
solidcraft_io::vfs::write(&file, &png).map_err(|e| bad(cmd, format!("{file}: {e}")))?;
|
||||||
|
bytes += png.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = json!({"path": path, "format": if gif { "gif" } else { "png" }, "frames": n, "width": width, "height": height});
|
||||||
|
if gif {
|
||||||
|
// GIF counts in hundredths of a second, and viewers slow anything under 2 down.
|
||||||
|
let delay = (100.0 / fps).round().clamp(2.0, 100.0) as u16;
|
||||||
|
let gif = crate::render::gif(width as usize, height as usize, &frames, delay).ok_or_else(|| bad(cmd, "GIF encoding failed"))?;
|
||||||
|
solidcraft_io::write_atomic(std::path::Path::new(path), &gif, false).map_err(io)?;
|
||||||
|
bytes = gif.len();
|
||||||
|
out["fps"] = json!(100.0 / f64::from(delay));
|
||||||
|
}
|
||||||
|
out["bytes"] = json!(bytes);
|
||||||
|
out["renderer"] = json!(drawn_by);
|
||||||
|
if let Some(w) = capture::warning(drawn_by) {
|
||||||
|
out["warning"] = json!(w);
|
||||||
|
}
|
||||||
|
out["stopped_at"] = stop.0;
|
||||||
|
out["stopped_by_loop"] = stop.1;
|
||||||
|
out["stopped_by_contact"] = stop.2;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The camera the frames are drawn from: the session's (`view.apply`), or the home view framing
|
||||||
|
/// the model, with the fields `given` sets (`ui.camera`'s reply sets them all).
|
||||||
|
fn camera(s: &Session, given: Option<&Value>, cmd: &str) -> Result<Camera> {
|
||||||
|
let base = s.camera.unwrap_or_else(|| crate::view::home_camera(s));
|
||||||
|
let Some(given) = given else { return Ok(base) };
|
||||||
|
let fields = given.as_object().ok_or_else(|| bad(cmd, "`camera` must be an object, such as ui.camera's reply"))?;
|
||||||
|
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
|
||||||
|
for (k, x) in fields {
|
||||||
|
if !FIELDS.contains(&k.as_str()) {
|
||||||
|
return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", "))));
|
||||||
|
}
|
||||||
|
if let Some(o) = v.as_object_mut() {
|
||||||
|
o.insert(k.clone(), x.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cam: Camera = serde_json::from_value(v).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||||
|
if !cam.is_valid() || cam.distance > 1e15 || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 || !(0.0..3.0).contains(&cam.fov) {
|
||||||
|
return Err(bad(
|
||||||
|
cmd,
|
||||||
|
"the camera must be finite, with a distance up to 1e15 mm, a pitch within ±π/2 and a fov of 0 (orthographic) to 3 radians",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Straight up or down keeps a heading, as ui.camera does.
|
||||||
|
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||||
|
cam.pitch = cam.pitch.clamp(-lim, lim);
|
||||||
|
Ok(cam)
|
||||||
|
}
|
||||||
@@ -173,3 +173,235 @@ fn exploded_views_show_without_changing_the_design() {
|
|||||||
);
|
);
|
||||||
assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2));
|
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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", ¶ms).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 ¶ms {
|
||||||
|
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}`.
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in new issue
Block a user