mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 04:52:10 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4db733ec68 | |||
| 1e6b1ec20f | |||
| 4d96c780e1 | |||
| 52afe212a8 | |||
| 0bb09d0b13 | |||
| 11abdba1ba | |||
| 7c9c1699de | |||
| cf9ae4d25e | |||
| 1f29f432ee | |||
| dc679cae34 | |||
| dbbfa437a5 | |||
| ba43d2798f | |||
| 6a1fe74683 | |||
| a9ff55ef27 | |||
| 3081630f92 | |||
| 323defa525 | |||
| 5eaf8c4181 | |||
| b464c48506 | |||
| 7a2f2949a7 | |||
| fcd4ccd09a | |||
| beaa43cb40 | |||
| 6085423d0d | |||
| 4425b87d2b | |||
| 59d032659f | |||
| 395679015f | |||
| 30be21a224 |
@@ -842,6 +842,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
|
||||
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "معامل المرونة الاتجاهي",
|
||||
"cmd.material_properties_validate": "التحقق من بيانات المادة الفيزيائية",
|
||||
"cmd.mesh_convert": "تحويل الشبكة",
|
||||
"cmd.model_body_features": "سرد ميزات الجسم",
|
||||
"cmd.model_edges": "قائمة الحواف",
|
||||
"cmd.model_faces": "قائمة الأوجه",
|
||||
"cmd.model_threads": "قائمة الأسنان اللولبية",
|
||||
@@ -2518,6 +2519,8 @@
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
"prefs.browser": "المستعرض",
|
||||
"prefs.browser_body_features": "الميزات تحت الأجسام",
|
||||
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات المخطط الزمني التي بنته، بترتيب المخطط الزمني.",
|
||||
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
|
||||
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
|
||||
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
|
||||
@@ -2558,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "درجات",
|
||||
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
|
||||
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
|
||||
"prefs.design_tools.hole_group": "تجميع الثقوب المصنوعة معًا",
|
||||
"prefs.design_tools.hole_group_help": "الثقوب المصنوعة دفعة واحدة تشترك في مجلد في السجل، مغلق في البداية؛ افتحه لتحرير كل ثقب على حدة.",
|
||||
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
|
||||
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
|
||||
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
|
||||
@@ -2642,6 +2647,8 @@
|
||||
"prefs.history": "السجل",
|
||||
"prefs.history_bar_groups": "طي شريط السجل في مجموعات",
|
||||
"prefs.history_bar_groups_note": "في سجل طويل، تُطوى سلسلة ميزات المكوّن الواحد في شريحة واحدة، وتبدأ مجلدات الميزات مطوية. تدخل مجموعة STEP المُدرجة في مجلد Group. عند الإيقاف تظهر كل ميزة، وتبدأ المجلدات مفتوحة، ولا يُنشأ أي مجلد.",
|
||||
"prefs.history_folder_kind_icon": "أيقونة المجلد من ميزاته",
|
||||
"prefs.history_folder_kind_icon_note": "مجلد السجل الذي تكون ميزاته كلها من نوع واحد يعرض أيقونتها.",
|
||||
"prefs.history_group_joints": "تجميع المفاصل في السجل",
|
||||
"prefs.history_group_joints_note": "يظهر مفصلان أو أكثر كزر واحد مع عددها، والنقر عليه يُظهر كل مفصل. عند الإيقاف يظهر كل مفصل على حدة.",
|
||||
"prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى",
|
||||
|
||||
+751
-3
File diff suppressed because it is too large
Load Diff
@@ -1018,6 +1018,7 @@
|
||||
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
|
||||
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
|
||||
"cmd.mesh_convert": "Netz konvertieren",
|
||||
"cmd.model_body_features": "Körper-Features auflisten",
|
||||
"cmd.model_edges": "Kanten auflisten",
|
||||
"cmd.model_faces": "Flächen auflisten",
|
||||
"cmd.model_threads": "Gewinde auflisten",
|
||||
@@ -2305,6 +2306,8 @@
|
||||
"prefs.bottom_centre": "Unten mittig",
|
||||
"prefs.bottom_right": "Unten rechts",
|
||||
"prefs.browser": "Browser",
|
||||
"prefs.browser_body_features": "Features unter Körpern",
|
||||
"prefs.browser_body_features_note": "Jeder Körper im Browser lässt sich aufklappen und zeigt die Features der Zeitleiste, die ihn erzeugt haben, in deren Reihenfolge.",
|
||||
"prefs.browser_components_open": "Komponenten zu Beginn aufgeklappt",
|
||||
"prefs.browser_components_open_note": "Jede Komponente im Browser ist wie bisher von Anfang an aufgeklappt, statt zugeklappt zu bleiben, bis du sie aufklappst.",
|
||||
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
|
||||
@@ -2345,6 +2348,8 @@
|
||||
"prefs.design_tools.degrees": "Grad",
|
||||
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
|
||||
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
|
||||
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
|
||||
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
|
||||
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
|
||||
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
|
||||
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
|
||||
@@ -2429,6 +2434,8 @@
|
||||
"prefs.history": "Verlauf",
|
||||
"prefs.history_bar_groups": "Verlaufsleiste in Gruppen falten",
|
||||
"prefs.history_bar_groups_note": "In einem langen Verlauf wird eine Folge von Features einer Komponente zu einem Chip gefaltet, und Merkmalsordner beginnen gefaltet. Eine eingefügte STEP-Baugruppe kommt in einen Group-Ordner. Aus zeigt jedes Feature, öffnet Ordner und legt keinen Ordner an.",
|
||||
"prefs.history_folder_kind_icon": "Ordnersymbol aus seinen Features",
|
||||
"prefs.history_folder_kind_icon_note": "Ein Ordner im Verlauf, dessen Features alle von einer Art sind, zeigt deren Symbol.",
|
||||
"prefs.history_group_joints": "Gelenke im Verlauf gruppieren",
|
||||
"prefs.history_group_joints_note": "Zwei oder mehr Gelenke erscheinen als eine Schaltfläche mit ihrer Anzahl; ein Klick zeigt jedes einzeln. Aus zeigt jedes Gelenk einzeln.",
|
||||
"prefs.history_origin_bodies": "Ursprung und Körper oben zeigen",
|
||||
|
||||
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "Directional Elastic Modulus",
|
||||
"cmd.material_properties_validate": "Validate Physical Material Data",
|
||||
"cmd.mesh_convert": "Convert Mesh",
|
||||
"cmd.model_body_features": "List Body Features",
|
||||
"cmd.model_edges": "List Edges",
|
||||
"cmd.model_faces": "List Faces",
|
||||
"cmd.model_threads": "List Threads",
|
||||
@@ -2518,6 +2519,8 @@
|
||||
"prefs.bottom_centre": "Bottom center",
|
||||
"prefs.bottom_right": "Bottom right",
|
||||
"prefs.browser": "Browser",
|
||||
"prefs.browser_body_features": "Features under bodies",
|
||||
"prefs.browser_body_features_note": "Each body in the browser folds open on the timeline features that built it, in timeline order.",
|
||||
"prefs.browser_components_open": "Components start open",
|
||||
"prefs.browser_components_open_note": "Every component in the browser starts open, as before, instead of closed until you open it.",
|
||||
"prefs.browser_eye_left": "Eye beside the fold arrow",
|
||||
@@ -2558,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "Degrees",
|
||||
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
|
||||
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
|
||||
"prefs.design_tools.hole_group": "Group holes made together",
|
||||
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
|
||||
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
|
||||
"prefs.design_tools.hole_several": "Several holes on one face",
|
||||
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
|
||||
@@ -2642,6 +2647,8 @@
|
||||
"prefs.history": "History",
|
||||
"prefs.history_bar_groups": "Fold the History bar into groups",
|
||||
"prefs.history_bar_groups_note": "In a long history, a run of one component's features folds into one chip, and feature folders start folded. An inserted STEP assembly goes into a Group folder. Off shows each feature, starts folders open and makes no folder.",
|
||||
"prefs.history_folder_kind_icon": "Folder icon from its features",
|
||||
"prefs.history_folder_kind_icon_note": "A History folder whose features are all of one kind shows their icon.",
|
||||
"prefs.history_group_joints": "Group joints in the History",
|
||||
"prefs.history_group_joints_note": "Two or more joints show as one button with their count, and a click shows each one. Off shows each joint.",
|
||||
"prefs.history_origin_bodies": "Show origin and bodies at the top",
|
||||
|
||||
@@ -1000,6 +1000,7 @@
|
||||
"cmd.material_properties_directional": "Module d'élasticité directionnel",
|
||||
"cmd.material_properties_validate": "Valider les données du matériau physique",
|
||||
"cmd.mesh_convert": "Convertir le maillage",
|
||||
"cmd.model_body_features": "Lister les fonctions du corps",
|
||||
"cmd.model_edges": "Lister les arêtes",
|
||||
"cmd.model_faces": "Lister les faces",
|
||||
"cmd.model_threads": "Lister les filetages",
|
||||
@@ -2164,6 +2165,8 @@
|
||||
"prefs.bottom_centre": "Bas centre",
|
||||
"prefs.bottom_right": "Bas droite",
|
||||
"prefs.browser": "Navigateur",
|
||||
"prefs.browser_body_features": "Fonctions sous les corps",
|
||||
"prefs.browser_body_features_note": "Chaque corps du navigateur se déplie sur les fonctions de l'historique qui l'ont construit, dans l'ordre de l'historique.",
|
||||
"prefs.browser_components_open": "Composants dépliés au départ",
|
||||
"prefs.browser_components_open_note": "Chaque composant du navigateur commence déplié, comme avant, au lieu de rester replié jusqu'à ce que vous le dépliiez.",
|
||||
"prefs.browser_eye_left": "Œil à côté de la flèche de dépliage",
|
||||
|
||||
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "מודול אלסטיות כיווני",
|
||||
"cmd.material_properties_validate": "אימות נתוני חומר פיזיקלי",
|
||||
"cmd.mesh_convert": "המרת רשת משולשים",
|
||||
"cmd.model_body_features": "הצגת פיצ'רים של גוף",
|
||||
"cmd.model_edges": "רשימת מקצועות",
|
||||
"cmd.model_faces": "רשימת פאות",
|
||||
"cmd.model_threads": "רשימת הברגות",
|
||||
@@ -2518,6 +2519,8 @@
|
||||
"prefs.bottom_centre": "למטה במרכז",
|
||||
"prefs.bottom_right": "למטה מימין",
|
||||
"prefs.browser": "עץ התכן",
|
||||
"prefs.browser_body_features": "פיצ'רים מתחת לגופים",
|
||||
"prefs.browser_body_features_note": "כל גוף בדפדפן נפתח ומציג את הפיצ'רים מציר הזמן שבנו אותו, לפי סדר ציר הזמן.",
|
||||
"prefs.browser_components_open": "רכיבים פתוחים מההתחלה",
|
||||
"prefs.browser_components_open_note": "כל רכיב בעץ התכן מתחיל פתוח, כמו קודם, במקום סגור עד שתפתח אותו.",
|
||||
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
|
||||
@@ -2558,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "מעלות",
|
||||
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
|
||||
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
|
||||
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
|
||||
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
|
||||
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
|
||||
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
|
||||
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
|
||||
@@ -2642,6 +2647,8 @@
|
||||
"prefs.history": "ציר הזמן",
|
||||
"prefs.history_bar_groups": "קיפול סרגל ציר הזמן לקבוצות",
|
||||
"prefs.history_bar_groups_note": "בציר זמן ארוך, רצף פיצ'רים של רכיב אחד מתקפל לשבב אחד, ותיקיות פיצ'רים מתחילות מקופלות. מכלול STEP שהוכנס נכנס לתיקיית Group. כשהאפשרות כבויה, כל פיצ'ר מוצג, התיקיות מתחילות פתוחות ולא נוצרת תיקייה.",
|
||||
"prefs.history_folder_kind_icon": "סמל התיקייה לפי התכונות שבה",
|
||||
"prefs.history_folder_kind_icon_note": "תיקייה בהיסטוריה שכל התכונות בה מאותו סוג מציגה את הסמל שלהן.",
|
||||
"prefs.history_group_joints": "קיבוץ מפרקים בציר הזמן",
|
||||
"prefs.history_group_joints_note": "שני מפרקים או יותר מוצגים ככפתור אחד עם מספרם, ולחיצה מציגה כל אחד מהם. כשהאפשרות כבויה, כל מפרק מוצג בנפרד.",
|
||||
"prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה",
|
||||
|
||||
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "方向別弾性率",
|
||||
"cmd.material_properties_validate": "物理材料データを検証",
|
||||
"cmd.mesh_convert": "メッシュを変換",
|
||||
"cmd.model_body_features": "ボディのフィーチャーを一覧表示",
|
||||
"cmd.model_edges": "エッジを一覧表示",
|
||||
"cmd.model_faces": "面を一覧表示",
|
||||
"cmd.model_threads": "ねじを一覧表示",
|
||||
@@ -2518,6 +2519,8 @@
|
||||
"prefs.bottom_centre": "下中央",
|
||||
"prefs.bottom_right": "右下",
|
||||
"prefs.browser": "ブラウザー",
|
||||
"prefs.browser_body_features": "ボディの下にフィーチャー",
|
||||
"prefs.browser_body_features_note": "ブラウザーの各ボディを展開すると、そのボディを作ったタイムラインのフィーチャーがタイムライン順に表示されます。",
|
||||
"prefs.browser_components_open": "コンポーネントを最初から展開",
|
||||
"prefs.browser_components_open_note": "ブラウザーのすべてのコンポーネントを以前と同じく最初から展開して表示します。オフの場合は展開するまで折りたたまれています。",
|
||||
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
|
||||
@@ -2558,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
|
||||
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
|
||||
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
|
||||
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
|
||||
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
|
||||
"prefs.design_tools.hole_several": "1つの面に複数の穴",
|
||||
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
|
||||
@@ -2642,6 +2647,8 @@
|
||||
"prefs.history": "履歴",
|
||||
"prefs.history_bar_groups": "履歴バーをグループにまとめる",
|
||||
"prefs.history_bar_groups_note": "長い履歴では、1つのコンポーネントの連続したフィーチャーを1つのチップにまとめ、フィーチャーフォルダは閉じた状態で始まります。挿入したSTEPアセンブリはGroupフォルダに入ります。オフにすると、各フィーチャーを表示し、フォルダは開いた状態で始まり、フォルダは作成されません。",
|
||||
"prefs.history_folder_kind_icon": "中のフィーチャーのアイコンをフォルダーに使う",
|
||||
"prefs.history_folder_kind_icon_note": "中のフィーチャーがすべて同じ種類の履歴フォルダーは、そのアイコンで表示されます。",
|
||||
"prefs.history_group_joints": "履歴でジョイントをまとめる",
|
||||
"prefs.history_group_joints_note": "2つ以上のジョイントを、数を添えた1つのボタンにまとめます。クリックすると各ジョイントを表示します。オフにすると各ジョイントを個別に表示します。",
|
||||
"prefs.history_origin_bodies": "原点とボディを先頭に表示",
|
||||
|
||||
@@ -1929,6 +1929,8 @@
|
||||
"prefs.design_tools.degrees": "도",
|
||||
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
|
||||
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
|
||||
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
|
||||
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
|
||||
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
|
||||
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
@@ -2006,6 +2008,8 @@
|
||||
"prefs.history": "이력",
|
||||
"prefs.history_bar_groups": "이력 막대를 그룹으로 접기",
|
||||
"prefs.history_bar_groups_note": "긴 이력에서는 한 컴포넌트의 연속된 특성을 하나의 칩으로 접고, 특성 폴더는 접힌 상태로 시작합니다. 삽입한 STEP 어셈블리는 Group 폴더에 들어갑니다. 끄면 각 특성을 표시하고, 폴더를 펼친 상태로 시작하며, 폴더를 만들지 않습니다.",
|
||||
"prefs.history_folder_kind_icon": "폴더 아이콘을 안의 피처에서 가져오기",
|
||||
"prefs.history_folder_kind_icon_note": "피처가 모두 같은 종류인 기록 폴더는 그 피처의 아이콘을 표시합니다.",
|
||||
"prefs.history_group_joints": "이력에서 조인트 묶기",
|
||||
"prefs.history_group_joints_note": "조인트가 두 개 이상이면 개수가 표시된 버튼 하나로 묶고, 클릭하면 각 조인트를 표시합니다. 끄면 각 조인트를 하나씩 표시합니다.",
|
||||
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
|
||||
|
||||
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "Mô đun đàn hồi theo hướng",
|
||||
"cmd.material_properties_validate": "Xác thực dữ liệu thuộc tính vật liệu",
|
||||
"cmd.mesh_convert": "Chuyển đổi lưới",
|
||||
"cmd.model_body_features": "Liệt kê tính năng của thân",
|
||||
"cmd.model_edges": "Danh sách các cạnh",
|
||||
"cmd.model_faces": "Danh sách các bề mặt",
|
||||
"cmd.model_threads": "Danh sách các đoạn ren",
|
||||
@@ -2518,6 +2519,8 @@
|
||||
"prefs.bottom_centre": "Giữa phía dưới",
|
||||
"prefs.bottom_right": "Góc dưới bên phải",
|
||||
"prefs.browser": "Cây thư mục",
|
||||
"prefs.browser_body_features": "Tính năng dưới thân",
|
||||
"prefs.browser_body_features_note": "Mỗi thân trong trình duyệt có thể mở ra để hiện các tính năng trên dòng thời gian đã tạo nên nó, theo thứ tự dòng thời gian.",
|
||||
"prefs.browser_components_open": "Thành phần mở sẵn",
|
||||
"prefs.browser_components_open_note": "Mọi thành phần trong trình duyệt đều mở sẵn như trước, thay vì đóng cho đến khi bạn mở.",
|
||||
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
|
||||
@@ -2558,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "Độ",
|
||||
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
|
||||
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
|
||||
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
|
||||
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
|
||||
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
|
||||
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
|
||||
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
|
||||
@@ -2642,6 +2647,8 @@
|
||||
"prefs.history": "Lịch sử",
|
||||
"prefs.history_bar_groups": "Thu gọn thanh Lịch sử thành nhóm",
|
||||
"prefs.history_bar_groups_note": "Trong lịch sử dài, một chuỗi tính năng của cùng một thành phần được thu gọn thành một thẻ, và thư mục tính năng bắt đầu ở dạng thu gọn. Cụm lắp ráp STEP được chèn vào sẽ nằm trong thư mục Group. Khi tắt, mỗi tính năng đều hiện, thư mục bắt đầu ở dạng mở và không tạo thư mục nào.",
|
||||
"prefs.history_folder_kind_icon": "Biểu tượng thư mục theo các tính năng bên trong",
|
||||
"prefs.history_folder_kind_icon_note": "Thư mục Lịch sử có các tính năng cùng một loại sẽ hiện biểu tượng của chúng.",
|
||||
"prefs.history_group_joints": "Nhóm các khớp nối trong Lịch sử",
|
||||
"prefs.history_group_joints_note": "Từ hai khớp nối trở lên hiện thành một nút kèm số lượng, nhấp vào để hiện từng khớp nối. Khi tắt, mỗi khớp nối hiện riêng.",
|
||||
"prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng",
|
||||
|
||||
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "方向弹性模量",
|
||||
"cmd.material_properties_validate": "验证物理材料数据",
|
||||
"cmd.mesh_convert": "转换网格",
|
||||
"cmd.model_body_features": "列出实体特征",
|
||||
"cmd.model_edges": "列出边",
|
||||
"cmd.model_faces": "列出面",
|
||||
"cmd.model_threads": "列出螺纹",
|
||||
@@ -2518,6 +2519,8 @@
|
||||
"prefs.bottom_centre": "底部居中",
|
||||
"prefs.bottom_right": "右下角",
|
||||
"prefs.browser": "浏览器",
|
||||
"prefs.browser_body_features": "实体下显示特征",
|
||||
"prefs.browser_body_features_note": "浏览器中的每个实体都可展开,按时间线顺序显示构建它的时间线特征。",
|
||||
"prefs.browser_components_open": "组件初始展开",
|
||||
"prefs.browser_components_open_note": "浏览器中的每个组件都像以前一样初始展开,而不是在您展开前保持折叠。",
|
||||
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
|
||||
@@ -2558,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
|
||||
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
|
||||
"prefs.design_tools.hole_group": "将一起创建的孔分组",
|
||||
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
|
||||
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
|
||||
"prefs.design_tools.hole_several": "一个面上多个孔",
|
||||
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
|
||||
@@ -2642,6 +2647,8 @@
|
||||
"prefs.history": "历史",
|
||||
"prefs.history_bar_groups": "将历史栏折叠为分组",
|
||||
"prefs.history_bar_groups_note": "在较长的历史中,同一组件的连续特征折叠为一个标签,特征文件夹开始时为折叠状态。插入的 STEP 装配体放入 Group 文件夹。关闭后显示每个特征,文件夹开始时为展开状态,且不创建文件夹。",
|
||||
"prefs.history_folder_kind_icon": "文件夹使用其中特征的图标",
|
||||
"prefs.history_folder_kind_icon_note": "历史中特征全部属于同一类型的文件夹显示该类型的图标。",
|
||||
"prefs.history_group_joints": "在历史中将关节分组",
|
||||
"prefs.history_group_joints_note": "两个或更多关节显示为一个带数量的按钮,单击即可显示每个关节。关闭后逐个显示每个关节。",
|
||||
"prefs.history_origin_bodies": "在顶部显示原点和实体",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Which timeline features built each body (#11945): the data behind `model.body_features` and
|
||||
//! the Browser's optional features-under-bodies tree.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::inspect::{BodyFeature, BodyFeatures, ModelBodyFeatures};
|
||||
use crate::{Result, Session};
|
||||
|
||||
impl Session {
|
||||
/// The features that built each body of the evaluated model, by the body's model name, in
|
||||
/// timeline order: the feature that created it, every later feature that changed its
|
||||
/// geometry, and the features of the bodies a feature consumed while it made or changed this
|
||||
/// one (a Combine's tools, a body joined in). A piece a cut split off keeps the features of
|
||||
/// the body it was cut from. Features behind the history marker, which built nothing yet,
|
||||
/// are not listed.
|
||||
///
|
||||
/// Decided on exact body identity (a body a feature passed through unchanged is the same
|
||||
/// record), never on a mesh.
|
||||
pub fn body_features(&self) -> BTreeMap<String, Vec<u64>> {
|
||||
let order: HashMap<u64, usize> = self.doc.features.iter().enumerate().map(|(i, f)| (f.id, i)).collect();
|
||||
// Each body before the feature being looked at: its geometry, its creator and its features.
|
||||
let mut before: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::new();
|
||||
for r in &self.model.results {
|
||||
let id = r.id;
|
||||
let after = &r.state.bodies;
|
||||
let names: HashSet<&str> = after.iter().map(|b| b.name.as_str()).collect();
|
||||
// What the feature consumed: bodies there before and gone after.
|
||||
let consumed: Vec<u64> =
|
||||
before.iter().filter(|(name, _)| !names.contains(name.as_str())).flat_map(|(_, (_, _, list))| list.iter().copied()).collect();
|
||||
let mut next: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::with_capacity(after.len());
|
||||
for b in after {
|
||||
let list = match before.get(&b.name) {
|
||||
// Passed through unchanged.
|
||||
Some((old, creator, list)) if old.same(&b.body) && *creator == b.feature => list.clone(),
|
||||
// Changed: it keeps what built it so far.
|
||||
Some((_, creator, list)) if *creator == b.feature => touched(list.clone(), &consumed, id, &order),
|
||||
// Made by this feature.
|
||||
_ if b.feature == id => touched(Vec::new(), &consumed, id, &order),
|
||||
// A piece of a body made earlier (a split): what built the bodies of its creator.
|
||||
_ => {
|
||||
let inherited =
|
||||
before.values().filter(|(_, creator, _)| *creator == b.feature).flat_map(|(_, _, list)| list.iter().copied()).collect();
|
||||
touched(inherited, &consumed, id, &order)
|
||||
}
|
||||
};
|
||||
next.insert(b.name.clone(), (b.body.clone(), b.feature, list));
|
||||
}
|
||||
before = next;
|
||||
}
|
||||
// The model as it stands (a sketch shown live keeps its last results).
|
||||
self.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| (b.name.clone(), before.get(&b.name).map(|(_, _, list)| list.clone()).unwrap_or_else(|| vec![b.feature])))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `list` with the features of what was consumed and the feature `id` added, once each, in
|
||||
/// timeline order.
|
||||
fn touched(mut list: Vec<u64>, consumed: &[u64], id: u64, order: &HashMap<u64, usize>) -> Vec<u64> {
|
||||
list.extend(consumed.iter().copied());
|
||||
list.push(id);
|
||||
list.sort_by_key(|f| order.get(f).copied().unwrap_or(usize::MAX));
|
||||
list.dedup();
|
||||
list
|
||||
}
|
||||
|
||||
/// `model.body_features`: the features that built each body, or one body (a placed copy's name
|
||||
/// means the body it shows).
|
||||
pub(super) fn model_body_features(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "model.body_features";
|
||||
let all = s.body_features();
|
||||
let wanted = match str_(p, "body") {
|
||||
Some(name) => {
|
||||
let st = s.world_state()?;
|
||||
let b = st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
|
||||
Some((b.name.clone(), b.definition_name().to_string()))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let feature = |id: &u64| BodyFeature { id: *id, name: s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_default() };
|
||||
let bodies = match wanted {
|
||||
Some((shown, definition)) => {
|
||||
vec![BodyFeatures { body: shown, features: all.get(&definition).map(|l| l.iter().map(feature).collect()).unwrap_or_default() }]
|
||||
}
|
||||
None => s
|
||||
.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| BodyFeatures { body: b.name.clone(), features: all.get(&b.name).map(|l| l.iter().map(feature).collect()).unwrap_or_default() })
|
||||
.collect(),
|
||||
};
|
||||
serde_json::to_value(ModelBodyFeatures { bodies, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
@@ -19,9 +19,11 @@ use crate::params::{bad, bool_, expr, str_};
|
||||
use crate::schema::browser::{
|
||||
BodyRenameParams, BodyRenamed, BrowserFoldParams, BrowserFoldResult, BrowserGroupMade, BrowserGroupParams, BrowserGroupRenamed,
|
||||
BrowserMoveParams, BrowserMoved, BrowserOrderParams, BrowserOrdered, BrowserRenameGroupParams, BrowserUngroupParams, BrowserUngrouped,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, RepairDone, RepairFace, RepairHole,
|
||||
RepairParams, RepairResult, RepairSewn, VisibilityState,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, PlaneFrame, RedefineAttachment,
|
||||
RedefineSketchParams, RedefineSketchResult, RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn, ViewApplyParams,
|
||||
ViewDeleteParams, ViewDeleted, ViewListParams, ViewListResult, ViewRenameParams, ViewRenamed, ViewSaveParams, ViewSaved, VisibilityState,
|
||||
};
|
||||
use crate::schema::view_navigation::CameraReply;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -35,6 +37,12 @@ const MOVE_CONTRACT: TypedContract<BrowserMoveParams> = TypedContract::new();
|
||||
const ORDER_CONTRACT: TypedContract<BrowserOrderParams> = TypedContract::new();
|
||||
const VISIBILITY_CONTRACT: TypedContract<BrowserVisibilityParams> = TypedContract::new();
|
||||
const FOLD_CONTRACT: TypedContract<BrowserFoldParams> = TypedContract::new();
|
||||
const REDEFINE_SKETCH_CONTRACT: TypedContract<RedefineSketchParams> = TypedContract::new();
|
||||
const VIEW_SAVE_CONTRACT: TypedContract<ViewSaveParams> = TypedContract::new();
|
||||
const VIEW_RENAME_CONTRACT: TypedContract<ViewRenameParams> = TypedContract::new();
|
||||
const VIEW_DELETE_CONTRACT: TypedContract<ViewDeleteParams> = TypedContract::new();
|
||||
const VIEW_LIST_CONTRACT: TypedContract<ViewListParams> = TypedContract::new();
|
||||
const VIEW_APPLY_CONTRACT: TypedContract<ViewApplyParams> = TypedContract::new();
|
||||
|
||||
/// A reply as JSON: the commands here build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
@@ -56,8 +64,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(
|
||||
"body: its name (a part the modelling kernel refuses, or an open part that should be a solid); gap?: expr (default 0.2 mm, at most 1 mm: free edges that pair up within it are sewn, each sewn edge carrying its own gap as its tolerance); patch_holes?: bool (default false: loops of free edges left are patched, in the plane of the faces around them where those are that plane, else tangent to them, and sewn in; otherwise the reply offers the patches); name? → the feature, and repair: what changed face by face (rebuilt and refused faces, sewn gaps, holes with their surface.patch), faces and largest edge tolerance before and after, closed, modelling, volume. Refused with why when nothing applies",
|
||||
),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", crate::checked!("sketch.redefine", redefine_sketch))
|
||||
.icon("plane")
|
||||
.contract(&REDEFINE_SKETCH_CONTRACT)
|
||||
.output(output_schema_for::<RedefineSketchResult>)
|
||||
.params("sketch: id or name; plane: as sketch.create; frame?: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
||||
CommandSpec::new("browser.group", "New Group", crate::checked!("browser.group", group))
|
||||
.icon("folder")
|
||||
@@ -84,13 +94,33 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&UNITS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<DocumentUnitsResult>)
|
||||
.params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"),
|
||||
CommandSpec::new("view.save", "New Named View", view_save)
|
||||
CommandSpec::new("view.save", "New Named View", crate::checked!("view.save", view_save))
|
||||
.icon("camera")
|
||||
.contract(&VIEW_SAVE_CONTRACT)
|
||||
.output(output_schema_for::<ViewSaved>)
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
|
||||
CommandSpec::new("view.rename", "Rename Named View", crate::checked!("view.rename", view_rename))
|
||||
.icon("rename")
|
||||
.contract(&VIEW_RENAME_CONTRACT)
|
||||
.output(output_schema_for::<ViewRenamed>)
|
||||
.params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", crate::checked!("view.delete", view_delete))
|
||||
.icon("delete")
|
||||
.contract(&VIEW_DELETE_CONTRACT)
|
||||
.output(output_schema_for::<ViewDeleted>)
|
||||
.params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", crate::checked!("view.list", view_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&VIEW_LIST_CONTRACT)
|
||||
.output(output_schema_for::<ViewListResult>)
|
||||
.doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", crate::checked!("view.apply", view_apply))
|
||||
.icon("camera")
|
||||
.noundo()
|
||||
.contract(&VIEW_APPLY_CONTRACT)
|
||||
.output(output_schema_for::<CameraReply>)
|
||||
.params(
|
||||
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?, roll?} (radians, mm, about the up axis; yaw: heading of the line of sight, 0 along +Y (Front), π/2 along +X (Left), π (Back), −π/2 (Right); pitch: the eye's elevation, within ±π/2, π/2 = Top; roll defaults to 0, turns the picture clockwise, must be finite and is clamped to ±π; the View angles readout shows these in degrees; up is refused: the up axis comes from the design settings) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
|
||||
),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", crate::checked!("browser.order", order))
|
||||
@@ -1049,14 +1079,47 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply(kept)
|
||||
}
|
||||
|
||||
/// A camera `view.save` keeps, as the handler reads it (and `ViewCamera`, its contract type): a
|
||||
/// [`solidcraft_doc::NamedView`] of finite numbers with a positive distance, or the message the
|
||||
/// handler refuses it with.
|
||||
pub(crate) fn saved_view_form(c: &Value) -> std::result::Result<solidcraft_doc::NamedView, String> {
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
|
||||
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
||||
if ok { Ok(v) } else { Err("the camera must be finite with a positive distance".into()) }
|
||||
}
|
||||
|
||||
/// The camera `view.apply` turns to for a `camera` it is given, as the handler reads it (and
|
||||
/// `ApplyCamera`, its contract type), or the message the handler refuses it with.
|
||||
pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<crate::render::Camera, String> {
|
||||
if c.get("up").is_some() {
|
||||
return Err("camera `up` is not supported: the up axis comes from the design settings".into());
|
||||
}
|
||||
let roll = match c.get("roll") {
|
||||
Some(value) => value.as_f64().filter(|r| r.is_finite()).ok_or("camera `roll` must be a finite number in radians")?,
|
||||
None => 0.0,
|
||||
};
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
|
||||
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
|
||||
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
|
||||
return Err("camera `pitch` must be within ±π/2 radians (±90°)".into());
|
||||
}
|
||||
let cam = crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) };
|
||||
if !usable_camera(&cam) {
|
||||
return Err("the camera must be finite with a positive distance".into());
|
||||
}
|
||||
Ok(cam)
|
||||
}
|
||||
|
||||
/// A camera the viewport can be put at: finite, with a positive distance and a field of view of
|
||||
/// at least 0.
|
||||
fn usable_camera(cam: &crate::render::Camera) -> bool {
|
||||
cam.is_valid() && cam.fov.is_finite() && cam.fov >= 0.0
|
||||
}
|
||||
|
||||
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.save";
|
||||
let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?;
|
||||
let mut v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
||||
if !ok {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
let mut v = saved_view_form(c).map_err(|e| bad(cmd, e))?;
|
||||
let n = s.doc.named_views.len();
|
||||
v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if n.len() <= 128 => n.to_string(),
|
||||
@@ -1070,7 +1133,7 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut();
|
||||
d.named_views.retain(|x| x.name != name);
|
||||
d.named_views.push(v);
|
||||
Ok(json!({ "view": name }))
|
||||
reply(cmd, ViewSaved { view: name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1078,23 +1141,7 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cam = match (str_(p, "view"), p.get("camera")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
|
||||
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
|
||||
(None, Some(c)) => {
|
||||
if c.get("up").is_some() {
|
||||
return Err(bad(cmd, "camera `up` is not supported: the up axis comes from the design settings"));
|
||||
}
|
||||
let roll = match c.get("roll") {
|
||||
Some(value) => {
|
||||
value.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(cmd, "camera `roll` must be a finite number in radians"))?
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
|
||||
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
|
||||
return Err(bad(cmd, "camera `pitch` must be within ±π/2 radians (±90°)"));
|
||||
}
|
||||
crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) }
|
||||
}
|
||||
(None, Some(c)) => applied_camera_form(c).map_err(|e| bad(cmd, e))?,
|
||||
// Standard view names come first, as in the browser's Named Views folder.
|
||||
(Some(name), None) => match crate::render::StandardView::parse(name) {
|
||||
Some(sv) => {
|
||||
@@ -1106,11 +1153,11 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
|
||||
},
|
||||
};
|
||||
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
|
||||
if !usable_camera(&cam) {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
s.camera = Some(cam);
|
||||
Ok(json!({ "camera": cam }))
|
||||
reply(cmd, CameraReply { camera: cam, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The orbit camera of a named view (Z up, no roll).
|
||||
@@ -1134,17 +1181,18 @@ fn view_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = s.doc_mut().named_views.get_mut(i) {
|
||||
v.name = name.to_string();
|
||||
}
|
||||
Ok(json!({ "view": name }))
|
||||
reply(cmd, ViewRenamed { view: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = view_index(s, p, "view.delete")?;
|
||||
let cmd = "view.delete";
|
||||
let i = view_index(s, p, cmd)?;
|
||||
let v = s.doc_mut().named_views.remove(i);
|
||||
Ok(json!({ "deleted": v.name }))
|
||||
reply(cmd, ViewDeleted { deleted: v.name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "views": s.doc.named_views }))
|
||||
reply("view.list", ViewListResult { views: s.doc.named_views.clone(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The keys whose strings name bodies, alone or in a list (`body`, `targets`…), at any depth of a
|
||||
@@ -1493,17 +1541,17 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
RedefineAttachment::ExactFace
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
RedefineAttachment::SheetFace
|
||||
} else {
|
||||
"fixed"
|
||||
RedefineAttachment::Fixed
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
let mut out = json!({ "sketch": id,"attachment":attachment });
|
||||
super::sketch::frame_reply(&mut out, frame, &placement);
|
||||
Ok(out)
|
||||
let note = frame.and_then(|f| super::sketch::frame_note(Some(f), &placement)).map(str::to_string);
|
||||
let frame = frame.and_then(PlaneFrame::from_name);
|
||||
reply(cmd, RedefineSketchResult { sketch: id, attachment, frame, note, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,37 +4,70 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FeatureKind, PlaneRef};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, name_or_id_values, vec3};
|
||||
use crate::schema::timeline_clipboard::{CopyParams, CopyResult, PasteError, PasteParams, PasteResult, Pasted};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
|
||||
const PASTE_CONTRACT: TypedContract<PasteParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.copy", "Copy Features", copy).icon("copy").noundo().params("features: [ids or names] (in timeline order on paste)"),
|
||||
CommandSpec::new("timeline.paste", "Paste Features", paste).icon("paste").params(
|
||||
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
|
||||
CommandSpec::new("timeline.copy", "Copy Features", crate::checked!("timeline.copy", copy))
|
||||
.icon("copy")
|
||||
.noundo()
|
||||
.contract(©_CONTRACT)
|
||||
.output(output_schema_for::<CopyResult>)
|
||||
.params("features: [ids or names] (in timeline order on paste)"),
|
||||
CommandSpec::new("timeline.paste", "Paste Features", crate::checked!("timeline.paste", paste))
|
||||
.icon("paste")
|
||||
.contract(&PASTE_CONTRACT)
|
||||
.output(output_schema_for::<PasteResult>)
|
||||
.params(
|
||||
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
|
||||
component_path?: its placement (default: the active one, else its only one); the copies go at the marker, each where \
|
||||
its original is in the world (copies into their own component keep its coordinates); body transfers are left out; \
|
||||
from_design?: true to paste sketch features from the host's cross-design clipboard snapshot",
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// A command's reply as JSON: they build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The `features` of `timeline.copy`: named or numbered, both kinds in one list, 1…1000 of them.
|
||||
/// The reader the command's contract uses too.
|
||||
pub(crate) fn copy_keys(p: &Value) -> Result<&[Value]> {
|
||||
let cmd = "timeline.copy";
|
||||
// The features are named or numbered, and both kinds can sit in one list.
|
||||
let keys = name_or_id_values(cmd, p, "features")?;
|
||||
if keys.is_empty() || keys.len() > 1000 {
|
||||
return Err(bad(cmd, "`features` must list 1…1000 features"));
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// A `component_path` of `timeline.paste`: 0 to 64 occurrence ids. The reader both pastes and the
|
||||
/// command's contract use.
|
||||
pub(crate) fn component_path_form(v: &Value) -> Option<Vec<u64>> {
|
||||
v.as_array().filter(|a| a.len() <= 64).and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.copy";
|
||||
let keys = copy_keys(p)?;
|
||||
let ids = keys.iter().map(|key| super::edit::feature_id(s, Some(key), cmd)).collect::<Result<Vec<_>>>()?;
|
||||
// Keep timeline order so sketches come before the features that use them.
|
||||
let copied: Vec<solidcraft_doc::Feature> = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect();
|
||||
let names: Vec<String> = copied.iter().map(|f| f.name.clone()).collect();
|
||||
s.clipboard = copied;
|
||||
s.foreign_copy = Some(std::sync::Arc::new(crate::ForeignCopy::of_features(s)));
|
||||
Ok(json!({"copied": names}))
|
||||
reply(cmd, CopyResult { copied: names, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Origin planes become explicit planes so a translation can move them.
|
||||
@@ -86,12 +119,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let given_path = match p.get("component_path") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.filter(|a| a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
),
|
||||
Some(v) => Some(component_path_form(v).ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?),
|
||||
};
|
||||
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
|
||||
@@ -230,15 +258,15 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
sketches.insert(f.id, id);
|
||||
names.insert(f.name.clone(), new_name.clone());
|
||||
made.push(json!({"id": id, "name": new_name, "from": f.name}));
|
||||
made.push(Pasted { id, name: new_name, from: f.name.clone() });
|
||||
}
|
||||
s.refresh();
|
||||
let errors: Vec<Value> = made
|
||||
let errors: Vec<PasteError> = made
|
||||
.iter()
|
||||
.filter_map(|m| m["id"].as_u64())
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
|
||||
.map(|m| m.id)
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|error| PasteError { feature: id, error }))
|
||||
.collect();
|
||||
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
|
||||
reply(cmd, PasteResult { pasted: made, errors, notices, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, PlaneRef};
|
||||
|
||||
use crate::params::{bad, vec3};
|
||||
use crate::schema::timeline_clipboard::{PasteResult, Pasted};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
const CMD: &str = "timeline.paste";
|
||||
@@ -186,11 +187,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let path = match p.get("component_path") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.filter(|a| a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
Some(v) => super::clipboard::component_path_form(v).ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
None if component == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(component)),
|
||||
None => super::component::placing::placement_of(s, &s.design_document(), component, None, "component_path", CMD)?.0,
|
||||
};
|
||||
@@ -249,7 +246,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ids.insert(feature.id, id);
|
||||
names.insert(feature.name.clone(), name.clone());
|
||||
made.push(json!({"id": id, "name": name, "from": feature.name}));
|
||||
made.push(Pasted { id, name: name.clone(), from: feature.name.clone() });
|
||||
}
|
||||
parameters(scratch.doc_mut(), ©.doc, ©.features, &mut mapped)?;
|
||||
let source_values = copy.doc.param_values().0;
|
||||
@@ -307,5 +304,6 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
scratch.selection = ids.values().map(|id| Sel::Feature { id: *id }).collect();
|
||||
s.accept_computed(scratch);
|
||||
Ok(json!({"pasted": made, "errors": [], "notices": []}))
|
||||
serde_json::to_value(PasteResult { pasted: made, errors: Vec::new(), notices: Vec::new(), param_warnings: Vec::new() })
|
||||
.map_err(|e| bad(CMD, e.to_string()))
|
||||
}
|
||||
|
||||
@@ -13,12 +13,23 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::schema::component::{
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams,
|
||||
MoveSketchesToPartsResult, OccurrenceAlignParams, OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
|
||||
ActivateParams, ActivateResult, ChildEntry, CompactPlacement, ComponentNode, CreateParams, CreateResult, DeleteParams, DeleteResult,
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, ListParams, ListResult,
|
||||
MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams,
|
||||
OccurrenceAlignResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams,
|
||||
ReparentItemsResult, UnlockParams, UnlockResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const ACTIVATE_CONTRACT: TypedContract<ActivateParams> = TypedContract::new();
|
||||
const CREATE_CONTRACT: TypedContract<CreateParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<DeleteParams> = TypedContract::new();
|
||||
const FROM_BODIES_CONTRACT: TypedContract<FromBodiesParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ListParams> = TypedContract::new();
|
||||
const PROPERTIES_CONTRACT: TypedContract<PropertiesParams> = TypedContract::new();
|
||||
const RENAME_CONTRACT: TypedContract<RenameParams> = TypedContract::new();
|
||||
const UNLOCK_CONTRACT: TypedContract<UnlockParams> = TypedContract::new();
|
||||
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
|
||||
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
|
||||
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
|
||||
@@ -67,14 +78,23 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.show_history", "Show Component History", show_history)
|
||||
.icon("eye").doc_only()
|
||||
.params("component: id or name; puts a component's features back in the History bar after Hide Component History (#10393)"),
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
CommandSpec::new("component.create", "New Component", crate::checked!("component.create", new_component))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<CreateResult>)
|
||||
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"),
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", crate::checked!("component.from_bodies", from_bodies))
|
||||
.icon("component")
|
||||
.contract(&FROM_BODIES_CONTRACT)
|
||||
.output(output_schema_for::<FromBodiesResult>)
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.activate", "Activate Component", crate::checked!("component.activate", activate))
|
||||
.icon("activate")
|
||||
.noundo()
|
||||
.contract(&ACTIVATE_CONTRACT)
|
||||
.output(output_schema_for::<ActivateResult>)
|
||||
.params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.edit_in_place", "Edit in Place", crate::checked!("component.edit_in_place", edit_in_place))
|
||||
.icon("edit")
|
||||
.noundo()
|
||||
@@ -99,22 +119,41 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
fails while the design has unsaved changes (run file.save first) or something is open in it; asked during the agent's \
|
||||
turn it finishes once the turn ends (queued: true); elsewhere nothing changes",
|
||||
),
|
||||
CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params(
|
||||
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
|
||||
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
|
||||
),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
CommandSpec::new("component.list", "List Components", crate::checked!("component.list", list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ListResult>)
|
||||
.params(
|
||||
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
|
||||
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
|
||||
),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", crate::checked!("component.properties", properties::component))
|
||||
.icon("measure").noundo()
|
||||
.contract(&PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<PropertiesResult>)
|
||||
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
|
||||
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
|
||||
move and take joints one by one → joints (the ids removed)",
|
||||
),
|
||||
CommandSpec::new("component.rename", "Rename Component", crate::checked!("component.rename", rename))
|
||||
.icon("rename")
|
||||
.contract(&RENAME_CONTRACT)
|
||||
.output(output_schema_for::<RenameResult>)
|
||||
.params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", crate::checked!("component.delete", delete))
|
||||
.icon("delete")
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(output_schema_for::<DeleteResult>)
|
||||
.params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.unlock", "Unlock Contents", crate::checked!("component.unlock", unlock))
|
||||
.icon("rigid_group")
|
||||
.contract(&UNLOCK_CONTRACT)
|
||||
.output(output_schema_for::<UnlockResult>)
|
||||
.params(
|
||||
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
|
||||
move and take joints one by one → joints (the ids removed)",
|
||||
),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params(
|
||||
"bodies: [names], component; component_path?: its placement to place them by (default: its only one, else the active \
|
||||
one; a component placed more than once gets them in every instance); from_path?: the placement of their component they \
|
||||
@@ -203,6 +242,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
|
||||
|
||||
/// A typed reply as the JSON a handler returns.
|
||||
pub(super) fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
|
||||
serde_json::to_value(value).map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
@@ -288,7 +332,14 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let occ = s.doc.occurrence_of(id);
|
||||
Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ.map(|o| o.id), "grounded": occ.is_some_and(|o| o.grounded)}))
|
||||
reply(CreateResult {
|
||||
component: id,
|
||||
name,
|
||||
parent,
|
||||
occurrence: occ.map(|o| o.id),
|
||||
grounded: occ.is_some_and(|o| o.grounded),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -334,7 +385,7 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = path;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
reply(ActivateResult { active: id, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -342,17 +393,20 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc;
|
||||
// Compact: what a large assembly needs to be found around in, without every name and matrix.
|
||||
let compact = bool_(p, "compact").unwrap_or(false);
|
||||
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
|
||||
let names = |v: Vec<&str>| if compact { NamesOrCount::Count(v.len()) } else { NamesOrCount::Names(v.into_iter().map(str::to_string).collect()) };
|
||||
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
|
||||
let placement = |o: &Occurrence| {
|
||||
if !compact {
|
||||
return json!(o);
|
||||
return PlacementEntry::Full(PlacementInfo::from(o));
|
||||
}
|
||||
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
|
||||
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
|
||||
v["transform"] = json!(o.transform);
|
||||
}
|
||||
v
|
||||
let moved = o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12);
|
||||
PlacementEntry::Compact(CompactPlacement {
|
||||
id: o.id,
|
||||
name: o.name.clone(),
|
||||
parent: o.parent,
|
||||
grounded: o.grounded,
|
||||
transform: moved.then_some(o.transform),
|
||||
})
|
||||
};
|
||||
// Each component's bodies, features, occurrences and children, grouped once rather than
|
||||
// searched for every component (thousands of each in a design with many components).
|
||||
@@ -376,17 +430,37 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sketches = named(|k| matches!(k, FeatureKind::Sketch { .. }));
|
||||
let construction = named(|k| matches!(k, FeatureKind::ConstructionPlane { .. }));
|
||||
let features = named(|k| !matches!(k, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }));
|
||||
let occurrences: Vec<Value> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
|
||||
let children: Vec<Value> = children_of.get(&id).into_iter().flatten().map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
|
||||
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
|
||||
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
|
||||
"construction": names(construction), "occurrences": occurrences, "children": children})
|
||||
let occurrences: Vec<PlacementEntry> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
|
||||
let children: Vec<ChildEntry> = children_of
|
||||
.get(&id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| if compact { ChildEntry::Id(o.id) } else { ChildEntry::Full(PlacementInfo::from(*o)) })
|
||||
.collect();
|
||||
let bodies =
|
||||
if body_names { NamesOrCount::Names(bodies.into_iter().map(str::to_string).collect()) } else { NamesOrCount::Count(bodies.len()) };
|
||||
ComponentNode {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
parent,
|
||||
bodies,
|
||||
features: names(features),
|
||||
sketches: names(sketches),
|
||||
construction: names(construction),
|
||||
occurrences,
|
||||
children,
|
||||
}
|
||||
};
|
||||
let mut out = vec![node(0, &doc.name, None)];
|
||||
for c in &doc.components {
|
||||
out.push(node(c.id, &c.name, Some(c.parent)));
|
||||
}
|
||||
Ok(json!({"active": s.active_component, "components": out, "pending_moves": s.pending_moves.keys().collect::<Vec<_>>()}))
|
||||
reply(ListResult {
|
||||
active: s.active_component,
|
||||
components: out,
|
||||
pending_moves: s.pending_moves.keys().copied().collect(),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -411,7 +485,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A sketch projecting a body of one of those placements keeps it.
|
||||
super::sketch_project::follow_placed_renames(d, &st, &placed, None);
|
||||
}
|
||||
Ok(json!({"component": id, "name": name}))
|
||||
reply(RenameResult { component: id, name: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -428,7 +502,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if gone.contains(&s.active_component) {
|
||||
s.active_component = 0;
|
||||
}
|
||||
Ok(json!({"deleted": gone, "features": removed.features}))
|
||||
reply(DeleteResult { deleted: gone, features: removed.features, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -448,7 +522,7 @@ fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
|
||||
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
|
||||
Ok(json!({"component": id, "joints": gone}))
|
||||
reply(UnlockResult { component: id, joints: gone, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -9,6 +9,7 @@ use solidcraft_geom::Aabb3;
|
||||
use solidcraft_geom::inertia::{self, Tensor3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::{BodyProperties, BoundingBox, InertiaAbout, PhysicalProperties, PropertiesResult, TotalProperties};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
@@ -84,10 +85,8 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
out["component"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
super::reply(PropertiesResult { component: id, name, properties, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -200,7 +199,12 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result<Pl
|
||||
})
|
||||
}
|
||||
|
||||
/// [`measure`] as the JSON `occurrence.properties` adds its own keys to.
|
||||
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
|
||||
super::reply(measure(s, doc, selected, descendants, about, cmd)?)
|
||||
}
|
||||
|
||||
fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<PhysicalProperties> {
|
||||
let state = s.model.state();
|
||||
let mut bodies = Vec::new();
|
||||
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
|
||||
@@ -292,13 +296,20 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
faces += measured.faces;
|
||||
edges += measured.edges;
|
||||
vertices += measured.vertices;
|
||||
bodies.push(json!({"name": body.name, "component": component, "path": path,
|
||||
"material": doc.body_material(&body.name),
|
||||
"closed": closed, "density_g_cm3": closed.then_some(density),
|
||||
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
|
||||
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
|
||||
"inertia_g_mm2": body_inertia,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
bodies.push(BodyProperties {
|
||||
name: body.name.to_string(),
|
||||
component,
|
||||
path: path.clone(),
|
||||
material: doc.body_material(&body.name),
|
||||
closed,
|
||||
density_g_cm3: closed.then_some(density),
|
||||
mass_g: closed.then_some(body_mass),
|
||||
volume_mm3: closed.then_some(measured.volume),
|
||||
area_mm2: measured.area,
|
||||
center_of_mass: closed.then_some(measured.centroid),
|
||||
inertia_g_mm2: body_inertia,
|
||||
bbox: BoundingBox { min: measured.bbox.min, max: measured.bbox.max },
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(w) = &watch {
|
||||
@@ -321,19 +332,36 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
});
|
||||
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
|
||||
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
|
||||
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
|
||||
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
|
||||
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
|
||||
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
|
||||
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
|
||||
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
|
||||
if let Some(point) = about {
|
||||
let bbox = (!bodies.is_empty()).then_some(BoundingBox { min: bounds.min, max: bounds.max });
|
||||
let inertia_about = about.map(|point| {
|
||||
let shifted =
|
||||
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
|
||||
}
|
||||
Ok(out)
|
||||
InertiaAbout { point, inertia_g_mm2: shifted }
|
||||
});
|
||||
Ok(PhysicalProperties {
|
||||
path: selected.to_vec(),
|
||||
coordinate_frame: "world".to_string(),
|
||||
descendants,
|
||||
placement_count,
|
||||
body_count: bodies.len(),
|
||||
bodies,
|
||||
total: TotalProperties {
|
||||
open_bodies_skipped: skipped,
|
||||
volume_mm3: volume,
|
||||
area_mm2: area,
|
||||
mass_g: mass,
|
||||
center_of_mass: centroid,
|
||||
first_moment_g_mm: moment,
|
||||
inertia_g_mm2: at_centre,
|
||||
principal_moments_g_mm2: principal.map(|(moments, _)| moments),
|
||||
principal_axes: principal.map(|(_, axes)| axes),
|
||||
bbox,
|
||||
faces,
|
||||
edges,
|
||||
vertices,
|
||||
inertia_about,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The tessellating work a properties command did on this thread, for tests: placed copies
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use crate::schema::component::{BodyComponent, FromBodiesResult};
|
||||
use solidcraft_doc::BodyName;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -128,10 +129,10 @@ pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (name, component) in bodies.into_iter().zip(targets) {
|
||||
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
|
||||
features.push(feature);
|
||||
made.push(json!({"component":component,"body":name,"feature":feature}));
|
||||
made.push(BodyComponent { component, body: name, feature });
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"components":made}))
|
||||
super::reply(FromBodiesResult { components: made, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A placement path parameter (`component_path`: the target's placement to place by,
|
||||
|
||||
@@ -1393,13 +1393,18 @@ mod tests {
|
||||
assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64), "{cmd}");
|
||||
assert!(bytes.starts_with(signature), "{cmd}");
|
||||
}
|
||||
// The triangles `file.export` writes (the 80-byte header holds the file's name): a box is
|
||||
// 12 triangles, 84 + 50 bytes each.
|
||||
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
|
||||
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
|
||||
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
|
||||
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
|
||||
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
|
||||
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
|
||||
assert_eq!(std::fs::metadata(at("box.stl")).unwrap().len(), 84 + 50 * 12);
|
||||
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
|
||||
let stl = std::fs::read(at("box.stl")).unwrap();
|
||||
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
|
||||
assert_eq!(stl.len(), 84 + 50 * triangles);
|
||||
let obj = std::fs::read_to_string(at("Obj.txt")).unwrap();
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12, "{obj}");
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), triangles, "{obj}");
|
||||
let r = s.execute("file.export_urdf", &json!({"path": at("robot.urdf")})).unwrap();
|
||||
assert_eq!(r["format"], "Urdf");
|
||||
assert!(std::fs::read_to_string(at("robot.urdf")).unwrap().contains("<robot"));
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::{Loop2, Seg2, Vec2};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::frame::{self, Anchor, MemberProfile, Profile, ProfileSpec};
|
||||
@@ -17,12 +17,23 @@ use super::features::{add_feature, check_expr, sketch_id};
|
||||
use super::sketch::edit;
|
||||
use super::{CommandSpec, in_sketch, not_in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, str_, string_list, vec2};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::frame::{
|
||||
CreatedIds, FrameMemberParams, ProfileInsertParams, ProfileInsertResult, ProfileKindInfo, ProfileListParams, ProfileListResult, ProfileOut,
|
||||
ProfilePropertiesParams, ProfilePropertiesResult, ProfileSection,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frame_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const MEMBER_CONTRACT: TypedContract<FrameMemberParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ProfileListParams> = TypedContract::new();
|
||||
const PROPERTIES_CONTRACT: TypedContract<ProfilePropertiesParams> = TypedContract::new();
|
||||
const INSERT_CONTRACT: TypedContract<ProfileInsertParams> = TypedContract::new();
|
||||
|
||||
/// The profile parameter, as every frame command documents it.
|
||||
macro_rules! profile_doc {
|
||||
() => {
|
||||
@@ -31,20 +42,40 @@ macro_rules! profile_doc {
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("frame.member", "Frame Member", member).at("WELDMENTS", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
|
||||
CommandSpec::new("frame.member", "Frame Member", crate::checked!("frame.member", member))
|
||||
.at("WELDMENTS", "CREATE")
|
||||
.icon("frame_member")
|
||||
.enabled(not_in_sketch)
|
||||
.contract(&MEMBER_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(concat!(
|
||||
"sketch: the frame sketch (id or name; 2D or 3D); lines?: [curve ids] (one member each; default: every straight line drawn in the sketch: not construction, not projected or otherwise linked, not of no length); ",
|
||||
profile_doc!(),
|
||||
" | profile_sketch: a sketch whose profile is the section, in its own x and y around its origin, with profiles?: picks as solid.extrude takes them (default: its material, nested loops alternating, so two circles make a tube); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point that sits on each line, default origin); angle?: angle expr (the profile turned about the line; default 0: its y as near world up as the line allows, a vertical member's x along world X); mirror?: bool (the profile mirrored across its y axis); name?; body_name? (what the members are named after, then numbered: `Rail`, `Rail (2)`…; default the profile's description, `Rect Tube 40x40x3`, or the profile sketch's name); names?: {line id: name} (names to keep, as Edit Feature keeps them). One body per line, from its start to its end; each member keeps its name when other lines are added or deleted. Returns the feature and the bodies it made"
|
||||
)),
|
||||
CommandSpec::new("frame.profile.list", "Profile Library", list).icon("library")
|
||||
CommandSpec::new("frame.profile.list", "Profile Library", crate::checked!("frame.profile.list", list))
|
||||
.icon("library")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ProfileListResult>)
|
||||
.params("Returns kinds: [{kind, label, required, optional, example}]: the built-in profiles, each SolidCraft's own parametric definition"),
|
||||
CommandSpec::new("frame.profile.properties", "Profile Properties", properties).icon("measure").noundo().doc_only().params(concat!(
|
||||
CommandSpec::new("frame.profile.properties", "Profile Properties", crate::checked!("frame.profile.properties", properties))
|
||||
.icon("measure")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<ProfilePropertiesResult>)
|
||||
.params(concat!(
|
||||
profile_doc!(),
|
||||
"; density?: kg/m³ (adds mass_per_m, kg/m). Returns profile (every default filled in), area (mm²), centroid [x,y] (mm, from the bounding box centre), ixx, iyy, ixy (mm⁴ about the centroid), width, height"
|
||||
)),
|
||||
CommandSpec::new("frame.profile.insert", "Insert Profile", insert).icon("import").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("frame.profile.insert", "Insert Profile", crate::checked!("frame.profile.insert", insert))
|
||||
.icon("import")
|
||||
.enabled(in_sketch)
|
||||
.contract(&INSERT_CONTRACT)
|
||||
.output(output_schema_for::<ProfileInsertResult>)
|
||||
.params(concat!(
|
||||
profile_doc!(),
|
||||
"; at?: [x,y] (sketch mm, default the origin); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point placed on `at`: its origin, the default and a built-in profile's centre, its bounding box's centre, its centroid, or a corner or edge middle of its bounding box); angle?: radians, counter-clockwise; fixed?: bool (default true: the curves are locked, so solving the sketch keeps the shape); sketch?: id|name (default: the sketch being edited). Returns the section as frame.profile.properties does",
|
||||
created_doc!()
|
||||
@@ -73,29 +104,32 @@ fn profile(s: &Session, cmd: &str, p: &Value) -> Result<Profile> {
|
||||
resolve(s, cmd, &profile_spec(cmd, p)?)
|
||||
}
|
||||
|
||||
/// A frame command's reply, built from its schema's type so the two can't drift apart.
|
||||
fn frame_reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"kinds": frame::profile_kinds()}))
|
||||
let kinds = frame::profile_kinds().iter().map(ProfileKindInfo::from).collect();
|
||||
frame_reply("frame.profile.list", ProfileListResult { kinds, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The profile's section as the commands report it.
|
||||
fn section_json(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<Value> {
|
||||
fn section(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<ProfileSection> {
|
||||
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let s = frame::section(®ion);
|
||||
let mut out = json!({
|
||||
"profile": profile.filled(),
|
||||
"area": s.area,
|
||||
"centroid": s.centroid,
|
||||
"ixx": s.ixx,
|
||||
"iyy": s.iyy,
|
||||
"ixy": s.ixy,
|
||||
"width": s.width,
|
||||
"height": s.height,
|
||||
});
|
||||
if let (Some(rho), Some(o)) = (density, out.as_object_mut()) {
|
||||
Ok(ProfileSection {
|
||||
profile: ProfileOut(profile.filled()),
|
||||
area: s.area,
|
||||
centroid: s.centroid,
|
||||
ixx: s.ixx,
|
||||
iyy: s.iyy,
|
||||
ixy: s.ixy,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
// mm² × 1e-6 m²/mm² × kg/m³ = kg/m.
|
||||
o.insert("mass_per_m".into(), json!(s.area * 1e-6 * rho));
|
||||
}
|
||||
Ok(out)
|
||||
mass_per_m: density.map(|rho| s.area * 1e-6 * rho),
|
||||
})
|
||||
}
|
||||
|
||||
fn properties(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -109,7 +143,7 @@ fn properties(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, "`density` must be a positive number of kg/m³ (at most 1e6)"))?,
|
||||
),
|
||||
};
|
||||
section_json(cmd, &profile, density)
|
||||
frame_reply(cmd, ProfilePropertiesResult { section: section(cmd, &profile, density)?, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -135,15 +169,16 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let placed = frame::placed(®ion, frame::anchor_point(std::slice::from_ref(®ion), anchor), at, angle);
|
||||
let section = section_json(cmd, &profile, None)?;
|
||||
let section = section(cmd, &profile, None)?;
|
||||
let (created, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let before = sk.clone();
|
||||
for l in std::iter::once(&placed.outer).chain(&placed.holes) {
|
||||
add_loop(sk, l, fixed, cmd)?;
|
||||
}
|
||||
Ok(super::sketch_created(Some(&before), sk))
|
||||
serde_json::from_value::<CreatedIds>(super::sketch_created(Some(&before), sk)).map_err(|e| bad(cmd, e.to_string()))
|
||||
})?;
|
||||
Ok(json!({"created": created, "section": section, "sketch": info}))
|
||||
let sketch = serde_json::from_value(info).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
frame_reply(cmd, ProfileInsertResult { created, section, sketch, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Draw the closed loop `l` (lines and arcs) into the sketch, each segment sharing its end
|
||||
|
||||
+328
-139
@@ -12,13 +12,25 @@ use solidcraft_sketch::CurveKind;
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::schema::inspect::{
|
||||
BodyListParams, FaceCylinder, ModelEdge, ModelEdges, ModelFace, ModelFaces, ModelThread, ModelThreads, ModelThreadsParams,
|
||||
BodyFailure, BodyFeaturesParams, BodyFigures, BodyListParams, BodyMeasurement, BodyName, BodyNames, BodyOutcome, BoundingBox, CommandEntry,
|
||||
CommandsParams, CurveShape, DocumentBody, DocumentBodyDetail, DocumentInspectParams, DocumentInspectResult, DocumentParam, DocumentSketch,
|
||||
FaceCylinder, KernelCounts, ListedMaterial, MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams,
|
||||
MeasureResult, MeasureTotal, Measured, MeasuredBodies, MeasuredBody, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces,
|
||||
ModelThread, ModelThreads, ModelThreadsParams, SketchConstraint, SketchCurve, SketchInspectParams, SketchInspected, SketchPoint, SketchProfile,
|
||||
SketchWire, TimelineEntry, WireArcInfo,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const BODY_LIST_CONTRACT: TypedContract<BodyListParams> = TypedContract::new();
|
||||
const THREADS_CONTRACT: TypedContract<ModelThreadsParams> = TypedContract::new();
|
||||
const BODY_FEATURES_CONTRACT: TypedContract<BodyFeaturesParams> = TypedContract::new();
|
||||
const MEASURE_CONTRACT: TypedContract<MeasureParams> = TypedContract::new();
|
||||
const DOCUMENT_INSPECT_CONTRACT: TypedContract<DocumentInspectParams> = TypedContract::new();
|
||||
const SKETCH_INSPECT_CONTRACT: TypedContract<SketchInspectParams> = TypedContract::new();
|
||||
const MATERIAL_ASSIGN_CONTRACT: TypedContract<MaterialAssignParams> = TypedContract::new();
|
||||
const MATERIAL_LIST_CONTRACT: TypedContract<MaterialListParams> = TypedContract::new();
|
||||
const COMMANDS_CONTRACT: TypedContract<CommandsParams> = TypedContract::new();
|
||||
|
||||
#[path = "appearance_chain.rs"]
|
||||
mod appearance_chain;
|
||||
@@ -26,23 +38,39 @@ mod appearance_chain;
|
||||
#[path = "appearance_gestures.rs"]
|
||||
pub(super) mod appearance_gestures;
|
||||
|
||||
#[path = "body_features.rs"]
|
||||
mod body_features;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
|
||||
.at("SOLID", "INSPECT").icon("colored_components").noundo()
|
||||
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
|
||||
CommandSpec::new("inspect.measure", "Measure", measure)
|
||||
CommandSpec::new("inspect.measure", "Measure", crate::checked!("inspect.measure", measure))
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("measure")
|
||||
.key("I")
|
||||
.noundo()
|
||||
.contract(&MEASURE_CONTRACT)
|
||||
.output(output_schema_for::<MeasureResult>)
|
||||
.params("bodies?: [names] (default all); names_only?: bool (each world body's name and its definition's, nothing measured) → closed per body; volume_mm3, center_of_mass, density_g_cm3 and mass_g only for closed, consistently oriented bodies; area, bbox and counts for all; total.open_bodies_skipped counts bodies excluded from volume/mass totals; or items: [1-2 selections] (face/edge: type, body, point; index optional) → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", crate::checked!("document.inspect", inspect))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&DOCUMENT_INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<DocumentInspectResult>)
|
||||
.params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
|
||||
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
|
||||
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
|
||||
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
|
||||
never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only",
|
||||
),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).at("SKETCH", "INSPECT").icon("inspect").noundo().params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", crate::checked!("sketch.inspect", sketch_inspect))
|
||||
.at("SKETCH", "INSPECT")
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&SKETCH_INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<SketchInspected>)
|
||||
.params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("model.edges", "List Edges", crate::checked!("model.edges", model_edges))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
@@ -65,9 +93,20 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&THREADS_CONTRACT)
|
||||
.output(output_schema_for::<ModelThreads>),
|
||||
CommandSpec::new("material.assign", "Physical Material", physical_material)
|
||||
CommandSpec::new("model.body_features", "List Body Features", crate::checked!("model.body_features", body_features::model_body_features))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&BODY_FEATURES_CONTRACT)
|
||||
.output(output_schema_for::<ModelBodyFeatures>)
|
||||
.params(
|
||||
"body?: name (a placed copy's name means the body it shows; default every body) → bodies: [{body, features: [{id, name}]}], \
|
||||
in timeline order the feature that created each body, every later one that changed it, and those of bodies consumed into it. Read-only",
|
||||
),
|
||||
CommandSpec::new("material.assign", "Physical Material", crate::checked!("material.assign", physical_material))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.contract(&MATERIAL_ASSIGN_CONTRACT)
|
||||
.output(output_schema_for::<MaterialAssignResult>)
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", crate::checked!("appearance.assign", appearance))
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -104,11 +143,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<AppearanceListResult>)
|
||||
.params("body?: name (or a placed copy's, `KB (K:2)`: the body it shows) → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
|
||||
CommandSpec::new("material.list", "List Materials", crate::checked!("material.list", material_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&MATERIAL_LIST_CONTRACT)
|
||||
.output(output_schema_for::<MaterialListResult>)
|
||||
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
|
||||
CommandSpec::new("engine.commands", "List Commands", crate::checked!("engine.commands", commands))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&COMMANDS_CONTRACT)
|
||||
.output(output_schema_for::<Vec<CommandEntry>>),
|
||||
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", describe))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
@@ -128,7 +174,7 @@ pub(crate) fn encloses_volume(body: &solidcraft_kernel::Body) -> bool {
|
||||
}
|
||||
|
||||
pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
|
||||
measure_sheet_json(b, None)
|
||||
serde_json::to_value(measure_sheet(b, None)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn measure_body(
|
||||
@@ -150,27 +196,53 @@ pub(super) fn measure_body(
|
||||
Ok(measured)
|
||||
}
|
||||
|
||||
fn measure_sheet_json(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> Value {
|
||||
fn measure_sheet(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> BodyMeasurement {
|
||||
let name = b.name.clone();
|
||||
match measure_body(b, definitions) {
|
||||
Ok(m) => {
|
||||
let closed = encloses_volume(&b.body);
|
||||
json!({
|
||||
"name": b.name,
|
||||
"closed": closed,
|
||||
"volume_mm3": closed.then_some(m.volume),
|
||||
"area_mm2": m.area,
|
||||
"center_of_mass": closed.then_some(m.centroid),
|
||||
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
|
||||
"faces": m.merged.faces,
|
||||
"edges": m.merged.edges,
|
||||
"vertices": m.merged.vertices,
|
||||
"face_types": m.merged.face_types,
|
||||
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
|
||||
"measured_by": m.measured_by,
|
||||
"notes": m.notes,
|
||||
})
|
||||
let outcome = BodyOutcome::Measured(BodyFigures {
|
||||
closed,
|
||||
volume_mm3: closed.then_some(m.volume),
|
||||
area_mm2: m.area,
|
||||
center_of_mass: closed.then(|| m.centroid.into()),
|
||||
bbox: BoundingBox { min: m.bbox.min.into(), max: m.bbox.max.into() },
|
||||
faces: count(m.merged.faces),
|
||||
edges: count(m.merged.edges),
|
||||
vertices: count(m.merged.vertices),
|
||||
face_types: m.merged.face_types,
|
||||
kernel: KernelCounts { faces: m.faces, edges: m.edges, vertices: m.vertices, shells: m.shells },
|
||||
measured_by: m.measured_by.to_string(),
|
||||
notes: m.notes,
|
||||
});
|
||||
BodyMeasurement { name, outcome }
|
||||
}
|
||||
Err(e) => measure_failed(name, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_failed(name: String, error: String) -> BodyMeasurement {
|
||||
BodyMeasurement { name, outcome: BodyOutcome::Failed(BodyFailure { error, faces: None, edges: None, vertices: None }) }
|
||||
}
|
||||
|
||||
/// A topology count as the replies give it.
|
||||
fn count(n: usize) -> i64 {
|
||||
i64::try_from(n).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A reply as JSON: the commands build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn typed_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
impl BodyMeasurement {
|
||||
/// The volume, when the body has one that is a number.
|
||||
fn volume(&self) -> Option<f64> {
|
||||
match &self.outcome {
|
||||
BodyOutcome::Measured(f) => f.volume_mm3.filter(|v| v.is_finite()),
|
||||
BodyOutcome::Failed(_) => None,
|
||||
}
|
||||
Err(e) => json!({"name": b.name, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,138 +259,217 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "unknown body name"));
|
||||
}
|
||||
if bool_(p, "names_only").unwrap_or(false) {
|
||||
let bodies: Vec<_> = selected.iter().map(|b| json!({"name": b.name, "definition": b.definition_name()})).collect();
|
||||
return Ok(json!({"body_count": bodies.len(), "bodies": bodies}));
|
||||
let bodies: Vec<_> = selected.iter().map(|b| BodyName { name: b.name.clone(), definition: b.definition_name().to_string() }).collect();
|
||||
let measured = Measured::Names(BodyNames { body_count: bodies.len(), bodies });
|
||||
return typed_reply(cmd, MeasureResult { measured, param_warnings: Vec::new() });
|
||||
}
|
||||
// Each body is measured on its own: on all cores, in body order.
|
||||
let mut bodies: Vec<Value> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet_json(b, Some(&definitions)))
|
||||
let measurements: Vec<BodyMeasurement> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet(b, Some(&definitions)))
|
||||
.into_iter()
|
||||
.zip(&selected)
|
||||
.map(|(m, b)| m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})))
|
||||
.map(|(m, b)| m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into())))
|
||||
.collect();
|
||||
// Mass from each body's material.
|
||||
for (b, body) in bodies.iter_mut().zip(selected) {
|
||||
let mut bodies: Vec<MeasuredBody> = Vec::with_capacity(measurements.len());
|
||||
for (measurement, body) in measurements.into_iter().zip(selected) {
|
||||
let source = body.definition_name();
|
||||
// Every body's material, measured or not: naming it takes no measuring.
|
||||
b["material"] = json!(s.doc.body_material(source));
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let mut b = MeasuredBody { measurement, material: s.doc.body_material(source), density_g_cm3: None, mass_g: None, sheet_metal: None };
|
||||
if let Some(v) = b.measurement.volume() {
|
||||
let rho = s.doc.density(source);
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
b.density_g_cm3 = Some(rho);
|
||||
b.mass_g = Some(v * rho / 1000.0);
|
||||
}
|
||||
// Only Measure exposes sheet boundary counts; document.inspect keeps kernel counts.
|
||||
if let Some(sheet) = definitions.sheets.iter().find(|sheet| sheet.body == source) {
|
||||
let (df, de, dv) = sheet.round_hole_correction();
|
||||
for (key, delta) in [("faces", df), ("edges", de), ("vertices", dv)] {
|
||||
b[key] = json!(b["kernel"][key].as_i64().unwrap_or(0) + delta);
|
||||
match &mut b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => {
|
||||
f.faces = count(f.kernel.faces) + df;
|
||||
f.edges = count(f.kernel.edges) + de;
|
||||
f.vertices = count(f.kernel.vertices) + dv;
|
||||
}
|
||||
BodyOutcome::Failed(x) => (x.faces, x.edges, x.vertices) = (Some(df), Some(de), Some(dv)),
|
||||
}
|
||||
b["sheet_metal"] = json!(true);
|
||||
b.sheet_metal = Some(true);
|
||||
}
|
||||
bodies.push(b);
|
||||
}
|
||||
fn figures(b: &MeasuredBody) -> Option<&BodyFigures> {
|
||||
match &b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => Some(f),
|
||||
BodyOutcome::Failed(_) => None,
|
||||
}
|
||||
}
|
||||
let skipped = bodies.iter().filter(|b| b["closed"] == false).count();
|
||||
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
|
||||
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
|
||||
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
|
||||
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
|
||||
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
|
||||
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
|
||||
Ok(
|
||||
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"open_bodies_skipped": skipped, "volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
|
||||
let counted = |n: Option<i64>| n.and_then(|n| u64::try_from(n).ok());
|
||||
let faces = |b: &MeasuredBody| match &b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => (counted(Some(f.faces)), counted(Some(f.edges)), counted(Some(f.vertices))),
|
||||
BodyOutcome::Failed(x) => (counted(x.faces), counted(x.edges), counted(x.vertices)),
|
||||
};
|
||||
let total = MeasureTotal {
|
||||
open_bodies_skipped: bodies.iter().filter(|b| figures(b).is_some_and(|f| !f.closed)).count(),
|
||||
volume_mm3: bodies.iter().filter_map(|b| b.measurement.volume()).sum(),
|
||||
area_mm2: bodies.iter().filter_map(|b| figures(b)).map(|f| f.area_mm2).filter(|a| a.is_finite()).sum(),
|
||||
mass_g: bodies.iter().filter_map(|b| b.mass_g).filter(|m| m.is_finite()).sum(),
|
||||
faces: bodies.iter().filter_map(|b| faces(b).0).sum(),
|
||||
edges: bodies.iter().filter_map(|b| faces(b).1).sum(),
|
||||
vertices: bodies.iter().filter_map(|b| faces(b).2).sum(),
|
||||
};
|
||||
typed_reply(
|
||||
cmd,
|
||||
MeasureResult { measured: Measured::Bodies(MeasuredBodies { body_count: bodies.len(), bodies, total }), param_warnings: Vec::new() },
|
||||
)
|
||||
}
|
||||
|
||||
/// A 2D position as the replies give it.
|
||||
fn at2(p: solidcore_record::Vec2) -> [f64; 2] {
|
||||
[p.x, p.y]
|
||||
}
|
||||
|
||||
/// Sketch `id`, placed where the placed model `world` has it (its component's placement).
|
||||
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<Value> {
|
||||
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<SketchInspected> {
|
||||
let state = s.model.state();
|
||||
let ss = state.sketch(id)?;
|
||||
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
|
||||
let sk = &ss.sketch;
|
||||
let curves: Vec<Value> = sk
|
||||
let curves: Vec<SketchCurve> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
|
||||
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
|
||||
let mut v = match c.kind {
|
||||
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => {
|
||||
let mut v = json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det});
|
||||
// A control spline with its own knots (a projected free-form edge) says them.
|
||||
if let (Some(n), Some(o)) = (nurbs, v.as_object_mut()) {
|
||||
o.insert("knots".into(), json!(n.knots));
|
||||
if !n.weights.is_empty() {
|
||||
o.insert("weights".into(), json!(n.weights));
|
||||
}
|
||||
}
|
||||
v
|
||||
let at = |k: usize| sk.point(k).map(at2);
|
||||
let shape = match c.kind {
|
||||
CurveKind::Line { a, b } => CurveShape::Line { start: pid(a), end: pid(b), start_at: at(a), end_at: at(b) },
|
||||
CurveKind::Circle { c: cc, r } => CurveShape::Circle { center: pid(cc), center_at: at(cc), radius: r },
|
||||
CurveKind::Arc { c: cc, a, b } => CurveShape::Arc {
|
||||
center: pid(cc),
|
||||
start: pid(a),
|
||||
end: pid(b),
|
||||
center_at: at(cc),
|
||||
start_at: at(a),
|
||||
end_at: at(b),
|
||||
radius: sk.radius(i),
|
||||
},
|
||||
CurveKind::Ellipse { c: cc, m, r } => {
|
||||
CurveShape::Ellipse { center: pid(cc), major: pid(m), center_at: at(cc), major_at: at(m), minor_radius: r }
|
||||
}
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => CurveShape::Spline {
|
||||
points: pts.iter().map(|q| pid(*q)).collect(),
|
||||
points_at: pts.iter().map(|q| at(*q)).collect(),
|
||||
control,
|
||||
degree,
|
||||
periodic,
|
||||
// A control spline with its own knots (a projected free-form edge) says them.
|
||||
knots: nurbs.as_ref().map(|n| n.knots.clone()),
|
||||
weights: nurbs.as_ref().filter(|n| !n.weights.is_empty()).map(|n| n.weights.clone()),
|
||||
},
|
||||
CurveKind::Conic { a, b, apex, rho } => {
|
||||
CurveShape::Conic { start: pid(a), end: pid(b), apex: pid(apex), start_at: at(a), end_at: at(b), apex_at: at(apex), rho }
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
o.insert("link".into(), json!(l));
|
||||
SketchCurve {
|
||||
id: c.id.clone(),
|
||||
shape,
|
||||
construction: c.construction,
|
||||
fixed: sk.curve_locked(i),
|
||||
fully_constrained: det,
|
||||
link: c.link.clone(),
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let locked = sk.locked_points();
|
||||
let points: Vec<Value> = sk
|
||||
let points: Vec<SketchPoint> = sk
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.map(|(i, p)| SketchPoint {
|
||||
id: p.id.clone(),
|
||||
at: at2(p.pos),
|
||||
world: plane.to_world(p.pos).into(),
|
||||
fixed: locked.get(i).copied().unwrap_or(false),
|
||||
link: p.link.clone(),
|
||||
fully_constrained: ss.report.point_determined.get(i).copied().unwrap_or(false),
|
||||
})
|
||||
.collect();
|
||||
let constraints: Vec<Value> = sk
|
||||
let constraints: Vec<SketchConstraint> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c.kind).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("id".into(), json!(c.id));
|
||||
o.insert("name".into(), json!(c.kind.name()));
|
||||
if let Some(p) = &c.param {
|
||||
o.insert("param".into(), json!(p));
|
||||
o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone())));
|
||||
}
|
||||
let mut kind = match serde_json::to_value(&c.kind) {
|
||||
Ok(Value::Object(kind)) => kind,
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The constraint's own keys win over any of the kind's.
|
||||
for key in ["id", "name", "param", "expression"] {
|
||||
kind.remove(key);
|
||||
}
|
||||
SketchConstraint {
|
||||
id: c.id.clone(),
|
||||
name: c.kind.name().to_string(),
|
||||
param: c.param.clone(),
|
||||
expression: c.param.as_ref().map(|p| s.doc.param(p).map(|x| x.expr.clone())),
|
||||
kind,
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let profiles: Vec<Value> = ss
|
||||
let profiles: Vec<SketchProfile> = ss
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
// A loop records one entry per piece it walks, and a conic or spline is walked as many
|
||||
// short pieces: each loop names a curve once, in loop order.
|
||||
.map(|(i, p)| {
|
||||
let holes: Vec<Vec<String>> = p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect();
|
||||
json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": solidcraft_sketch::distinct_curves(&p.outer_curves), "holes": holes})
|
||||
.map(|(i, p)| SketchProfile {
|
||||
index: i,
|
||||
area_mm2: p.area,
|
||||
centroid_sketch: at2(p.centroid),
|
||||
curves: solidcraft_sketch::distinct_curves(&p.outer_curves),
|
||||
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
|
||||
})
|
||||
.collect();
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"name": ss.name,
|
||||
"plane": ss.plane,
|
||||
"status": ss.report.status,
|
||||
"dof": ss.report.dof,
|
||||
"fully_constrained": ss.report.fully_constrained(),
|
||||
"failing": ss.report.failing,
|
||||
"curves": curves,
|
||||
"points": points,
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link, "arc": w.arc.map(|a| json!({"center": a.center, "radius": a.radius, "start": a.start, "end": a.end, "sweep_deg": solidcraft_sketch::arc_sweep(&a).to_degrees()}))})).collect::<Vec<_>>(),
|
||||
"visible": crate::view::sketch_shown(s, id),
|
||||
}))
|
||||
let wires = sk
|
||||
.wires
|
||||
.iter()
|
||||
.map(|w| SketchWire {
|
||||
id: w.id.clone(),
|
||||
points: w.pts.len(),
|
||||
start: w.pts.first().map(|&p| p.into()),
|
||||
end: w.pts.last().map(|&p| p.into()),
|
||||
link: w.link.clone(),
|
||||
arc: w.arc.map(|a| WireArcInfo {
|
||||
center: a.center.into(),
|
||||
radius: a.radius,
|
||||
start: a.start.into(),
|
||||
end: a.end.into(),
|
||||
sweep_deg: solidcraft_sketch::arc_sweep(&a).to_degrees(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
Some(SketchInspected {
|
||||
id,
|
||||
name: ss.name.clone(),
|
||||
plane: serde_json::to_value(ss.plane).unwrap_or_default(),
|
||||
status: ss.report.status.into(),
|
||||
dof: ss.report.dof,
|
||||
fully_constrained: ss.report.fully_constrained(),
|
||||
failing: ss.report.failing.clone(),
|
||||
curves,
|
||||
points,
|
||||
constraints,
|
||||
profiles,
|
||||
links: serde_json::to_value(&sk.links).unwrap_or_default(),
|
||||
wires,
|
||||
visible: crate::view::sketch_shown(s, id),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::sketch_store::target(s, p, "sketch.inspect")?;
|
||||
sketch_json(s, &*s.world_state()?, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
|
||||
let inspected = sketch_json(s, &*s.world_state()?, id)
|
||||
.ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))?;
|
||||
typed_reply("sketch.inspect", inspected)
|
||||
}
|
||||
|
||||
/// Revisions of every design of this run (#6646).
|
||||
@@ -377,24 +528,38 @@ fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let (vals, perr) = s.doc.param_values();
|
||||
let params: Vec<Value> = s
|
||||
let params: Vec<DocumentParam> = s
|
||||
.doc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)}))
|
||||
.map(|p| DocumentParam {
|
||||
name: p.name.clone(),
|
||||
expression: p.expr.clone(),
|
||||
unit: p.unit.clone(),
|
||||
value: vals.get(&p.name).map(|v| v.v),
|
||||
model: p.model,
|
||||
comment: p.comment.clone(),
|
||||
error: perr.get(&p.name).cloned(),
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
|
||||
let timeline: Vec<Value> = s
|
||||
let timeline: Vec<TimelineEntry> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let r = s.model.result(f.id);
|
||||
json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
||||
})
|
||||
TimelineEntry {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: f.kind.type_name().to_string(),
|
||||
suppressed: f.suppressed,
|
||||
rolled_back: i >= marker,
|
||||
error: r.and_then(|r| r.error.clone()),
|
||||
warning: r.and_then(|r| r.warning.clone()),
|
||||
ms: r.map(|r| r.ms),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let definitions = s.model.state();
|
||||
@@ -402,46 +567,69 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let with_counts = bool_(p, "sketch_counts").unwrap_or(false);
|
||||
// Each body is measured on its own: on all cores, in body order (as `inspect.measure`).
|
||||
let mut measured =
|
||||
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet_json(b, Some(&definitions))) } else { Vec::new() }
|
||||
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet(b, Some(&definitions))) } else { Vec::new() }
|
||||
.into_iter();
|
||||
let bodies: Vec<Value> = st
|
||||
let bodies: Vec<DocumentBody> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut v = match measured.next() {
|
||||
Some(m) => m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})),
|
||||
None => json!({"name": b.name, "feature": b.feature}),
|
||||
};
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(s.body_visible(&b.name)));
|
||||
if s.body_removed_later(&b.name) {
|
||||
// Editing a sketch rolls the timeline back; this body is deleted further on.
|
||||
o.insert("removed_later".into(), json!(true));
|
||||
let detail = match measured.next() {
|
||||
Some(m) => {
|
||||
DocumentBodyDetail::Measured(Box::new(m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into()))))
|
||||
}
|
||||
}
|
||||
v
|
||||
None => DocumentBodyDetail::Listed { name: b.name.clone(), feature: b.feature },
|
||||
};
|
||||
// Editing a sketch rolls the timeline back; a body deleted further on is `removed_later`.
|
||||
DocumentBody { detail, visible: s.body_visible(&b.name), removed_later: s.body_removed_later(&b.name).then_some(true) }
|
||||
})
|
||||
.collect();
|
||||
let sketches: Vec<Value> = st
|
||||
let sketches: Vec<DocumentSketch> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| {
|
||||
let mut v = json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)});
|
||||
let mut v = DocumentSketch {
|
||||
id: ss.feature,
|
||||
name: ss.name.clone(),
|
||||
dof: ss.report.dof,
|
||||
status: ss.report.status.into(),
|
||||
curves: ss.sketch.curves.len(),
|
||||
profiles: ss.profiles.len(),
|
||||
visible: crate::view::sketch_shown(s, ss.feature),
|
||||
plane: None,
|
||||
points: None,
|
||||
constraints: None,
|
||||
};
|
||||
// A summary that stands in for the sketch's contents (the compact MCP inspect_design).
|
||||
if with_counts && let Some(o) = v.as_object_mut() {
|
||||
o.insert("plane".into(), json!(ss.plane));
|
||||
o.insert("points".into(), json!(ss.sketch.points.len()));
|
||||
o.insert("constraints".into(), json!(ss.sketch.constraints.len()));
|
||||
if with_counts {
|
||||
v.plane = Some(serde_json::to_value(ss.plane).unwrap_or_default());
|
||||
v.points = Some(ss.sketch.points.len());
|
||||
v.constraints = Some(ss.sketch.constraints.len());
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
|
||||
}))
|
||||
typed_reply(
|
||||
"document.inspect",
|
||||
DocumentInspectResult {
|
||||
name: s.doc.name.clone(),
|
||||
units: s.doc.units.clone(),
|
||||
metadata: s.doc.metadata.0.clone(),
|
||||
path: s.path.clone(),
|
||||
dirty: s.is_dirty(),
|
||||
revision: s.revision,
|
||||
active_sketch: s.active_sketch,
|
||||
marker,
|
||||
params,
|
||||
timeline,
|
||||
bodies,
|
||||
sketches,
|
||||
selection: serde_json::to_value(&s.selection).unwrap_or_default(),
|
||||
undo: s.undo.len(),
|
||||
redo: s.redo.len(),
|
||||
hidden_origin: s.visibility.hidden_origin.clone(),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn body_of<'a>(st: &'a solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solidcraft_doc::ModelBody> {
|
||||
@@ -530,7 +718,8 @@ fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
|
||||
let list: Vec<CommandEntry> = crate::command_specs().iter().map(|c| c.info(s).into()).collect();
|
||||
typed_reply("engine.commands", list)
|
||||
}
|
||||
|
||||
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -554,7 +743,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.doc_mut().materials.insert(b.clone(), canonical.clone());
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "material": canonical}))
|
||||
typed_reply(cmd, MaterialAssignResult { bodies, material: canonical, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
@@ -901,18 +1090,18 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| ListedMaterial::BuiltIn { name: n.to_string(), density_g_cm3: *d });
|
||||
let user = s
|
||||
.doc
|
||||
.user_materials
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut out = super::materials::material_json(m)?;
|
||||
out["appearance"] = json!(s.doc.material_look(&m.name).as_ref().map(look_json));
|
||||
Ok(out)
|
||||
let mut out = super::materials::listed_material(m)?;
|
||||
out.appearance = s.doc.material_look(&m.name).as_ref().map(LookReply::from);
|
||||
Ok(ListedMaterial::Design(Box::new(out)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
typed_reply("material.list", MaterialListResult { materials: built_in.chain(user).collect(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn colored_components(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
+218
-101
@@ -15,11 +15,24 @@ use solidcraft_geom::Aabb3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, vec3};
|
||||
use crate::schema::joints::EditJointResult;
|
||||
use crate::schema::joints::{
|
||||
AsBuiltJointParams, CamLift, ContactStop, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams, DriveJointResult,
|
||||
EditJointResult, FloatingChain, HeldValue, InspectJointParams, InspectJointResult, JointAddedResult, JointDeleted, JointFrame, JointLimitsParams,
|
||||
JointProblem, LinkInfo, ListJointsParams, ListJointsResult, ListedJoint, ListedOrigin, LoopStop, NamedValue, OccurrenceDof, SolveJointsParams,
|
||||
SolveReport, SolveResult, ValueUnit,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const EDIT_CONTRACT: TypedContract<crate::schema::joints::EditJointParams> = TypedContract::new();
|
||||
const CREATE_CONTRACT: TypedContract<CreateJointParams> = TypedContract::new();
|
||||
const AS_BUILT_CONTRACT: TypedContract<AsBuiltJointParams> = TypedContract::new();
|
||||
const DRIVE_CONTRACT: TypedContract<DriveJointParams> = TypedContract::new();
|
||||
const LIMITS_CONTRACT: TypedContract<JointLimitsParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<DeleteJointParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ListJointsParams> = TypedContract::new();
|
||||
const INSPECT_CONTRACT: TypedContract<InspectJointParams> = TypedContract::new();
|
||||
const SOLVE_CONTRACT: TypedContract<SolveJointsParams> = TypedContract::new();
|
||||
|
||||
#[path = "joints_inspect.rs"]
|
||||
mod inspect;
|
||||
@@ -28,7 +41,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size; z?: [x,y,z] the frame's z instead of the face's normal, along the edge when it runs that way, x chosen from z: an edge or corner picked as it is, kept as a point where it can't be found by name) | point: [x,y,z], z?: [x,y,z]; a circle's z?: [x,y,z] (a round edge's centre with the way out of it: its end by name when that is the frame it shows, else a point there); body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
),
|
||||
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
|
||||
CommandSpec::new("joint.create", "Joint", crate::checked!("joint.create", joint))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("joint")
|
||||
.key("J")
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<JointAddedResult>)
|
||||
.params(
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball, or a relation: parallel|perpendicular|angle|point_on_plane|point_on_line|line_on_plane \
|
||||
(ties only some directions or points, made where the parts are; several between two parts add up); a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; z? for a point, end?, x? as joint.origin} | origin name); \
|
||||
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
|
||||
@@ -40,12 +59,22 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
kept with the joint and worked out again whenever the design changes, so the joint follows the parameters it names; a joint whose \
|
||||
expression no longer works out (a parameter deleted) is left out of the solve with the reason in its errors",
|
||||
),
|
||||
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
|
||||
CommandSpec::new("joint.as_built", "As-Built Joint", crate::checked!("joint.as_built", as_built))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("as_built")
|
||||
.contract(&AS_BUILT_CONTRACT)
|
||||
.output(output_schema_for::<JointAddedResult>)
|
||||
.params(
|
||||
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; one on a or b is kept on its face or hole there, so the joint follows it as the design changes; \
|
||||
name?; limits?: [[min, max] | null…]; quiet?: bool",
|
||||
),
|
||||
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
|
||||
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
|
||||
CommandSpec::new("joint.drive", "Drive Joints", crate::checked!("joint.drive", drive))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("drive")
|
||||
.contract(&DRIVE_CONTRACT)
|
||||
.output(output_schema_for::<DriveJointResult>)
|
||||
.params(
|
||||
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; a value driven drops the expression joint.create gave it; clamped to the limits; the other joints of a \
|
||||
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
|
||||
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; requested: the values asked for, in the same form; \
|
||||
@@ -83,7 +112,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
|
||||
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is, also when the link replaces another (at the current pose)",
|
||||
),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", crate::checked!("joint.limits", limits))
|
||||
.icon("joint")
|
||||
.contract(&LIMITS_CONTRACT)
|
||||
.output(output_schema_for::<SolveResult>)
|
||||
.params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", crate::checked!("joint.edit", edit))
|
||||
.icon("edit")
|
||||
.contract(&EDIT_CONTRACT)
|
||||
@@ -94,12 +127,26 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
its limits and expressions (a relation's values start again where its parts are); an edit that places it again (not a rename alone) \
|
||||
meets a loop it closes as joint.create does, turning an angle a half turn where only that meets it",
|
||||
),
|
||||
CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"),
|
||||
CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params(
|
||||
CommandSpec::new("joint.delete", "Delete Joint", crate::checked!("joint.delete", delete))
|
||||
.icon("delete")
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(output_schema_for::<DeleteJointResult>)
|
||||
.params("joint: id|name (or origin: name, link: index)"),
|
||||
CommandSpec::new("joint.list", "List Joints", crate::checked!("joint.list", list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ListJointsResult>)
|
||||
.params(
|
||||
"→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
|
||||
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
|
||||
),
|
||||
CommandSpec::new("joint.inspect", "Inspect Joint", inspect::inspect).icon("inspect").noundo().params(
|
||||
CommandSpec::new("joint.inspect", "Inspect Joint", crate::checked!("joint.inspect", inspect::inspect))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<InspectJointResult>)
|
||||
.params(
|
||||
"joint?: id|name (default: every joint) → joints: [{id, name, type, suppressed, visible, flip, offset (mm), angle (deg), values: as joint.list \
|
||||
gives them, dofs, expressions?: {offset?, angle?, values?}, a, b: {occurrence, occurrence_name, component, component_name, path \
|
||||
(occurrence names from the top, `/` between), snap (as saved: type, pick, face, end, edge, corner, x, point, z, in the component's \
|
||||
@@ -108,7 +155,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
unverified?: a name saved before exact names}], resolves, frame?: {origin, z, x} in the world, error?: why the origin is lost}, \
|
||||
links: [{index, role: driver|driven, …as joint.list}], errors, conflicts}] (reads only: nothing changes)",
|
||||
),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", crate::checked!("joint.solve", solve_cmd))
|
||||
.icon("compute")
|
||||
.contract(&SOLVE_CONTRACT)
|
||||
.output(output_schema_for::<SolveResult>)
|
||||
.params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", crate::checked!("inspect.interference", super::interference::interference))
|
||||
.contract(&crate::schema::TypedContract::<crate::schema::interference::InterferenceParams>::new())
|
||||
.output(crate::schema::output_schema_for::<crate::schema::interference::InterferenceResult>)
|
||||
@@ -228,30 +279,57 @@ fn response_analysis(p: &Value) -> joints::Analysis {
|
||||
}
|
||||
|
||||
/// A solve's answer; `quiet: true` in the command's params leaves out the per-occurrence lists.
|
||||
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
|
||||
let mut out = json!({
|
||||
"conflicts": sol.conflicts.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
"errors": sol.errors.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
});
|
||||
if !bool_(p, "quiet").unwrap_or(false) {
|
||||
out["dof"] = sol.dof.iter().map(|(o, d)| json!({"occurrence": o, "dof": d})).collect();
|
||||
out["fixed"] = json!(sol.fixed);
|
||||
fn solve_report(sol: &joints::Solution, p: &Value) -> SolveReport {
|
||||
let problems = |list: &[(u64, String, String)]| -> Vec<JointProblem> {
|
||||
list.iter().map(|(joint, name, why)| JointProblem { joint: *joint, name: name.clone(), why: why.clone() }).collect()
|
||||
};
|
||||
let quiet = bool_(p, "quiet").unwrap_or(false);
|
||||
SolveReport {
|
||||
conflicts: problems(&sol.conflicts),
|
||||
errors: problems(&sol.errors),
|
||||
dof: (!quiet).then(|| sol.dof.iter().map(|(o, d)| OccurrenceDof { occurrence: *o, dof: *d }).collect()),
|
||||
fixed: (!quiet).then(|| sol.fixed.clone()),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`solve_report`] as JSON, for the commands that add to it.
|
||||
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
|
||||
serde_json::to_value(solve_report(sol, p)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A command's reply as JSON: the joint commands that have output schemas build their replies from
|
||||
/// the types those describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with
|
||||
/// its name and limits. The numbers are rounded for reading (#10747); the joint keeps full
|
||||
/// precision.
|
||||
pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
named_value_list(j, q).into_iter().map(|v| serde_json::to_value(v).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// [`named_values`], typed.
|
||||
pub(crate) fn named_value_list(j: &Joint, q: &[f64]) -> Vec<NamedValue> {
|
||||
j.kind
|
||||
.dofs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (v, angle) = (q.get(i).copied().unwrap_or(0.0), j.kind.is_angle(i));
|
||||
json!({"name": n, "value": expr::round_reported(if angle { v.to_degrees() } else { v }), "unit": if angle { "deg" } else { "mm" },
|
||||
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())] } else { [expr::round_reported(a), expr::round_reported(b)] })})
|
||||
NamedValue {
|
||||
name: (*n).to_string(),
|
||||
value: expr::round_reported(if angle { v.to_degrees() } else { v }),
|
||||
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
|
||||
limits: j.limits.get(i).copied().flatten().map(|(a, b)| {
|
||||
if angle {
|
||||
[expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())]
|
||||
} else {
|
||||
[expr::round_reported(a), expr::round_reported(b)]
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -259,9 +337,14 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
/// A joint's values as the solver places it (after motion links, so a screw's travel follows its
|
||||
/// turn), named as `joint.list` gives them.
|
||||
pub(crate) fn effective_values(s: &Session, id: u64) -> Option<Vec<Value>> {
|
||||
effective_value_list(s, id).map(|list| list.into_iter().map(|v| serde_json::to_value(v).unwrap_or_default()).collect())
|
||||
}
|
||||
|
||||
/// [`effective_values`], typed.
|
||||
fn effective_value_list(s: &Session, id: u64) -> Option<Vec<NamedValue>> {
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
Some(named_values(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
|
||||
Some(named_value_list(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
|
||||
}
|
||||
|
||||
fn occurrence_name(s: &Session, id: u64) -> String {
|
||||
@@ -293,7 +376,7 @@ pub(crate) fn cannot_move(s: &Session, sol: &joints::Solution, id: u64) -> Optio
|
||||
|
||||
/// Joint chains joined to nothing grounded, which the solver places none of: each chain's
|
||||
/// occurrences and joints, by name.
|
||||
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
|
||||
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<FloatingChain> {
|
||||
let placed = |o: u64| o == 0 || sol.world.contains_key(&o);
|
||||
let errors: BTreeSet<u64> = sol.errors.iter().map(|e| e.0).collect();
|
||||
let mut next: BTreeMap<u64, Vec<(u64, &Joint)>> = BTreeMap::new();
|
||||
@@ -319,8 +402,10 @@ fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(json!({"occurrences": occs.iter().map(|o| occurrence_name(s, *o)).collect::<Vec<_>>(),
|
||||
"joints": js.iter().map(|(_, n)| *n).collect::<Vec<_>>()}));
|
||||
out.push(FloatingChain {
|
||||
occurrences: occs.iter().map(|o| occurrence_name(s, *o)).collect(),
|
||||
joints: js.iter().map(|(_, n)| (*n).to_string()).collect(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -754,21 +839,27 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
let mut q = j.values.clone();
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
joints::clamp(&j, &mut q);
|
||||
let round = |v: &Value| v.as_f64().map(|x| (x * 1e6).round() / 1e6).unwrap_or(f64::NAN);
|
||||
let warnings: Vec<String> = named_values(&j, &q)
|
||||
let round = |v: f64| (v * 1e6).round() / 1e6;
|
||||
// As the reply shows them: a value that is not a number reads as null, and null is null.
|
||||
let same = |a: f64, b: f64| a == b || (a.is_nan() && b.is_nan());
|
||||
let warnings: Vec<String> = named_value_list(&j, &q)
|
||||
.iter()
|
||||
.zip(named_values(&j, &j.values))
|
||||
.filter(|(now, was)| now["value"] != was["value"])
|
||||
.zip(named_value_list(&j, &j.values))
|
||||
.filter(|(now, was)| !same(now.value, was.value))
|
||||
.map(|(now, was)| {
|
||||
let unit = now["unit"].as_str().unwrap_or("");
|
||||
let unit = match now.unit {
|
||||
ValueUnit::Deg => "deg",
|
||||
ValueUnit::Mm => "mm",
|
||||
};
|
||||
let [lo, hi] = now.limits.unwrap_or([f64::NAN; 2]);
|
||||
format!(
|
||||
"{} {} starts at {} {unit}, inside its limits [{}, {}], not at {} {unit}: the part moved there as it was joined",
|
||||
j.name,
|
||||
now["name"].as_str().unwrap_or(""),
|
||||
round(&now["value"]),
|
||||
round(&now["limits"][0]),
|
||||
round(&now["limits"][1]),
|
||||
round(&was["value"])
|
||||
now.name,
|
||||
round(now.value),
|
||||
round(lo),
|
||||
round(hi),
|
||||
round(was.value)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -782,13 +873,7 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
if sol.conflicts.iter().any(|(c, ..)| *c == id) && close_by_half_turn(s, id, &sol) {
|
||||
sol = resolve(s);
|
||||
}
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joint"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
if !warnings.is_empty() {
|
||||
out["warnings"] = json!(warnings);
|
||||
}
|
||||
Ok(out)
|
||||
reply("joint.create", JointAddedResult { solved: solve_report(&sol, p), joint: id, name, warnings, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Sets of angle values a new loop-closing joint tries a half turn round, at most.
|
||||
@@ -1087,41 +1172,48 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? }
|
||||
};
|
||||
let sol = solution(s, &BTreeMap::new(), response_analysis(p));
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joint"] = json!(id);
|
||||
out["values"] = json!(effective_values(s, id));
|
||||
if bool_(p, "continuous") == Some(true) {
|
||||
out["continuous"] = json!(true);
|
||||
}
|
||||
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
|
||||
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
|
||||
let moved = super::motion::moved(&before, &super::motion::positions(s)).next().is_some();
|
||||
out["moved"] = json!(moved);
|
||||
// How motion stopped, as `motion::drive_with_contact` says: an object, or null.
|
||||
let stop = |key: &str| r.get(key).filter(|v| v.is_object()).cloned();
|
||||
let stopped_by_contact =
|
||||
stop("stopped_by_contact").map(serde_json::from_value::<ContactStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let stopped_by_loop = stop("stopped_by_loop").map(serde_json::from_value::<LoopStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut out = DriveJointResult {
|
||||
solved: solve_report(&sol, p),
|
||||
joint: id,
|
||||
values: effective_value_list(s, id),
|
||||
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
|
||||
stopped_by_contact,
|
||||
stopped_by_loop,
|
||||
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
|
||||
requested: None,
|
||||
held: None,
|
||||
reason: None,
|
||||
unconnected: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) {
|
||||
out["requested"] = json!(named_values(j, &requested));
|
||||
out.requested = Some(named_value_list(j, &requested));
|
||||
let held = if hold { Vec::new() } else { held_values(s, j, &requested, &driven, &r) };
|
||||
if let Some(first) = held.first()
|
||||
&& !moved
|
||||
&& !out.moved
|
||||
{
|
||||
out["reason"] = first["why"].clone();
|
||||
out.reason = Some(first.why.clone());
|
||||
}
|
||||
if !held.is_empty() {
|
||||
out["held"] = json!(held);
|
||||
out.held = Some(held);
|
||||
}
|
||||
}
|
||||
if let Some((why, unconnected)) = cannot_move(s, &sol, id) {
|
||||
out["reason"] = json!(why);
|
||||
if let Some(o) = unconnected {
|
||||
out["unconnected"] = json!(o);
|
||||
}
|
||||
out.reason = Some(why);
|
||||
out.unconnected = unconnected;
|
||||
}
|
||||
Ok(out)
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The driven values of `j` that ended up other than `requested`, each with why: a motion link
|
||||
/// sets it from another coordinate (a screw's travel follows its turn), a limit holds it, or
|
||||
/// contact or a closed loop stopped the motion short. Degrees and mm, as `values` (#9742).
|
||||
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<Value> {
|
||||
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<HeldValue> {
|
||||
let now = joints::joint_values(&s.doc.assembly).remove(&j.id).unwrap_or_else(|| j.values.clone());
|
||||
let names = j.kind.dofs();
|
||||
let mut out = Vec::new();
|
||||
@@ -1159,7 +1251,13 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
out.push(json!({"name": name, "requested": shown(want), "value": shown(got), "unit": if angle { "deg" } else { "mm" }, "why": why}));
|
||||
out.push(HeldValue {
|
||||
name: name.to_string(),
|
||||
requested: shown(want),
|
||||
value: shown(got),
|
||||
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
|
||||
why,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1422,14 +1520,19 @@ fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their
|
||||
/// difference, in degrees (`angle`) or mm.
|
||||
fn lift_json(l: &MotionLink, angle: bool) -> Value {
|
||||
serde_json::to_value(lift_info(l, angle)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`lift_json`], typed: `None` for a table that is not whole.
|
||||
fn lift_info(l: &MotionLink, angle: bool) -> Option<CamLift> {
|
||||
// A table that is not whole (a hand-edited file) moves nothing, and says so.
|
||||
if l.lift.len() != cam::CAM_SAMPLES {
|
||||
return Value::Null;
|
||||
return None;
|
||||
}
|
||||
let unit = |v: f64| if angle { v.to_degrees() } else { v };
|
||||
let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset));
|
||||
json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }})
|
||||
Some(CamLift { min: lo, max: hi, rise: hi - lo, unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm } })
|
||||
}
|
||||
|
||||
/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's
|
||||
@@ -1585,7 +1688,7 @@ fn limits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
joints::clamp(j, &mut q);
|
||||
j.values = q;
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol, p))
|
||||
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1711,7 +1814,10 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.assembly.origins.len() == before {
|
||||
return Err(bad(cmd, format!("no joint origin `{name}`")));
|
||||
}
|
||||
return Ok(json!({"deleted_origin": name, "joints": used}));
|
||||
return reply(
|
||||
cmd,
|
||||
DeleteJointResult { deleted: JointDeleted::Origin { deleted_origin: name.to_string(), joints: used }, param_warnings: Vec::new() },
|
||||
);
|
||||
}
|
||||
if let Some(i) = num(p, "link") {
|
||||
let i = i.max(0.0) as usize;
|
||||
@@ -1720,72 +1826,83 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "no such motion link"));
|
||||
}
|
||||
doc.assembly.links.remove(i);
|
||||
return Ok(json!({"deleted_link": i}));
|
||||
return reply(cmd, DeleteJointResult { deleted: JointDeleted::Link { deleted_link: i }, param_warnings: Vec::new() });
|
||||
}
|
||||
let id = joint_id(s, p.get("joint"), cmd)?;
|
||||
delete_joints(s.doc_mut(), &[id]);
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["deleted"] = json!(id);
|
||||
Ok(out)
|
||||
reply(cmd, DeleteJointResult { deleted: JointDeleted::Joint { solved: solve_report(&sol, p), deleted: id }, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let sol = joints::solve(&s.doc, &st);
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
let js: Vec<Value> = s
|
||||
let js: Vec<ListedJoint> = s
|
||||
.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.map(|j| {
|
||||
let named = named_values(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default());
|
||||
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
|
||||
let mut out = json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
|
||||
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)});
|
||||
if !j.exprs.is_empty() {
|
||||
out["expressions"] = json!(j.exprs);
|
||||
let world = |o: &JointOrigin| {
|
||||
s.doc.origin_world(&st, o).ok().map(|m| JointFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]] })
|
||||
};
|
||||
ListedJoint {
|
||||
id: j.id,
|
||||
name: j.name.clone(),
|
||||
kind: j.kind,
|
||||
a: j.a.occurrence,
|
||||
b: j.b.occurrence,
|
||||
values: named_value_list(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default()),
|
||||
flip: j.flip,
|
||||
offset: j.offset,
|
||||
suppressed: j.suppressed,
|
||||
visible: s.joint_visible(j.id),
|
||||
frame_a: world(&j.a),
|
||||
frame_b: world(&j.b),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<Value> = s.doc.assembly.links.iter().map(|l| link_json(s, &st, l)).collect();
|
||||
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joints"] = json!(js);
|
||||
out["links"] = json!(links);
|
||||
out["origins"] = json!(origins);
|
||||
out["floating"] = json!(floating_chains(s, &sol));
|
||||
Ok(out)
|
||||
let out = ListJointsResult {
|
||||
solved: solve_report(&sol, p),
|
||||
joints: js,
|
||||
links: s.doc.assembly.links.iter().map(|l| link_info(s, &st, l)).collect(),
|
||||
origins: s.doc.assembly.origins.iter().map(|o| ListedOrigin { name: o.name.clone(), occurrence: o.origin.occurrence }).collect(),
|
||||
floating: floating_chains(s, &sol),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply("joint.list", out)
|
||||
}
|
||||
|
||||
/// A motion link as `joint.list` and `joint.inspect` give it: ratio per degree, offset in b's
|
||||
/// units, a coupling's definition (and why it can't be worked out), a cam's lift in place of a ratio.
|
||||
fn link_json(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> Value {
|
||||
fn link_info(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> LinkInfo {
|
||||
let angle = is_angle(s, l.b, l.ib);
|
||||
let offset = if angle { l.offset.to_degrees() } else { l.offset };
|
||||
let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
|
||||
let mut v = json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }});
|
||||
if let Some(c) = &l.coupling {
|
||||
v["coupling"] = json!(c);
|
||||
if let Some(Err(e)) = joints::coupling_values(&s.doc, st, l) {
|
||||
v["error"] = json!(e.to_string());
|
||||
}
|
||||
}
|
||||
// A cam's follower follows its table, not a ratio: its travel instead.
|
||||
if matches!(l.coupling, Some(Coupling::Cam(_)))
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.remove("ratio");
|
||||
o.insert("lift".into(), lift_json(l, angle));
|
||||
let cam = matches!(l.coupling, Some(Coupling::Cam(_)));
|
||||
LinkInfo {
|
||||
a: l.a,
|
||||
ia: l.ia,
|
||||
b: l.b,
|
||||
ib: l.ib,
|
||||
ratio: (!cam).then_some(ratio),
|
||||
offset,
|
||||
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
|
||||
coupling: l.coupling.clone(),
|
||||
error: match &l.coupling {
|
||||
Some(_) => joints::coupling_values(&s.doc, st, l).and_then(|r| r.err()).map(|e| e.to_string()),
|
||||
None => None,
|
||||
},
|
||||
lift: cam.then(|| lift_info(l, angle)),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol, p))
|
||||
reply("joint.solve", SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
//! persistent name refers to), whether each origin is found today and why not, its kind, offset,
|
||||
//! angle, flip, limits, values, expressions and the motion links it takes part in. Reads only.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::joints::{self, Joint, JointOrigin, Snap, XRef};
|
||||
use solidcraft_doc::naming::legacy_name;
|
||||
use solidcraft_doc::persistent_name::{Base, EdgeName, FaceName, Role};
|
||||
use solidcraft_doc::{Document, MAX_PLACEMENT_DEPTH, ModelState};
|
||||
|
||||
use super::{component_of, joint_id, link_json, named_values, occurrence_name};
|
||||
use super::{component_of, joint_id, link_info, named_value_list, occurrence_name, reply};
|
||||
use crate::schema::joints::{
|
||||
EdgeReference, FaceReference, InspectJointResult, InspectedJoint, JointLink, JointSide, LinkRole, MadeBy, Reference, ReferenceRole, Referent,
|
||||
SweptFrom, WorldFrame,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -21,7 +25,7 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let sol = joints::solve(&s.doc, &st);
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
let out: Vec<Value> = ids
|
||||
let out: Vec<InspectedJoint> = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.doc.assembly.joints.iter().find(|j| j.id == *id))
|
||||
.map(|j| {
|
||||
@@ -29,50 +33,42 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
joint_json(s, &st, &sol, j, q)
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "joints": out }))
|
||||
reply(cmd, InspectJointResult { joints: out, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> Value {
|
||||
let links: Vec<Value> = s
|
||||
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> InspectedJoint {
|
||||
let links: Vec<JointLink> = s
|
||||
.doc
|
||||
.assembly
|
||||
.links
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.a == j.id || l.b == j.id)
|
||||
.map(|(i, l)| {
|
||||
let mut v = link_json(s, st, l);
|
||||
v["index"] = json!(i);
|
||||
v["role"] = json!(if l.b == j.id { "driven" } else { "driver" });
|
||||
v
|
||||
})
|
||||
.map(|(i, l)| JointLink { index: i, role: if l.b == j.id { LinkRole::Driven } else { LinkRole::Driver }, link: link_info(s, st, l) })
|
||||
.collect();
|
||||
let why = |list: &[(u64, String, String)]| list.iter().filter(|(id, ..)| *id == j.id).map(|(.., why)| why.clone()).collect::<Vec<_>>();
|
||||
let mut v = json!({
|
||||
"id": j.id,
|
||||
"name": j.name,
|
||||
"type": j.kind,
|
||||
"suppressed": j.suppressed,
|
||||
"visible": s.joint_visible(j.id),
|
||||
"flip": j.flip,
|
||||
"offset": j.offset,
|
||||
"angle": j.angle.to_degrees(),
|
||||
"values": named_values(j, q),
|
||||
"dofs": j.kind.dofs(),
|
||||
"a": side_json(s, st, &j.a),
|
||||
"b": side_json(s, st, &j.b),
|
||||
"links": links,
|
||||
"errors": why(&sol.errors),
|
||||
"conflicts": why(&sol.conflicts),
|
||||
});
|
||||
if !j.exprs.is_empty() {
|
||||
v["expressions"] = json!(j.exprs);
|
||||
InspectedJoint {
|
||||
id: j.id,
|
||||
name: j.name.clone(),
|
||||
kind: j.kind,
|
||||
suppressed: j.suppressed,
|
||||
visible: s.joint_visible(j.id),
|
||||
flip: j.flip,
|
||||
offset: j.offset,
|
||||
angle: j.angle.to_degrees(),
|
||||
values: named_value_list(j, q),
|
||||
dofs: j.kind.dofs().iter().map(|d| (*d).to_string()).collect(),
|
||||
a: side_json(s, st, &j.a),
|
||||
b: side_json(s, st, &j.b),
|
||||
links,
|
||||
errors: why(&sol.errors),
|
||||
conflicts: why(&sol.conflicts),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// One of a joint's origins: what it sits on, what it refers to, and where it is now.
|
||||
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
|
||||
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> JointSide {
|
||||
let component = component_of(s, o.occurrence);
|
||||
let component_name = if component == 0 {
|
||||
"root".to_string()
|
||||
@@ -80,24 +76,24 @@ fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
|
||||
s.doc.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default()
|
||||
};
|
||||
let occurrence_name = if o.occurrence == 0 { "root".to_string() } else { occurrence_name(s, o.occurrence) };
|
||||
let mut v = json!({
|
||||
"occurrence": o.occurrence,
|
||||
"occurrence_name": occurrence_name,
|
||||
"component": component,
|
||||
"component_name": component_name,
|
||||
"path": occurrence_path(&s.doc, o.occurrence),
|
||||
"snap": o.snap,
|
||||
"references": references(&s.doc, &o.snap),
|
||||
});
|
||||
let mut v = JointSide {
|
||||
occurrence: o.occurrence,
|
||||
occurrence_name,
|
||||
component,
|
||||
component_name,
|
||||
path: occurrence_path(&s.doc, o.occurrence),
|
||||
snap: o.snap.clone(),
|
||||
references: references(&s.doc, &o.snap),
|
||||
resolves: false,
|
||||
frame: None,
|
||||
error: None,
|
||||
};
|
||||
match s.doc.origin_world(st, o) {
|
||||
Ok(m) => {
|
||||
v["resolves"] = json!(true);
|
||||
v["frame"] = json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]], "x": [m[0][0], m[0][1], m[0][2]]});
|
||||
}
|
||||
Err(e) => {
|
||||
v["resolves"] = json!(false);
|
||||
v["error"] = json!(e.to_string());
|
||||
v.resolves = true;
|
||||
v.frame = Some(WorldFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]], x: [m[0][0], m[0][1], m[0][2]] });
|
||||
}
|
||||
Err(e) => v.error = Some(e.to_string()),
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -118,36 +114,36 @@ fn occurrence_path(doc: &Document, occurrence: u64) -> String {
|
||||
}
|
||||
|
||||
/// Every persistent name the snap holds, with what it refers to.
|
||||
fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
|
||||
fn references(doc: &Document, snap: &Snap) -> Vec<Reference> {
|
||||
let mut out = Vec::new();
|
||||
let mut face = |role: &str, name: &Option<String>| {
|
||||
let mut face = |role: ReferenceRole, name: &Option<String>| {
|
||||
if let Some(n) = name {
|
||||
out.push(with_role(role, face_ref(doc, n)));
|
||||
out.push(Reference { role, to: Referent::Face(face_ref(doc, n)) });
|
||||
}
|
||||
};
|
||||
match snap {
|
||||
Snap::FaceCenter { face: f, x, .. } => {
|
||||
face("face", f);
|
||||
face(ReferenceRole::Face, f);
|
||||
if let Some(XRef::Face { face: n, .. }) = x {
|
||||
face("x", &Some(n.clone()));
|
||||
face(ReferenceRole::X, &Some(n.clone()));
|
||||
}
|
||||
}
|
||||
Snap::CircleCenter { face: f, end, x, .. } => {
|
||||
face("face", f);
|
||||
face("end", end);
|
||||
face(ReferenceRole::Face, f);
|
||||
face(ReferenceRole::End, end);
|
||||
if let Some(XRef::Face { face: n, .. }) = x {
|
||||
face("x", &Some(n.clone()));
|
||||
face(ReferenceRole::X, &Some(n.clone()));
|
||||
}
|
||||
}
|
||||
Snap::Edge { edge, face: f, corner, .. } => {
|
||||
if let Some(e) = edge {
|
||||
out.push(with_role("edge", edge_ref(doc, e)));
|
||||
out.push(Reference { role: ReferenceRole::Edge, to: Referent::Edge(edge_ref(doc, e)) });
|
||||
}
|
||||
if let Some(n) = f {
|
||||
out.push(with_role("face", face_ref(doc, n)));
|
||||
out.push(Reference { role: ReferenceRole::Face, to: Referent::Face(face_ref(doc, n)) });
|
||||
}
|
||||
if let Some(e) = corner {
|
||||
out.push(with_role("corner", edge_ref(doc, e)));
|
||||
out.push(Reference { role: ReferenceRole::Corner, to: Referent::Edge(edge_ref(doc, e)) });
|
||||
}
|
||||
}
|
||||
Snap::Frame { .. } | Snap::Point { .. } => {}
|
||||
@@ -155,19 +151,14 @@ fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
fn with_role(role: &str, mut v: Value) -> Value {
|
||||
v["role"] = json!(role);
|
||||
v
|
||||
}
|
||||
|
||||
/// What a face's persistent name refers to: `F<feature>:<role>` names the feature that made the
|
||||
/// face, and a side `side:<curve>` (or a later profile's `side:s<k>.<curve>`) the sketch curve
|
||||
/// it was swept from. Names of another form are given as they are.
|
||||
fn face_ref(doc: &Document, name: &str) -> Value {
|
||||
let mut v = json!({ "name": name });
|
||||
fn face_ref(doc: &Document, name: &str) -> FaceReference {
|
||||
let mut v = FaceReference { name: name.to_string(), unverified: None, role_in_feature: None, feature: None, sketch: None };
|
||||
let bare = match legacy_name(name) {
|
||||
Some(n) => {
|
||||
v["unverified"] = json!(true);
|
||||
v.unverified = Some(true);
|
||||
n
|
||||
}
|
||||
None => name,
|
||||
@@ -175,38 +166,36 @@ fn face_ref(doc: &Document, name: &str) -> Value {
|
||||
let Ok(face) = FaceName::parse(bare) else { return v };
|
||||
let Some(fid) = face.made_by().map(|id| id.0) else { return v };
|
||||
let stem = face.stem().to_string();
|
||||
v["role_in_feature"] = json!(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem));
|
||||
v.role_in_feature = Some(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem).to_string());
|
||||
let Some(f) = doc.feature(fid) else {
|
||||
v["feature"] = json!({"id": fid, "error": format!("feature {fid} is gone")});
|
||||
v.feature = Some(MadeBy::Gone { id: fid, error: format!("feature {fid} is gone") });
|
||||
return v;
|
||||
};
|
||||
v["feature"] = json!({"id": f.id, "name": f.name, "type": f.kind.type_name()});
|
||||
v.feature = Some(MadeBy::Feature { id: f.id, name: f.name.clone(), kind: f.kind.type_name().to_string() });
|
||||
// A side swept from a sketch curve (a later profile's too, `side:s<k>.<curve>`).
|
||||
if let Base::Made { role: Role::Side { curve, .. }, .. } = &face.base {
|
||||
let found = f.kind.input_sketches().into_iter().find_map(|id| {
|
||||
// design-only: a joint names a curve of one of the design's sketches.
|
||||
let sk = doc.sketch(id).ok()?;
|
||||
let c = sk.curves.iter().find(|c| c.id == *curve)?;
|
||||
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").cloned()).unwrap_or(Value::Null);
|
||||
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").and_then(Value::as_str).map(str::to_string));
|
||||
let name = doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Some(json!({"id": id, "name": name, "curve": curve, "curve_type": curve_type}))
|
||||
Some(SweptFrom::Curve { id, name, curve: curve.clone(), curve_type })
|
||||
});
|
||||
v["sketch"] = found.unwrap_or_else(|| json!({"curve": curve, "error": format!("no sketch of {} has a curve `{curve}`", f.name)}));
|
||||
v.sketch = Some(
|
||||
found.unwrap_or_else(|| SweptFrom::Missing { curve: curve.clone(), error: format!("no sketch of {} has a curve `{curve}`", f.name) }),
|
||||
);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// An edge's persistent name (`<face A>|<face B>`, `~k` for one of several between them) with
|
||||
/// what each of its faces refers to.
|
||||
fn edge_ref(doc: &Document, name: &str) -> Value {
|
||||
fn edge_ref(doc: &Document, name: &str) -> EdgeReference {
|
||||
let bare = legacy_name(name).unwrap_or(name);
|
||||
let faces: Vec<Value> = match EdgeName::parse(bare) {
|
||||
let faces: Vec<FaceReference> = match EdgeName::parse(bare) {
|
||||
Ok(e) => e.faces.iter().map(|f| face_ref(doc, &f.to_string())).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let mut v = json!({"name": name, "faces": faces});
|
||||
if legacy_name(name).is_some() {
|
||||
v["unverified"] = json!(true);
|
||||
}
|
||||
v
|
||||
EdgeReference { name: name.to_string(), faces, unverified: legacy_name(name).is_some().then_some(true) }
|
||||
}
|
||||
|
||||
@@ -159,9 +159,9 @@ fn material_reply(cmd: &str, m: &UserMaterial) -> Result<MaterialReply> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`material_reply`] as JSON, for `material.list` (`cmd/inspect.rs`), which isn't converted yet.
|
||||
pub(super) fn material_json(m: &UserMaterial) -> Result<Value> {
|
||||
reply("material.list", material_reply("material.list", m)?)
|
||||
/// [`material_reply`] for `material.list` (`cmd/inspect.rs`).
|
||||
pub(super) fn listed_material(m: &UserMaterial) -> Result<MaterialReply> {
|
||||
material_reply("material.list", m)
|
||||
}
|
||||
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
|
||||
@@ -11,6 +11,7 @@ use solidcraft_geom::{Mesh, UnitVec3};
|
||||
|
||||
use super::measure_shape::{Shape, Slot, add_round, centre};
|
||||
use crate::params::bad;
|
||||
use crate::schema::inspect::{MeasureKind, MeasureResult, Measured, MeasuredItems};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
/// Geometry of a picked item, as points or triangles.
|
||||
@@ -665,16 +666,20 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces, bodies, sketch curves or profiles)"))?,
|
||||
);
|
||||
}
|
||||
let mut out = json!({"items": described.iter().map(|d| d.json.clone()).collect::<Vec<_>>()});
|
||||
let mut out =
|
||||
MeasuredItems { items: described.iter().map(|d| d.json.as_object().cloned().unwrap_or_default()).collect(), ..MeasuredItems::default() };
|
||||
if let [one] = described.as_slice() {
|
||||
for k in ["diameter", "radius", "circumference", "angle_deg", "arc_length", "center", "slot"] {
|
||||
if let Some(v) = one.json.get(k) {
|
||||
out[k] = v.clone();
|
||||
}
|
||||
}
|
||||
let copy = |k: &str| one.json.get(k).cloned();
|
||||
out.diameter = copy("diameter");
|
||||
out.radius = copy("radius");
|
||||
out.circumference = copy("circumference");
|
||||
out.angle_deg = copy("angle_deg");
|
||||
out.arc_length = copy("arc_length");
|
||||
out.center = copy("center");
|
||||
out.slot = copy("slot");
|
||||
if let Some(sl) = &one.slot {
|
||||
out["center_from"] = json!(sl.centres.0);
|
||||
out["center_to"] = json!(sl.centres.1);
|
||||
out.center_from = Some(sl.centres.0.into());
|
||||
out.center_to = Some(sl.centres.1.into());
|
||||
}
|
||||
}
|
||||
if let [a, b] = described.as_slice() {
|
||||
@@ -698,42 +703,45 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
max = Some(exact);
|
||||
}
|
||||
if let (Some((p, q)), Some(d)) = (near, min) {
|
||||
out["distance_mm"] = json!(d);
|
||||
out["from"] = json!(p);
|
||||
out["to"] = json!(q);
|
||||
out["delta"] = json!(q - p);
|
||||
out["min"] = json!(d);
|
||||
out.set_closest(d, p, q);
|
||||
}
|
||||
let unbounded = |i: usize| matches!(sels.get(i), Some(Sel::Plane { .. } | Sel::Axis { .. }));
|
||||
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape, near.map(|(p, _)| p), (unbounded(0), unbounded(1)))
|
||||
.filter(|(d, p, q)| d.is_finite() && p.is_finite() && q.is_finite())
|
||||
{
|
||||
out["distance_mm"] = json!(d);
|
||||
out["from"] = json!(p);
|
||||
out["to"] = json!(q);
|
||||
out["delta"] = json!(q - p);
|
||||
out["min"] = json!(d);
|
||||
out.set_closest(d, p, q);
|
||||
}
|
||||
if let (Some((p, q)), Some(d)) = (far, max) {
|
||||
out["max"] = json!(d);
|
||||
out["max_from"] = json!(p);
|
||||
out["max_to"] = json!(q);
|
||||
out["max_delta"] = json!(q - p);
|
||||
out.max = Some(d);
|
||||
out.max_from = Some(p.into());
|
||||
out.max_to = Some(q.into());
|
||||
out.max_delta = Some((q - p).into());
|
||||
}
|
||||
if let Some(c) = c {
|
||||
out["center_distance"] = json!(c.distance);
|
||||
out["center_from"] = json!(c.from);
|
||||
out["center_to"] = json!(c.to);
|
||||
out["center_delta"] = json!(c.to - c.from);
|
||||
out.center_distance = Some(c.distance);
|
||||
out.center_from = Some(c.from.into());
|
||||
out.center_to = Some(c.to.into());
|
||||
out.center_delta = Some((c.to - c.from).into());
|
||||
}
|
||||
out["measure"] = json!(if c.is_some() { "center" } else { "min" });
|
||||
out.measure = Some(if c.is_some() { MeasureKind::Center } else { MeasureKind::Min });
|
||||
if let (Some(da), Some(db)) = (a.dir, b.dir) {
|
||||
let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]);
|
||||
let cos = if both_faces { da.dot(db) } else { corner_cos(a.shape, b.shape).unwrap_or_else(|| da.dot(db).abs()) };
|
||||
out["angle_deg"] = json!(cos.clamp(-1.0, 1.0).acos().to_degrees());
|
||||
out.angle_deg = Some(json!(cos.clamp(-1.0, 1.0).acos().to_degrees()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
serde_json::to_value(MeasureResult { measured: Measured::Items(Box::new(out)), param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
impl MeasuredItems {
|
||||
/// The closest distance between two items and where it is: `distance_mm` and `min` are one.
|
||||
fn set_closest(&mut self, d: f64, p: Vec3, q: Vec3) {
|
||||
self.distance_mm = Some(d);
|
||||
self.min = Some(d);
|
||||
self.from = Some(p.into());
|
||||
self.to = Some(q.into());
|
||||
self.delta = Some((q - p).into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Farthest pair of points between two pieces of geometry (on their samples and vertices).
|
||||
|
||||
@@ -85,8 +85,10 @@ mod point_cloud;
|
||||
mod script;
|
||||
mod section;
|
||||
pub mod settings;
|
||||
pub(crate) use clipboard::{component_path_form, copy_keys};
|
||||
pub(crate) use measure_sel::{measure_items, shape_of};
|
||||
pub(crate) use measure_shape::Shape;
|
||||
pub(crate) use script::step_params;
|
||||
#[cfg(test)]
|
||||
mod kernel_heal_tests;
|
||||
mod panels;
|
||||
@@ -110,7 +112,7 @@ mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
pub(crate) use sketch::collapse_reason;
|
||||
pub(crate) use sketch::created as sketch_created;
|
||||
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form};
|
||||
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form, point_arg_form};
|
||||
mod canvas_sketch;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
@@ -118,6 +120,7 @@ pub mod sketch_clipboard;
|
||||
mod sketch_constraints;
|
||||
mod sketch_construction;
|
||||
mod sketch_create;
|
||||
pub(crate) use sketch_create::{AT_MUST, MAX_TEXT_EDITS};
|
||||
mod sketch_divide;
|
||||
mod sketch_ends;
|
||||
mod sketch_equation;
|
||||
@@ -126,6 +129,7 @@ mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
pub(crate) use sketch_project::{RefForm, ref_form};
|
||||
mod sketch_store;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
|
||||
@@ -16,8 +16,10 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, plac
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::schema::motion::{
|
||||
ContactCheck, ContactCollision, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetChange,
|
||||
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, FindLimitsParams, FindLimitsResult, MotionListParams,
|
||||
MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo, MotionStudyParams, MotionStudySaved,
|
||||
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, ExplodeCreateParams, ExplodeCreated, ExplodeDeleteParams,
|
||||
ExplodeDeleted, ExplodeListParams, ExplodeMoveInfo, ExplodeShowParams, ExplodeShown, ExplodeViews, ExplodedViewInfo, FindLimitsParams,
|
||||
FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo,
|
||||
MotionStudyParams, MotionStudySaved,
|
||||
};
|
||||
use crate::schema::motion_play::{
|
||||
DriveReport, ExportParams, PartPose, PlayParams, Pose, StudyExport, StudyExported, StudyPlayed, StudySample, UnmovedJoint,
|
||||
@@ -53,6 +55,10 @@ const CONTACT_EDIT_CONTRACT: TypedContract<ContactEditParams> = TypedContract::n
|
||||
const STUDY_CONTRACT: TypedContract<MotionStudyParams> = TypedContract::new();
|
||||
const STUDY_DELETE_CONTRACT: TypedContract<MotionStudyDeleteParams> = TypedContract::new();
|
||||
const STUDY_LIST_CONTRACT: TypedContract<MotionListParams> = TypedContract::new();
|
||||
const EXPLODE_CREATE_CONTRACT: TypedContract<ExplodeCreateParams> = TypedContract::new();
|
||||
const EXPLODE_SHOW_CONTRACT: TypedContract<ExplodeShowParams> = TypedContract::new();
|
||||
const EXPLODE_LIST_CONTRACT: TypedContract<ExplodeListParams> = TypedContract::new();
|
||||
const EXPLODE_DELETE_CONTRACT: TypedContract<ExplodeDeleteParams> = TypedContract::new();
|
||||
const PLAY_CONTRACT: TypedContract<PlayParams> = TypedContract::new();
|
||||
const EXPORT_CONTRACT: TypedContract<ExportParams> = TypedContract::new();
|
||||
|
||||
@@ -175,16 +181,32 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&STUDY_DELETE_CONTRACT)
|
||||
.output(output_schema_for::<MotionStudyDeleted>)
|
||||
.params("study"),
|
||||
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").enabled(has_parts_to_explode).params(
|
||||
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
|
||||
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
|
||||
),
|
||||
CommandSpec::new("explode.show", "Show Exploded View", explode_show)
|
||||
CommandSpec::new("explode.create", "Exploded View", crate::checked!("explode.create", explode_create))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("explode")
|
||||
.enabled(has_parts_to_explode)
|
||||
.contract(&EXPLODE_CREATE_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeCreated>)
|
||||
.params(
|
||||
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
|
||||
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
|
||||
),
|
||||
CommandSpec::new("explode.show", "Show Exploded View", crate::checked!("explode.show", explode_show))
|
||||
.icon("explode")
|
||||
.noundo()
|
||||
.contract(&EXPLODE_SHOW_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeShown>)
|
||||
.params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"),
|
||||
CommandSpec::new("explode.list", "List Exploded Views", explode_list).icon("list").noundo(),
|
||||
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).icon("delete").params("view"),
|
||||
CommandSpec::new("explode.list", "List Exploded Views", crate::checked!("explode.list", explode_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&EXPLODE_LIST_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeViews>),
|
||||
CommandSpec::new("explode.delete", "Delete Exploded View", crate::checked!("explode.delete", explode_delete))
|
||||
.icon("delete")
|
||||
.contract(&EXPLODE_DELETE_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeDeleted>)
|
||||
.params("view"),
|
||||
];
|
||||
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
@@ -1233,7 +1255,8 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name =
|
||||
str_(p, "name").map(str::to_string).unwrap_or_else(|| free_name("Exploded View", s.doc.assembly.exploded.iter().map(|v| v.name.as_str())));
|
||||
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
|
||||
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
|
||||
let moves = moves.iter().map(ExplodeMoveInfo::from).collect();
|
||||
typed_reply(cmd, ExplodeCreated { view: id, name, moves, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1242,7 +1265,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.pending_moves.clear();
|
||||
s.exploded.clear();
|
||||
s.revision += 1;
|
||||
return Ok(json!({"view": null}));
|
||||
return typed_reply(cmd, ExplodeShown { view: None, name: None, param_warnings: Vec::new() });
|
||||
}
|
||||
let i = view_index(s, p, cmd)?;
|
||||
let v = s.doc.assembly.exploded.get(i).cloned().ok_or_else(|| bad(cmd, "view"))?;
|
||||
@@ -1254,7 +1277,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.exploded.clone_from(&moves);
|
||||
s.pending_moves = moves;
|
||||
s.revision += 1;
|
||||
Ok(json!({"view": v.id, "name": v.name}))
|
||||
typed_reply(cmd, ExplodeShown { view: Some(v.id), name: Some(v.name), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Whether the pending moves are an exploded view shown (`explode.show`), which places the
|
||||
@@ -1287,13 +1310,14 @@ fn exploded_at(moves: &[(u64, Mat, Vec3)], amount: f64) -> std::collections::BTr
|
||||
}
|
||||
|
||||
fn explode_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"views": s.doc.assembly.exploded}))
|
||||
let views = s.doc.assembly.exploded.iter().map(ExplodedViewInfo::from).collect();
|
||||
typed_reply("explode.list", ExplodeViews { views, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn explode_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = view_index(s, p, "explode.delete")?;
|
||||
let v = s.doc_mut().assembly.exploded.remove(i);
|
||||
Ok(json!({"deleted": v.name}))
|
||||
typed_reply("explode.delete", ExplodeDeleted { deleted: v.name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// World position of a point of an occurrence (tests).
|
||||
|
||||
@@ -10,8 +10,11 @@ use solidcraft_sketch::{Analysis, ConstraintKind, CurveKind, Hold, Sketch, solve
|
||||
use super::{CommandSpec, sketch_store};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::schema::parameters::{
|
||||
AddedValue, ParamAddParams, ParamAdded, ParamCommentParams, ParamCommented, ParamDeleted, ParamFavoriteParams, ParamFavorited, ParamMoveParams,
|
||||
ParamMoved, ParamNameParams, ParamRenameParams, ParamRenamed,
|
||||
AddedValue, Completion, CompletionKind, DrivenFeature, EvalKind, ExprCompleteParams, ExprCompletions, ExprEvaluateParams, ExprEvaluated,
|
||||
ExprEvaluatedBody, FailedFeature, ParamAddParams, ParamAdded, ParamChangeParams, ParamChanged, ParamChangedBody, ParamChangedOne,
|
||||
ParamCommentParams, ParamCommented, ParamDeleted, ParamExportParams, ParamExportResult, ParamFavoriteParams, ParamFavorited, ParamGraphParams,
|
||||
ParamGraphResult, ParamImportParams, ParamImportResult, ParamListParams, ParamListResult, ParamMoveParams, ParamMoved, ParamNameParams,
|
||||
ParamRename, ParamRenameParams, ParamRenamed, ParamUsersParams, ParamUsersResult, ParameterRow, ParameterUser, SwitchedFeature,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -22,9 +25,22 @@ const NAME_CONTRACT: TypedContract<ParamNameParams> = TypedContract::new();
|
||||
const COMMENT_CONTRACT: TypedContract<ParamCommentParams> = TypedContract::new();
|
||||
const FAVORITE_CONTRACT: TypedContract<ParamFavoriteParams> = TypedContract::new();
|
||||
const MOVE_CONTRACT: TypedContract<ParamMoveParams> = TypedContract::new();
|
||||
const CHANGE_CONTRACT: TypedContract<ParamChangeParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ParamListParams> = TypedContract::new();
|
||||
const USERS_CONTRACT: TypedContract<ParamUsersParams> = TypedContract::new();
|
||||
const GRAPH_CONTRACT: TypedContract<ParamGraphParams> = TypedContract::new();
|
||||
const EXPORT_CONTRACT: TypedContract<ParamExportParams> = TypedContract::new();
|
||||
const IMPORT_CONTRACT: TypedContract<ParamImportParams> = TypedContract::new();
|
||||
const EVALUATE_CONTRACT: TypedContract<ExprEvaluateParams> = TypedContract::new();
|
||||
const COMPLETE_CONTRACT: TypedContract<ExprCompleteParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
||||
CommandSpec::new("parameters.change", "Change Parameters", crate::checked!("parameters.change", change_param))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("params")
|
||||
.contract(&CHANGE_CONTRACT)
|
||||
.output(output_schema_for::<ParamChanged>)
|
||||
.params(
|
||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit, a length the design's units; \
|
||||
a text parameter's expression is its text, quotes round it optional: sketch texts' words are built from it), comment?, \
|
||||
favorite?: bool, new_name?: rename (references follow; an expression `new = value` renames to `new` and sets value, \
|
||||
@@ -34,7 +50,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
(the configuration rules that switch a feature's suppression and read a parameter whose value changed run in their order, as on \
|
||||
activation; driven parameters and restrictions wait for a configuration edit or activation)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params(
|
||||
CommandSpec::new("parameters.list", "List Parameters", crate::checked!("parameters.list", list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ParamListResult>)
|
||||
.params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value (mm/deg in every \
|
||||
reply), display_value (in its own unit), source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
),
|
||||
@@ -68,30 +90,42 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&COMMENT_CONTRACT)
|
||||
.output(output_schema_for::<ParamCommented>)
|
||||
.params("name, comment"),
|
||||
CommandSpec::new("parameters.users", "Parameter Users", users)
|
||||
CommandSpec::new("parameters.users", "Parameter Users", crate::checked!("parameters.users", users))
|
||||
.icon("graph")
|
||||
.noundo()
|
||||
.contract(&USERS_CONTRACT)
|
||||
.output(output_schema_for::<ParamUsersResult>)
|
||||
.params("name → users (parameters, feature inputs, dimensions) and the features it drives"),
|
||||
CommandSpec::new("parameters.graph", "Parameter Graph", graph)
|
||||
CommandSpec::new("parameters.graph", "Parameter Graph", crate::checked!("parameters.graph", graph))
|
||||
.icon("graph")
|
||||
.noundo()
|
||||
.contract(&GRAPH_CONTRACT)
|
||||
.output(output_schema_for::<ParamGraphResult>)
|
||||
.params("→ nodes, edges [user, used] (users include Feature:<name>), cycles, errors"),
|
||||
CommandSpec::new("parameters.export", "Export Parameters", export)
|
||||
CommandSpec::new("parameters.export", "Export Parameters", crate::checked!("parameters.export", export))
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.contract(&EXPORT_CONTRACT)
|
||||
.output(output_schema_for::<ParamExportResult>)
|
||||
.params("format?: csv|json (default csv, or from the path), path?: write to a file → text"),
|
||||
CommandSpec::new("parameters.import", "Import Parameters", import)
|
||||
CommandSpec::new("parameters.import", "Import Parameters", crate::checked!("parameters.import", import))
|
||||
.icon("import")
|
||||
.contract(&IMPORT_CONTRACT)
|
||||
.output(output_schema_for::<ParamImportResult>)
|
||||
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", crate::checked!("expr.evaluate", evaluate))
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&EVALUATE_CONTRACT)
|
||||
.output(output_schema_for::<ExprEvaluated>)
|
||||
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…; text: the text a text parameter would hold, as `text`) → ok, value (mm/rad), display_value, references, error"),
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", complete)
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", crate::checked!("parameters.complete", complete))
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&COMPLETE_CONTRACT)
|
||||
.output(output_schema_for::<ExprCompletions>)
|
||||
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
|
||||
];
|
||||
|
||||
@@ -108,8 +142,8 @@ fn param_name<'a>(cmd: &str, p: &'a Value) -> Result<&'a str> {
|
||||
}
|
||||
|
||||
/// Features that fail (name and error).
|
||||
fn failing(s: &Session) -> Vec<Value> {
|
||||
s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect()
|
||||
fn failing(s: &Session) -> Vec<FailedFeature> {
|
||||
FailedFeature::of(&s.model.results)
|
||||
}
|
||||
|
||||
pub(super) fn known(s: &Session, name: &str) -> bool {
|
||||
@@ -130,19 +164,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n.trim())?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
return Ok(json!({"deleted": n.trim()}));
|
||||
return reply(cmd, ParamChanged { body: ParamChangedBody::Deleted { deleted: n.trim().to_string() }, param_warnings: Vec::new() });
|
||||
}
|
||||
let mut name = param_name(cmd, p)?.to_string();
|
||||
let mut out = json!({});
|
||||
let mut renamed: Option<ParamRename> = None;
|
||||
let mut changed_through: Option<String> = None;
|
||||
if let Some(new) = str_(p, "new_name") {
|
||||
let n = s.doc_mut().rename_param(&name, new)?;
|
||||
out["renamed"] = json!({"from": name, "to": new.trim(), "references_updated": n});
|
||||
renamed = Some(ParamRename { from: name, to: new.trim().to_string(), references_updated: n });
|
||||
name = new.trim().to_string();
|
||||
}
|
||||
let has_expr = p.get("expression").is_some() || p.get("value").is_some();
|
||||
let mut e = if has_expr {
|
||||
Some(expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?)
|
||||
} else if out.get("renamed").is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
|
||||
} else if renamed.is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
|
||||
None
|
||||
} else {
|
||||
return Err(bad(cmd, "`expression` must be a number or text"));
|
||||
@@ -151,7 +186,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// `d7`, a feature input's) is renamed, references following, and takes the expression. A
|
||||
// text parameter's text is only text.
|
||||
if let Some((new, rhs)) = e.as_deref().filter(|_| !is_text(s, &name, str_(p, "unit"))).and_then(expr::definition) {
|
||||
if out.get("renamed").is_some() {
|
||||
if renamed.is_some() {
|
||||
return Err(bad(cmd, format!("`{new} = …` renames `{name}` already: give `new_name` or `name = expression`, not both")));
|
||||
}
|
||||
if rhs.is_empty() {
|
||||
@@ -162,7 +197,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// `w = 20` shows `w`, and `w = 30` typed over it sets `w`.
|
||||
let refers_to = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| *n == name).map(|(_, e, _)| e);
|
||||
if refers_to.as_deref().map(str::trim) == Some(new.as_str()) && s.doc.param(&new).is_some() {
|
||||
out["changed_through"] = json!(name);
|
||||
changed_through = Some(name);
|
||||
name = new.clone();
|
||||
}
|
||||
if new != name {
|
||||
@@ -170,7 +205,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("there is already a parameter `{new}`: type `{new}` to use it, or change it in Parameters")));
|
||||
}
|
||||
let n = s.doc_mut().rename_param(&name, &new)?;
|
||||
out["renamed"] = json!({"from": name, "to": new, "references_updated": n});
|
||||
renamed = Some(ParamRename { from: name, to: new.clone(), references_updated: n });
|
||||
name = new;
|
||||
}
|
||||
e = Some(rhs);
|
||||
@@ -197,20 +232,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
if !switched.is_empty() {
|
||||
out["switched"] = json!(switched);
|
||||
}
|
||||
out["name"] = json!(name);
|
||||
out["expression"] = json!(e);
|
||||
if let Some(t) = s.doc.text_param(&name) {
|
||||
out["text"] = json!(t.text);
|
||||
}
|
||||
out["value"] = json!(vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))));
|
||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name).map(expr::round_reported));
|
||||
out["recomputed"] = json!(s.model.last_recomputed);
|
||||
out["restored"] = json!(s.model.last_restored);
|
||||
out["errors"] = json!(failing(s));
|
||||
Ok(out)
|
||||
let one = ParamChangedOne {
|
||||
renamed,
|
||||
changed_through,
|
||||
switched,
|
||||
text: s.doc.text_param(&name).map(|t| t.text.clone()),
|
||||
value: vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))),
|
||||
display_value: s.doc.param_display_value(&vals, &name).map(expr::round_reported),
|
||||
recomputed: s.model.last_recomputed,
|
||||
restored: s.model.last_restored,
|
||||
errors: failing(s),
|
||||
name,
|
||||
expression: e,
|
||||
};
|
||||
reply(cmd, ParamChanged { body: ParamChangedBody::One(Box::new(one)), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The point a length dimension keeps when its value changes: the first it measures from (a
|
||||
@@ -308,19 +343,15 @@ fn change_many(s: &mut Session, changes: &Value, solved: &crate::SketchValues) -
|
||||
}
|
||||
s.settle_value_edit(solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values: serde_json::Map<String, Value> =
|
||||
list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v)))))).collect();
|
||||
let mut out = json!({"changed": values, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored, "errors": failing(s)});
|
||||
if !switched.is_empty() {
|
||||
out["switched"] = json!(switched);
|
||||
}
|
||||
Ok(out)
|
||||
let changed = list.iter().map(|(n, _)| (n.clone(), vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v))))).collect();
|
||||
let body = ParamChangedBody::Many { changed, recomputed: s.model.last_recomputed, restored: s.model.last_restored, errors: failing(s), switched };
|
||||
reply(cmd, ParamChanged { body, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The configuration rules that switch a feature's suppression and read a parameter this edit
|
||||
/// changed (`Document::apply_suppression_rules`), applied before the design is rebuilt, so that
|
||||
/// one rebuild has them: `[{feature, name, suppressed}]` for each feature they switched.
|
||||
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<Value> {
|
||||
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<SwitchedFeature> {
|
||||
if s.doc.configs.rules.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -329,12 +360,13 @@ fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String,
|
||||
.into_iter()
|
||||
.map(|(id, suppressed)| {
|
||||
let name = s.doc.features.iter().find(|f| f.id == id).map(|f| f.name.clone());
|
||||
json!({"feature": id, "name": name, "suppressed": suppressed})
|
||||
SwitchedFeature { feature: id, name, suppressed }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.list";
|
||||
let filter = match (bool_(p, "favorites"), str_(p, "filter")) {
|
||||
(Some(true), _) => "favorites".to_string(),
|
||||
(_, Some(f)) => f.to_ascii_lowercase(),
|
||||
@@ -354,7 +386,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Favourites first; otherwise the table order (user, sketch, feature).
|
||||
rows.sort_by_key(|r| !r.favorite);
|
||||
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
|
||||
Ok(json!({"parameters": rows, "count": rows.len(), "cycles": cycles}))
|
||||
let count = rows.len();
|
||||
let parameters = rows.into_iter().map(ParameterRow::from).collect();
|
||||
reply(cmd, ParamListResult { parameters, count, cycles: cycles.into_iter().collect(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -438,17 +472,24 @@ fn users(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no parameter `{name}`")));
|
||||
}
|
||||
let users = s.doc.param_users(name);
|
||||
let drives: Vec<Value> =
|
||||
s.doc.features_using_param(name).iter().filter_map(|id| s.doc.feature(*id)).map(|f| json!({"id": f.id, "name": f.name})).collect();
|
||||
Ok(json!({"name": name, "users": users, "drives": drives}))
|
||||
let users = users.into_iter().map(ParameterUser::from).collect();
|
||||
let drives = s
|
||||
.doc
|
||||
.features_using_param(name)
|
||||
.iter()
|
||||
.filter_map(|id| s.doc.feature(*id))
|
||||
.map(|f| DrivenFeature { id: f.id, name: f.name.clone() })
|
||||
.collect();
|
||||
reply(cmd, ParamUsersResult { name: name.to_string(), users, drives, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn graph(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.graph";
|
||||
let (_, errs) = s.doc.param_values();
|
||||
let nodes: Vec<String> = s.doc.param_defs().into_iter().map(|d| d.name).collect();
|
||||
let edges: Vec<[String; 2]> = s.doc.param_graph().into_iter().map(|(a, b)| [a, b]).collect();
|
||||
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
|
||||
Ok(json!({"nodes": nodes, "edges": edges, "cycles": cycles, "errors": errs}))
|
||||
reply(cmd, ParamGraphResult { nodes, edges, cycles: cycles.into_iter().collect(), errors: errs, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -477,7 +518,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(path) = path {
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
}
|
||||
Ok(json!({"format": format, "path": path, "text": text}))
|
||||
reply(cmd, ParamExportResult { format, path: path.map(str::to_string), text, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Largest parameter table we read from a file.
|
||||
@@ -495,7 +536,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let solved = s.solved_sketches();
|
||||
let names = s.doc_mut().import_params(&text)?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"imported": names, "errors": failing(s)}))
|
||||
reply(cmd, ParamImportResult { imported: names, errors: failing(s), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -504,7 +545,8 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let unit = str_(p, "unit");
|
||||
// A text parameter's expression is its text.
|
||||
if unit.is_some_and(solidcraft_doc::text_params::is_text_unit) {
|
||||
return Ok(json!({"ok": true, "kind": "text", "text": solidcraft_doc::text_params::unquote(&e), "references": []}));
|
||||
let body = ExprEvaluatedBody::Text { ok: true, kind: "text".into(), text: solidcraft_doc::text_params::unquote(&e), references: Vec::new() };
|
||||
return reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() });
|
||||
}
|
||||
if let Some(u) = unit
|
||||
&& expr::unit_info(u).is_none()
|
||||
@@ -531,7 +573,7 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
// `name = expression` is worth its expression, and names what it would define (#4071).
|
||||
let defines = expr::definition(&e).map(|(n, _)| n);
|
||||
let mut out = match r {
|
||||
let body = match r {
|
||||
Ok((v, k)) => {
|
||||
let value = match k {
|
||||
Kind::Length => expr::Value::length(v),
|
||||
@@ -543,17 +585,27 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Kind::Angle) => v.to_degrees(),
|
||||
(None, _) => v,
|
||||
};
|
||||
json!({"ok": true, "value": expr::round_reported(v), "kind": k, "display_value": expr::round_reported(shown), "references": refs})
|
||||
ExprEvaluatedBody::Value {
|
||||
ok: true,
|
||||
value: expr::round_reported(v),
|
||||
kind: EvalKind::from(k),
|
||||
display_value: expr::round_reported(shown),
|
||||
references: refs,
|
||||
defines: defines.map(str::to_string),
|
||||
}
|
||||
}
|
||||
Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}),
|
||||
Err(err) => ExprEvaluatedBody::Failed {
|
||||
ok: false,
|
||||
error: err.to_string().trim_start_matches("expression: ").to_string(),
|
||||
references: refs,
|
||||
defines: defines.map(str::to_string),
|
||||
},
|
||||
};
|
||||
if let Some(name) = defines {
|
||||
out["defines"] = json!(name);
|
||||
}
|
||||
Ok(out)
|
||||
reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.complete";
|
||||
let text = str_(p, "prefix").unwrap_or("");
|
||||
// The identifier being typed: trailing letters, digits and underscores.
|
||||
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
|
||||
@@ -565,7 +617,7 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// start.
|
||||
let contains = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
|
||||
let within = |n: &str| contains(n) || solidcraft_doc::name_words_match(n, &word);
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut out: Vec<Completion> = Vec::new();
|
||||
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
|
||||
// Names that start with the word, then names that contain it, then names matched only by
|
||||
// the starts of their words; within each, favourites, then user parameters, then the others.
|
||||
@@ -580,22 +632,22 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !r.comment.is_empty() {
|
||||
detail.push_str(&format!(" — {}", r.comment));
|
||||
}
|
||||
out.push(json!({"text": r.name, "kind": "parameter", "detail": detail}));
|
||||
out.push(Completion { text: r.name, kind: CompletionKind::Parameter, detail });
|
||||
}
|
||||
for (f, sig) in expr::FUNCTIONS {
|
||||
if starts(f) {
|
||||
out.push(json!({"text": format!("{f}("), "kind": "function", "detail": sig}));
|
||||
out.push(Completion { text: format!("{f}("), kind: CompletionKind::Function, detail: sig.to_string() });
|
||||
}
|
||||
}
|
||||
if starts("pi") {
|
||||
out.push(json!({"text": "pi", "kind": "constant", "detail": "3.14159…"}));
|
||||
out.push(Completion { text: "pi".into(), kind: CompletionKind::Constant, detail: "3.14159…".into() });
|
||||
}
|
||||
for (u, k, _) in expr::UNITS {
|
||||
if !word.is_empty() && starts(u) {
|
||||
out.push(json!({"text": u, "kind": "unit", "detail": k}));
|
||||
out.push(Completion { text: u.to_string(), kind: CompletionKind::Unit, detail: EvalKind::from(*k).word().to_string() });
|
||||
}
|
||||
}
|
||||
Ok(json!({"word": word, "suggestions": out}))
|
||||
reply(cmd, ExprCompletions { word, suggestions: out, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -11,15 +11,30 @@ use solidcraft_doc::{Document, Feature, FeatureKind, Param, expr};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::timeline_clipboard::{AddParams, AddResult, ExportScriptParams, ExportScriptResult, Script, ScriptAbout, ScriptStep};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
|
||||
const EXPORT_SCRIPT_CONTRACT: TypedContract<ExportScriptParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.add", "Add Recorded Feature", add).icon("new").params(
|
||||
CommandSpec::new("timeline.add", "Add Recorded Feature", crate::checked!("timeline.add", add))
|
||||
.icon("new")
|
||||
.contract(&ADD_CONTRACT)
|
||||
.output(output_schema_for::<AddResult>)
|
||||
.params(
|
||||
"feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; the name is kept even when another \
|
||||
feature has it, the id is kept and refused when another feature has it, so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \
|
||||
dimensions use); the feature goes at the marker → feature, name, error, warning",
|
||||
),
|
||||
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params(
|
||||
CommandSpec::new("timeline.export_script", "Export Command Script", crate::checked!("timeline.export_script", export_script))
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&EXPORT_SCRIPT_CONTRACT)
|
||||
.output(output_schema_for::<ExportScriptResult>)
|
||||
.params(
|
||||
"path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \
|
||||
steps, omitted: what the script leaves out (appearances, folders, views, configurations, print-face assignments); components and assemblies \
|
||||
are not supported yet",
|
||||
@@ -56,7 +71,14 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.refresh();
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({"feature": id, "name": name, "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone())}))
|
||||
let reply = AddResult {
|
||||
feature: id,
|
||||
name,
|
||||
error: r.and_then(|r| r.error.clone()),
|
||||
warning: r.and_then(|r| r.warning.clone()),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// timeline.add keeps accepting legacy name-keyed pattern and mirror seeds and construction
|
||||
@@ -139,7 +161,8 @@ fn recorded_feature(cmd: &str, raw: &Map<String, Value>) -> Result<Feature> {
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
|
||||
/// The model parameters of a `timeline.add` step. The reader the command's contract uses too.
|
||||
pub(crate) fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
|
||||
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
|
||||
let list = v.as_array().filter(|a| a.len() <= MAX_STEP_PARAMS).ok_or_else(|| bad(cmd, "`params` must be a list of parameters"))?;
|
||||
let text = |p: &Value, k: &str| match p.get(k) {
|
||||
@@ -164,15 +187,15 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.export_script";
|
||||
let (commands, omitted) = script_steps(&s.doc)?;
|
||||
let steps = commands.len();
|
||||
let script = json!({
|
||||
"about": {
|
||||
"design": s.doc.name,
|
||||
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"replay": "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)",
|
||||
let script = Script {
|
||||
about: ScriptAbout {
|
||||
design: s.doc.name.clone(),
|
||||
format: solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
|
||||
replay: "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
},
|
||||
"commands": commands,
|
||||
});
|
||||
commands,
|
||||
};
|
||||
let path = str_(p, "path");
|
||||
if let Some(path) = path {
|
||||
let text = serde_json::to_string_pretty(&script).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -180,11 +203,12 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else if p.get("path").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`path` must be a file path"));
|
||||
}
|
||||
Ok(json!({"script": script, "steps": steps, "omitted": omitted, "path": path}))
|
||||
let path = path.map(str::to_string);
|
||||
serde_json::to_value(ExportScriptResult { omitted, path, script, steps, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn step(command: &str, params: Value) -> Value {
|
||||
json!({"command": command, "params": params})
|
||||
serde_json::to_value(ScriptStep { command: command.to_string(), params }).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn not_supported(what: &str) -> EngineError {
|
||||
|
||||
+225
-56
@@ -11,7 +11,11 @@ use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list
|
||||
use crate::schema::sketch::{
|
||||
CancelResult, CoincidentMade, CoincidentParams, CoincidentResult, CollinearParams, ConcentricParams, Constrained, ConstraintResult, EditParams,
|
||||
EditResult, EqualMade, EqualParams, EqualResult, ExitParams, FinishResult, FixOutcome, FixParams, FixResult, HorizontalVerticalParams,
|
||||
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentParams,
|
||||
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
|
||||
};
|
||||
use crate::schema::sketch_shapes::{
|
||||
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, Created, DrawResult, LineParams,
|
||||
PointParams, PointResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
|
||||
@@ -53,6 +57,13 @@ const MIDPOINT_CONTRACT: TypedContract<MidpointParams> = TypedContract::new();
|
||||
const CONCENTRIC_CONTRACT: TypedContract<ConcentricParams> = TypedContract::new();
|
||||
const COLLINEAR_CONTRACT: TypedContract<CollinearParams> = TypedContract::new();
|
||||
const SYMMETRY_CONTRACT: TypedContract<SymmetryParams> = TypedContract::new();
|
||||
const LINE_CONTRACT: TypedContract<LineParams> = TypedContract::new();
|
||||
const POINT_CONTRACT: TypedContract<PointParams> = TypedContract::new();
|
||||
const CIRCLE_CENTER_CONTRACT: TypedContract<CircleCenterParams> = TypedContract::new();
|
||||
const CIRCLE_TWO_POINT_CONTRACT: TypedContract<CircleTwoPointParams> = TypedContract::new();
|
||||
const CIRCLE_THREE_POINT_CONTRACT: TypedContract<CircleThreePointParams> = TypedContract::new();
|
||||
const ARC_THREE_POINT_CONTRACT: TypedContract<ArcThreePointParams> = TypedContract::new();
|
||||
const ARC_CENTER_POINT_CONTRACT: TypedContract<ArcCenterPointParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
@@ -73,11 +84,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.contract(&EXIT_CONTRACT).output(output_schema_for::<CancelResult>)
|
||||
.params("Leaves the sketch without keeping what changed since it was created or opened for editing: the design goes back to how it was then (a new sketch is removed), as one step that Undo brings back. Refused when the undo history no longer leads back to that point (use Undo instead). restore_marker?: bool (default true); false transfers the temporary marker to another editor"),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
CommandSpec::new("sketch.line", "Line", crate::checked!("sketch.line", draw_line))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("line")
|
||||
.key("L")
|
||||
.enabled(in_sketch)
|
||||
.contract(&LINE_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…] (x and y each a number or an expression over the design's parameters, \"W / 2\": such a point is fixed where it comes to and follows the parameters; likewise wherever a point argument also takes a point id), closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
|
||||
.enabled(in_sketch)
|
||||
@@ -103,31 +116,39 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("rect_center")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center, corner: [x,y] (a coordinate may be an expression over the design's parameters: the point follows them); construction?: bool. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
|
||||
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", crate::checked!("sketch.circle.center", circle_center))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle")
|
||||
.key("C")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CIRCLE_CENTER_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!(
|
||||
"center: [x,y] or point ref (a coordinate may be an expression over the design's parameters: the centre follows them), radius | diameter: number or expression (an expression naming a parameter is a dimension driven by it; with `through` a point ref, that and the point both fix the radius); through?: point ref (a point the circle passes through, kept on it: where the tool's second click snapped); construction?: bool",
|
||||
connect_doc!(),
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
|
||||
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", crate::checked!("sketch.circle.two_point", circle_two))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle2")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CIRCLE_TWO_POINT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("p0, p1: diameter ends; construction?: bool", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
|
||||
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", crate::checked!("sketch.circle.three_point", circle_three))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle3")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CIRCLE_THREE_POINT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("p0, p1, p2: [x,y] or point ref (a referenced point stays on the circle); construction?: bool", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through: [x,y] or point ref (a referenced through point stays on the arc); construction?: bool", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
|
||||
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", crate::checked!("sketch.arc.three_point", arc_three)).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).contract(&ARC_THREE_POINT_CONTRACT).output(output_schema_for::<DrawResult>).params(concat!("start, end, through: [x,y] or point ref (a referenced through point stays on the arc); construction?: bool", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", crate::checked!("sketch.arc.center_point", arc_center))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_center")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ARC_CENTER_POINT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("center, start, end (counter-clockwise from start; clockwise?: bool for clockwise) | center, start, sweep: signed degrees; construction?: bool", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -154,7 +175,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("slot_overall")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", crate::checked!("sketch.point", draw_point)).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).contract(&POINT_CONTRACT).output(output_schema_for::<PointResult>).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends the Sketch preference, on by default): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
),
|
||||
@@ -177,8 +198,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("c_tangent")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params(concat!("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
|
||||
.output(output_schema_for::<TangentResult>)
|
||||
.params(concat!("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or the end point of a line or an arc: that end then lies on the other curve (or on the other end point), and its curve is tangent there, in one step (`constraints` lists both)", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.equal", "Equal", crate::checked!("sketch.constraint.equal", c_equal))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_equal")
|
||||
@@ -613,7 +634,7 @@ pub(super) fn parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, v: &Value, c
|
||||
if let Some(at) = v.get("at").and_then(vec2) {
|
||||
return Ok(PArg::At(at));
|
||||
}
|
||||
Err(bad(cmd, "a point must be [x, y], a point id like \"l1.end\", or {at, ref}"))
|
||||
Err(bad(cmd, POINT_ARG_MUST))
|
||||
}
|
||||
|
||||
pub(super) fn req_parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, k: &str, cmd: &str) -> Result<PArg> {
|
||||
@@ -622,6 +643,39 @@ pub(super) fn req_parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Valu
|
||||
|
||||
const POINT_MUST: &str = "a point's coordinates must be numbers or expressions over the design's parameters";
|
||||
|
||||
/// What a point argument must be, when it is none of the forms [`parg`] reads.
|
||||
const POINT_ARG_MUST: &str = "a point must be [x, y], a point id like \"l1.end\", or {at, ref}";
|
||||
|
||||
/// Whether `v` is a point argument [`parg`] reads, looking only at the JSON: what the design
|
||||
/// decides (an unknown point id, an expression naming no parameter) stays the handler's. `snaps`:
|
||||
/// the command also reads the forms `sketch_project::snap_points` rewrites first (`{vertex}`,
|
||||
/// `{on_edge}`). The error is the handler's message.
|
||||
pub(crate) fn point_arg_form(v: &Value, snaps: bool) -> std::result::Result<(), &'static str> {
|
||||
if snaps && (v.get("vertex").and_then(vec3).is_some() || v.get("on_edge").and_then(vec3).is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
if vec2(v).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some([x, y]) = v.as_array().map(Vec::as_slice)
|
||||
&& (x.is_string() || y.is_string())
|
||||
{
|
||||
// The coordinates `expr_point` reads: a finite number, or text that isn't blank.
|
||||
let coordinate = |c: &Value| match c {
|
||||
Value::Number(n) => n.as_f64().and_then(crate::params::finite).is_some(),
|
||||
Value::String(e) => !e.trim().is_empty() && e.len() <= 4096,
|
||||
_ => false,
|
||||
};
|
||||
return if coordinate(x) && coordinate(y) { Ok(()) } else { Err(POINT_MUST) };
|
||||
}
|
||||
let named = match v {
|
||||
Value::String(_) => true,
|
||||
Value::Object(o) => o.get("ref").is_some_and(Value::is_string),
|
||||
_ => false,
|
||||
};
|
||||
if named || v.get("at").and_then(vec2).is_some() { Ok(()) } else { Err(POINT_ARG_MUST) }
|
||||
}
|
||||
|
||||
/// A point given as `[x, y]` with a coordinate typed as text: where it is now and, when a
|
||||
/// coordinate names a parameter, the expressions that place it (none: a plain point).
|
||||
pub(super) fn expr_point(doc: &solidcraft_doc::Document, v: &Value, cmd: &str) -> Result<Option<(Vec2, Option<PointExprs>)>> {
|
||||
@@ -708,14 +762,16 @@ pub(crate) fn created(before: Option<&Sketch>, after: &Sketch) -> Value {
|
||||
let curves: Vec<&str> = after.curves.iter().map(|x| x.id.as_str()).filter(new).collect();
|
||||
let (dims, cons): (Vec<_>, Vec<_>) = after.constraints.iter().filter(|c| new(&c.id.as_str())).partition(|c| c.kind.is_dimension());
|
||||
let ids = |v: Vec<&solidcraft_sketch::Constraint>| v.into_iter().map(|c| c.id.clone()).collect::<Vec<_>>();
|
||||
let mut out = json!({"points": points, "curves": curves, "constraints": ids(cons), "dimensions": ids(dims)});
|
||||
let wires: Vec<&str> = after.wires.iter().map(|x| x.id.as_str()).filter(new).collect();
|
||||
if !wires.is_empty()
|
||||
&& let Some(o) = out.as_object_mut()
|
||||
{
|
||||
o.insert("wires".into(), json!(wires));
|
||||
}
|
||||
out
|
||||
let wires: Vec<String> = after.wires.iter().map(|x| x.id.as_str()).filter(new).map(str::to_string).collect();
|
||||
let owned = |v: Vec<&str>| v.into_iter().map(str::to_string).collect::<Vec<_>>();
|
||||
let out = Created { points: owned(points), curves: owned(curves), constraints: ids(cons), dimensions: ids(dims), wires };
|
||||
serde_json::to_value(out).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The reply of a line, circle or arc: what `result` gives, built from [`DrawResult`].
|
||||
fn draw_reply(out: (Vec<String>, Vec<String>), info: Value) -> Result<Value> {
|
||||
let reply = DrawResult { curves: out.0, constraints: out.1, sketch: info, created: None, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
|
||||
@@ -826,17 +882,23 @@ pub(super) fn plane_frame(p: &Value, cmd: &str) -> Result<Option<&'static str>>
|
||||
}
|
||||
}
|
||||
|
||||
/// The note a reply carries when a world plane went into a moved or turned component
|
||||
/// (`placement`: that component's) for an explicit plane read in `frame`, where it differs from
|
||||
/// the component's own.
|
||||
pub(super) fn frame_note(frame: Option<&str>, placement: &solidcraft_doc::Mat) -> Option<&'static str> {
|
||||
(frame == Some("world") && !solidcraft_doc::is_identity(placement)).then_some(
|
||||
"the plane was read in world coordinates and the component is moved or turned, so it is stored at that world place in the component; frame: \"component\" reads it in the component's own coordinates",
|
||||
)
|
||||
}
|
||||
|
||||
/// The reply's `frame` for an explicit plane, with a note when a world plane went into a moved
|
||||
/// or turned component (`placement`: that component's), where it differs from the component's own.
|
||||
pub(super) fn frame_reply(out: &mut Value, frame: Option<&str>, placement: &solidcraft_doc::Mat) {
|
||||
let Some(o) = out.as_object_mut() else { return };
|
||||
let Some(frame) = frame else { return };
|
||||
o.insert("frame".into(), json!(frame));
|
||||
if frame == "world" && !solidcraft_doc::is_identity(placement) {
|
||||
o.insert(
|
||||
"note".into(),
|
||||
json!("the plane was read in world coordinates and the component is moved or turned, so it is stored at that world place in the component; frame: \"component\" reads it in the component's own coordinates"),
|
||||
);
|
||||
if let Some(note) = frame_note(Some(frame), placement) {
|
||||
o.insert("note".into(), json!(note));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1254,7 +1316,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &curves, p);
|
||||
Ok((ids_of(sk, &curves), cons))
|
||||
})?;
|
||||
Ok(result((curves, cons), info))
|
||||
draw_reply((curves, cons), info)
|
||||
}
|
||||
|
||||
/// A relation the new line `c` has with the curve it starts from (drawn exactly so): tangent
|
||||
@@ -1657,7 +1719,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((ids_of(sk, &[ci]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
draw_reply(out, info)
|
||||
}
|
||||
|
||||
fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1672,7 +1734,7 @@ fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &[ci], p);
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
draw_reply(out, info)
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
@@ -1707,7 +1769,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((ids_of(sk, &[ci]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
draw_reply(out, info)
|
||||
}
|
||||
|
||||
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1734,7 +1796,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((ids_of(sk, &[ci]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
draw_reply(out, info)
|
||||
}
|
||||
|
||||
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1801,7 +1863,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &[ci], p);
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
draw_reply(out, info)
|
||||
}
|
||||
|
||||
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
|
||||
@@ -1894,6 +1956,12 @@ fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result<Value> {
|
||||
let (out, info) = slot_parts(s, p, cmd, c0, c1, w)?;
|
||||
Ok(result(out, info))
|
||||
}
|
||||
|
||||
/// What [`slot`] adds: the curve and constraint ids, and the sketch's solve.
|
||||
pub(super) fn slot_parts(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result<((Vec<String>, Vec<String>), Value)> {
|
||||
let h = w / 2.0;
|
||||
let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
|
||||
let n = d.perp();
|
||||
@@ -1912,7 +1980,7 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
|
||||
cons.push(add_c(sk, ConstraintKind::Equal { a: a0, b: a1 })?);
|
||||
Ok((ids_of(sk, &[l1, a1, l2, a0]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
Ok((out, info))
|
||||
}
|
||||
|
||||
fn slot_c2c(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1967,7 +2035,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
};
|
||||
let ((), info) = edit(s, p, cmd, false, |_, _| Ok(()))?;
|
||||
return Ok(json!({"point": pid, "constraints": [], "sketch": info}));
|
||||
return point_reply(pid, Vec::new(), info);
|
||||
}
|
||||
let ((pid, cons), info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let i = match expr_point(doc, p.get("point").unwrap_or(&Value::Null), cmd)? {
|
||||
@@ -1978,7 +2046,13 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cons = connect_points(sk, i, p)?;
|
||||
Ok((sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default(), cons))
|
||||
})?;
|
||||
Ok(json!({"point": pid, "constraints": cons, "sketch": info}))
|
||||
point_reply(pid, cons, info)
|
||||
}
|
||||
|
||||
/// The reply of `sketch.point`, built from [`PointResult`].
|
||||
fn point_reply(point: String, constraints: Vec<String>, sketch: Value) -> Result<Value> {
|
||||
let reply = PointResult { point, constraints, sketch, created: None, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1996,8 +2070,7 @@ fn constrain_reply(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sk
|
||||
reply(cmd, ConstraintResult { made, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The constraint a command added and, when the sketch already determined it, what determines it
|
||||
/// (see [`constraint_result`], which the other constraint commands build their reply with).
|
||||
/// The constraint a command added and, when the sketch already determined it, what determines it.
|
||||
fn constrained(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>) -> Result<Constrained> {
|
||||
let Some(by) = by else { return Ok(Constrained { constraint: id, sketch: info, redundant: None, determined_by: None }) };
|
||||
keep_redundant(p, cmd, &by)?;
|
||||
@@ -2310,21 +2383,6 @@ pub(super) fn keep_redundant(p: &Value, cmd: &str, by: &[String]) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The result of a constraint command: the constraint, and whether it is redundant (and what
|
||||
/// determines it).
|
||||
pub(super) fn constraint_result(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>, extra: Value) -> Result<Value> {
|
||||
let mut out = json!({"constraint": id, "sketch": info});
|
||||
if let Some(by) = by {
|
||||
keep_redundant(p, cmd, &by)?;
|
||||
out["redundant"] = json!(true);
|
||||
out["determined_by"] = json!(by);
|
||||
}
|
||||
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The residual of the constraint `id` where the sketch is now (see
|
||||
/// [`solidcraft_sketch::constraint_residual`]).
|
||||
pub(super) fn residual_of(sk: &Sketch, id: &str) -> Option<f64> {
|
||||
@@ -2578,12 +2636,123 @@ fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, u
|
||||
}
|
||||
|
||||
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.tangent", &["a", "b"])?;
|
||||
constrain_reply(s, p, "sketch.constraint.tangent", |sk| {
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.tangent")
|
||||
.and_then(|ab| two_different("sketch.constraint.tangent", ab))
|
||||
.map(|(a, b)| ConstraintKind::Tangent { a, b })
|
||||
})
|
||||
let cmd = "sketch.constraint.tangent";
|
||||
let p = &super::sketch_project::project_operands(s, p, cmd, &["a", "b"])?;
|
||||
let sk = sketch_store::read(&s.doc, sketch_store::target(s, p, cmd)?)?;
|
||||
if tangent_end(sk, p, "a").is_some() || tangent_end(sk, p, "b").is_some() {
|
||||
return tangent_at_end(s, p, cmd);
|
||||
}
|
||||
let made = constrain(s, p, cmd, |sk| {
|
||||
two_curves_or_axes(sk, p, cmd).and_then(|ab| two_different(cmd, ab)).map(|(a, b)| ConstraintKind::Tangent { a, b })
|
||||
})?;
|
||||
reply(cmd, TangentResult { made: TangentMade::Curves(made), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The point a Tangent operand names when it names no curve and no sketch axis (#12092). `x` and
|
||||
/// `y` name the axes even before their lines exist, as in [`line_or_axis_ref`].
|
||||
fn tangent_end(sk: &Sketch, p: &Value, key: &str) -> Option<usize> {
|
||||
let id = p.get(key)?.as_str()?;
|
||||
if sk.curve_index(id).is_some() || solidcraft_sketch::sketch_axis_geom(id).is_some() {
|
||||
return None;
|
||||
}
|
||||
sk.resolve_point(id)
|
||||
}
|
||||
|
||||
/// The one curve that ends at point `q`, leaving out `other`; `None` when no curve or more than
|
||||
/// one does.
|
||||
fn ended_by(sk: &Sketch, q: usize, other: Option<usize>) -> Option<usize> {
|
||||
let mut owners = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, c)| Some(*i) != other && c.kind.ends().is_some_and(|(start, end)| start == q || end == q))
|
||||
.map(|(i, _)| i);
|
||||
let first = owners.next()?;
|
||||
owners.next().is_none().then_some(first)
|
||||
}
|
||||
|
||||
/// Whether `k` already holds through the sketch's own constraints, so Tangent need not add it
|
||||
/// (#12092): the same constraint (a coincident in either order), or a point on a curve that is one
|
||||
/// of that curve's ends or is joined to one by a coincident.
|
||||
fn already_held(sk: &Sketch, k: &ConstraintKind) -> bool {
|
||||
let joined = |p: usize, q: usize| {
|
||||
p == q
|
||||
|| sk
|
||||
.constraints
|
||||
.iter()
|
||||
.any(|c| !c.driven && matches!(c.kind, ConstraintKind::Coincident { p: x, q: y } if (x, y) == (p, q) || (x, y) == (q, p)))
|
||||
};
|
||||
match *k {
|
||||
ConstraintKind::Coincident { p, q } => joined(p, q),
|
||||
ConstraintKind::PointOnCurve { p, c } => {
|
||||
sk.curves.get(c).and_then(|curve| curve.kind.ends()).is_some_and(|(start, end)| joined(p, start) || joined(p, end))
|
||||
|| sk.constraints.iter().any(|x| !x.driven && x.kind == *k)
|
||||
}
|
||||
_ => sk.constraints.iter().any(|c| !c.driven && c.kind == *k),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tangent with a curve's end point for `a` or `b` (#12092): that end lies on the other curve,
|
||||
/// or on the other end point, and the curve it ends is tangent to the other curve there. Both
|
||||
/// constraints come in one step. A line's end picked with a curve is what moves; otherwise the
|
||||
/// first pick stays and the second moves, as with two curves.
|
||||
fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let not_an_end = |key: &str| {
|
||||
let id = p.get(key).and_then(Value::as_str).unwrap_or_default();
|
||||
bad(cmd, format!("`{id}` is not the end of one curve: pick a curve, or the end point of a line or an arc"))
|
||||
};
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let rep = solve(&mut sk.clone());
|
||||
let (pa, pb) = (tangent_end(sk, p, "a"), tangent_end(sk, p, "b"));
|
||||
let ca = if pa.is_none() { Some(line_or_axis_ref(sk, p.get("a"), cmd, "a")?) } else { None };
|
||||
let cb = if pb.is_none() { Some(line_or_axis_ref(sk, p.get("b"), cmd, "b")?) } else { None };
|
||||
let a = match (ca, pa) {
|
||||
(Some(c), _) => c,
|
||||
(None, Some(q)) => ended_by(sk, q, cb).ok_or_else(|| not_an_end("a"))?,
|
||||
(None, None) => return Err(not_an_end("a")),
|
||||
};
|
||||
let b = match (cb, pb) {
|
||||
(Some(c), _) => c,
|
||||
(None, Some(q)) => ended_by(sk, q, Some(a)).ok_or_else(|| not_an_end("b"))?,
|
||||
(None, None) => return Err(not_an_end("b")),
|
||||
};
|
||||
let (a, b) = two_different(cmd, (a, b))?;
|
||||
let touch = match (pa, pb) {
|
||||
(Some(q), Some(r)) if q != r => Some(ConstraintKind::Coincident { p: q, q: r }),
|
||||
(Some(q), None) => Some(ConstraintKind::PointOnCurve { p: q, c: b }),
|
||||
(None, Some(r)) => Some(ConstraintKind::PointOnCurve { p: r, c: a }),
|
||||
_ => None,
|
||||
};
|
||||
// A joint: the end already lies on the other curve (it is that curve's end, or joined to it).
|
||||
let joint = touch.as_ref().is_some_and(|k| already_held(sk, k));
|
||||
let mut ids = Vec::new();
|
||||
if let Some(k) = touch.filter(|_| !joint) {
|
||||
ids.push(add_c(sk, k)?);
|
||||
}
|
||||
let tangent = add_c(sk, ConstraintKind::Tangent { a, b })?;
|
||||
ids.push(tangent.clone());
|
||||
// A line's end and a curve, in either order: only that end moves, so the line turns about
|
||||
// its other end and gets longer or shorter to meet the curve (#12092). At a joint that end
|
||||
// can't leave the other curve: the curve it ends stays, and the other curve moves. Otherwise,
|
||||
// as for two curves, the first pick stays and the second moves.
|
||||
let end = match (pa, pb) {
|
||||
(Some(q), None) => Some((q, a, b)),
|
||||
(None, Some(r)) => Some((r, b, a)),
|
||||
_ => None,
|
||||
};
|
||||
let far = end.filter(|_| !joint).and_then(|(q, line, other)| match sk.curves.get(line).map(|c| &c.kind) {
|
||||
Some(k @ CurveKind::Line { .. }) => k.ends().map(|(start, end)| (q, if start == q { end } else { start }, other)),
|
||||
_ => None,
|
||||
});
|
||||
let (stay, moves) = end.filter(|_| joint).map_or((a, b), |(_, own, other)| (own, other));
|
||||
match far {
|
||||
Some((q, f, other)) => apply_in_order(sk, &rep, &[Ent::Curve(other), Ent::Point(f)], Ent::Point(q), cmd, &ids)?,
|
||||
None => apply_in_order(sk, &rep, &[Ent::Curve(stay)], Ent::Curve(moves), cmd, &ids)?,
|
||||
}
|
||||
Ok((tangent, ids))
|
||||
})?;
|
||||
let (constraint, constraints) = out;
|
||||
reply(cmd, TangentResult { made: TangentMade::End { constraint, constraints, sketch: info }, param_warnings: Vec::new() })
|
||||
}
|
||||
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("curves").is_some() {
|
||||
|
||||
@@ -11,16 +11,30 @@ use super::sketch::edit;
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, req_vec3, vec3};
|
||||
use crate::schema::sketch_3d::{Curves3dResult, Line3dParams, Move3dParams, Move3dResult, Point3dParams, Point3dResult, Spline3dParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const LINE3D_CONTRACT: TypedContract<Line3dParams> = TypedContract::new();
|
||||
const SPLINE3D_CONTRACT: TypedContract<Spline3dParams> = TypedContract::new();
|
||||
const POINT3D_CONTRACT: TypedContract<Point3dParams> = TypedContract::new();
|
||||
const MOVE3D_CONTRACT: TypedContract<Move3dParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch_3d).params(concat!(
|
||||
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
|
||||
CommandSpec::new("sketch.line3d", "3D Line", crate::checked!("sketch.line3d", line3d))
|
||||
.icon("line")
|
||||
.enabled(in_sketch_3d)
|
||||
.contract(&LINE3D_CONTRACT)
|
||||
.output(output_schema_for::<Curves3dResult>)
|
||||
.params(concat!(
|
||||
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline3d", "3D Spline", crate::checked!("sketch.spline3d", spline3d))
|
||||
.icon("spline")
|
||||
.enabled(in_sketch_3d)
|
||||
.contract(&SPLINE3D_CONTRACT)
|
||||
.output(output_schema_for::<Curves3dResult>)
|
||||
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
|
||||
CommandSpec::new("sketch.arc3d", "3D Arc", arc3d)
|
||||
.icon("arc3")
|
||||
@@ -29,16 +43,26 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"start, through, end: [x,y,z] world points: an exact circular arc through them, from start through through to end (needs 3D Sketch on). Stored exactly (centre, radius, angles), so path consumers can treat it as a true arc",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.point3d", "3D Point", point3d)
|
||||
CommandSpec::new("sketch.point3d", "3D Point", crate::checked!("sketch.point3d", point3d))
|
||||
.icon("point")
|
||||
.enabled(in_sketch_3d)
|
||||
.contract(&POINT3D_CONTRACT)
|
||||
.output(output_schema_for::<Point3dResult>)
|
||||
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
|
||||
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
|
||||
CommandSpec::new("sketch.move3d", "Move 3D Point", crate::checked!("sketch.move3d", move3d))
|
||||
.icon("sketch_move")
|
||||
.enabled(in_sketch)
|
||||
.contract(&MOVE3D_CONTRACT)
|
||||
.output(output_schema_for::<Move3dResult>)
|
||||
.params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"),
|
||||
];
|
||||
|
||||
/// A command's reply as JSON: built from the type its output schema describes, so the two can't
|
||||
/// drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn points(cmd: &str, p: &Value, min: usize) -> Result<Vec<Vec3>> {
|
||||
let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must list [x,y,z] points"))?;
|
||||
if a.len() < min || a.len() > solidcraft_sketch::MAX_FIT_POINTS {
|
||||
@@ -106,7 +130,7 @@ fn line3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
needs_3d(sk, cmd)?;
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
})?;
|
||||
Ok(json!({"curves": ids, "sketch": info}))
|
||||
reply(cmd, Curves3dResult { curves: ids, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -117,7 +141,7 @@ fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"curves": [id], "sketch": info}))
|
||||
reply(cmd, Curves3dResult { curves: vec![id], sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn arc3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -145,7 +169,7 @@ fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"point": id, "sketch": info}))
|
||||
reply(cmd, Point3dResult { point: id, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -183,7 +207,7 @@ fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
Ok(sk.move_drawn_point(&wire, index, to)?)
|
||||
})?;
|
||||
Ok(json!({"moved": n, "sketch": info}))
|
||||
reply(cmd, Move3dResult { moved: n, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -95,6 +95,23 @@ fn a_real_curve_named_like_an_axis_wins() {
|
||||
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// Tangent also takes a curve's end point (#12092), but `x` and `y` still name the axes before
|
||||
/// their lines exist: a point called `x` does not take the axis's place.
|
||||
#[test]
|
||||
fn a_point_named_like_an_axis_leaves_tangent_on_the_axis() {
|
||||
for (a, b) in [("c", "x"), ("x", "c")] {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.point", json!({"point": [3, 7], "id": "x", "connect": false}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
|
||||
run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(axis_lines(&sk).iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["x"], "{a}, {b}: the x axis's line");
|
||||
let CurveKind::Circle { c: centre, r } = sk.curves[sk.curve_index("c").unwrap()].kind else { panic!() };
|
||||
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "{a}, {b}: tangent to x");
|
||||
assert!(pt(&sk, "x").dist(Vec2::new(3.0, 7.0)) < 1e-9, "{a}, {b}: the point called x moved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
|
||||
let mut s = on(json!("XY"));
|
||||
|
||||
@@ -1,54 +1,81 @@
|
||||
//! More sketch constraints (curvature, polygon), constraint glyph anchors and snapping
|
||||
//! inference for interactive tools.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
|
||||
use super::sketch::{add_c, dof_now, edit, keep_redundant, listed, redundancy, residual_of};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
|
||||
use crate::schema::sketch_constraints::{ToggleDiameterParams, ToggleDiameterResult};
|
||||
use crate::schema::sketch_constraints::{
|
||||
AutoConstrainDatumParams, AutoConstrainParams, AutoConstrainResult, ConstrainerParams, ConstrainerResult, ConstraintAdded, CurvatureParams,
|
||||
FinishAutoConstrainParams, Glyph, GlyphsParams, GlyphsResult, PolygonParams, PolygonResult, Snap, SnapParams, SnapResult, ToggleDiameterParams,
|
||||
ToggleDiameterResult, ToggleDrivenParams, ToggleDrivenResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const TOGGLE_DIAMETER_CONTRACT: TypedContract<ToggleDiameterParams> = TypedContract::new();
|
||||
const CURVATURE_CONTRACT: TypedContract<CurvatureParams> = TypedContract::new();
|
||||
const POLYGON_CONTRACT: TypedContract<PolygonParams> = TypedContract::new();
|
||||
const AUTO_CONSTRAIN_CONTRACT: TypedContract<AutoConstrainParams> = TypedContract::new();
|
||||
const AUTO_CONSTRAIN_DATUM_CONTRACT: TypedContract<AutoConstrainDatumParams> = TypedContract::new();
|
||||
const FINISH_AUTO_CONSTRAIN_CONTRACT: TypedContract<FinishAutoConstrainParams> = TypedContract::new();
|
||||
const CONSTRAINER_CONTRACT: TypedContract<ConstrainerParams> = TypedContract::new();
|
||||
const TOGGLE_DRIVEN_CONTRACT: TypedContract<ToggleDrivenParams> = TypedContract::new();
|
||||
const GLYPHS_CONTRACT: TypedContract<GlyphsParams> = TypedContract::new();
|
||||
const SNAP_CONTRACT: TypedContract<SnapParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
|
||||
CommandSpec::new("sketch.constraint.curvature", "Curvature", crate::checked!("sketch.constraint.curvature", c_smooth))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_smooth")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CURVATURE_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintAdded>)
|
||||
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
|
||||
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
|
||||
CommandSpec::new("sketch.constraint.polygon", "Polygon", crate::checked!("sketch.constraint.polygon", c_polygon))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_polygon")
|
||||
.enabled(in_sketch)
|
||||
.contract(&POLYGON_CONTRACT)
|
||||
.output(output_schema_for::<PolygonResult>)
|
||||
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
|
||||
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
|
||||
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", crate::checked!("sketch.auto_constrain", auto_constrain))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("auto_constrain")
|
||||
.enabled(in_sketch)
|
||||
.contract(&AUTO_CONSTRAIN_CONTRACT)
|
||||
.output(output_schema_for::<AutoConstrainResult>)
|
||||
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
|
||||
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", auto_constrain_datum)
|
||||
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", crate::checked!("sketch.auto_constrain_from_datum", auto_constrain_datum))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("auto_constrain")
|
||||
.enabled(in_sketch)
|
||||
.contract(&AUTO_CONSTRAIN_DATUM_CONTRACT)
|
||||
.output(output_schema_for::<AutoConstrainResult>)
|
||||
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
|
||||
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
|
||||
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", crate::checked!("sketch.finish_auto_constrain", auto_constrain_finish))
|
||||
.at("SKETCH", "FINISH SKETCH")
|
||||
.icon("finish")
|
||||
.enabled(in_sketch)
|
||||
.contract(&FINISH_AUTO_CONSTRAIN_CONTRACT)
|
||||
.output(output_schema_for::<AutoConstrainResult>)
|
||||
.params("like AutoConstrain, then Finish Sketch"),
|
||||
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
|
||||
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", crate::checked!("sketch.constrainer", smart_constrain))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("constrainer")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CONSTRAINER_CONTRACT)
|
||||
.output(output_schema_for::<ConstrainerResult>)
|
||||
.params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"),
|
||||
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven).icon("dimension")
|
||||
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", crate::checked!("sketch.toggle_driven", toggle_driven)).icon("dimension")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TOGGLE_DRIVEN_CONTRACT)
|
||||
.output(output_schema_for::<ToggleDrivenResult>)
|
||||
.params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"),
|
||||
CommandSpec::new("sketch.toggle_diameter", "Toggle Radius / Diameter", crate::checked!("sketch.toggle_diameter", toggle_diameter))
|
||||
.icon("dimension")
|
||||
@@ -56,13 +83,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&TOGGLE_DIAMETER_CONTRACT)
|
||||
.output(output_schema_for::<ToggleDiameterResult>)
|
||||
.params("constraint: a circle's or an arc's radius or diameter dimension id; diameter?: bool (default: the other one); sketch?: id|name (default: the one being edited). The circle stays the same size: its parameter is halved or doubled (a plain value is rewritten, an expression becomes `(e) / 2` or `2 * (e)`); refused when anything else uses that parameter"),
|
||||
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs).icon("eye")
|
||||
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", crate::checked!("sketch.glyphs", glyphs)).icon("eye")
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.contract(&GLYPHS_CONTRACT)
|
||||
.output(output_schema_for::<GlyphsResult>)
|
||||
.params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"),
|
||||
CommandSpec::new("sketch.snap", "Snap Inference", snap).icon("snap")
|
||||
CommandSpec::new("sketch.snap", "Snap Inference", crate::checked!("sketch.snap", snap)).icon("snap")
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.contract(&SNAP_CONTRACT)
|
||||
.output(output_schema_for::<SnapResult>)
|
||||
.params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"),
|
||||
];
|
||||
|
||||
@@ -78,7 +109,26 @@ fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(id)
|
||||
})?;
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, Value::Null)
|
||||
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
|
||||
reply(cmd, ConstraintAdded { constraint: id, sketch: info, redundant, determined_by, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A command's reply as JSON: they build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// What a new constraint's reply says of redundancy: whether it is, and by which constraints. A
|
||||
/// redundant one is refused when the call asked for `if_redundant: "refuse"`.
|
||||
fn redundant_part(p: &Value, cmd: &str, by: Option<Vec<String>>) -> Result<(bool, Option<Vec<String>>)> {
|
||||
match by {
|
||||
Some(by) => {
|
||||
keep_redundant(p, cmd, &by)?;
|
||||
Ok((true, Some(by)))
|
||||
}
|
||||
None => Ok((false, None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
@@ -126,7 +176,7 @@ fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(cons)
|
||||
})?;
|
||||
Ok(json!({"constraints": out, "sketch": info}))
|
||||
reply(cmd, PolygonResult { constraints: out, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Most constraints AutoConstrain tries.
|
||||
@@ -273,7 +323,7 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
// its freedom whenever they change constraints; no separate initial solve is needed.
|
||||
Ok(run_auto(sk, doc, p, datum, rep.dof))
|
||||
})?;
|
||||
Ok(json!({"added": added, "dof": dof, "sketch": info}))
|
||||
reply(cmd, AutoConstrainResult { added, dof, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -375,7 +425,19 @@ fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(id)
|
||||
})?;
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, json!({"type": name}))
|
||||
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
|
||||
reply(
|
||||
cmd,
|
||||
ConstrainerResult {
|
||||
constraint: id,
|
||||
sketch: info,
|
||||
redundant,
|
||||
determined_by,
|
||||
kind: name.to_string(),
|
||||
created: None,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -417,7 +479,7 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.doc_mut().prune_model_params();
|
||||
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
|
||||
reply(cmd, ToggleDrivenResult { constraint: cid, driven, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn toggle_diameter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -528,26 +590,32 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
|
||||
let failing = &ss.report.failing;
|
||||
let list: Vec<Value> = ss
|
||||
let glyphs: Vec<Glyph> = ss
|
||||
.sketch
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"name": c.kind.name(),
|
||||
"dimension": c.kind.is_dimension(),
|
||||
"driven": c.driven,
|
||||
"value": c.kind.value(),
|
||||
"param": c.param,
|
||||
"at": anchor(&ss.sketch, &c.kind),
|
||||
"failing": failing.contains(&c.id),
|
||||
// Implied by the others (it removes no freedom) but holding: shown dimmed.
|
||||
"redundant": ss.report.redundant.contains(&c.id),
|
||||
})
|
||||
.map(|c| Glyph {
|
||||
id: c.id.clone(),
|
||||
name: c.kind.name().to_string(),
|
||||
dimension: c.kind.is_dimension(),
|
||||
driven: c.driven,
|
||||
value: c.kind.value(),
|
||||
param: c.param.clone(),
|
||||
at: anchor(&ss.sketch, &c.kind).map(|a| [a.x, a.y]),
|
||||
failing: failing.contains(&c.id),
|
||||
// Implied by the others (it removes no freedom) but holding: shown dimmed.
|
||||
redundant: ss.report.redundant.contains(&c.id),
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"sketch": id, "glyphs": list, "dof": ss.report.dof, "fully_constrained": ss.report.fully_constrained(), "conflicts": failing}))
|
||||
let result = GlyphsResult {
|
||||
sketch: id,
|
||||
glyphs,
|
||||
dof: ss.report.dof,
|
||||
fully_constrained: ss.report.fully_constrained(),
|
||||
conflicts: failing.clone(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply("sketch.glyphs", result)
|
||||
}
|
||||
|
||||
/// Snap inference for a click at `at`: the nearest sketch point (coincident), the midpoint of a
|
||||
@@ -562,12 +630,12 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
|
||||
let sk = &ss.sketch;
|
||||
let mut out: Vec<(f64, Value)> = Vec::new();
|
||||
let mut out: Vec<(f64, Snap)> = Vec::new();
|
||||
// Points (end points, centres, the origin).
|
||||
for q in &sk.points {
|
||||
let d = q.pos.dist(at);
|
||||
if d <= r {
|
||||
out.push((d * 0.5, json!({"type": "coincident", "point": q.id, "at": q.pos})));
|
||||
out.push((d * 0.5, Snap::Coincident { point: q.id.clone(), at: [q.pos.x, q.pos.y] }));
|
||||
}
|
||||
}
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
@@ -577,13 +645,13 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = (pa + pb) * 0.5;
|
||||
let d = m.dist(at);
|
||||
if d <= r {
|
||||
out.push((d * 0.6, json!({"type": "midpoint", "curve": c.id, "at": m})));
|
||||
out.push((d * 0.6, Snap::Midpoint { curve: c.id.clone(), at: [m.x, m.y] }));
|
||||
}
|
||||
}
|
||||
if let Some(sh) = sk.shape(i) {
|
||||
let q = sh.project(at);
|
||||
if sh.dist(at) <= r && q.dist(at) <= r {
|
||||
out.push((sh.dist(at) + r * 0.5, json!({"type": "on_curve", "curve": c.id, "at": q})));
|
||||
out.push((sh.dist(at) + r * 0.5, Snap::OnCurve { curve: c.id.clone(), at: [q.x, q.y] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -591,15 +659,15 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = from {
|
||||
let d = at - f;
|
||||
if d.y.abs() <= r && d.x.abs() > r {
|
||||
out.push((d.y.abs() + r, json!({"type": "horizontal", "at": [at.x, f.y]})));
|
||||
out.push((d.y.abs() + r, Snap::Horizontal { at: [at.x, f.y] }));
|
||||
}
|
||||
if d.x.abs() <= r && d.y.abs() > r {
|
||||
out.push((d.x.abs() + r, json!({"type": "vertical", "at": [f.x, at.y]})));
|
||||
out.push((d.x.abs() + r, Snap::Vertical { at: [f.x, at.y] }));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let list: Vec<Value> = out.into_iter().take(8).map(|x| x.1).collect();
|
||||
Ok(json!({"snaps": list}))
|
||||
let snaps: Vec<Snap> = out.into_iter().take(8).map(|x| x.1).collect();
|
||||
reply(cmd, SnapResult { snaps, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,11 +13,16 @@ macro_rules! text_style_doc {
|
||||
};
|
||||
}
|
||||
|
||||
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
|
||||
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, slot_parts};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list};
|
||||
use crate::schema::sketch_create::{SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams, SplineReverseResult};
|
||||
use crate::schema::sketch_create::{
|
||||
ArcSlotCenterParams, ArcSlotResult, ArcSlotThreePointParams, ArcTangentParams, BlendCurveParams, CenterlineParams, ConicParams,
|
||||
ControlPointSplineParams, DrawResult, EditTextParams, EditTextResult, EllipseParams, FitPointSplineParams, InfiniteLineParams, LineTypeResult,
|
||||
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams,
|
||||
SplineReverseResult, TextChange, TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -29,62 +34,108 @@ mod text_move_tests;
|
||||
#[path = "sketch_text_expr_tests.rs"]
|
||||
mod text_expr_tests;
|
||||
|
||||
const MIDPOINT_LINE_CONTRACT: TypedContract<MidpointLineParams> = TypedContract::new();
|
||||
const ARC_TANGENT_CONTRACT: TypedContract<ArcTangentParams> = TypedContract::new();
|
||||
const TWO_TANGENT_CONTRACT: TypedContract<TwoTangentCircleParams> = TypedContract::new();
|
||||
const THREE_TANGENT_CONTRACT: TypedContract<ThreeTangentCircleParams> = TypedContract::new();
|
||||
const SLOT_CENTER_POINT_CONTRACT: TypedContract<SlotCenterPointParams> = TypedContract::new();
|
||||
const ARC_SLOT_THREE_CONTRACT: TypedContract<ArcSlotThreePointParams> = TypedContract::new();
|
||||
const ARC_SLOT_CENTER_CONTRACT: TypedContract<ArcSlotCenterParams> = TypedContract::new();
|
||||
const ELLIPSE_CONTRACT: TypedContract<EllipseParams> = TypedContract::new();
|
||||
const SPLINE_FIT_CONTRACT: TypedContract<FitPointSplineParams> = TypedContract::new();
|
||||
const SPLINE_CONTROL_CONTRACT: TypedContract<ControlPointSplineParams> = TypedContract::new();
|
||||
const CONIC_CONTRACT: TypedContract<ConicParams> = TypedContract::new();
|
||||
const BLEND_CONTRACT: TypedContract<BlendCurveParams> = TypedContract::new();
|
||||
const CENTERLINE_CONTRACT: TypedContract<CenterlineParams> = TypedContract::new();
|
||||
const INFINITE_CONTRACT: TypedContract<InfiniteLineParams> = TypedContract::new();
|
||||
const TEXT_CONTRACT: TypedContract<SketchTextParams> = TypedContract::new();
|
||||
const EDIT_TEXT_CONTRACT: TypedContract<EditTextParams> = TypedContract::new();
|
||||
const SPLINE_REVERSE_CONTRACT: TypedContract<SplineReverseParams> = TypedContract::new();
|
||||
const SPLINE_REMOVE_POINT_CONTRACT: TypedContract<SplineRemovePointParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", crate::checked!("sketch.line.midpoint", midpoint_line))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("line_mid")
|
||||
.enabled(in_sketch)
|
||||
.contract(&MIDPOINT_LINE_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("mid: [x,y], point ref or snap (\"mid:<line>\", \"on:<curve>:x,y\", model vertex/edge); end: [x,y]; construction?", created_doc!())),
|
||||
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", crate::checked!("sketch.arc.tangent", arc_tangent))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_tangent")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ARC_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!(
|
||||
"start: end point of a line or arc (\"l1.end\"), end: [x,y] or an existing point's id (\"l2.start\": the arc ends on that point); curve?: the curve to be tangent to (default: the one ending at start)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
|
||||
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", crate::checked!("sketch.circle.two_tangent", circle_tan_tan))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_tt")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TWO_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())),
|
||||
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
|
||||
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", crate::checked!("sketch.circle.three_tangent", circle_three_tan))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_ttt")
|
||||
.enabled(in_sketch)
|
||||
.contract(&THREE_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
|
||||
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", crate::checked!("sketch.slot.center_point", slot_center_point))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot_center")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SLOT_CENTER_POINT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
|
||||
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", crate::checked!("sketch.slot.arc_three_point", arc_slot_three))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ARC_SLOT_THREE_CONTRACT)
|
||||
.output(output_schema_for::<ArcSlotResult>)
|
||||
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
|
||||
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", crate::checked!("sketch.slot.arc_center", arc_slot_center))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot_center")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ARC_SLOT_CENTER_CONTRACT)
|
||||
.output(output_schema_for::<ArcSlotResult>)
|
||||
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise) or sweep: degrees (0 < sweep < 360), width; returns centre_arc", created_doc!())),
|
||||
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("sketch.ellipse", "Ellipse", crate::checked!("sketch.ellipse", ellipse))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("ellipse")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ELLIPSE_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!(
|
||||
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius. Also adds its major and minor axes as construction lines through the centre (their ends: the major end and the point opposite it, and the two ends of the minor axis on the ellipse), held by midpoint, perpendicular and point-on-curve constraints, so a half-axis is the distance from the centre to an axis end and an axis line's length is a full axis; axes?: bool (default true; false: the ellipse alone, as the Ellipse tool draws it with Draw ellipses with their axes off)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
|
||||
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", crate::checked!("sketch.spline.fit_point", spline_fit))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_FIT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", crate::checked!("sketch.spline.control_point", spline_cv3))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_CONTROL_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: control points (2…500), cubic", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
|
||||
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", crate::checked!("sketch.spline.control_point_5", spline_cv5))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_CONTROL_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: control points (2…500), degree 5", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.insert_point", "Add Spline Point", spline_insert_point)
|
||||
.icon("spline")
|
||||
@@ -102,36 +153,54 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&SPLINE_REVERSE_CONTRACT)
|
||||
.output(output_schema_for::<SplineReverseResult>)
|
||||
.params("curve: spline id (fit point, control point or closed): its last point becomes its first and the curve runs the other way along the same path; its points and the constraints on them stay. Refused for a projected spline and for one an offset uses or makes. Returns curve"),
|
||||
CommandSpec::new("sketch.conic", "Conic Curve", conic)
|
||||
CommandSpec::new("sketch.conic", "Conic Curve", crate::checked!("sketch.conic", conic))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("conic")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CONIC_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())),
|
||||
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
|
||||
CommandSpec::new("sketch.blend_curve", "Blend Curve", crate::checked!("sketch.blend_curve", blend))
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("blend")
|
||||
.enabled(in_sketch)
|
||||
.contract(&BLEND_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!(
|
||||
"a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.centerline", "Centerline", centerline)
|
||||
CommandSpec::new("sketch.centerline", "Centerline", crate::checked!("sketch.centerline", centerline))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("centerline")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CENTERLINE_CONTRACT)
|
||||
.output(output_schema_for::<LineTypeResult>)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite")
|
||||
CommandSpec::new("sketch.infinite", "Infinite Line", crate::checked!("sketch.infinite", infinite_line))
|
||||
.icon("infinite")
|
||||
.enabled(in_sketch)
|
||||
.contract(&INFINITE_CONTRACT)
|
||||
.output(output_schema_for::<LineTypeResult>)
|
||||
.params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"),
|
||||
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("sketch.text", "Text", crate::checked!("sketch.text", text))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_text")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TEXT_CONTRACT)
|
||||
.output(output_schema_for::<TextResult>)
|
||||
.params(concat!(
|
||||
"text: string (\\n breaks lines), or text_expression: the words built from text parameters (parameters.add with unit text), number parameters and quoted texts joined by + (\"Label + '-L'\", \"'W' + Width\"; a number parameter shows its value in the design's units, \"85 mm\", an angle in degrees, \"30°\"; number(Width) is the number alone and number(Width, 2) has 2 decimals): the text keeps it, its words follow the parameters (a parameter edit or a configuration row relabels it) and `exprs` returns it as `text`; at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
|
||||
font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
|
||||
text_style_doc!(),
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text).icon("edit")
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", crate::checked!("sketch.edit_text", edit_text))
|
||||
.icon("edit")
|
||||
.enabled(in_sketch)
|
||||
.live(edit_text_live)
|
||||
.contract(&EDIT_TEXT_CONTRACT)
|
||||
.output(output_schema_for::<EditTextResult>)
|
||||
.params(concat!(
|
||||
"link: the text's link id (or entity: one of its curves), text? or text_expression? (as in sketch.text; typed words replace the expression), at?, height?, angle?: deg (numbers or expressions as in sketch.text; a number replaces an expression, and so does moving the text), font?: family or file path (empty: the built-in font), ",
|
||||
text_style_doc!(),
|
||||
@@ -141,6 +210,20 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
)),
|
||||
];
|
||||
|
||||
/// A reply made of a typed value, which is also what its output schema describes.
|
||||
fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
|
||||
serde_json::to_value(value).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// What a drawing command returns: the curves and constraints it made, and the sketch's solve.
|
||||
fn drawn_result(out: (Vec<String>, Vec<String>), info: Value) -> DrawResult {
|
||||
DrawResult { curves: out.0, constraints: out.1, sketch: info, created: None, param_warnings: Vec::new() }
|
||||
}
|
||||
|
||||
fn drawn(out: (Vec<String>, Vec<String>), info: Value) -> Result<Value> {
|
||||
reply(drawn_result(out, info))
|
||||
}
|
||||
|
||||
/// The style a command's parameters give, over `old`.
|
||||
fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
|
||||
let mut style = old;
|
||||
@@ -181,7 +264,7 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
|
||||
}
|
||||
|
||||
/// What a text's `at` must be.
|
||||
const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
|
||||
pub(crate) const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
|
||||
|
||||
/// One number of a text, from a command's parameter `key`. A number is that number (mm, or
|
||||
/// degrees for the angle). A string is an expression over the design's parameters: a bare number
|
||||
@@ -278,15 +361,17 @@ fn text_source(doc: &solidcraft_doc::Document, p: &Value, cmd: &str, old: Option
|
||||
}
|
||||
|
||||
/// The expressions behind a text's numbers, for a command's reply (`{"height": "plate_h / 10"}`).
|
||||
fn text_exprs_json(src: &LinkSource) -> Option<Value> {
|
||||
fn text_exprs(src: &LinkSource) -> Option<TextExpressions> {
|
||||
let LinkSource::Text { exprs, .. } = src else { return None };
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, e) in [("x", &exprs.x), ("y", &exprs.y), ("height", &exprs.height), ("angle", &exprs.angle), ("text", &exprs.text)] {
|
||||
if let Some(e) = e {
|
||||
out.insert(key.into(), json!(e));
|
||||
}
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
let out = TextExpressions {
|
||||
x: exprs.x.clone(),
|
||||
y: exprs.y.clone(),
|
||||
height: exprs.height.clone(),
|
||||
angle: exprs.angle.clone(),
|
||||
text: exprs.text.clone(),
|
||||
};
|
||||
let any = [&out.x, &out.y, &out.height, &out.angle, &out.text].iter().any(|e| e.is_some());
|
||||
any.then_some(out)
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -296,7 +381,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let src = text_source(&s.doc, p, cmd, None)?;
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
let exprs = text_exprs_json(&src);
|
||||
let exprs = text_exprs(&src);
|
||||
let (l, curves, guide) = sketch_store::update(s, id, |_, _, sk| {
|
||||
// A text needs nothing outside its sketch (a path is one of its curves).
|
||||
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
@@ -308,11 +393,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let curves = ids_of(sk, &sk.link_curves(&l));
|
||||
Ok((l, curves, guide))
|
||||
})?;
|
||||
let mut out = json!({"link": l, "curves": curves, "guide": guide});
|
||||
if let Some(exprs) = exprs {
|
||||
out["exprs"] = exprs;
|
||||
}
|
||||
Ok(out)
|
||||
reply(TextResult { change: TextChange { link: l, curves, guide, exprs }, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A text's path is its guide: the curve it follows becomes construction. Left as it was, it
|
||||
@@ -378,17 +459,18 @@ pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation:
|
||||
}
|
||||
|
||||
/// The most texts one `sketch.edit_text {edits}` changes.
|
||||
const MAX_TEXT_EDITS: usize = 1000;
|
||||
pub(crate) const MAX_TEXT_EDITS: usize = 1000;
|
||||
|
||||
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.edit_text";
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
sketch_store::update(s, id, |_, doc, sk| {
|
||||
let edited = sketch_store::update(s, id, |_, doc, sk| {
|
||||
// The numbers its expressions give now (a parameter may have changed since the sketch was
|
||||
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
|
||||
let _ = doc.apply_text_values(&doc.param_values().0, sk);
|
||||
apply_text_edits(sk, doc, p)
|
||||
})
|
||||
})?;
|
||||
reply(edited)
|
||||
}
|
||||
|
||||
/// One frame of an interactive resize or rotation: the sketch is solved and shown, the
|
||||
@@ -399,21 +481,24 @@ fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::Li
|
||||
let (out, report) = super::sketch::edit_solved(s, id, false, solidcraft_sketch::Analysis::Full, |sk, doc| {
|
||||
Ok((apply_text_edits(sk, doc, p)?, super::sketch::Left::Unsolved))
|
||||
})?;
|
||||
Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
Ok((reply(out)?, report.map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
}
|
||||
|
||||
/// `sketch.edit_text`'s change: one text's (`link` or `entity`), or each of `edits` in turn.
|
||||
/// One command, so the features after the sketch are rebuilt once for all of them.
|
||||
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
|
||||
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<EditTextResult> {
|
||||
let cmd = "sketch.edit_text";
|
||||
match p.get("edits") {
|
||||
None | Some(Value::Null) => apply_text_edit(sk, doc, p),
|
||||
None | Some(Value::Null) => {
|
||||
let change = apply_text_edit(sk, doc, p)?;
|
||||
Ok(EditTextResult::One(TextResult { change, created: None, param_warnings: Vec::new() }))
|
||||
}
|
||||
Some(Value::Array(edits)) => {
|
||||
if edits.is_empty() || edits.len() > MAX_TEXT_EDITS {
|
||||
return Err(bad(cmd, format!("`edits` must hold 1 to {MAX_TEXT_EDITS} edits")));
|
||||
}
|
||||
let outs = edits.iter().map(|e| apply_text_edit(sk, doc, e)).collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"edits": outs}))
|
||||
Ok(EditTextResult::Many(TextEditsResult { edits: outs, created: None, param_warnings: Vec::new() }))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`edits` must be a list of edits")),
|
||||
}
|
||||
@@ -421,7 +506,7 @@ fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value)
|
||||
|
||||
/// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines
|
||||
/// regenerated from them.
|
||||
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
|
||||
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<TextChange> {
|
||||
let cmd = "sketch.edit_text";
|
||||
let l = match (str_(p, "link"), str_(p, "entity")) {
|
||||
(Some(l), _) => l.to_string(),
|
||||
@@ -438,15 +523,11 @@ fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -
|
||||
return Err(bad(cmd, "the text makes no outline"));
|
||||
}
|
||||
let guide = guide(sk, &src);
|
||||
let exprs = text_exprs_json(&src);
|
||||
let exprs = text_exprs(&src);
|
||||
sk.set_link_source(&l, src.clone());
|
||||
sk.update_link(&l, &geom)?;
|
||||
let curves = ids_of(sk, &sk.link_curves(&l));
|
||||
let mut out = json!({"link": l, "curves": curves, "guide": guide});
|
||||
if let Some(exprs) = exprs {
|
||||
out["exprs"] = exprs;
|
||||
}
|
||||
Ok(out)
|
||||
Ok(TextChange { link: l, curves, guide, exprs })
|
||||
}
|
||||
|
||||
/// The curve ending at point `pi` and the direction leaving it there.
|
||||
@@ -552,7 +633,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
solve_new_only(sk, first_new);
|
||||
Ok((ids_of(sk, &[sp]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -577,7 +658,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(ids.len())
|
||||
})?;
|
||||
Ok(json!({"changed": n, "sketch": info}))
|
||||
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -601,7 +682,7 @@ fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(ids.len())
|
||||
})?;
|
||||
Ok(json!({"changed": n, "sketch": info}))
|
||||
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -659,7 +740,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
];
|
||||
Ok((ids_of(sk, &[e]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn point_list(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
@@ -704,7 +785,7 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
|
||||
mark_construction(sk, &[c], p);
|
||||
Ok((ids_of(sk, &[c]), Vec::new()))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -790,7 +871,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &[c], p);
|
||||
Ok((ids_of(sk, &[c]), Vec::new()))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -812,7 +893,7 @@ fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &[l], p);
|
||||
Ok((ids_of(sk, &[l]), vec![k]))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
/// Direction leaving curve `ci` at its end point `pi` (continuing the curve past that end).
|
||||
@@ -966,7 +1047,7 @@ fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = add_c(sk, ConstraintKind::Tangent { a: ci, b: a })?;
|
||||
Ok((ids_of(sk, &[a]), vec![k]))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
/// Initial radius for a circle near `at` touching the curves: the mean distance to them.
|
||||
@@ -1052,7 +1133,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
|
||||
if info["solved"] != json!(true) {
|
||||
return Err(bad(cmd, "no tangent circle there"));
|
||||
}
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1079,7 +1160,8 @@ fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.slot.center_point";
|
||||
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
|
||||
let w = width(p, cmd)?;
|
||||
slot(s, p, cmd, c * 2.0 - e, e, w)
|
||||
let (out, info) = slot_parts(s, p, cmd, c * 2.0 - e, e, w)?;
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
/// Arc slot around `o` (centre-line radius `r`) from angle `t0` sweeping `sweep` (> 0).
|
||||
@@ -1117,9 +1199,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
|
||||
}
|
||||
Ok((ids_of(sk, &[outer, cap_e, inner, cap_s]), cons))
|
||||
})?;
|
||||
let mut out = result(out, info);
|
||||
out["centre_arc"] = json!(centre_arc);
|
||||
Ok(out)
|
||||
reply(ArcSlotResult { drawn: drawn_result(out, info), centre_arc })
|
||||
}
|
||||
|
||||
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1534,7 +1614,7 @@ mod blend_tests {
|
||||
assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}");
|
||||
assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}");
|
||||
assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}");
|
||||
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "ids": "chord"}));
|
||||
// Drop the two references: what is left is the blend and its chord, one closed region.
|
||||
run(&mut s, "sketch.delete", json!({"entities": [a, b]}));
|
||||
let id = s.active_sketch.unwrap();
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_io::{Geom2, MAX_DRAWING_BYTES};
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
|
||||
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
|
||||
.noundo()
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
|
||||
];
|
||||
|
||||
/// The sketch import command for a drawing file name, regardless of extension case.
|
||||
@@ -290,39 +290,248 @@ fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert(s, p, "sketch.insert_svg", true)
|
||||
}
|
||||
|
||||
fn sorted_curve_ids<'a>(ids: &'a [String]) -> Vec<&'a str> {
|
||||
let mut v: Vec<&'a str> = ids.iter().map(String::as_str).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.export_dxf";
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
|
||||
let sk = &ss.sketch;
|
||||
let mut geom = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default();
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
|
||||
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
|
||||
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
|
||||
|
||||
let units_val = p.get("units").filter(|v| !v.is_null());
|
||||
if let Some(v) = units_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`units` must be a string"));
|
||||
}
|
||||
let units_param = str_(p, "units").map(str::to_ascii_lowercase);
|
||||
let (insunits, scale) = match units_param.as_deref() {
|
||||
None => (4, 1.0),
|
||||
Some("design") => {
|
||||
let doc_units = solidcraft_doc::expr::length_unit_or_mm(&s.doc.units);
|
||||
match doc_units.to_ascii_lowercase().as_str() {
|
||||
"in" | "inch" => (1, 1.0 / 25.4),
|
||||
"ft" => (2, 1.0 / 304.8),
|
||||
"cm" => (5, 0.1),
|
||||
"m" => (6, 0.001),
|
||||
_ => (4, 1.0),
|
||||
}
|
||||
}
|
||||
Some("in" | "inch") => (1, 1.0 / 25.4),
|
||||
Some("ft") => (2, 1.0 / 304.8),
|
||||
Some("mm") => (4, 1.0),
|
||||
Some("cm") => (5, 0.1),
|
||||
Some("m") => (6, 0.001),
|
||||
Some(other) => {
|
||||
return Err(bad(cmd, format!("unsupported unit `{other}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")")));
|
||||
}
|
||||
};
|
||||
|
||||
let layer_val = p.get("layer").filter(|v| !v.is_null());
|
||||
if let Some(v) = layer_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`layer` must be a string"));
|
||||
}
|
||||
let text = solidcraft_io::write_dxf(&geom);
|
||||
let custom_layer = str_(p, "layer").map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
let layers_val = p.get("layers").filter(|v| !v.is_null());
|
||||
if let Some(v) = layers_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`layers` must be \"single\" or \"roles\""));
|
||||
}
|
||||
let roles_layers = match str_(p, "layers").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("single") => false,
|
||||
Some("roles") => true,
|
||||
Some(_) => return Err(bad(cmd, "`layers` must be \"single\" or \"roles\"")),
|
||||
};
|
||||
|
||||
let join = match p.get("join").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`join` must be true or false")),
|
||||
};
|
||||
|
||||
let path_val = p.get("path").filter(|v| !v.is_null());
|
||||
if let Some(v) = path_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`path` must be a string"));
|
||||
}
|
||||
|
||||
let is_default = units_val.is_none() && layer_val.is_none() && layers_val.is_none() && !join;
|
||||
|
||||
let (text, entity_count) = if is_default {
|
||||
let mut geom = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default();
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
|
||||
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
|
||||
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
|
||||
}
|
||||
}
|
||||
}
|
||||
let count = geom.len();
|
||||
(solidcraft_io::write_dxf(&geom), count)
|
||||
} else if !join {
|
||||
let hole_keys: std::collections::HashSet<Vec<&str>> =
|
||||
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
|
||||
|
||||
let mut outline_ids = std::collections::HashSet::new();
|
||||
let mut cut_ids = std::collections::HashSet::new();
|
||||
if roles_layers {
|
||||
for prof in &ss.profiles {
|
||||
for holes in &prof.hole_curves {
|
||||
for id in holes {
|
||||
cut_ids.insert(id.as_str());
|
||||
}
|
||||
}
|
||||
if !hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
|
||||
for id in &prof.outer_curves {
|
||||
outline_ids.insert(id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let base_layer = custom_layer.unwrap_or("0");
|
||||
let mut entities = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let layer = if roles_layers {
|
||||
if cut_ids.contains(c.id.as_str()) {
|
||||
"CUT"
|
||||
} else if outline_ids.contains(c.id.as_str()) {
|
||||
"OUTLINE"
|
||||
} else {
|
||||
base_layer
|
||||
}
|
||||
} else {
|
||||
base_layer
|
||||
};
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => entities.push((layer, Geom2::Line(pt(*a), pt(*b)))),
|
||||
CurveKind::Circle { c, r } => entities.push((layer, Geom2::Circle(pt(*c), *r * scale))),
|
||||
CurveKind::Arc { c, a, b } => entities.push((layer, Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) })),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
for w in poly.windows(2) {
|
||||
entities.push((layer, Geom2::Line(w[0] * scale, w[1] * scale)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let count = entities.len();
|
||||
(solidcraft_io::write_dxf_entities(&entities, insunits), count)
|
||||
} else {
|
||||
// join: true -> emit closed loops as LWPOLYLINE with bulges for arcs
|
||||
let hole_keys: std::collections::HashSet<Vec<&str>> =
|
||||
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
|
||||
|
||||
let mut outline_ids = std::collections::HashSet::new();
|
||||
let mut cut_ids = std::collections::HashSet::new();
|
||||
for prof in &ss.profiles {
|
||||
for id in &prof.outer_curves {
|
||||
outline_ids.insert(id.as_str());
|
||||
}
|
||||
for holes in &prof.hole_curves {
|
||||
for id in holes {
|
||||
cut_ids.insert(id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
let base_layer = custom_layer.unwrap_or("0");
|
||||
let mut entities = Vec::new();
|
||||
for prof in &ss.profiles {
|
||||
if hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
|
||||
continue;
|
||||
}
|
||||
let outer_layer = if roles_layers { "OUTLINE" } else { base_layer };
|
||||
let shape = solidcraft_io::loop_lwpolyline_scaled(&prof.region.outer, scale);
|
||||
entities.push(DxfEntity { layer: outer_layer.into(), shape });
|
||||
|
||||
for hole in &prof.region.holes {
|
||||
let hole_layer = if roles_layers { "CUT" } else { base_layer };
|
||||
let shape = solidcraft_io::loop_lwpolyline_scaled(hole, scale);
|
||||
entities.push(DxfEntity { layer: hole_layer.into(), shape });
|
||||
}
|
||||
}
|
||||
// Preserve any non-profile open curves as disconnected entities so nothing is lost
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction || outline_ids.contains(c.id.as_str()) || cut_ids.contains(c.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => {
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Line(pt(*a), pt(*b)) })
|
||||
}
|
||||
CurveKind::Circle { c, r } => {
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Circle { c: pt(*c), r: *r * scale } })
|
||||
}
|
||||
CurveKind::Arc { c, a, b } => {
|
||||
let center = pt(*c);
|
||||
let pa = pt(*a);
|
||||
let pb = pt(*b);
|
||||
let r = center.dist(pa);
|
||||
let a0 = (pa - center).angle().to_degrees().rem_euclid(360.0);
|
||||
let a1 = (pb - center).angle().to_degrees().rem_euclid(360.0);
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Arc { c: center, r, a0, a1 } });
|
||||
}
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
let points: Vec<Vec2> = poly.into_iter().map(|p| p * scale).collect();
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Polyline { points } });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut layer_names = std::collections::BTreeSet::new();
|
||||
if roles_layers {
|
||||
layer_names.insert("OUTLINE".to_string());
|
||||
layer_names.insert("CUT".to_string());
|
||||
}
|
||||
for e in &entities {
|
||||
layer_names.insert(e.layer.clone());
|
||||
}
|
||||
let layers: Vec<DxfLayer> = layer_names
|
||||
.iter()
|
||||
.map(|name| match name.as_str() {
|
||||
"OUTLINE" => DxfLayer::new(name, 7),
|
||||
"CUT" => DxfLayer::new(name, 4),
|
||||
_ => DxfLayer::new(name, 7),
|
||||
})
|
||||
.collect();
|
||||
let count = entities.len();
|
||||
(solidcraft_io::write_dxf_drawing_units(&layers, &entities, insunits)?, count)
|
||||
};
|
||||
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "entities": geom.len()}));
|
||||
return Ok(json!({"path": path, "entities": entity_count}));
|
||||
}
|
||||
Ok(json!({"dxf": text, "entities": geom.len()}))
|
||||
Ok(json!({"dxf": text, "entities": entity_count}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_io::Geom2;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -495,4 +704,197 @@ mod tests {
|
||||
assert!(s.execute("sketch.insert_svg", &p).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_default_is_byte_identical_to_before() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [40, 30]})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [15, 15], "radius": 5})).unwrap();
|
||||
|
||||
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
|
||||
let text = out["dxf"].as_str().unwrap();
|
||||
|
||||
// Build expected R12 DXF exactly as before
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(1).unwrap();
|
||||
let sk = &ss.sketch;
|
||||
let mut geom = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default();
|
||||
match &c.kind {
|
||||
solidcraft_sketch::CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
|
||||
solidcraft_sketch::CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
|
||||
solidcraft_sketch::CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected = solidcraft_io::write_dxf(&geom);
|
||||
assert_eq!(text, expected, "default output must be byte-identical to previous write_dxf output");
|
||||
|
||||
// Null options must be treated as omitted (default behavior)
|
||||
for null_params in [
|
||||
json!({"units": null}),
|
||||
json!({"layer": null}),
|
||||
json!({"layers": null}),
|
||||
json!({"join": null}),
|
||||
json!({"path": null}),
|
||||
json!({"units": null, "layer": null, "layers": null, "join": null, "path": null}),
|
||||
] {
|
||||
let out_null = s.execute("sketch.export_dxf", &null_params).unwrap();
|
||||
assert_eq!(out_null["dxf"].as_str().unwrap(), expected, "null options must behave identically to default options");
|
||||
assert_eq!(out_null["entities"], out["entities"]);
|
||||
}
|
||||
|
||||
// Hostile inputs
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"units": 123})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"units": "unknown_unit"})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"layer": 456})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"layers": 123})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"layers": "all"})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"join": "true"})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"path": 123})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_layers_present() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50, 50]})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [25, 25], "radius": 10})).unwrap();
|
||||
|
||||
// 1. Single custom layer
|
||||
let out_cut = s.execute("sketch.export_dxf", &json!({"layer": "CUT"})).unwrap();
|
||||
let dxf_cut = out_cut["dxf"].as_str().unwrap();
|
||||
assert!(dxf_cut.contains("\n8\nCUT\n"));
|
||||
assert!(!dxf_cut.contains("\n8\n0\n"));
|
||||
|
||||
// 2. Roles layers (OUTLINE for outer profile, CUT for holes)
|
||||
let out_roles = s.execute("sketch.export_dxf", &json!({"layers": "roles"})).unwrap();
|
||||
let dxf_roles = out_roles["dxf"].as_str().unwrap();
|
||||
assert!(dxf_roles.contains("\n8\nOUTLINE\n"));
|
||||
assert!(dxf_roles.contains("\n8\nCUT\n"));
|
||||
|
||||
// 3. Roles layers with join: true
|
||||
let out_joined_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
|
||||
assert_eq!(out_joined_roles["entities"], 2);
|
||||
let dxf_joined = out_joined_roles["dxf"].as_str().unwrap();
|
||||
assert!(dxf_joined.contains("AcDbPolyline"));
|
||||
assert!(dxf_joined.contains("\n 8\nOUTLINE\n") || dxf_joined.contains("\n8\nOUTLINE\n"));
|
||||
assert!(dxf_joined.contains("\n 8\nCUT\n") || dxf_joined.contains("\n8\nCUT\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_join_does_not_duplicate_holes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [100, 80]})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [30, 40], "radius": 5})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [70, 40], "radius": 8})).unwrap();
|
||||
|
||||
// With join: true, layers: "roles": exactly 3 entities (1 outer outline, 2 hole cuts)
|
||||
let out_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
|
||||
assert_eq!(out_roles["entities"], 3, "expected 1 outer outline and 2 hole cuts, got {}", out_roles["entities"]);
|
||||
let dxf_roles = out_roles["dxf"].as_str().unwrap();
|
||||
let lwpolyline_count = dxf_roles.lines().filter(|l| l.trim() == "LWPOLYLINE").count();
|
||||
assert_eq!(lwpolyline_count, 3, "expected exactly 3 LWPOLYLINE entities in DXF");
|
||||
|
||||
// Without roles (single layer): still exactly 3 entities
|
||||
let out_single = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
|
||||
assert_eq!(out_single["entities"], 3, "entities without roles must also be exactly 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_join_closed_lwpolyline_with_bulge() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
// Quarter-pie shape: center at (0, 0), arc from (10, 0) to (0, 10), and lines to (0, 0)
|
||||
s.execute("sketch.line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("sketch.arc.center_point", &json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]})).unwrap();
|
||||
s.execute("sketch.line", &json!({"points": [[0, 10], [0, 0]]})).unwrap();
|
||||
|
||||
let out = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
|
||||
let dxf = out["dxf"].as_str().unwrap();
|
||||
assert!(dxf.contains("LWPOLYLINE"));
|
||||
// 70: bit 1 (closed) + bit 128 (plinegen) = 129
|
||||
assert!(dxf.contains("\n 70\n129\n") || dxf.contains("\n70\n129\n"));
|
||||
// Bulge: 90° arc has sweep = pi/2, bulge = tan(sweep / 4) = tan(pi / 8) = sqrt(2) - 1 ≈ 0.414213562373
|
||||
let expected_bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan();
|
||||
let has_bulge = dxf
|
||||
.lines()
|
||||
.zip(dxf.lines().skip(1))
|
||||
.any(|(k, v)| k.trim() == "42" && (v.trim().parse::<f64>().unwrap_or(0.0).abs() - expected_bulge).abs() < 1e-6);
|
||||
assert!(has_bulge, "DXF must contain group 42 bulge approx {expected_bulge}");
|
||||
|
||||
// Re-import the joined DXF into a new sketch and verify round-trip profile area
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
|
||||
let id = r["sketch"].as_u64().unwrap();
|
||||
let ss = s.model.state().sketch(id).unwrap().clone();
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let expected_area = std::f64::consts::PI * 100.0 / 4.0; // quarter circle of radius 10
|
||||
assert!((ss.profiles[0].area - expected_area).abs() < 1e-4, "area was {}", ss.profiles[0].area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_inch_design_round_trip() {
|
||||
let mut s = Session::default();
|
||||
s.doc_mut().units = "in".to_string();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
// A 2 in by 3 in rectangle in an inch design (stored internally as 50.8 mm by 76.2 mm)
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50.8, 76.2]})).unwrap();
|
||||
|
||||
// 1. Export with units: "design"
|
||||
let out = s.execute("sketch.export_dxf", &json!({"units": "design"})).unwrap();
|
||||
let dxf = out["dxf"].as_str().unwrap();
|
||||
// $INSUNITS must be 1 (inches)
|
||||
assert!(dxf.contains("\n$INSUNITS\n70\n1\n"));
|
||||
// Sizes in inches: 2.0 and 3.0
|
||||
assert!(dxf.contains("2.000000000"));
|
||||
assert!(dxf.contains("3.000000000"));
|
||||
|
||||
// Round-trip through read_dxf: reads $INSUNITS=1, scales by 25.4 to mm
|
||||
let geom = solidcraft_io::read_dxf(dxf).unwrap();
|
||||
let mut widths = Vec::new();
|
||||
let mut heights = Vec::new();
|
||||
for g in &geom {
|
||||
if let Geom2::Line(a, b) = g {
|
||||
let dx = (b.x - a.x).abs() / 25.4;
|
||||
let dy = (b.y - a.y).abs() / 25.4;
|
||||
if dx > 0.1 {
|
||||
widths.push(dx);
|
||||
}
|
||||
if dy > 0.1 {
|
||||
heights.push(dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
for w in widths {
|
||||
assert!((w - 2.0).abs() < 1e-8, "width in inches was {w}");
|
||||
}
|
||||
for h in heights {
|
||||
assert!((h - 3.0).abs() < 1e-8, "height in inches was {h}");
|
||||
}
|
||||
|
||||
// 2. Export with join: true and explicit units: "in"
|
||||
let out_join = s.execute("sketch.export_dxf", &json!({"join": true, "units": "in"})).unwrap();
|
||||
let dxf_join = out_join["dxf"].as_str().unwrap();
|
||||
assert!(dxf_join.contains("LWPOLYLINE"));
|
||||
assert!(dxf_join.contains("\n$INSUNITS\n 70\n1\n") || dxf_join.contains("\n$INSUNITS\n70\n1\n"));
|
||||
|
||||
let geom_join = solidcraft_io::read_dxf(dxf_join).unwrap();
|
||||
assert_eq!(geom_join.len(), 4);
|
||||
for g in &geom_join {
|
||||
if let Geom2::Line(a, b) = g {
|
||||
let len_in = a.dist(*b) / 25.4;
|
||||
assert!((len_in - 2.0).abs() < 1e-8 || (len_in - 3.0).abs() < 1e-8, "edge length {len_in}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::schema::sketch_project::{
|
||||
AutoProjectParams, AutoProjectResult, FitCurvesParams, FitSectionResult, Include3dParams, IntersectParams, IntersectionCurveParams,
|
||||
IsoCurveParams, LinkMade, ProjectParams, ProjectResult, ProjectToSurfaceParams, ProjectToSurfaceResult, ProjectedLink, SketchBreakLinkParams,
|
||||
SketchBreakLinkResult, SketchLinkResult, SpunProfileParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
macro_rules! refs_doc {
|
||||
@@ -21,105 +27,160 @@ macro_rules! refs_doc {
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT_CONTRACT: TypedContract<ProjectParams> = TypedContract::new();
|
||||
const INTERSECT_CONTRACT: TypedContract<IntersectParams> = TypedContract::new();
|
||||
const INCLUDE_3D_CONTRACT: TypedContract<Include3dParams> = TypedContract::new();
|
||||
const PROJECT_TO_SURFACE_CONTRACT: TypedContract<ProjectToSurfaceParams> = TypedContract::new();
|
||||
const INTERSECTION_CURVE_CONTRACT: TypedContract<IntersectionCurveParams> = TypedContract::new();
|
||||
const SPUN_PROFILE_CONTRACT: TypedContract<SpunProfileParams> = TypedContract::new();
|
||||
const ISO_CURVE_CONTRACT: TypedContract<IsoCurveParams> = TypedContract::new();
|
||||
const FIT_SECTION_CONTRACT: TypedContract<FitCurvesParams> = TypedContract::new();
|
||||
const BREAK_LINK_CONTRACT: TypedContract<SketchBreakLinkParams> = TypedContract::new();
|
||||
const AUTO_PROJECT_CONTRACT: TypedContract<AutoProjectParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
|
||||
CommandSpec::new("sketch.project", "Project", crate::checked!("sketch.project", project_cmd)).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).contract(&PROJECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
|
||||
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
|
||||
),
|
||||
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
|
||||
CommandSpec::new("sketch.intersect", "Intersect", crate::checked!("sketch.intersect", intersect_cmd)).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).contract(&INTERSECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
|
||||
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
|
||||
),
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", crate::checked!("sketch.include_3d", include_cmd))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("include")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
|
||||
.contract(&INCLUDE_3D_CONTRACT).output(output_schema_for::<ProjectResult>).params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", crate::checked!("sketch.project_to_surface", project_to_surface))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("project_surface")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
|
||||
.contract(&PROJECT_TO_SURFACE_CONTRACT).output(output_schema_for::<ProjectToSurfaceResult>).params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", crate::checked!("sketch.intersection_curve", intersection_curve))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("intersection_curve")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
|
||||
.contract(&INTERSECTION_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.spun_profile", "Spun Profile", crate::checked!("sketch.spun_profile", spun_profile))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spun")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
|
||||
.contract(&SPUN_PROFILE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", crate::checked!("sketch.isoparametric_curve", iso_curve))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("iso_curve")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
|
||||
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
|
||||
.contract(&ISO_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
|
||||
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", crate::checked!("sketch.fit_curves_to_section", fit_section))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("fit_section")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break")
|
||||
.contract(&FIT_SECTION_CONTRACT).output(output_schema_for::<FitSectionResult>).params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", crate::checked!("sketch.break_link", break_link)).icon("sketch_break")
|
||||
.enabled(in_sketch)
|
||||
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project")
|
||||
.contract(&BREAK_LINK_CONTRACT).output(output_schema_for::<SketchBreakLinkResult>).params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", crate::checked!("sketch.auto_project", auto_project)).icon("project")
|
||||
.noundo()
|
||||
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
.contract(&AUTO_PROJECT_CONTRACT).output(output_schema_for::<AutoProjectResult>).params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
];
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
/// What a reference says in its JSON, before the design is asked what it names (`source` goes on
|
||||
/// from here, and so does the contract of the commands that take references).
|
||||
pub(crate) enum RefForm<'a> {
|
||||
/// Text: `origin`, an axis letter or a body name, which the design decides.
|
||||
Name(&'a str),
|
||||
/// `{edge: [x,y,z], body?}`.
|
||||
Edge { body: String, at: Vec3 },
|
||||
/// `{face: [x,y,z], body?}`.
|
||||
Face { body: String, at: Vec3 },
|
||||
/// `{vertex: [x,y,z], body?}`.
|
||||
Vertex { body: String, at: Vec3 },
|
||||
/// `{sketch, curve}` or `{sketch, point}`: the sketch as the lookup reads it (an id or a name)
|
||||
/// and the `curve` or `point` given. With neither, the handler refuses it once the sketch is
|
||||
/// found.
|
||||
Sketch { key: String, curve: Option<&'a str>, point: Option<&'a str> },
|
||||
/// `{body: name}`.
|
||||
Body(&'a str),
|
||||
/// `{axis: X|Y|Z|name}`.
|
||||
Axis(&'a str),
|
||||
/// `{plane: XY|XZ|YZ|name}`.
|
||||
Plane(&'a str),
|
||||
/// `{origin: …}`.
|
||||
Origin,
|
||||
}
|
||||
|
||||
/// The form of one reference, or the message (without the command's name) for what no design
|
||||
/// could make sense of. The forms are tried in this order, the first that fits wins: an edge, face
|
||||
/// or vertex whose value is not a point is passed over, not refused.
|
||||
pub(crate) fn ref_form(v: &Value) -> std::result::Result<RefForm<'_>, String> {
|
||||
if let Some(name) = v.as_str() {
|
||||
return match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
};
|
||||
return Ok(RefForm::Name(name));
|
||||
}
|
||||
let body = || str_(v, "body").unwrap_or("").to_string();
|
||||
if let Some(at) = v.get("edge").and_then(vec3) {
|
||||
return Ok(LinkSource::Edge { body: body(), at });
|
||||
return Ok(RefForm::Edge { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("face").and_then(vec3) {
|
||||
return Ok(LinkSource::Face { body: body(), at });
|
||||
return Ok(RefForm::Face { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("vertex").and_then(vec3) {
|
||||
return Ok(LinkSource::Vertex { body: body(), at });
|
||||
return Ok(RefForm::Vertex { body: body(), at });
|
||||
}
|
||||
if let Some(sk) = v.get("sketch") {
|
||||
let key = match sk {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
|
||||
_ => return Err("`sketch` must be an id or a name".into()),
|
||||
};
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = str_(v, "curve") {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = str_(v, "point") {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
return Err(bad(cmd, "a sketch reference needs `curve` or `point`"));
|
||||
return Ok(RefForm::Sketch { key, curve: str_(v, "curve"), point: str_(v, "point") });
|
||||
}
|
||||
if let Some(b) = str_(v, "body") {
|
||||
return Ok(LinkSource::Body { body: b.to_string() });
|
||||
return Ok(RefForm::Body(b));
|
||||
}
|
||||
if let Some(a) = str_(v, "axis") {
|
||||
return axis_source(s, a, cmd);
|
||||
return Ok(RefForm::Axis(a));
|
||||
}
|
||||
if let Some(p) = str_(v, "plane") {
|
||||
return Ok(LinkSource::Plane { name: p.to_string() });
|
||||
return Ok(RefForm::Plane(p));
|
||||
}
|
||||
if v.get("origin").is_some() {
|
||||
return Ok(LinkSource::Origin);
|
||||
return Ok(RefForm::Origin);
|
||||
}
|
||||
Err(format!("cannot project {v}"))
|
||||
}
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
match ref_form(v).map_err(|why| bad(cmd, why))? {
|
||||
RefForm::Name(name) => match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
},
|
||||
RefForm::Edge { body, at } => Ok(LinkSource::Edge { body, at }),
|
||||
RefForm::Face { body, at } => Ok(LinkSource::Face { body, at }),
|
||||
RefForm::Vertex { body, at } => Ok(LinkSource::Vertex { body, at }),
|
||||
RefForm::Sketch { key, curve, point } => {
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = curve {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = point {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
Err(bad(cmd, "a sketch reference needs `curve` or `point`"))
|
||||
}
|
||||
RefForm::Body(b) => Ok(LinkSource::Body { body: b.to_string() }),
|
||||
RefForm::Axis(a) => axis_source(s, a, cmd),
|
||||
RefForm::Plane(p) => Ok(LinkSource::Plane { name: p.to_string() }),
|
||||
RefForm::Origin => Ok(LinkSource::Origin),
|
||||
}
|
||||
Err(bad(cmd, format!("cannot project {v}")))
|
||||
}
|
||||
|
||||
/// An origin axis (`X`, `Y` or `Z`, in any case), or a construction axis by its name (#9324).
|
||||
@@ -391,10 +452,17 @@ fn add_link_unless_present(
|
||||
Ok(Some(link))
|
||||
}
|
||||
|
||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||
/// A command's reply as JSON: they build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The ids of the curves and of the points that link `link` made.
|
||||
fn link_parts(sk: &Sketch, link: &str) -> (Vec<String>, Vec<String>) {
|
||||
let curves: Vec<String> = sk.link_curves(link).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
|
||||
let points: Vec<String> = sk.link_points(link).iter().filter_map(|p| sk.points.get(*p).map(|p| p.id.clone())).collect();
|
||||
json!({"link": link, "curves": curves, "points": points})
|
||||
(curves, points)
|
||||
}
|
||||
|
||||
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
|
||||
@@ -418,9 +486,10 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
let what = src.describe();
|
||||
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
|
||||
Ok(Some(l)) => {
|
||||
let mut result = link_json(sk, &l);
|
||||
let (curves, points) = link_parts(sk, &l);
|
||||
let mut result = ProjectedLink { link: Some(l.clone()), curves, points };
|
||||
if !projection_link {
|
||||
result["link"] = Value::Null;
|
||||
result.link = None;
|
||||
sk.break_link(&l)?;
|
||||
}
|
||||
out.push(result);
|
||||
@@ -435,11 +504,7 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
}
|
||||
Ok((out, present))
|
||||
})?;
|
||||
let mut r = json!({"links": out});
|
||||
if !present.is_empty() {
|
||||
r["already_projected"] = json!(present);
|
||||
}
|
||||
Ok(r)
|
||||
reply(cmd, ProjectResult { links: out, already_projected: present, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -543,16 +608,15 @@ pub(super) fn project_entities(s: &mut Session, p: &Value, cmd: &str) -> Result<
|
||||
}
|
||||
|
||||
/// Add one link from `src` to the active sketch and report it.
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<LinkMade> {
|
||||
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
sketch_store::update(s, id, |s, doc, sk| {
|
||||
refresh(s, doc, id, sk);
|
||||
let what = src.describe();
|
||||
let l = add_link(s, doc, id, sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
|
||||
let mut out = link_json(sk, &l);
|
||||
let (curves, points) = link_parts(sk, &l);
|
||||
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
|
||||
out["wires"] = json!(wires);
|
||||
Ok(out)
|
||||
Ok(LinkMade { link: l, curves, points, wires })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -571,7 +635,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
out.push(one_link(s, cmd, LinkKind::Project, LinkSource::OnSurface { sketch, curve, body: body.clone(), at })?);
|
||||
}
|
||||
Ok(json!({"links": out}))
|
||||
reply(cmd, ProjectToSurfaceResult { links: out, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -584,7 +648,8 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (a, b) = (side("a")?, side("b")?);
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })
|
||||
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -605,7 +670,8 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(wa, wb - wa)
|
||||
}
|
||||
};
|
||||
one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })
|
||||
let made = one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -620,7 +686,8 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format!("{},{},{}", d.x, d.y, d.z)
|
||||
}
|
||||
};
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })
|
||||
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -636,7 +703,7 @@ fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.break_link(&l)?;
|
||||
Ok(curves)
|
||||
})?;
|
||||
Ok(json!({"curves": curves}))
|
||||
reply(cmd, FitSectionResult { curves, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -670,12 +737,12 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(links)
|
||||
})?;
|
||||
Ok(json!({"broken": links}))
|
||||
reply(cmd, SketchBreakLinkResult { broken: links, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
|
||||
Ok(json!({"auto_project": s.auto_project}))
|
||||
reply("sketch.auto_project", AutoProjectResult { auto_project: s.auto_project, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// `"x:<a>+<b>:x,y"` from interactive snapping: two distinct curve ids and a hint point.
|
||||
|
||||
@@ -340,6 +340,63 @@ fn tangent_moves_only_the_second_pick() {
|
||||
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
|
||||
}
|
||||
|
||||
/// #12092: Tangent with a line's end point and a circle puts that end on the circle and makes the
|
||||
/// line tangent there, in one step. In either pick order only the picked end moves: the line turns
|
||||
/// about its other end and gets longer or shorter, and the circle stays.
|
||||
#[test]
|
||||
fn tangent_at_a_lines_end_puts_the_end_on_the_circle() {
|
||||
// Where the line ends up: its end's distance from the centre, and the line's.
|
||||
let touch = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let (a, b) = (pt(s, "l1.start"), pt(s, "l1.end"));
|
||||
let c = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap();
|
||||
let line = (b - a).cross(c - a).abs() / a.dist(b);
|
||||
(b.dist(c), line, sk.radius(sk.curve_index("c1").unwrap()).unwrap())
|
||||
};
|
||||
for (a, b) in [("c1", "l1.end"), ("l1.end", "c1")] {
|
||||
let mut s = line_and_circle();
|
||||
let circle = snap(&s, "c1");
|
||||
let undo = s.undo.len();
|
||||
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
|
||||
let (end, line, radius) = touch(&s);
|
||||
assert!((radius - 5.0).abs() < 1e-9, "{a}, {b}: the circle keeps its size: {radius}");
|
||||
assert!((end - 5.0).abs() < 1e-6 && (line - 5.0).abs() < 1e-6, "{a}, {b}: end {end}, line {line} from the centre");
|
||||
assert!(same(&snap(&s, "c1"), &circle), "{a}, {b}: the circle moved");
|
||||
let start = pt(&s, "l1.start");
|
||||
assert!(start.dist(solidcore_record::Vec2::new(5.0, 3.0)) < 1e-9, "{a}, {b}: the line's other end moved to {start:?}");
|
||||
assert_eq!(s.undo.len(), undo + 1, "{a}, {b}: one undo step");
|
||||
}
|
||||
// A point that ends no curve names no curve to be tangent: the refusal says what to pick.
|
||||
let mut s = line_and_circle();
|
||||
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1.center"})).unwrap_err().to_string();
|
||||
assert!(e.contains("end"), "{e}");
|
||||
}
|
||||
|
||||
/// A line whose end is already joined to an arc's end (the coincident a click there adds): Tangent
|
||||
/// on that end and the arc, or on the two ends in either order, adds only the tangent, since the
|
||||
/// end already lies on the arc. The joint holds. With the end and the arc the line stays and the
|
||||
/// arc moves; with two ends the first pick stays.
|
||||
#[test]
|
||||
fn tangent_at_a_joint_adds_only_the_tangent() {
|
||||
for (a, b, stays) in [("l1.end", "a1", "l1"), ("a1", "l1.end", "l1"), ("l1.end", "a1.start", "l1"), ("a1.start", "l1.end", "a1")] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[2, 3], [12, 3]]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [16, 9], "start": [12, 3], "sweep": 90}));
|
||||
let joints = sketch(&s).constraints.len();
|
||||
let kept = snap(&s, stays);
|
||||
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
assert!(same(&snap(&s, stays), &kept), "{a}, {b}: {stays} moved");
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(1), "{a}, {b}: {r}");
|
||||
assert_eq!(sketch(&s).constraints.len(), joints + 1, "{a}, {b}: only the tangent is new");
|
||||
let sk = sketch(&s);
|
||||
let arc = sk.curve_index("a1").unwrap();
|
||||
let (p, q, c) = (pt(&s, "l1.start"), pt(&s, "l1.end"), sk.center(arc).unwrap());
|
||||
assert!(q.dist(pt(&s, "a1.start")) < 1e-6, "{a}, {b}: the joint came apart");
|
||||
assert!(((q - p).cross(c - p).abs() / p.dist(q) - sk.radius(arc).unwrap()).abs() < 1e-6, "{a}, {b}: not tangent");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_second_pick_makes_the_first_move() {
|
||||
let mut s = line_and_circle();
|
||||
|
||||
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use solidcraft_doc::BrowserEntry;
|
||||
|
||||
use super::check::whole;
|
||||
use super::canvas::CanvasPlane;
|
||||
use super::check::{located, whole};
|
||||
use super::types::{FeatureCommon, LengthExpr, feature_common, present};
|
||||
|
||||
/// Repair a part the modelling kernel refuses, or an open part that should be a solid: faces
|
||||
@@ -1107,6 +1108,469 @@ pub struct BodyRenamed {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- Named views and Redefine Sketch (#12499) ----
|
||||
//
|
||||
// As above: each key reads as its handler reads it and fails with the handler's message. A key
|
||||
// the handler can't do without is optional in the type and required in the schema, so the
|
||||
// handler's own message is what a call that leaves it out gets. Whether a view or a sketch
|
||||
// exists, and whether a plane or a face can be found, stays the handler's to check.
|
||||
|
||||
fn view_required(schema: &mut Schema) {
|
||||
require(schema, &["view"]);
|
||||
}
|
||||
|
||||
fn view_and_name_required(schema: &mut Schema) {
|
||||
require(schema, &["view", "name"]);
|
||||
}
|
||||
|
||||
fn camera_required(schema: &mut Schema) {
|
||||
require(schema, &["camera"]);
|
||||
}
|
||||
|
||||
fn sketch_and_plane_required(schema: &mut Schema) {
|
||||
require(schema, &["sketch", "plane"]);
|
||||
}
|
||||
|
||||
/// A named view, as `view.rename` and `view.delete` read `view`: text.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ViewKey(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewKey {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(n) => Ok(ViewKey(n)),
|
||||
_ => Err(D::Error::custom(whole("`view` must be a view name"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewKey {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewKey".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A named view's name.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
fn some_view<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ViewKey>, D::Error> {
|
||||
ViewKey::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// The name `view.save` gives a view: at most 128 bytes once surrounding spaces are cut; nothing
|
||||
/// left means the next free "Named View" number.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SavedViewName(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SavedViewName {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(n) if n.trim().len() > MAX_NAME_BYTES => Err(D::Error::custom(whole("`name` is too long"))),
|
||||
Value::String(n) => Ok(SavedViewName(n)),
|
||||
_ => Err(D::Error::custom("must be text")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SavedViewName {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SavedViewName".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A name of at most 128 bytes once surrounding spaces are cut.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// The point a saved camera looks at, as the schemas of the view commands give it.
|
||||
fn target_schema() -> Value {
|
||||
json!({
|
||||
"description": "The point the camera looks at, in mm: [x, y, z].",
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
})
|
||||
}
|
||||
|
||||
/// The camera `view.save` keeps, as the handler reads it: finite numbers and a positive
|
||||
/// distance. A `name` inside it is replaced by the call's `name`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ViewCamera(pub solidcraft_doc::NamedView);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewCamera {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::browser::saved_view_form(&Value::deserialize(d)?).map(ViewCamera).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewCamera {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewCamera".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An orbit camera. Angles are in radians, about the up axis.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": target_schema(),
|
||||
"yaw": {"description": "Turn about the up axis; 0 looks along +Y, from the front.", "type": "number"},
|
||||
"pitch": {"description": "Elevation of the eye above the ground plane.", "type": "number"},
|
||||
"distance": {"description": "From the target to the eye, in mm.", "type": "number", "exclusiveMinimum": 0},
|
||||
"fov": {"description": "Vertical field of view; 0 is orthographic. Default: 0.", "type": "number", "default": 0.0}
|
||||
},
|
||||
"required": ["target", "yaw", "pitch", "distance"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the camera as a named view of the design. One undo step. A view of the same name is
|
||||
/// replaced.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = camera_required)]
|
||||
pub struct ViewSaveParams {
|
||||
// Optional in the type and required in the schema (`camera_required`): left out, the handler
|
||||
// refuses it with its own message.
|
||||
/// The camera to keep: `{target: [x, y, z], yaw, pitch, distance, fov?}`, finite, with a
|
||||
/// distance above 0.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewCamera")]
|
||||
pub camera: Option<ViewCamera>,
|
||||
/// The view's name, at most 128 bytes once surrounding spaces are cut. Default: "Named View"
|
||||
/// and the first number no view's name has. A value that is not text is reported, though the
|
||||
/// handler would read it as left out.
|
||||
#[serde(default)]
|
||||
pub name: Option<SavedViewName>,
|
||||
}
|
||||
|
||||
/// What `view.save` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewSaved {
|
||||
/// The name the view was kept under.
|
||||
pub view: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Rename a named view. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = view_and_name_required)]
|
||||
pub struct ViewRenameParams {
|
||||
// Required in the schema (`view_and_name_required`), optional in the type, as `group` above.
|
||||
/// The view to rename, by name.
|
||||
#[serde(default, deserialize_with = "some_view", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewKey")]
|
||||
pub view: Option<ViewKey>,
|
||||
/// The new name, 1 to 128 characters once surrounding spaces are cut; no other view may
|
||||
/// have it.
|
||||
#[serde(default, deserialize_with = "some_new_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "NewName")]
|
||||
pub name: Option<NewName>,
|
||||
}
|
||||
|
||||
/// What `view.rename` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewRenamed {
|
||||
/// The view's name now, without surrounding spaces.
|
||||
pub view: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Delete a named view. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = view_required)]
|
||||
pub struct ViewDeleteParams {
|
||||
// Required in the schema (`view_required`), optional in the type.
|
||||
/// The view to delete, by name.
|
||||
#[serde(default, deserialize_with = "some_view", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewKey")]
|
||||
pub view: Option<ViewKey>,
|
||||
}
|
||||
|
||||
/// What `view.delete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewDeleted {
|
||||
/// The name of the view that is gone.
|
||||
pub deleted: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `view.list` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ViewListParams {}
|
||||
|
||||
/// A named view, as `view.list` gives it. Only its schema is described here: the reply carries
|
||||
/// the design's own [`solidcraft_doc::NamedView`] values.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewEntry {
|
||||
/// The view's name.
|
||||
pub name: String,
|
||||
/// The point the camera looks at, in mm.
|
||||
pub target: [f64; 3],
|
||||
/// Turn about the up axis, in radians.
|
||||
pub yaw: f64,
|
||||
/// Elevation of the eye, in radians.
|
||||
pub pitch: f64,
|
||||
/// From the target to the eye, in mm.
|
||||
pub distance: f64,
|
||||
/// Vertical field of view in radians; 0 is orthographic.
|
||||
pub fov: f64,
|
||||
}
|
||||
|
||||
/// What `view.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewListResult {
|
||||
/// The design's named views, in the order they were made.
|
||||
#[schemars(with = "Vec<ViewEntry>")]
|
||||
pub views: Vec<solidcraft_doc::NamedView>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The camera `view.apply` turns to, as the handler reads it: `up` is refused, `roll` is finite,
|
||||
/// the pitch within ±π/2 and the rest finite with a positive distance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ApplyCamera(pub solidcraft_render::Camera);
|
||||
|
||||
impl<'de> Deserialize<'de> for ApplyCamera {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::browser::applied_camera_form(&Value::deserialize(d)?).map(ApplyCamera).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ApplyCamera {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ApplyCamera".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An orbit camera. Angles are in radians, about the up axis, which comes from the design settings.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": target_schema(),
|
||||
"yaw": {"description": "Turn about the up axis; 0 looks along +Y (Front), π/2 along +X (Left), π (Back), -π/2 (Right).", "type": "number"},
|
||||
"pitch": {"description": "Elevation of the eye above the ground plane, within ±π/2; π/2 is Top.", "type": "number", "minimum": -std::f64::consts::FRAC_PI_2, "maximum": std::f64::consts::FRAC_PI_2},
|
||||
"distance": {"description": "From the target to the eye, in mm.", "type": "number", "exclusiveMinimum": 0},
|
||||
"fov": {"description": "Vertical field of view; 0 is orthographic. Default: 0.", "type": "number", "minimum": 0, "default": 0.0},
|
||||
"roll": {"description": "Turn about the viewing direction, clockwise; a finite number, clamped to ±π. Default: 0.", "type": "number", "default": 0.0}
|
||||
},
|
||||
"required": ["target", "yaw", "pitch", "distance"],
|
||||
"not": {"required": ["up"]}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the viewport (and the MCP screenshot) to a standard view, a named view or a camera. View
|
||||
/// state only: not saved with the design and not an undo step. Give `view` or `camera`, not both.
|
||||
/// (A `view` that is not text is read as absent when a `camera` is given, as the handler does.)
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ViewApplyKeys {
|
||||
/// A standard view: front, back, top, bottom, left, right, iso or home (the model framed
|
||||
/// from there), or a named view's name. Not with `camera`.
|
||||
#[serde(default)]
|
||||
pub view: Option<String>,
|
||||
/// A camera to turn to. Not with `view`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ApplyCamera")]
|
||||
pub camera: Option<ApplyCamera>,
|
||||
}
|
||||
|
||||
/// `view.apply`'s parameters: [`ViewApplyKeys`] with the two forms told apart.
|
||||
///
|
||||
/// The handler reads `view` as text and takes anything else as absent, and reads `camera` only
|
||||
/// when `view` is not text, so the same holds here: a `view` that is not text is not looked at
|
||||
/// when a `camera` is given, and one of neither is left to the handler's message.
|
||||
#[derive(Debug)]
|
||||
pub struct ViewApplyParams(pub ViewApplyKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewApplyParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if value.get("view").is_some_and(Value::is_string) {
|
||||
if value.get("camera").is_some() {
|
||||
return Err(D::Error::custom(whole("give `view` or `camera`, not both")));
|
||||
}
|
||||
} else if let Some(keys) = value.as_object_mut() {
|
||||
keys.remove("view");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ViewApplyParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewApplyParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewApplyParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = ViewApplyKeys::json_schema(generator);
|
||||
schema.insert("anyOf".into(), json!([{"required": ["view"]}, {"required": ["camera"]}]));
|
||||
schema.insert("not".into(), json!({"required": ["view", "camera"]}));
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch by id or name, as `sketch.redefine` reads `sketch`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SketchKey(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchKey {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => Ok(SketchKey(n.to_string())),
|
||||
Value::String(s) => Ok(SketchKey(s)),
|
||||
_ => Err(D::Error::custom(whole("`sketch` must be an id or name"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchKey {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchKey".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A sketch's feature id (a number) or its name.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
fn some_sketch<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SketchKey>, D::Error> {
|
||||
SketchKey::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// The coordinates an explicit plane `{origin, x_dir, y_dir}` is given in: `world` or
|
||||
/// `component`, the component's own. (No doc on the variants: they would make the schema a
|
||||
/// `oneOf` of constants instead of a plain `enum`.)
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PlaneFrame {
|
||||
World,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl PlaneFrame {
|
||||
/// The frame a handler names, as `world` or `component`.
|
||||
pub fn from_name(name: &str) -> Option<PlaneFrame> {
|
||||
match name {
|
||||
"world" => Some(PlaneFrame::World),
|
||||
"component" => Some(PlaneFrame::Component),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PlaneFrame {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(f) if f == "world" => Ok(PlaneFrame::World),
|
||||
Value::String(f) if f == "component" => Ok(PlaneFrame::Component),
|
||||
_ => Err(D::Error::custom(whole("`frame` must be \"world\" or \"component\""))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The occurrence ids of the placement a sketch is authored in, from the outermost down, as
|
||||
/// `sketch.redefine` reads `author_path`: at most 64 nonzero whole numbers.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AuthorPath(pub Vec<u64>);
|
||||
|
||||
impl<'de> Deserialize<'de> for AuthorPath {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Array(path) if path.len() <= MAX_PATH => path
|
||||
.iter()
|
||||
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| D::Error::custom(whole("invalid author_path id"))))
|
||||
.collect::<Result<_, _>>()
|
||||
.map(AuthorPath),
|
||||
_ => Err(D::Error::custom(whole("author_path must contain at most64 ids"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for AuthorPath {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AuthorPath".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Occurrence ids, outermost first: at most 64 whole numbers above 0.",
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"maxItems": MAX_PATH
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a sketch to another plane, keeping its geometry and its dimension names: an origin or
|
||||
/// construction plane, an explicit plane, or a planar face of the model. A face it follows is
|
||||
/// refused where the sketch's own geometry would depend on later features. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = sketch_and_plane_required)]
|
||||
pub struct RedefineSketchParams {
|
||||
// Optional in the type and required in the schema (`sketch_and_plane_required`): left out,
|
||||
// the handler refuses it with its own message.
|
||||
/// The sketch to move, by feature id or name.
|
||||
#[serde(default, deserialize_with = "some_sketch", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchKey")]
|
||||
pub sketch: Option<SketchKey>,
|
||||
/// The new plane, as `sketch.create` takes it. A picked face is read as it reads one.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "CanvasPlane")]
|
||||
pub plane: Option<CanvasPlane>,
|
||||
/// Move the plane this far along its normal (mm or a length expression). Default: 0.
|
||||
#[serde(default)]
|
||||
pub offset: Option<LengthExpr>,
|
||||
/// The placement the sketch is authored in, as occurrence ids from the outermost down. Default: the
|
||||
/// sketch's saved one, else the active occurrence, else the component's only placement; a
|
||||
/// component sketch in a design with several placements needs it.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "AuthorPath")]
|
||||
pub author_path: Option<AuthorPath>,
|
||||
/// For an explicit plane `{origin, x_dir, y_dir}`: whether it is in the world's or the
|
||||
/// component's own coordinates. Default: world. A named plane or a face takes none.
|
||||
#[serde(default)]
|
||||
pub frame: Option<PlaneFrame>,
|
||||
}
|
||||
|
||||
/// How a redefined sketch is attached to its plane: `exact_face` (to a face of a body, which it
|
||||
/// follows), `sheet_face` (to a face of a sheet part) or `fixed` (where it is: an origin,
|
||||
/// construction or explicit plane).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RedefineAttachment {
|
||||
ExactFace,
|
||||
SheetFace,
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// What `sketch.redefine` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RedefineSketchResult {
|
||||
/// The sketch's feature id.
|
||||
pub sketch: u64,
|
||||
/// How it is attached now.
|
||||
pub attachment: Option<RedefineAttachment>,
|
||||
/// For an explicit plane: the coordinates it was read in.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<PlaneFrame>,
|
||||
/// For a world plane put into a component that is moved or turned: how it was stored.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// What the session reports for every sketch command: the ids of the points, curves,
|
||||
/// constraints and dimensions the sketch gained, `{points, curves, constraints, dimensions}`.
|
||||
/// The handler leaves it out; the session adds it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "browser_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,10 +3,13 @@ use serde_json::{Value, json};
|
||||
use super::{
|
||||
BodyRenameParams, BodyRenamed, BrowserFoldParams, BrowserFoldResult, BrowserGroupMade, BrowserGroupParams, BrowserGroupRenamed,
|
||||
BrowserMoveParams, BrowserMoved, BrowserOrderParams, BrowserOrdered, BrowserRenameGroupParams, BrowserUngroupParams, BrowserUngrouped,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, RepairParams, RepairResult, VisibilityState,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, RedefineSketchParams, RedefineSketchResult,
|
||||
RepairParams, RepairResult, ViewApplyParams, ViewDeleteParams, ViewDeleted, ViewListParams, ViewListResult, ViewRenameParams, ViewRenamed,
|
||||
ViewSaveParams, ViewSaved, VisibilityState,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::view_navigation::CameraReply;
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
@@ -330,3 +333,413 @@ fn browser_commands_reply_with_their_schemas() {
|
||||
let error = s.execute("browser.visibility", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`itms`"), "{error}");
|
||||
}
|
||||
|
||||
// ---- Named views and Redefine Sketch (#12499) ----
|
||||
|
||||
const CAMERA: fn() -> Value = || json!({"target": [1, 2, 3], "yaw": 0.5, "pitch": -0.6, "distance": 150});
|
||||
|
||||
#[test]
|
||||
fn view_schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ViewSaveParams>(), &["camera", "name"]);
|
||||
assert_keys(input_schema_for::<ViewRenameParams>(), &["name", "view"]);
|
||||
assert_keys(input_schema_for::<ViewDeleteParams>(), &["view"]);
|
||||
assert_keys(input_schema_for::<ViewListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ViewApplyParams>(), &["camera", "view"]);
|
||||
assert_keys(input_schema_for::<RedefineSketchParams>(), &["author_path", "frame", "offset", "plane", "sketch"]);
|
||||
}
|
||||
|
||||
/// Each call has one problem the handler already refuses: strict gives its message. The session
|
||||
/// has a sketch (`1`) and no named view, so a view that must exist is looked for in the second
|
||||
/// test.
|
||||
#[test]
|
||||
fn view_strict_keeps_todays_errors() {
|
||||
let long = "n".repeat(129);
|
||||
let deep: Vec<u64> = (1..=65).collect();
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("view.save", json!({})),
|
||||
("view.save", json!({"name": "A"})),
|
||||
("view.save", json!({"camera": null})),
|
||||
("view.save", json!({"camera": "front"})),
|
||||
("view.save", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0}})),
|
||||
("view.save", json!({"camera": {"target": [0, 0], "yaw": 0, "pitch": 0, "distance": 5}})),
|
||||
("view.save", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}})),
|
||||
("view.save", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -3}})),
|
||||
("view.save", json!({"camera": CAMERA(), "name": long})),
|
||||
("view.rename", json!({})),
|
||||
("view.rename", json!({"name": "B"})),
|
||||
("view.rename", json!({"view": 5, "name": "B"})),
|
||||
("view.rename", json!({"view": null, "name": "B"})),
|
||||
("view.rename", json!({"view": "Nope", "name": "B"})),
|
||||
("view.delete", json!({})),
|
||||
("view.delete", json!({"view": 5})),
|
||||
("view.delete", json!({"view": null})),
|
||||
("view.delete", json!({"view": "Nope"})),
|
||||
("view.apply", json!({})),
|
||||
("view.apply", json!({"view": 5})),
|
||||
("view.apply", json!({"view": null})),
|
||||
("view.apply", json!({"view": "Nope"})),
|
||||
("view.apply", json!({"view": "top", "camera": CAMERA()})),
|
||||
("view.apply", json!({"view": "top", "camera": null})),
|
||||
("view.apply", json!({"camera": null})),
|
||||
("view.apply", json!({"camera": "front"})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "up": "y"}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "up": null}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": "x"}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": null}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 1.6, "distance": 80}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": -1.6, "distance": 80}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "fov": -0.5}})),
|
||||
("sketch.redefine", json!({})),
|
||||
("sketch.redefine", json!({"plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": true, "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": null, "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": [1], "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": 999, "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": 1})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": null})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": true})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": 1.5})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": -1})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"x_dir": [1, 0, 0]}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"x_dir": [1, 0, 0], "y_dir": [2, 0, 0]}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"origin": "1 mm", "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"face": "x"}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"face": [1, 2, 3], "body": "Box"}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "frame": "local"})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "frame": 3})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "frame": "world"})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": "1"})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": null})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": [0]})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": ["a"]})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": deep})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The calls that need a named view to get as far as their message.
|
||||
#[test]
|
||||
fn view_strict_keeps_todays_errors_for_views() {
|
||||
let long = "n".repeat(129);
|
||||
let cases = [
|
||||
("view.rename", json!({"view": "Front 2", "name": ""})),
|
||||
("view.rename", json!({"view": "Front 2", "name": " "})),
|
||||
("view.rename", json!({"view": "Front 2", "name": 5})),
|
||||
("view.rename", json!({"view": "Front 2", "name": null})),
|
||||
("view.rename", json!({"view": "Front 2", "name": long})),
|
||||
("view.rename", json!({"view": "Front 2"})),
|
||||
("view.rename", json!({"view": "Front 2", "name": "Other"})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let run = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for name in ["Front 2", "Other"] {
|
||||
s.execute("view.save", &json!({"camera": CAMERA(), "name": name})).unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = run(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(run(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the handlers refuse is refused by the types with the same message, and what they take
|
||||
/// is taken: the check names no problem for any call that works today, in the forms that read
|
||||
/// different keys (`view` or `camera`) and in each form of `plane`.
|
||||
#[test]
|
||||
fn the_check_reads_each_key_as_the_handler_does() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
let cam = |extra: Value| {
|
||||
let mut c = CAMERA();
|
||||
c["distance"] = json!(80);
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
c[k] = v.clone();
|
||||
}
|
||||
c
|
||||
};
|
||||
// view.apply: a view, or a camera.
|
||||
assert_eq!(issues("view.apply", json!({"view": "top"})), none);
|
||||
assert_eq!(issues("view.apply", json!({"view": "Anything"})), none);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"fov": 0.5, "roll": 1.0}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"roll": 100}))})), none, "a roll past π is clamped");
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"pitch": std::f64::consts::FRAC_PI_2}))})), none);
|
||||
// `view` is read as text only: where `camera` is the form, one that is not text is not looked at.
|
||||
assert_eq!(issues("view.apply", json!({"view": null, "camera": cam(json!({}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"view": 5, "camera": cam(json!({}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"view": "top", "camera": cam(json!({}))})), ["give `view` or `camera`, not both"]);
|
||||
assert_eq!(issues("view.apply", json!({"view": "top", "camera": null})), ["give `view` or `camera`, not both"]);
|
||||
assert_eq!(
|
||||
issues("view.apply", json!({"camera": cam(json!({"up": "z"}))})),
|
||||
["camera `up` is not supported: the up axis comes from the design settings"]
|
||||
);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"roll": "x"}))})), ["camera `roll` must be a finite number in radians"]);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"pitch": 2}))})), ["camera `pitch` must be within ±π/2 radians (±90°)"]);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"distance": 0}))})), ["the camera must be finite with a positive distance"]);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"fov": -1}))})), ["the camera must be finite with a positive distance"]);
|
||||
assert!(issues("view.apply", json!({"camera": {"yaw": 1}}))[0].starts_with("bad camera: missing field `target`"));
|
||||
// Neither: the handler's own message (the type leaves it to the handler).
|
||||
assert_eq!(issues("view.apply", json!({})), none);
|
||||
// view.save.
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA()})), none);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": " "})), none);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": null})), none);
|
||||
// Not text: the handler reads it as left out; the check reports it.
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": 5})).len(), 1);
|
||||
assert_eq!(
|
||||
issues("view.save", json!({"camera": {"name": "old", "target": [1, 2, 3], "yaw": 0, "pitch": 7, "distance": 1, "fov": 0.3}, "name": "N"})),
|
||||
none
|
||||
);
|
||||
assert_eq!(issues("view.save", json!({"camera": cam(json!({"distance": -1}))})), ["the camera must be finite with a positive distance"]);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": "n".repeat(129)})), ["`name` is too long"]);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": format!(" {} ", "n".repeat(128))})), none);
|
||||
// view.rename and view.delete.
|
||||
assert_eq!(issues("view.rename", json!({"view": "a", "name": "b"})), none);
|
||||
assert_eq!(issues("view.rename", json!({"view": 1, "name": "b"})), ["`view` must be a view name"]);
|
||||
assert_eq!(issues("view.rename", json!({"view": "a", "name": ""})), ["`name` must be 1…128 characters"]);
|
||||
assert_eq!(issues("view.delete", json!({"view": null})), ["`view` must be a view name"]);
|
||||
assert_eq!(issues("view.list", json!({})), none);
|
||||
assert_eq!(issues("view.list", json!({"views": 1})).len(), 1);
|
||||
// sketch.redefine: each form of the plane, and `frame` and `author_path` as given.
|
||||
let plane = |p: Value| issues("sketch.redefine", json!({"sketch": "S", "plane": p}));
|
||||
for p in [
|
||||
json!("XY"),
|
||||
json!("front"),
|
||||
json!("Plane1"),
|
||||
json!(7),
|
||||
json!({"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}),
|
||||
json!({"origin": [1, 2, 3], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}),
|
||||
json!({"origin": null, "x_dir": {"x": 1, "y": 0, "z": 0}, "y_dir": [0, 1, 0]}),
|
||||
json!({"face": [1, 2, 3]}),
|
||||
json!({"face": [1, 2, 3], "body": "Box", "index": 2, "path": [1, 2], "fixed": true}),
|
||||
] {
|
||||
assert_eq!(plane(p.clone()), none, "{p}");
|
||||
}
|
||||
assert_eq!(plane(json!(1.5)).len(), 1);
|
||||
assert_eq!(plane(json!(null)), ["`plane` must be XY, XZ, YZ, a construction plane name or id, or an object"]);
|
||||
assert_eq!(plane(json!({"x_dir": [1, 0, 0]})), ["plane needs y_dir"]);
|
||||
let with = |extra: Value| {
|
||||
let mut p = json!({"sketch": 1, "plane": "XZ"});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
issues("sketch.redefine", p)
|
||||
};
|
||||
assert_eq!(with(json!({"offset": 5})), none);
|
||||
assert_eq!(with(json!({"offset": "w / 2"})), none);
|
||||
assert_eq!(with(json!({"frame": null})), none);
|
||||
assert_eq!(with(json!({"frame": "component", "author_path": [1, 2]})), none);
|
||||
assert_eq!(with(json!({"author_path": []})), none);
|
||||
assert_eq!(with(json!({"frame": "x"})), ["`frame` must be \"world\" or \"component\""]);
|
||||
assert_eq!(with(json!({"author_path": [0]})), ["invalid author_path id"]);
|
||||
assert_eq!(with(json!({"author_path": null})), ["author_path must contain at most64 ids"]);
|
||||
assert_eq!(issues("sketch.redefine", json!({"sketch": 2.5, "plane": "XZ"})), none);
|
||||
assert_eq!(issues("sketch.redefine", json!({"sketch": {}, "plane": "XZ"})), ["`sketch` must be an id or name"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need, and no more.
|
||||
#[test]
|
||||
fn the_view_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
let cam = |extra: Value| {
|
||||
let mut c = CAMERA();
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
c[k] = v.clone();
|
||||
}
|
||||
c
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<ViewSaveParams>(),
|
||||
&[
|
||||
(json!({"camera": CAMERA()}), true),
|
||||
(json!({"camera": cam(json!({"fov": 0.4})), "name": "N"}), true),
|
||||
(json!({"name": "N"}), false),
|
||||
(json!({"camera": cam(json!({"distance": 0}))}), false),
|
||||
(json!({"camera": {"yaw": 0}}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ViewApplyParams>(),
|
||||
&[
|
||||
(json!({"view": "top"}), true),
|
||||
(json!({"camera": cam(json!({"roll": 0.3, "fov": 0.5}))}), true),
|
||||
(json!({}), false),
|
||||
(json!({"view": "top", "camera": CAMERA()}), false),
|
||||
(json!({"camera": cam(json!({"up": "z"}))}), false),
|
||||
(json!({"camera": cam(json!({"pitch": 2}))}), false),
|
||||
(json!({"camera": cam(json!({"fov": -1}))}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ViewRenameParams>(),
|
||||
&[(json!({"view": "a", "name": "b"}), true), (json!({"view": "a"}), false), (json!({"name": "b"}), false)],
|
||||
);
|
||||
takes(input_schema_for::<ViewDeleteParams>(), &[(json!({"view": "a"}), true), (json!({}), false), (json!({"view": 1}), false)]);
|
||||
takes(input_schema_for::<ViewListParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
|
||||
takes(
|
||||
input_schema_for::<RedefineSketchParams>(),
|
||||
&[
|
||||
(json!({"sketch": 3, "plane": "XZ"}), true),
|
||||
(
|
||||
json!({"sketch": "S", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "frame": "component", "offset": "2 mm", "author_path": [1]}),
|
||||
true,
|
||||
),
|
||||
(json!({"sketch": "S", "plane": {"face": [1, 2, 3]}}), true),
|
||||
(json!({"plane": "XZ"}), false),
|
||||
(json!({"sketch": 3}), false),
|
||||
(json!({"sketch": 3, "plane": "XZ", "frame": "x"}), false),
|
||||
(json!({"sketch": 3, "plane": "XZ", "author_path": [0]}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Every form of the six commands, in Warn: each real reply matches its output schema and
|
||||
/// carries no warning, and the replies are the JSON the handlers have always returned.
|
||||
#[test]
|
||||
fn view_and_redefine_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value, schema: schemars::Schema| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
assert!(conforms(schema, schema, &r), "{id} {p}: {r} doesn't match {schema}");
|
||||
r
|
||||
};
|
||||
let saved = run("view.save", json!({"camera": CAMERA()}), output_schema_for::<ViewSaved>());
|
||||
assert_eq!(saved, json!({"view": "Named View1"}));
|
||||
let saved = run(
|
||||
"view.save",
|
||||
json!({"camera": {"target": [1, 2, 3], "yaw": 0, "pitch": 0.2, "distance": 9, "fov": 0.4}, "name": " Detail "}),
|
||||
output_schema_for::<ViewSaved>(),
|
||||
);
|
||||
assert_eq!(saved, json!({"view": "Detail"}));
|
||||
run("view.save", json!({"camera": CAMERA(), "name": null}), output_schema_for::<ViewSaved>());
|
||||
let listed = run("view.list", json!({}), output_schema_for::<ViewListResult>());
|
||||
assert_eq!(listed["views"].as_array().map(Vec::len), Some(3), "{listed}");
|
||||
assert_eq!(listed["views"][1]["name"], "Detail", "{listed}");
|
||||
let schema = output_schema_for::<ViewListResult>();
|
||||
let entry = &listed["views"][0];
|
||||
assert!(entry["target"].is_array() && entry["fov"] == 0.0 && entry["distance"] == 150.0, "{listed}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &listed));
|
||||
let renamed = run("view.rename", json!({"view": "Named View1", "name": " Overview "}), output_schema_for::<ViewRenamed>());
|
||||
assert_eq!(renamed, json!({"view": "Overview"}));
|
||||
let deleted = run("view.delete", json!({"view": "Overview"}), output_schema_for::<ViewDeleted>());
|
||||
assert_eq!(deleted, json!({"deleted": "Overview"}));
|
||||
let empty = run("view.list", json!({}), output_schema_for::<ViewListResult>());
|
||||
assert_eq!(empty["views"].as_array().map(Vec::len), Some(2), "{empty}");
|
||||
|
||||
let camera = output_schema_for::<CameraReply>;
|
||||
for view in ["front", "back", "top", "iso", "home"] {
|
||||
run("view.apply", json!({"view": view}), camera());
|
||||
}
|
||||
let named = run("view.apply", json!({"view": "Detail"}), camera());
|
||||
assert!((named["camera"]["pitch"].as_f64().unwrap_or_default() - 0.2).abs() < 1e-12, "{named}");
|
||||
let turned =
|
||||
run("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5, "roll": 1.0}}), camera());
|
||||
assert_eq!(turned["camera"]["roll"], 1.0, "{turned}");
|
||||
let clamped = run("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "roll": 100}}), camera());
|
||||
assert!((clamped["camera"]["roll"].as_f64().unwrap_or_default() - std::f64::consts::PI).abs() < 1e-12, "{clamped}");
|
||||
run("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": std::f64::consts::FRAC_PI_2, "distance": 80}}), camera());
|
||||
|
||||
// Redefine Sketch: a sketch, moved to a named plane, offset, an explicit plane in each frame,
|
||||
// and a face of a body.
|
||||
let sketch = |s: &mut Session| s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"))["sketch"].clone();
|
||||
let sk = sketch(&mut s);
|
||||
let mut run = |id: &str, p: Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = output_schema_for::<RedefineSketchResult>();
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id} {p}: {r}");
|
||||
assert!(r["created"].is_object(), "the session adds `created`: {r}");
|
||||
let mut r = r;
|
||||
r.as_object_mut().map(|o| o.remove("created"));
|
||||
r
|
||||
};
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed"}));
|
||||
let r = run("sketch.redefine", json!({"sketch": sk.to_string(), "plane": "YZ", "offset": "5 mm", "frame": null}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed"}));
|
||||
let explicit = json!({"origin": [1, 2, 3], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]});
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": explicit}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed", "frame": "world"}));
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": explicit, "frame": "component"}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed", "frame": "component"}));
|
||||
}
|
||||
|
||||
/// A face of a body that comes before the sketch: the sketch follows it, or stays where the face
|
||||
/// is now with `fixed`.
|
||||
#[test]
|
||||
fn redefine_sketch_onto_a_face_matches_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sk = s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"))["sketch"].clone();
|
||||
let mut run = |p: Value| {
|
||||
let r = s.execute("sketch.redefine", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let schema = output_schema_for::<RedefineSketchResult>();
|
||||
assert!(r.get("param_warnings").is_none() && conforms(schema.as_value(), schema.as_value(), &r), "{p}: {r}");
|
||||
r
|
||||
};
|
||||
let r = run(json!({"sketch": sk, "plane": {"face": [5, 5, 10]}}));
|
||||
assert_eq!(r["attachment"], "exact_face", "{r}");
|
||||
let r = run(json!({"sketch": sk, "plane": {"face": [5, 5, 10], "body": "Box", "index": 0, "fixed": true}}));
|
||||
assert_eq!(r["attachment"], "fixed", "{r}");
|
||||
}
|
||||
|
||||
/// A moved component: a world plane put into it says so in `note`, which the schema describes.
|
||||
#[test]
|
||||
fn redefine_sketch_note_matches_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let part = run("component.create", json!({"name": "Part", "grounded": false}));
|
||||
let sk = run("sketch.create", json!({"plane": "XY"}))["sketch"].clone();
|
||||
run("occurrence.move", json!({"occurrence": part["occurrence"], "translate": [10, 0, 0]}));
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "author_path": [part["occurrence"]]}));
|
||||
let schema = output_schema_for::<RedefineSketchResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
assert_eq!(r["frame"], "world", "{r}");
|
||||
assert!(r["note"].as_str().is_some_and(|n| n.contains("world coordinates")), "{r}");
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn, and says so, with a reply that matches; Strict refuses it.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_for_each_view_command() {
|
||||
let mut s = Session::default();
|
||||
s.execute("view.save", &json!({"camera": CAMERA(), "name": "V"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sk = s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"))["sketch"].clone();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let cases: Vec<(&str, Value, &str, schemars::Schema)> = vec![
|
||||
("view.apply", json!({"view": "top", "camra": 1}), "camra", output_schema_for::<CameraReply>()),
|
||||
("view.list", json!({"view": "V"}), "view", output_schema_for::<ViewListResult>()),
|
||||
("view.rename", json!({"view": "V", "name": "W", "nmae": 1}), "nmae", output_schema_for::<ViewRenamed>()),
|
||||
("view.save", json!({"camera": CAMERA(), "name": "X", "nmae": 1}), "nmae", output_schema_for::<ViewSaved>()),
|
||||
("sketch.redefine", json!({"sketch": sk, "plane": "XZ", "frme": "world"}), "frme", output_schema_for::<RedefineSketchResult>()),
|
||||
("view.delete", json!({"view": "X", "vew": 1}), "vew", output_schema_for::<ViewDeleted>()),
|
||||
];
|
||||
for (id, p, key, schema) in cases {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(r["param_warnings"].to_string().contains(key), "{id}: {r}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r}");
|
||||
}
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("view.apply", &json!({"view": "top", "camra": 1})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`camra`"), "{error}");
|
||||
}
|
||||
|
||||
/// The commands run exactly as before with the check off, whatever the call (the types only
|
||||
/// describe).
|
||||
#[test]
|
||||
fn the_view_commands_still_read_their_calls_as_before_with_the_check_off() {
|
||||
let mut s = Session::default();
|
||||
s.execute("view.save", &json!({"camera": CAMERA(), "name": 5})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(s.execute("view.list", &json!({"x": 1})).unwrap_or_else(|e| panic!("{e}"))["views"][0]["name"], "Named View1");
|
||||
s.execute("view.apply", &json!({"view": 5, "camera": CAMERA()})).unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
//! Contracts and result types for the commands in `cmd/component.rs`.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::Occurrence;
|
||||
|
||||
use super::check::{located, whole};
|
||||
use super::component_link::{ComponentRef, component_key, require};
|
||||
use super::types::{IdOrName, Point3, PointExpr3, not_blank_pattern, present};
|
||||
use crate::params;
|
||||
|
||||
@@ -473,6 +478,658 @@ pub struct ReparentItemsResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// component.create, from_bodies, activate, list, properties, rename, delete and unlock (#12504).
|
||||
//
|
||||
// The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
// reads it and fails with the handler's message. Whether a component, an occurrence or a body
|
||||
// exists stays the handler's to check. A key the handler refuses when it is left out is optional
|
||||
// in the type and required in the schema, so that the handler's message, not the check's "is
|
||||
// required", is what a caller sees.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `message`, the handler's, for a value that is not a boolean (`null` included).
|
||||
fn flag<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(D::Error::custom(whole(message))),
|
||||
}
|
||||
}
|
||||
|
||||
fn grounded_flag<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
flag(d, "grounded must be true or false")
|
||||
}
|
||||
|
||||
fn descendants_flag<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
flag(d, "`descendants` must be a boolean")
|
||||
}
|
||||
|
||||
/// A placement path as `component.create` reads `parent_path`: up to 63 occurrence ids (none
|
||||
/// is the design itself).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ParentPath(pub Vec<u64>);
|
||||
|
||||
impl<'de> Deserialize<'de> for ParentPath {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_array()
|
||||
.filter(|a| a.len() < 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.map(ParentPath)
|
||||
.ok_or_else(|| D::Error::custom(whole("parent_path must list up to 63 occurrence ids")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ParentPath {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ParentPath".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An exact placement path: up to 63 occurrence ids from the root down; empty for the design itself.",
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
|
||||
"maxItems": 63
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parent<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ComponentRef>, D::Error> {
|
||||
component_key(d, "parent")
|
||||
}
|
||||
|
||||
fn component<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ComponentRef>, D::Error> {
|
||||
component_key(d, "component")
|
||||
}
|
||||
|
||||
fn component_required(schema: &mut Schema) {
|
||||
require(schema, "component");
|
||||
}
|
||||
|
||||
/// Make a new component, in the active component unless `parent` says another, and make it the
|
||||
/// active one unless `activate` is false. The design's first part is grounded; the others are
|
||||
/// not. Bodies are not moved here (`component.move_bodies`, `component.from_bodies`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CreateParams {
|
||||
/// The new component's name. Default: a name made from the design's naming.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// The component to put it in, by id or name. Default: the active one.
|
||||
#[serde(default, deserialize_with = "parent", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub parent: Option<ComponentRef>,
|
||||
/// The exact placement of the parent to place it by, as occurrence ids from the top of the
|
||||
/// design (up to 63; none is the design itself). It must belong to the parent. Default: the
|
||||
/// active placement when the parent is the active component, else the parent's first.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ParentPath")]
|
||||
pub parent_path: Option<ParentPath>,
|
||||
/// Make it the active component. Default: `true`.
|
||||
#[serde(default)]
|
||||
pub activate: Option<bool>,
|
||||
/// Ground its placement. Default: `true` for the design's first part, `false` otherwise.
|
||||
#[serde(default, deserialize_with = "grounded_flag", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub grounded: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `component.create` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CreateResult {
|
||||
/// The new component, by id.
|
||||
pub component: u64,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// The component it is in, by id (0 is the design).
|
||||
pub parent: u64,
|
||||
/// Its placement, by id.
|
||||
pub occurrence: Option<u64>,
|
||||
/// Whether its placement is grounded.
|
||||
pub grounded: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `component.from_bodies`' `bodies`: a list of body names, each once, as `body_names` reads
|
||||
/// it. The handler also looks each name up in the design.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DefinitionBodies(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for DefinitionBodies {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let names = Value::deserialize(d)?
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| D::Error::custom(whole("bodies must be an array of body names")))?;
|
||||
if names.is_empty() || names.len() > 1000 || names.iter().collect::<BTreeSet<_>>().len() != names.len() {
|
||||
return Err(D::Error::custom(whole("bodies must list 1..1000 distinct existing definition bodies")));
|
||||
}
|
||||
Ok(DefinitionBodies(names))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DefinitionBodies {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DefinitionBodies".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "1 to 1000 different body names.",
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
"maxItems": 1000,
|
||||
"uniqueItems": true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn bodies_required(schema: &mut Schema) {
|
||||
require(schema, "bodies");
|
||||
}
|
||||
|
||||
/// Make one new component per body, in the active component and named after the body, and move
|
||||
/// each body into its component, keeping it where it is in the world. All or nothing, one undo
|
||||
/// step; refused while the timeline is rolled back.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = bodies_required)]
|
||||
pub struct FromBodiesParams {
|
||||
// Optional in the type and required in the schema (`bodies_required`).
|
||||
/// The bodies, by name.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "DefinitionBodies")]
|
||||
pub bodies: Option<DefinitionBodies>,
|
||||
}
|
||||
|
||||
/// One component `component.from_bodies` made.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyComponent {
|
||||
/// The new component, by id.
|
||||
pub component: u64,
|
||||
/// The body it holds, by name.
|
||||
pub body: String,
|
||||
/// The Body Transfer feature that moved the body, by id.
|
||||
pub feature: u64,
|
||||
}
|
||||
|
||||
/// What `component.from_bodies` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FromBodiesResult {
|
||||
/// The new components, in the order of `bodies`.
|
||||
pub components: Vec<BodyComponent>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// An occurrence by its id (a number) or its name, as `occurrence` reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OccurrenceRef(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for OccurrenceRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => Ok(OccurrenceRef(n.to_string())),
|
||||
Value::String(s) => Ok(OccurrenceRef(s)),
|
||||
_ => Err(D::Error::custom(whole("`occurrence` must be an id or name"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for OccurrenceRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"OccurrenceRef".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An occurrence's id (a number) or its name.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_schema(schema: &mut Schema) {
|
||||
require(schema, "component");
|
||||
schema.insert("dependentRequired".into(), json!({"path": ["occurrence"]}));
|
||||
}
|
||||
|
||||
/// Make a component the active one: new sketches and features go into it, authored in its
|
||||
/// frame. With `occurrence` (and `path`), the placement that shared geometry is authored
|
||||
/// through. A change of what is active, not an undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = activate_schema)]
|
||||
pub struct ActivateKeys {
|
||||
// Optional in the type and required in the schema (`activate_schema`).
|
||||
/// The component to make active, by id or name (0 or `"root"` for the design).
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
/// The placement the component is authored in, by id or name, for a component placed more
|
||||
/// than once. Needed when `path` is given.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrenceRef")]
|
||||
pub occurrence: Option<OccurrenceRef>,
|
||||
/// The occurrence's exact placement, as occurrence ids from the top of the design (1 to 64),
|
||||
/// ending in `occurrence`, and a placement of the component. Default: the occurrence's
|
||||
/// first placement.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrencePath")]
|
||||
pub path: Option<OccurrencePath>,
|
||||
}
|
||||
|
||||
/// `component.activate`'s parameters: [`ActivateKeys`], with `path` asked to come with
|
||||
/// `occurrence`, as the handler asks.
|
||||
#[derive(Debug)]
|
||||
pub struct ActivateParams(pub ActivateKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ActivateParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
let keys: ActivateKeys = serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))?;
|
||||
if keys.path.is_some() && keys.occurrence.is_none() {
|
||||
return Err(D::Error::custom(whole("`occurrence` must be an id or name")));
|
||||
}
|
||||
Ok(ActivateParams(keys))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ActivateParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ActivateParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ActivateKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `component.activate` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ActivateResult {
|
||||
/// The active component now, by id.
|
||||
pub active: u64,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// List the components as a tree: each with its bodies, features, sketches, construction planes,
|
||||
/// placements and children, the active component and the pending moves. The design is not
|
||||
/// changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ListKeys {
|
||||
/// Counts of bodies, features, sketches and construction planes instead of their names;
|
||||
/// placements leave out identity transforms; children as occurrence ids. Default: `false`.
|
||||
#[serde(default)]
|
||||
pub compact: Option<bool>,
|
||||
/// With `compact`: body names, not their count. Read only with `compact: true`. Default:
|
||||
/// `false`.
|
||||
#[serde(default)]
|
||||
pub bodies: Option<bool>,
|
||||
}
|
||||
|
||||
/// `component.list`'s parameters: [`ListKeys`], with `bodies` read only when `compact` is true,
|
||||
/// as the handler reads it.
|
||||
#[derive(Debug)]
|
||||
pub struct ListParams(pub ListKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ListParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut()
|
||||
&& keys.get("compact").and_then(Value::as_bool) != Some(true)
|
||||
{
|
||||
keys.remove("bodies");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ListParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ListParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ListParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ListKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// A placement as `component.list` shows it, in full.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PlacementInfo {
|
||||
/// The occurrence, by id.
|
||||
pub id: u64,
|
||||
/// The component it places, by id.
|
||||
pub component: u64,
|
||||
/// The component it sits in, by id (0 is the design).
|
||||
pub parent: u64,
|
||||
pub name: String,
|
||||
/// Its placement in its parent, as a 4×4 matrix.
|
||||
pub transform: [[f64; 4]; 4],
|
||||
/// Whether it is grounded.
|
||||
pub grounded: bool,
|
||||
}
|
||||
|
||||
impl From<&Occurrence> for PlacementInfo {
|
||||
fn from(o: &Occurrence) -> Self {
|
||||
PlacementInfo { id: o.id, component: o.component, parent: o.parent, name: o.name.clone(), transform: o.transform, grounded: o.grounded }
|
||||
}
|
||||
}
|
||||
|
||||
/// A placement as `compact` `component.list` shows it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CompactPlacement {
|
||||
/// The occurrence, by id.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The component it sits in, by id (0 is the design).
|
||||
pub parent: u64,
|
||||
/// Whether it is grounded.
|
||||
pub grounded: bool,
|
||||
/// Its placement in its parent, as a 4×4 matrix; left out when it is the identity.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub transform: Option<[[f64; 4]; 4]>,
|
||||
}
|
||||
|
||||
/// A placement in a component's `occurrences`: in full, or compact.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum PlacementEntry {
|
||||
Full(PlacementInfo),
|
||||
Compact(CompactPlacement),
|
||||
}
|
||||
|
||||
/// A placement in a component's `children`: in full, or with `compact` its id.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ChildEntry {
|
||||
Full(PlacementInfo),
|
||||
Id(u64),
|
||||
}
|
||||
|
||||
/// Names, or with `compact` how many there are.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum NamesOrCount {
|
||||
Names(Vec<String>),
|
||||
Count(usize),
|
||||
}
|
||||
|
||||
/// One component of `component.list`; the design itself is the first, with id 0.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ComponentNode {
|
||||
/// The component, by id (0 is the design).
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The component it is in; null for the design.
|
||||
pub parent: Option<u64>,
|
||||
/// Its bodies by name; with `compact` their count unless `bodies` is true.
|
||||
pub bodies: NamesOrCount,
|
||||
/// Its features other than sketches and construction planes, by name; with `compact` their count.
|
||||
pub features: NamesOrCount,
|
||||
/// Its sketches, by name; with `compact` their count.
|
||||
pub sketches: NamesOrCount,
|
||||
/// Its construction planes, by name; with `compact` their count.
|
||||
pub construction: NamesOrCount,
|
||||
/// Where it is placed.
|
||||
pub occurrences: Vec<PlacementEntry>,
|
||||
/// The placements it holds.
|
||||
pub children: Vec<ChildEntry>,
|
||||
}
|
||||
|
||||
/// What `component.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ListResult {
|
||||
/// The active component, by id.
|
||||
pub active: u64,
|
||||
/// The design and its components.
|
||||
pub components: Vec<ComponentNode>,
|
||||
/// The occurrences with a move not captured yet, by id.
|
||||
pub pending_moves: Vec<u64>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `about`, a point in mm or `null`, with the handler's message for anything else.
|
||||
fn about<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point3>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => params::vec3(&v).map(|p| Some(Point3(p))).ok_or_else(|| D::Error::custom(whole("`about` must be a point [x, y, z] in mm"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure a component where its first placement puts it (every placement for the design):
|
||||
/// material-aware mass and centre of mass in world coordinates, volume, density, and inertia
|
||||
/// for closed, consistently oriented bodies, with totals and principal moments. Pending moves
|
||||
/// are included. The design is not changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PropertiesParams {
|
||||
/// The component, by id or name. Default: the active component.
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
/// Include the components and placements inside it. Default: `true`.
|
||||
#[serde(default, deserialize_with = "descendants_flag", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub descendants: Option<bool>,
|
||||
/// A point in mm, also given the total inertia about it (`total.inertia_about`).
|
||||
#[serde(default, deserialize_with = "about", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<Point3>")]
|
||||
pub about: Option<Point3>,
|
||||
}
|
||||
|
||||
/// A body's box, in world coordinates (mm).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BoundingBox {
|
||||
#[schemars(with = "[f64; 3]")]
|
||||
pub min: Vec3,
|
||||
#[schemars(with = "[f64; 3]")]
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
/// One body's measurements at one placement. Volume, density, mass, centre of mass and inertia
|
||||
/// are null for a body that is not closed.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyProperties {
|
||||
pub name: String,
|
||||
/// The component it belongs to, by id.
|
||||
pub component: u64,
|
||||
/// The placement it is measured at, as occurrence ids from the top.
|
||||
pub path: Vec<u64>,
|
||||
/// Its material's name.
|
||||
pub material: String,
|
||||
/// Whether it is closed and consistently oriented.
|
||||
pub closed: bool,
|
||||
pub density_g_cm3: Option<f64>,
|
||||
pub mass_g: Option<f64>,
|
||||
pub volume_mm3: Option<f64>,
|
||||
pub area_mm2: f64,
|
||||
/// Its centre of mass, in world coordinates (mm).
|
||||
#[schemars(with = "Option<[f64; 3]>")]
|
||||
pub center_of_mass: Option<Vec3>,
|
||||
/// The inertia tensor about its centre of mass, in world axes (g·mm²).
|
||||
pub inertia_g_mm2: Option<[[f64; 3]; 3]>,
|
||||
pub bbox: BoundingBox,
|
||||
}
|
||||
|
||||
/// The inertia about the point `about` asked for.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct InertiaAbout {
|
||||
/// The point, in mm.
|
||||
#[schemars(with = "[f64; 3]")]
|
||||
pub point: Vec3,
|
||||
/// The total inertia tensor about it (g·mm²); null when it is not known.
|
||||
pub inertia_g_mm2: Option<[[f64; 3]; 3]>,
|
||||
}
|
||||
|
||||
/// The totals of a physical-properties measurement, over the closed bodies unless said.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TotalProperties {
|
||||
/// How many open bodies are left out of the volume and mass totals.
|
||||
pub open_bodies_skipped: usize,
|
||||
pub volume_mm3: f64,
|
||||
/// The area of all bodies, closed or not.
|
||||
pub area_mm2: f64,
|
||||
pub mass_g: f64,
|
||||
/// The centre of mass (mm); null when the mass is zero.
|
||||
#[schemars(with = "Option<[f64; 3]>")]
|
||||
pub center_of_mass: Option<Vec3>,
|
||||
/// Mass times centre of mass (g·mm).
|
||||
#[schemars(with = "[f64; 3]")]
|
||||
pub first_moment_g_mm: Vec3,
|
||||
/// The inertia tensor about the centre of mass, in world axes (g·mm²); null when it is not known.
|
||||
pub inertia_g_mm2: Option<[[f64; 3]; 3]>,
|
||||
/// The principal moments, smallest first (g·mm²).
|
||||
pub principal_moments_g_mm2: Option<[f64; 3]>,
|
||||
/// The principal axes: unit vectors, right-handed.
|
||||
#[schemars(with = "Option<[[f64; 3]; 3]>")]
|
||||
pub principal_axes: Option<[Vec3; 3]>,
|
||||
/// The box around all bodies; null when there are none.
|
||||
pub bbox: Option<BoundingBox>,
|
||||
pub faces: usize,
|
||||
pub edges: usize,
|
||||
pub vertices: usize,
|
||||
/// With `about`: the inertia about that point.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inertia_about: Option<InertiaAbout>,
|
||||
}
|
||||
|
||||
/// Physical properties of a placement and what it holds, in world coordinates.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PhysicalProperties {
|
||||
/// The placement measured, as occurrence ids from the top; empty for the design.
|
||||
pub path: Vec<u64>,
|
||||
/// Always `world`.
|
||||
pub coordinate_frame: String,
|
||||
/// Whether what is inside the placement is included.
|
||||
pub descendants: bool,
|
||||
pub placement_count: usize,
|
||||
pub body_count: usize,
|
||||
pub bodies: Vec<BodyProperties>,
|
||||
pub total: TotalProperties,
|
||||
}
|
||||
|
||||
/// What `component.properties` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PropertiesResult {
|
||||
/// The component, by id.
|
||||
pub component: u64,
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub properties: PhysicalProperties,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The new name `component.rename` reads: text of 1 to 128 bytes once trimmed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ComponentName(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for ComponentName {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.trim().is_empty() && s.trim().len() <= 128 => Ok(ComponentName(s)),
|
||||
_ => Err(D::Error::custom(whole("`name` must be 1…128 characters"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ComponentName {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ComponentName".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Text with something in it; once surrounding blanks are cut, at most 128 bytes.",
|
||||
"type": "string",
|
||||
"pattern": not_blank_pattern()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_schema(schema: &mut Schema) {
|
||||
require(schema, "component");
|
||||
require(schema, "name");
|
||||
}
|
||||
|
||||
/// Rename a component, or the design itself (id 0). Placements still named after it follow.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = rename_schema)]
|
||||
pub struct RenameParams {
|
||||
// Optional in the type and required in the schema (`rename_schema`).
|
||||
/// The component, by id or name (0 or `"root"` for the design).
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
/// The new name.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentName")]
|
||||
pub name: Option<ComponentName>,
|
||||
}
|
||||
|
||||
/// What `component.rename` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RenameResult {
|
||||
/// The component, by id.
|
||||
pub component: u64,
|
||||
/// Its name now, trimmed.
|
||||
pub name: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Delete a component with its subcomponents, placements and features. Refused for the design
|
||||
/// itself, and for a tree that supplies a body transfer recorded outside it.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = component_required)]
|
||||
pub struct DeleteParams {
|
||||
// Optional in the type and required in the schema (`component_required`).
|
||||
/// The component, by id or name.
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
}
|
||||
|
||||
/// What `component.delete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DeleteResult {
|
||||
/// The components removed, by id: it and everything inside it.
|
||||
pub deleted: Vec<u64>,
|
||||
/// The features that were inside them, by id.
|
||||
pub features: Vec<u64>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Remove the rigid joints that hold a component's contents together (an imported assembly's
|
||||
/// rigid group), so its parts move and take joints one by one. Refused for the design and when
|
||||
/// nothing holds the contents together.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = component_required)]
|
||||
pub struct UnlockParams {
|
||||
// Optional in the type and required in the schema (`component_required`).
|
||||
/// The component, by id or name.
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
}
|
||||
|
||||
/// What `component.unlock` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct UnlockResult {
|
||||
/// The component, by id.
|
||||
pub component: u64,
|
||||
/// The joints removed, by id.
|
||||
pub joints: Vec<u64>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -52,7 +52,7 @@ impl JsonSchema for ComponentRef {
|
||||
|
||||
/// `key`, a component's id or name, with the handler's message for anything else, `null`
|
||||
/// included.
|
||||
fn component_key<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<ComponentRef>, D::Error> {
|
||||
pub(super) fn component_key<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<ComponentRef>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => Ok(Some(ComponentRef(n.to_string()))),
|
||||
Value::String(s) => Ok(Some(ComponentRef(s))),
|
||||
@@ -95,7 +95,7 @@ fn path_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
|
||||
/// Adds `key` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it when it's left out.
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
pub(super) fn require(schema: &mut Schema, key: &str) {
|
||||
match schema.get_mut("required") {
|
||||
Some(Value::Array(required)) => {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
|
||||
@@ -4,11 +4,12 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
EditInPlaceParams, FinishEditParams, MoveFeaturesParams, MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult,
|
||||
ReparentItemsParams,
|
||||
ActivateParams, ActivateResult, CreateParams, CreateResult, DeleteParams, DeleteResult, EditInPlaceParams, FinishEditParams, FromBodiesParams,
|
||||
FromBodiesResult, ListParams, ListResult, MoveFeaturesParams, MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult,
|
||||
PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams, UnlockParams, UnlockResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
@@ -36,6 +37,340 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MoveFeaturesParams>(), &["component", "features"]);
|
||||
assert_keys(input_schema_for::<MoveSketchesToPartsParams>(), &["sketches"]);
|
||||
assert_keys(input_schema_for::<ReparentItemsParams>(), &["bodies", "component", "component_path", "features", "occurrences"]);
|
||||
assert_keys(input_schema_for::<CreateParams>(), &["activate", "grounded", "name", "parent", "parent_path"]);
|
||||
assert_keys(input_schema_for::<FromBodiesParams>(), &["bodies"]);
|
||||
assert_keys(input_schema_for::<ActivateParams>(), &["component", "occurrence", "path"]);
|
||||
assert_keys(input_schema_for::<ListParams>(), &["bodies", "compact"]);
|
||||
assert_keys(input_schema_for::<PropertiesParams>(), &["about", "component", "descendants"]);
|
||||
assert_keys(input_schema_for::<RenameParams>(), &["component", "name"]);
|
||||
assert_keys(input_schema_for::<DeleteParams>(), &["component"]);
|
||||
assert_keys(input_schema_for::<UnlockParams>(), &["component"]);
|
||||
}
|
||||
|
||||
/// The calls these commands already refuse (the handlers' own messages), refused alike in strict.
|
||||
/// An unknown key to `component.create` is left out: its handler lists the accepted parameters,
|
||||
/// where strict gives the check's `unknown key` with a suggestion (refused either way).
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("component.create", json!({"parent": null})),
|
||||
("component.create", json!({"parent": true})),
|
||||
("component.create", json!({"parent": [1]})),
|
||||
("component.create", json!({"parent": "Nope"})),
|
||||
("component.create", json!({"parent_path": "1"})),
|
||||
("component.create", json!({"parent_path": null})),
|
||||
("component.create", json!({"parent_path": [1, "a"]})),
|
||||
("component.create", json!({"parent_path": vec![1; 64]})),
|
||||
("component.create", json!({"grounded": "yes"})),
|
||||
("component.create", json!({"grounded": null})),
|
||||
("component.from_bodies", json!({})),
|
||||
("component.from_bodies", json!({"bodies": null})),
|
||||
("component.from_bodies", json!({"bodies": "Box"})),
|
||||
("component.from_bodies", json!({"bodies": [1]})),
|
||||
("component.from_bodies", json!({"bodies": []})),
|
||||
("component.from_bodies", json!({"bodies": ["A", "A"]})),
|
||||
("component.from_bodies", json!({"bodies": vec!["A"; 1001]})),
|
||||
("component.from_bodies", json!({"bodies": ["Nope"]})),
|
||||
("component.activate", json!({})),
|
||||
("component.activate", json!({"component": null})),
|
||||
("component.activate", json!({"component": [1]})),
|
||||
("component.activate", json!({"component": "Nope"})),
|
||||
("component.activate", json!({"component": 0, "path": [1]})),
|
||||
("component.activate", json!({"component": 0, "occurrence": null})),
|
||||
("component.activate", json!({"component": 0, "occurrence": true})),
|
||||
("component.properties", json!({"component": null})),
|
||||
("component.properties", json!({"component": true})),
|
||||
("component.properties", json!({"descendants": "yes"})),
|
||||
("component.properties", json!({"descendants": null})),
|
||||
("component.properties", json!({"about": [1, 2]})),
|
||||
("component.properties", json!({"about": "origin"})),
|
||||
("component.properties", json!({"about": [1, 2, 1e9]})),
|
||||
("component.rename", json!({})),
|
||||
("component.rename", json!({"component": 0})),
|
||||
("component.rename", json!({"name": "A"})),
|
||||
("component.rename", json!({"component": null, "name": "A"})),
|
||||
("component.rename", json!({"component": 0, "name": ""})),
|
||||
("component.rename", json!({"component": 0, "name": " \t "})),
|
||||
("component.rename", json!({"component": 0, "name": 5})),
|
||||
("component.rename", json!({"component": 0, "name": null})),
|
||||
("component.rename", json!({"component": 0, "name": "x".repeat(129)})),
|
||||
("component.delete", json!({})),
|
||||
("component.delete", json!({"component": false})),
|
||||
("component.delete", json!({"component": 0})),
|
||||
("component.unlock", json!({})),
|
||||
("component.unlock", json!({"component": [0]})),
|
||||
("component.unlock", json!({"component": 0})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A name of 128 bytes is taken once trimmed, and one of 129 is not, as the handler counts.
|
||||
#[test]
|
||||
fn a_name_is_counted_as_the_handler_counts_it() {
|
||||
let issues = |p: Value| crate::schema::issues("component.rename", &p).len();
|
||||
assert_eq!(issues(json!({"component": 0, "name": format!(" {} ", "x".repeat(128))})), 0);
|
||||
assert_eq!(issues(json!({"component": 0, "name": "x".repeat(129)})), 1);
|
||||
// 64 two-byte characters are 128 bytes.
|
||||
assert_eq!(issues(json!({"component": 0, "name": "é".repeat(64)})), 0);
|
||||
assert_eq!(issues(json!({"component": 0, "name": "é".repeat(65)})), 1);
|
||||
}
|
||||
|
||||
/// The contracts say what the handlers need: a key a handler refuses without is required in the
|
||||
/// schema, though the type leaves it to the handler's message.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<CreateParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"name": "Arm", "parent": "Frame", "parent_path": [1, 2], "activate": false, "grounded": true}), true),
|
||||
(json!({"parent": 3, "parent_path": []}), true),
|
||||
(json!({"parent": true}), false),
|
||||
(json!({"parent_path": vec![1; 64]}), false),
|
||||
(json!({"grounded": "yes"}), false),
|
||||
(json!({"bodies": ["A"]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<FromBodiesParams>(),
|
||||
&[
|
||||
(json!({"bodies": ["A", "B"]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"bodies": []}), false),
|
||||
(json!({"bodies": ["A", "A"]}), false),
|
||||
(json!({"bodies": "A"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ActivateParams>(),
|
||||
&[
|
||||
(json!({"component": 0}), true),
|
||||
(json!({"component": "root"}), true),
|
||||
(json!({"component": "Arm", "occurrence": "Arm:2", "path": [1, 2]}), true),
|
||||
(json!({"component": "Arm", "occurrence": 2}), true),
|
||||
(json!({}), false),
|
||||
(json!({"component": true}), false),
|
||||
(json!({"component": 1, "occurrence": true}), false),
|
||||
(json!({"component": 1, "path": []}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ListParams>(),
|
||||
&[(json!({}), true), (json!({"compact": true, "bodies": true}), true), (json!({"compact": "yes"}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<PropertiesParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"component": "Arm", "descendants": false, "about": [1, 2, 3]}), true),
|
||||
(json!({"about": {"x": 1, "y": 2, "z": 3}}), true),
|
||||
(json!({"about": [1, 2]}), false),
|
||||
(json!({"descendants": 1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<RenameParams>(),
|
||||
&[(json!({"component": "Arm", "name": "Bar"}), true), (json!({"component": "Arm"}), false), (json!({"name": "Bar"}), false)],
|
||||
);
|
||||
// `conforms` doesn't read patterns: pin the text that keeps blank names out.
|
||||
let rename = input_schema_for::<RenameParams>();
|
||||
assert_eq!(rename.as_value()["$defs"]["ComponentName"]["pattern"], crate::schema::types::not_blank_pattern());
|
||||
takes(input_schema_for::<DeleteParams>(), &[(json!({"component": 2}), true), (json!({}), false)]);
|
||||
takes(input_schema_for::<UnlockParams>(), &[(json!({"component": "rig"}), true), (json!({}), false)]);
|
||||
}
|
||||
|
||||
/// Keys the handlers read only in some cases are read only in those cases: `bodies` of
|
||||
/// `component.list` with `compact`, `path` of `component.activate` with `occurrence`. And the
|
||||
/// warnings say what is wrong with a key.
|
||||
#[test]
|
||||
fn the_check_reads_keys_as_the_handlers_do() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
assert_eq!(issues("component.list", json!({"bodies": "x"})), none);
|
||||
assert_eq!(issues("component.list", json!({"compact": false, "bodies": 3})), none);
|
||||
assert_eq!(issues("component.list", json!({"compact": true, "bodies": true})), none);
|
||||
assert_eq!(issues("component.list", json!({"compact": true, "bodies": "x"})).len(), 1);
|
||||
assert_eq!(issues("component.list", json!({"compact": "x"})).len(), 1);
|
||||
assert_eq!(issues("component.list", json!({"compat": true})), ["unknown key `compat`; did you mean `compact`?"]);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2})), none);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2, "path": [1, 2]})), none);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "path": [2]})), ["`occurrence` must be an id or name"]);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": null})), ["`occurrence` must be an id or name"]);
|
||||
assert_eq!(issues("component.create", json!({"parent": true})), ["`parent` must be a component id or name"]);
|
||||
assert_eq!(issues("component.create", json!({"parent_path": [1, null]})), ["parent_path must list up to 63 occurrence ids"]);
|
||||
assert_eq!(issues("component.create", json!({"grounded": 1})), ["grounded must be true or false"]);
|
||||
assert_eq!(issues("component.create", json!({"parent_path": vec![1; 63]})), none);
|
||||
assert_eq!(issues("component.create", json!({"parent_path": vec![1; 64]})), ["parent_path must list up to 63 occurrence ids"]);
|
||||
assert_eq!(issues("component.create", json!({"activate": "no"})).len(), 1);
|
||||
assert_eq!(issues("component.from_bodies", json!({"bodies": [1]})), ["bodies must be an array of body names"]);
|
||||
assert_eq!(issues("component.from_bodies", json!({"bodies": ["a", "a"]})), ["bodies must list 1..1000 distinct existing definition bodies"]);
|
||||
assert_eq!(issues("component.properties", json!({"about": [1]})), ["`about` must be a point [x, y, z] in mm"]);
|
||||
assert_eq!(issues("component.properties", json!({"about": null})), none);
|
||||
assert_eq!(issues("component.properties", json!({"descendants": null})), ["`descendants` must be a boolean"]);
|
||||
assert_eq!(issues("component.properties", json!({"component": null})), ["`component` must be a component id or name"]);
|
||||
assert_eq!(issues("component.rename", json!({"component": 0, "name": ""})), ["`name` must be 1…128 characters"]);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// A glTF file whose node `rig` holds a `Part` (one cube): imported, it is a component held
|
||||
/// together by a rigid group that `component.unlock` releases.
|
||||
fn rig_file(name: &str) -> String {
|
||||
let side = 0.01f32;
|
||||
let points: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * side, ((i >> 1) & 1) as f32 * side, ((i >> 2) & 1) as f32 * side]).collect();
|
||||
let tris: [u16; 36] = [0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
|
||||
let mut bin: Vec<u8> = points.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
|
||||
bin.extend(tris.iter().flat_map(|v| v.to_le_bytes()));
|
||||
let doc = json!({
|
||||
"asset": {"version": "2.0"},
|
||||
"scene": 0,
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"nodes": [{"name": "Base", "mesh": 0, "children": [1]}, {"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0]}],
|
||||
"meshes": [{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
|
||||
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [side, side, side]},
|
||||
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
|
||||
],
|
||||
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
|
||||
"buffers": [{"byteLength": bin.len()}],
|
||||
});
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-component-schema-{}-{name}.glb", std::process::id()));
|
||||
std::fs::write(&path, solidcraft_io::glb_container(doc.to_string().as_bytes(), &bin)).unwrap_or_else(|e| panic!("{e}"));
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Every command, in each form it takes, in Warn: the real reply matches its output schema and
|
||||
/// carries no `param_warnings` (Warn runs a call the contract refuses anyway, so the replies
|
||||
/// are also checked for warnings), and a key spelt wrongly on purpose is reported.
|
||||
#[test]
|
||||
fn replies_match_the_output_schemas_of_the_component_commands() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
|
||||
let arm = run("component.create", json!({"name": "Arm"}));
|
||||
assert_eq!(arm["name"], "Arm");
|
||||
assert_eq!(arm["grounded"], true, "the design's first part is grounded: {arm}");
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &arm);
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "ArmBox"}));
|
||||
let hand = run("component.create", json!({"name": "Hand", "parent": "Arm", "activate": false, "grounded": false}));
|
||||
assert!(hand["parent"] == arm["component"] && hand["grounded"] == false, "{hand}");
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &hand);
|
||||
let at = run("component.create", json!({"name": "Thumb", "parent": hand["parent"], "parent_path": [arm["occurrence"]]}));
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &at);
|
||||
let top = run("component.create", json!({"name": "Top", "parent": 0, "parent_path": []}));
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &top);
|
||||
|
||||
let active = run("component.activate", json!({"component": "root"}));
|
||||
assert_eq!(active["active"], 0);
|
||||
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
|
||||
let active = run("component.activate", json!({"component": arm["component"]}));
|
||||
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
|
||||
let active = run("component.activate", json!({"component": "Arm", "occurrence": arm["occurrence"], "path": [arm["occurrence"]]}));
|
||||
assert_eq!(active["active"], arm["component"], "{active}");
|
||||
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
|
||||
|
||||
run("solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "RootBox"}));
|
||||
run("solid.box", json!({"length": 6, "width": 6, "height": 6, "body_name": "RootBox2", "operation": "new"}));
|
||||
let made = run("component.from_bodies", json!({"bodies": ["RootBox", "RootBox2"]}));
|
||||
assert_eq!(made["components"].as_array().map(Vec::len), Some(2), "{made}");
|
||||
matches(output_schema_for::<FromBodiesResult>(), "component.from_bodies", &made);
|
||||
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"compact": true}),
|
||||
json!({"compact": true, "bodies": true}),
|
||||
json!({"compact": false, "bodies": "ignored"}),
|
||||
json!({"bodies": true}),
|
||||
] {
|
||||
let listed = run("component.list", p.clone());
|
||||
matches(output_schema_for::<ListResult>(), "component.list", &listed);
|
||||
assert!(listed["components"].as_array().is_some_and(|c| c.len() >= 5), "{p}: {listed}");
|
||||
}
|
||||
let moved = run("occurrence.move", json!({"occurrence": hand["occurrence"], "translate": [5, 0, 0], "capture": false, "move_grounded": true}));
|
||||
assert_eq!(moved["pending"], true, "{moved}");
|
||||
run("occurrence.move", json!({"occurrence": top["occurrence"], "translate": [0, 7, 0], "move_grounded": true}));
|
||||
let listed = run("component.list", json!({"compact": true}));
|
||||
assert!(listed["pending_moves"].as_array().is_some_and(|m| !m.is_empty()), "{listed}");
|
||||
// Compact placements carry `transform` only when it is not the identity.
|
||||
let placements: Vec<&Value> =
|
||||
listed["components"].as_array().into_iter().flatten().flat_map(|c| c["occurrences"].as_array().into_iter().flatten()).collect();
|
||||
assert!(placements.iter().any(|o| o.get("transform").is_some()) && placements.iter().any(|o| o.get("transform").is_none()), "{listed}");
|
||||
matches(output_schema_for::<ListResult>(), "component.list", &listed);
|
||||
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"component": "Arm"}),
|
||||
json!({"component": 0, "descendants": false}),
|
||||
json!({"component": "root", "about": [10, 0, 0]}),
|
||||
json!({"about": {"x": 1, "y": 2, "z": 3}, "descendants": true}),
|
||||
json!({"about": null}),
|
||||
json!({"component": "Top"}),
|
||||
] {
|
||||
let measured = run("component.properties", p.clone());
|
||||
matches(output_schema_for::<PropertiesResult>(), "component.properties", &measured);
|
||||
assert_eq!(measured["coordinate_frame"], "world", "{p}");
|
||||
assert_eq!(measured["total"].get("inertia_about").is_some(), p.get("about").is_some_and(|a| !a.is_null()), "{p}: {measured}");
|
||||
}
|
||||
let measured = run("component.properties", json!({"component": 0}));
|
||||
assert!(measured["body_count"].as_u64().is_some_and(|n| n >= 3) && measured["total"]["bbox"].is_object(), "{measured}");
|
||||
let empty = run("component.properties", json!({"component": "Top"}));
|
||||
assert!(empty["total"]["bbox"].is_null() && empty["total"]["center_of_mass"].is_null(), "{empty}");
|
||||
|
||||
let renamed = run("component.rename", json!({"component": "Hand", "name": " Palm "}));
|
||||
assert_eq!(renamed["name"], "Palm");
|
||||
matches(output_schema_for::<RenameResult>(), "component.rename", &renamed);
|
||||
let renamed = run("component.rename", json!({"component": 0, "name": "Whole"}));
|
||||
matches(output_schema_for::<RenameResult>(), "component.rename", &renamed);
|
||||
|
||||
let deleted = run("component.delete", json!({"component": "Arm"}));
|
||||
assert!(deleted["deleted"].as_array().is_some_and(|d| d.len() >= 3), "{deleted}");
|
||||
matches(output_schema_for::<DeleteResult>(), "component.delete", &deleted);
|
||||
|
||||
// Unlock: a glTF node tree is held together by a rigid group.
|
||||
let inserted = run("file.insert_mesh", json!({"path": rig_file("rig"), "up": "z"}));
|
||||
let rig = inserted["component"].clone();
|
||||
let unlocked = run("component.unlock", json!({"component": rig}));
|
||||
assert!(unlocked["joints"].as_array().is_some_and(|j| !j.is_empty()), "{unlocked}");
|
||||
matches(output_schema_for::<UnlockResult>(), "component.unlock", &unlocked);
|
||||
|
||||
// Misspelt keys still run in Warn, say so, and the reply still matches.
|
||||
let cases: [(&str, Value, &str, schemars::Schema); 3] = [
|
||||
("component.list", json!({"compat": true}), "compat", output_schema_for::<ListResult>()),
|
||||
("component.properties", json!({"descendents": false}), "descendents", output_schema_for::<PropertiesResult>()),
|
||||
("component.rename", json!({"component": 0, "nmae": "x", "name": "Again"}), "nmae", output_schema_for::<RenameResult>()),
|
||||
];
|
||||
for (id, p, key, schema) in cases {
|
||||
let warned = run(id, p);
|
||||
let warnings = warned["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains(key)), "{id}: {warned}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{id}: {warned}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown key is refused by `component.create` as before (its own message lists the
|
||||
/// accepted parameters), at every check level but strict, where the check names the key first.
|
||||
#[test]
|
||||
fn create_refuses_an_unknown_key_as_before() {
|
||||
for level in ParamCheck::ALL {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let e = s.execute("component.create", &json!({"nme": "A"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `nme`") ^ (level == ParamCheck::Strict), "{level:?}: {e}");
|
||||
assert!(e.contains("nme"), "{e}");
|
||||
}
|
||||
let mut s = Session::default();
|
||||
let e = s.execute("component.create", &json!({"bodies": ["A"]})).unwrap_err().to_string();
|
||||
assert!(e.contains("use component.move_bodies or component.from_bodies"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Contracts and result types for the commands in `cmd/frame.rs`: `frame.member`,
|
||||
//! `frame.profile.list`, `frame.profile.properties` and `frame.profile.insert` (#12500).
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a sketch exists, a dimension evaluates
|
||||
//! or a profile can be built stays the handler's to check. A key the handler refuses when it is
|
||||
//! left out (`profile`, `sketch`) is optional in the type and required in the schema, so that the
|
||||
//! handler's message, not the check's "is required", is what a caller sees. A call with several
|
||||
//! problems may get a different one first in strict than with the check off (the check goes key
|
||||
//! by key, the handlers in their own order).
|
||||
//!
|
||||
//! `frame.member` reads some keys only in some cases. With `profile` (a built-in profile) it
|
||||
//! never reads `profiles` or `exact_regions`, which pick among a section sketch's profiles; with
|
||||
//! `profile_sketch` it never reads `profile`. Given both, or neither, it refuses the call before
|
||||
//! it reads anything about the section, so none of the three is read then either.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_doc::Expr;
|
||||
use solidcraft_doc::frame::{self, Anchor, MAX_MEMBERS, Profile, ProfileSpec};
|
||||
|
||||
use super::check::{keyed, located, whole};
|
||||
use super::motion::require;
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{AngleExpr, Point2, ProfilesParam, SketchRef, StringList, not_blank_pattern, present};
|
||||
use crate::params;
|
||||
|
||||
/// The words `Anchor` is spelled with, as the handlers' message lists them.
|
||||
const ANCHORS: [&str; 11] =
|
||||
["origin", "center", "centroid", "top_left", "top", "top_right", "left", "right", "bottom_left", "bottom", "bottom_right"];
|
||||
|
||||
/// The longest name a member may be given, in bytes once trimmed, as the handler requires.
|
||||
const MAX_NAME: usize = 128;
|
||||
|
||||
/// `v` as the only key of an object, for the helpers that read `p.get(key)`.
|
||||
fn wrap(key: &str, v: Value) -> Value {
|
||||
json!({ key: v })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A built-in profile as the commands take it: `{kind, …dimensions}`, each dimension a number or
|
||||
/// an expression, read by `ProfileSpec`'s own deserializer. Whether a dimension evaluates, and
|
||||
/// whether the profile can be built from the values (a wall thicker than the tube), is the
|
||||
/// handler's to check, as it has the design.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProfileParam(pub ProfileSpec);
|
||||
|
||||
impl<'de> Deserialize<'de> for ProfileParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
// The handler's `` `profile`: {error} ``, with serde_json's own wording of the error.
|
||||
serde_json::from_value::<ProfileSpec>(Value::deserialize(d)?).map(ProfileParam).map_err(|e| D::Error::custom(keyed(e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema of one dimension, a number or an expression.
|
||||
fn dimension_schema() -> Value {
|
||||
json!({
|
||||
"anyOf": [
|
||||
{"type": "number"},
|
||||
{"type": "string", "minLength": 1, "maxLength": 4096, "pattern": not_blank_pattern()}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// One schema per built-in profile kind: its `kind`, its required dimensions and its optional
|
||||
/// ones, each as `dimension` says, and nothing else.
|
||||
fn profile_kinds_schema(dimension: &Value) -> Vec<Value> {
|
||||
frame::profile_kinds()
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
let mut properties = Map::new();
|
||||
properties.insert("kind".into(), json!({"enum": [k.kind]}));
|
||||
for name in k.required.iter().chain(k.optional) {
|
||||
properties.insert((*name).into(), dimension.clone());
|
||||
}
|
||||
let required: Vec<&str> = std::iter::once("kind").chain(k.required.iter().copied()).collect();
|
||||
json!({"type": "object", "properties": properties, "required": required, "additionalProperties": false})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl JsonSchema for ProfileParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProfileParam".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A built-in profile: {\"kind\": bar | round_bar | rect_tube | round_tube | angle | channel | i_beam, …its dimensions}. Each dimension is a number in the design's units or a length expression over its parameters (\"tube_wall\", \"2 in\"); `frame.profile.list` gives each kind's dimensions and an example. A dimension that is left out and isn't required takes a default worked out from the profile's size.",
|
||||
"anyOf": profile_kinds_schema(&dimension_schema())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A built-in profile as a command reports it, every number in mm: serialized as the profile's
|
||||
/// own JSON (`{kind, …dimensions}`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProfileOut(pub Profile);
|
||||
|
||||
impl JsonSchema for ProfileOut {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProfileOut".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A built-in profile: {\"kind\", …its dimensions}, each in mm.",
|
||||
"anyOf": profile_kinds_schema(&json!({"type": "number"}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a profile is held to its placement point, as `Anchor` is spelled in JSON.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnchorParam(pub Anchor);
|
||||
|
||||
impl<'de> Deserialize<'de> for AnchorParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
serde_json::from_value::<Anchor>(Value::deserialize(d)?).map(AnchorParam).map_err(|_| {
|
||||
D::Error::custom(whole(
|
||||
"`anchor` must be origin, center, centroid, top_left, top, top_right, left, right, bottom_left, bottom or bottom_right",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for AnchorParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AnchorParam".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The profile point that is placed: its origin (the default; a built-in profile's centre), `center` (its bounding box's centre), `centroid`, or a corner or edge middle of its bounding box.",
|
||||
"type": "string",
|
||||
"enum": ANCHORS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.profile.list
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `frame.profile.list` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ProfileListParams {}
|
||||
|
||||
/// One built-in profile kind.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileKindInfo {
|
||||
/// The kind's id, as `profile.kind` takes it.
|
||||
pub kind: String,
|
||||
/// The kind's name for people.
|
||||
pub label: String,
|
||||
/// The dimensions it needs.
|
||||
pub required: Vec<String>,
|
||||
/// The dimensions that default when left out.
|
||||
pub optional: Vec<String>,
|
||||
/// A complete example profile.
|
||||
pub example: ProfileOut,
|
||||
}
|
||||
|
||||
impl From<&frame::ProfileKind> for ProfileKindInfo {
|
||||
fn from(k: &frame::ProfileKind) -> Self {
|
||||
ProfileKindInfo {
|
||||
kind: k.kind.to_string(),
|
||||
label: k.label.to_string(),
|
||||
required: k.required.iter().map(|d| d.to_string()).collect(),
|
||||
optional: k.optional.iter().map(|d| d.to_string()).collect(),
|
||||
example: ProfileOut(k.example.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `frame.profile.list` returns: the built-in profiles, each SolidCraft's own parametric
|
||||
/// definition.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileListResult {
|
||||
/// Every built-in profile kind.
|
||||
pub kinds: Vec<ProfileKindInfo>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.profile.properties and the section both profile commands report
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A density in kg/m³: above 0 and at most 1e6, as `frame.profile.properties` requires.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Density(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for Density {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::num(&wrap("density", Value::deserialize(d)?), "density")
|
||||
.filter(|d| *d > 0.0 && *d <= 1e6)
|
||||
.map(Density)
|
||||
.ok_or_else(|| D::Error::custom(whole("`density` must be a positive number of kg/m³ (at most 1e6)")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Density {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Density".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A density in kg/m³: above 0 and at most 1e6.",
|
||||
"type": "number", "exclusiveMinimum": 0, "maximum": 1e6
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_required(schema: &mut Schema) {
|
||||
require(schema, "profile");
|
||||
}
|
||||
|
||||
/// Work out a built-in profile's section: its area, centroid and second moments, about axes
|
||||
/// through the centroid, parallel to the profile's x and y.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = profile_required)]
|
||||
pub struct ProfilePropertiesParams {
|
||||
// Optional in the type and required in the schema (`profile_required`).
|
||||
/// The profile, as `frame.profile.list` describes the kinds.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfileParam")]
|
||||
pub profile: Option<ProfileParam>,
|
||||
/// The material's density, which adds `mass_per_m`. Default: none, and no `mass_per_m`.
|
||||
#[serde(default)]
|
||||
pub density: Option<Density>,
|
||||
}
|
||||
|
||||
/// A profile's section, as `frame.profile.properties` and `frame.profile.insert` report it.
|
||||
/// Lengths are in mm, areas in mm² and second moments in mm⁴.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileSection {
|
||||
/// The profile with every default filled in.
|
||||
pub profile: ProfileOut,
|
||||
/// The section's area, mm².
|
||||
pub area: f64,
|
||||
/// The centroid [x, y], mm, in the profile's own plane (x across, y up) from the centre of its
|
||||
/// bounding box.
|
||||
pub centroid: [f64; 2],
|
||||
/// ∫ y² dA about the centroid: bending about the x axis, mm⁴.
|
||||
pub ixx: f64,
|
||||
/// ∫ x² dA about the centroid: bending about the y axis, mm⁴.
|
||||
pub iyy: f64,
|
||||
/// ∫ x·y dA about the centroid, mm⁴.
|
||||
pub ixy: f64,
|
||||
/// The bounding box's width, mm.
|
||||
pub width: f64,
|
||||
/// The bounding box's height, mm.
|
||||
pub height: f64,
|
||||
/// With a `density`: the mass of a metre of the profile, kg/m.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mass_per_m: Option<f64>,
|
||||
}
|
||||
|
||||
/// What `frame.profile.properties` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfilePropertiesResult {
|
||||
#[serde(flatten)]
|
||||
pub section: ProfileSection,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.profile.insert
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A point of the sketch in mm: `[x, y]` or `{x, y}`, with `frame.profile.insert`'s message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct InsertAt(pub Vec2);
|
||||
|
||||
impl<'de> Deserialize<'de> for InsertAt {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec2(&Value::deserialize(d)?).map(InsertAt).ok_or_else(|| D::Error::custom(whole("`at` must be [x, y] in sketch millimetres")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for InsertAt {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"InsertAt".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point2>()
|
||||
}
|
||||
}
|
||||
|
||||
/// An angle in radians as `frame.profile.insert` reads it: a number, at most 1e6 in size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Radians(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for Radians {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::num(&wrap("angle", Value::deserialize(d)?), "angle")
|
||||
.filter(|a| a.abs() <= 1e6)
|
||||
.map(Radians)
|
||||
.ok_or_else(|| D::Error::custom(whole("`angle` must be a number of radians")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Radians {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Radians".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An angle in radians, counter-clockwise: a number of at most 1e6 in size.", "type": "number", "minimum": -1e6, "maximum": 1e6})
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a built-in profile into a sketch as lines and arcs, placed by its anchor and turned about
|
||||
/// it. A tube's inside is drawn too, so the wall is one profile. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = profile_required)]
|
||||
pub struct ProfileInsertParams {
|
||||
// Optional in the type and required in the schema (`profile_required`).
|
||||
/// The profile, as `frame.profile.list` describes the kinds.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfileParam")]
|
||||
pub profile: Option<ProfileParam>,
|
||||
/// Where the anchor goes, in sketch mm. Default: the origin.
|
||||
#[serde(default)]
|
||||
pub at: Option<InsertAt>,
|
||||
/// The profile point that is placed on `at`. Default: origin.
|
||||
#[serde(default)]
|
||||
pub anchor: Option<AnchorParam>,
|
||||
/// How far the profile turns about the anchor, in radians counter-clockwise (not degrees).
|
||||
/// Default: 0.
|
||||
#[serde(default)]
|
||||
pub angle: Option<Radians>,
|
||||
/// Lock the curves, so that solving the sketch keeps the shape. Default: true.
|
||||
#[serde(default)]
|
||||
pub fixed: Option<bool>,
|
||||
/// The sketch to draw in, by id or name. Default: the sketch being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// The ids of what a sketch edit added, stable and never reused.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CreatedIds {
|
||||
/// The points added.
|
||||
pub points: Vec<String>,
|
||||
/// The curves added.
|
||||
pub curves: Vec<String>,
|
||||
/// The constraints added, dimensions apart.
|
||||
pub constraints: Vec<String>,
|
||||
/// The dimensions added.
|
||||
pub dimensions: Vec<String>,
|
||||
/// The wires added. Left out when there are none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wires: Vec<String>,
|
||||
}
|
||||
|
||||
/// The sketch's solve after an edit.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SketchSolve {
|
||||
/// Whether the constraints solved, keeping the sketch's orientation.
|
||||
pub solved: bool,
|
||||
/// The degrees of freedom left.
|
||||
pub dof: usize,
|
||||
}
|
||||
|
||||
/// What `frame.profile.insert` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileInsertResult {
|
||||
/// What the insertion added to the sketch.
|
||||
pub created: CreatedIds,
|
||||
/// The inserted profile's section, as `frame.profile.properties` reports it.
|
||||
pub section: ProfileSection,
|
||||
/// The sketch's solve, or null when it was not solved again.
|
||||
pub sketch: Option<SketchSolve>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.member
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `lines`: one to 1000 curve ids, none twice, as `frame.member` reads them (a single id isn't
|
||||
/// taken).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LineIds(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for LineIds {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let fail = || D::Error::custom(whole(format!("`lines` must list 1…{MAX_MEMBERS} curve ids")));
|
||||
let Value::Array(a) = Value::deserialize(d)? else { return Err(fail()) };
|
||||
if a.is_empty() || a.len() > MAX_MEMBERS {
|
||||
return Err(fail());
|
||||
}
|
||||
let lines = a.into_iter().map(|v| if let Value::String(s) = v { Some(s) } else { None }).collect::<Option<Vec<_>>>().ok_or_else(fail)?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
match lines.iter().find(|l| !seen.insert(l.as_str())) {
|
||||
Some(twice) => Err(D::Error::custom(whole(format!("line `{twice}` is listed twice")))),
|
||||
None => Ok(LineIds(lines)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for LineIds {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"LineIds".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Curve ids of the frame sketch's lines, each once.",
|
||||
"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_MEMBERS, "uniqueItems": true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The name members are named after: text of 1 to 128 bytes once trimmed, as `frame.member` reads
|
||||
/// `body_name`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberName(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for MemberName {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.trim().is_empty() && s.trim().len() <= MAX_NAME => Ok(MemberName(s)),
|
||||
_ => Err(D::Error::custom(whole(format!("`body_name` must be 1…{MAX_NAME} characters")))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MemberName {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MemberName".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A name: text that isn't blank, at most 128 bytes once trimmed of the whitespace around it.",
|
||||
"type": "string", "pattern": not_blank_pattern()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `names`: up to 1000 line ids, each with a name of 1 to 128 bytes once trimmed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberNames(pub BTreeMap<String, String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for MemberNames {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Object(m) if m.len() <= MAX_MEMBERS => m
|
||||
.into_iter()
|
||||
.map(|(line, v)| match v.as_str().map(str::trim).filter(|n| !n.is_empty() && n.len() <= MAX_NAME) {
|
||||
Some(name) => Ok((line, name.to_string())),
|
||||
None => Err(D::Error::custom(whole(format!("`names`: the name of line `{line}` must be 1…{MAX_NAME} characters")))),
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
.map(MemberNames),
|
||||
_ => Err(D::Error::custom(whole(format!("`names` must map up to {MAX_MEMBERS} line ids to names")))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MemberNames {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MemberNames".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Names to keep, by line id. A name is text that isn't blank, at most 128 bytes once trimmed.",
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string", "pattern": not_blank_pattern()},
|
||||
"maxProperties": MAX_MEMBERS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `angle` as `frame.member` reads it: a number of degrees or an expression, with its message. Whether
|
||||
/// the expression evaluates as an angle is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberAngle(pub Expr);
|
||||
|
||||
impl<'de> Deserialize<'de> for MemberAngle {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::expr(&wrap("angle", Value::deserialize(d)?), "angle")
|
||||
.map(MemberAngle)
|
||||
.ok_or_else(|| D::Error::custom(whole("`angle` must be an angle expression")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MemberAngle {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MemberAngle".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<AngleExpr>()
|
||||
}
|
||||
}
|
||||
|
||||
/// `participants`, read as `features::add_feature` reads it: a name or a list of names, with the
|
||||
/// message of `params::string_list`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Participants(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for Participants {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match params::string_list("", &wrap("participants", Value::deserialize(d)?), "participants") {
|
||||
Ok(names) => Ok(Participants(names)),
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
|
||||
Err(e) => Err(D::Error::custom(whole(e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Participants {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Participants".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<StringList>()
|
||||
}
|
||||
}
|
||||
|
||||
/// `sketch` is required and one of `profile` and `profile_sketch`, not both, as the schema says;
|
||||
/// the type leaves them to the handler's messages.
|
||||
fn member_required(schema: &mut Schema) {
|
||||
require(schema, "sketch");
|
||||
let only = |key: &str, other: &str| json!({"required": [key], "not": {"required": [other]}});
|
||||
schema.insert("anyOf".into(), json!([only("profile", "profile_sketch"), only("profile_sketch", "profile")]));
|
||||
}
|
||||
|
||||
/// Lay structural members along the lines of a frame sketch: one body per line, from its start
|
||||
/// to its end, its cross-section square to the line. The section is a built-in `profile` or the
|
||||
/// profiles of a `profile_sketch`, one of them. Each member keeps its name when other lines are
|
||||
/// added or deleted. Returns the feature and the bodies it made.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = member_required)]
|
||||
pub struct FrameMemberKeys {
|
||||
// Optional in the type and required in the schema (`member_required`).
|
||||
/// The frame sketch, 2D or 3D, by id or name.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub sketch: Option<SketchRef>,
|
||||
/// The lines to lay members along, by curve id, one member each. Default: every straight
|
||||
/// line drawn in the sketch that isn't construction, projected or otherwise linked, or of no
|
||||
/// length.
|
||||
#[serde(default)]
|
||||
pub lines: Option<LineIds>,
|
||||
/// The section as a built-in profile. Give it or `profile_sketch`, not both.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfileParam")]
|
||||
pub profile: Option<ProfileParam>,
|
||||
/// The section as a sketch: its profiles, in the sketch's own x and y around its origin. It
|
||||
/// can't be the frame sketch. Give it or `profile`, not both.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub profile_sketch: Option<SketchRef>,
|
||||
/// With `profile_sketch`: which of its profiles are the section, as `solid.extrude` takes
|
||||
/// them. Default: its material, nested loops alternating, so two circles make a tube. Not
|
||||
/// used with `profile`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfilesParam")]
|
||||
pub profiles: Option<ProfilesParam>,
|
||||
/// With `profile_sketch`: each index or point pick in `profiles` is exactly the region it
|
||||
/// names, where it would otherwise take the whole drawn profile a face's projected outline
|
||||
/// cuts into regions. Default: false. Not used with `profile`.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The profile point that sits on each line. Default: origin.
|
||||
#[serde(default)]
|
||||
pub anchor: Option<AnchorParam>,
|
||||
/// How far the profile turns about the line, a number of degrees or an expression. Default:
|
||||
/// 0, which puts its y as near world up as the line allows (a vertical member's x along
|
||||
/// world X).
|
||||
#[serde(default)]
|
||||
pub angle: Option<MemberAngle>,
|
||||
/// The profile mirrored across its y axis, before it turns. Default: false.
|
||||
#[serde(default)]
|
||||
pub mirror: Option<bool>,
|
||||
/// The feature's name in the timeline.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// What the members are named after, then numbered: `Rail`, `Rail (2)`… Default: the
|
||||
/// profile's description (`Rect Tube 40x40x3`), or the profile sketch's name.
|
||||
#[serde(default)]
|
||||
pub body_name: Option<MemberName>,
|
||||
/// Names to keep, by line id, as Edit Feature keeps them. A line not listed gets a name from
|
||||
/// `body_name`.
|
||||
#[serde(default)]
|
||||
pub names: Option<MemberNames>,
|
||||
/// The bodies taking part, which every feature records; each an existing body or `*`. Members
|
||||
/// are new bodies, whatever this lists.
|
||||
#[serde(default)]
|
||||
pub participants: Option<Participants>,
|
||||
}
|
||||
|
||||
/// `frame.member`'s parameters: [`FrameMemberKeys`], reading the section's keys only as the
|
||||
/// handler does (see the module docs).
|
||||
#[derive(Debug)]
|
||||
pub struct FrameMemberParams(pub FrameMemberKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for FrameMemberParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut() {
|
||||
// A key counts as given when it is there, `null` included, as the handler tests it.
|
||||
match (keys.contains_key("profile"), keys.contains_key("profile_sketch")) {
|
||||
// A built-in profile: the sketch's profile picks are never read.
|
||||
(true, false) => {
|
||||
keys.remove("profiles");
|
||||
keys.remove("exact_regions");
|
||||
}
|
||||
(false, true) => {}
|
||||
// Both or neither: the handler refuses before it reads any of them.
|
||||
_ => {
|
||||
for key in ["profile", "profile_sketch", "profiles", "exact_regions"] {
|
||||
keys.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(FrameMemberParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FrameMemberParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FrameMemberParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
FrameMemberKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frame_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,568 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::frame::{self, MAX_MEMBERS};
|
||||
|
||||
use super::{
|
||||
FrameMemberParams, ProfileInsertParams, ProfileInsertResult, ProfileListParams, ProfileListResult, ProfilePropertiesParams,
|
||||
ProfilePropertiesResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
fn tube() -> Value {
|
||||
json!({"kind": "rect_tube", "width": 40, "height": 40, "wall": 3})
|
||||
}
|
||||
|
||||
/// The id of the first sketch of `s`.
|
||||
fn first_sketch(s: &Session) -> u64 {
|
||||
s.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id).unwrap_or_else(|| panic!("no sketch"))
|
||||
}
|
||||
|
||||
/// A session with the 600 × 400 rectangle `Frame` (its four line ids returned) and the sketch
|
||||
/// `Section`, two concentric circles: a tube's wall.
|
||||
fn frame_session(level: ParamCheck) -> (Session, Vec<String>) {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run("sketch.create", json!({"plane": "XY", "name": "Frame"}));
|
||||
let rectangle = run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [600, 400]}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("sketch.create", json!({"plane": "XY", "name": "Section"}));
|
||||
run("sketch.circle.center", json!({"center": [0, 0], "radius": 20}));
|
||||
run("sketch.circle.center", json!({"center": [0, 0], "radius": 17}));
|
||||
run("sketch.finish", json!({}));
|
||||
let lines =
|
||||
rectangle["created"]["curves"].as_array().map(|c| c.iter().filter_map(|c| c.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
(s, lines)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<FrameMemberParams>(),
|
||||
&[
|
||||
"anchor",
|
||||
"angle",
|
||||
"body_name",
|
||||
"exact_regions",
|
||||
"lines",
|
||||
"mirror",
|
||||
"name",
|
||||
"names",
|
||||
"participants",
|
||||
"profile",
|
||||
"profile_sketch",
|
||||
"profiles",
|
||||
"sketch",
|
||||
],
|
||||
);
|
||||
assert_keys(input_schema_for::<ProfileListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ProfilePropertiesParams>(), &["density", "profile"]);
|
||||
assert_keys(input_schema_for::<ProfileInsertParams>(), &["anchor", "angle", "at", "fixed", "profile", "sketch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let sketch = {
|
||||
let mut s = Session::default();
|
||||
sketch_rectangle(&mut s);
|
||||
first_sketch(&s)
|
||||
};
|
||||
let member = |extra: Value| {
|
||||
let mut p = json!({"sketch": sketch, "profile": tube()});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
("frame.member", p)
|
||||
};
|
||||
let bar = json!({"kind": "round_bar", "diameter": 10});
|
||||
let insert = |extra: Value| {
|
||||
let mut p = json!({"profile": bar});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
("frame.profile.insert", p)
|
||||
};
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
// frame.member
|
||||
("frame.member", json!({})),
|
||||
("frame.member", json!({"sketch": null, "profile": tube()})),
|
||||
("frame.member", json!({"sketch": true, "profile": tube()})),
|
||||
("frame.member", json!({"sketch": [1], "profile": tube()})),
|
||||
("frame.member", json!({"sketch": sketch})),
|
||||
("frame.member", json!({"sketch": sketch, "profile": tube(), "profile_sketch": sketch})),
|
||||
("frame.member", json!({"sketch": sketch, "profile": tube(), "profile_sketch": null})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": true})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": null})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": sketch})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": "Nowhere"})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": 99999, "profiles": 3})),
|
||||
("frame.member", json!({"sketch": "Nowhere", "profile": tube()})),
|
||||
member(json!({"profile": null})),
|
||||
member(json!({"profile": "tube"})),
|
||||
member(json!({"profile": {}})),
|
||||
member(json!({"profile": {"kind": "hexagon"}})),
|
||||
member(json!({"profile": {"kind": 5}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": 10}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": 10, "height": 5, "diameter": 3}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": true, "height": 5}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": " ", "height": 5}})),
|
||||
member(json!({"profile": {"kind": "rect_tube", "width": 10, "height": 10, "wall": 5}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": "nonsense(", "height": 5}})),
|
||||
member(json!({"lines": []})),
|
||||
member(json!({"lines": "l1"})),
|
||||
member(json!({"lines": [1]})),
|
||||
member(json!({"lines": ["l1", "l1"]})),
|
||||
member(json!({"lines": (0..=MAX_MEMBERS).map(|i| format!("l{i}")).collect::<Vec<_>>()})),
|
||||
member(json!({"lines": ["nope"]})),
|
||||
member(json!({"body_name": ""})),
|
||||
member(json!({"body_name": " "})),
|
||||
member(json!({"body_name": 5})),
|
||||
member(json!({"body_name": "x".repeat(129)})),
|
||||
member(json!({"anchor": "middle"})),
|
||||
member(json!({"anchor": 5})),
|
||||
member(json!({"anchor": ["top"]})),
|
||||
member(json!({"angle": true})),
|
||||
member(json!({"angle": ""})),
|
||||
member(json!({"angle": "x".repeat(4097)})),
|
||||
member(json!({"angle": "a lot"})),
|
||||
member(json!({"mirror": 3})),
|
||||
member(json!({"mirror": "yes"})),
|
||||
member(json!({"names": ["Rail"]})),
|
||||
member(json!({"names": {"l1": " "}})),
|
||||
member(json!({"names": {"l1": 5}})),
|
||||
member(json!({"names": {"l1": "x".repeat(129)}})),
|
||||
member(json!({"participants": 5})),
|
||||
member(json!({"participants": [5]})),
|
||||
member(json!({"participants": ["Nobody"]})),
|
||||
// frame.profile.properties
|
||||
("frame.profile.properties", json!({})),
|
||||
("frame.profile.properties", json!({"profile": null})),
|
||||
("frame.profile.properties", json!({"profile": {"kind": "hexagon"}})),
|
||||
("frame.profile.properties", json!({"profile": {"kind": "round_bar"}})),
|
||||
("frame.profile.properties", json!({"profile": {"kind": "round_bar", "diameter": -1}})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": -1})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": 0})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": "steel"})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": 1e7})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": true})),
|
||||
// frame.profile.insert
|
||||
("frame.profile.insert", json!({})),
|
||||
("frame.profile.insert", json!({"profile": {"kind": "bar", "width": 5}})),
|
||||
insert(json!({"at": "here"})),
|
||||
insert(json!({"at": [1]})),
|
||||
insert(json!({"at": [1e300, 0]})),
|
||||
insert(json!({"at": {"x": 1}})),
|
||||
insert(json!({"anchor": "middle"})),
|
||||
insert(json!({"anchor": 3})),
|
||||
insert(json!({"angle": "a lot"})),
|
||||
insert(json!({"angle": 1e7})),
|
||||
insert(json!({"fixed": 3})),
|
||||
insert(json!({"fixed": "false"})),
|
||||
insert(json!({"sketch": true})),
|
||||
insert(json!({"sketch": -1})),
|
||||
insert(json!({"sketch": 1.5})),
|
||||
insert(json!({"sketch": "Nowhere"})),
|
||||
// No sketch is being edited, and none is named.
|
||||
insert(json!({})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Each key is read as its handler reads it: a call the handler takes has no issue, and a call
|
||||
/// it refuses over a key has one. (A call with a key the handler ignores is told apart below.)
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let (_, lines) = frame_session(ParamCheck::Off);
|
||||
let line = lines.first().cloned().unwrap_or_default();
|
||||
let member = |extra: Value| {
|
||||
let mut p = json!({"sketch": "Frame", "profile": tube()});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
p
|
||||
};
|
||||
let section = |extra: Value| {
|
||||
let mut p = json!({"sketch": "Frame", "profile_sketch": "Section"});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
p
|
||||
};
|
||||
// (call, whether the handler takes it)
|
||||
let cases = [
|
||||
(member(json!({})), true),
|
||||
(member(json!({"lines": [line]})), true),
|
||||
(member(json!({"lines": null})), true),
|
||||
(member(json!({"lines": []})), false),
|
||||
(member(json!({"lines": [line, line]})), false),
|
||||
(member(json!({"body_name": "Rail"})), true),
|
||||
(member(json!({"body_name": " Rail "})), true),
|
||||
(member(json!({"body_name": null})), true),
|
||||
(member(json!({"body_name": "x".repeat(128)})), true),
|
||||
(member(json!({"body_name": "x".repeat(129)})), false),
|
||||
(member(json!({"body_name": " "})), false),
|
||||
(member(json!({"anchor": "bottom_left"})), true),
|
||||
(member(json!({"anchor": null})), true),
|
||||
(member(json!({"anchor": "Top"})), false),
|
||||
(member(json!({"angle": "90 deg"})), true),
|
||||
(member(json!({"angle": 45})), true),
|
||||
(member(json!({"angle": " 30 deg "})), true),
|
||||
(member(json!({"angle": null})), true),
|
||||
(member(json!({"angle": ""})), false),
|
||||
(member(json!({"angle": 1e9})), false),
|
||||
(member(json!({"mirror": true})), true),
|
||||
(member(json!({"mirror": null})), true),
|
||||
(member(json!({"mirror": 1})), false),
|
||||
(member(json!({"names": {line.clone(): " Mine "}})), true),
|
||||
(member(json!({"names": {}})), true),
|
||||
(member(json!({"names": null})), true),
|
||||
(member(json!({"names": {line.clone(): ""}})), false),
|
||||
(member(json!({"names": [line]})), false),
|
||||
(member(json!({"name": "Frame members"})), true),
|
||||
(member(json!({"participants": "*"})), true),
|
||||
(member(json!({"participants": ["*"]})), true),
|
||||
(member(json!({"participants": null})), true),
|
||||
(member(json!({"participants": [3]})), false),
|
||||
(member(json!({"participants": {"a": 1}})), false),
|
||||
(member(json!({"profile": {"kind": "bar", "width": "40 mm", "height": 10, "corner_radius": 0}})), true),
|
||||
(member(json!({"profile": {"kind": "bar", "width": 40}})), false),
|
||||
(member(json!({"profile": {"kind": "i_beam", "height": 100, "width": 50, "web": 4.5, "flange": 7, "root_radius": null}})), false),
|
||||
(section(json!({})), true),
|
||||
(section(json!({"profiles": "all"})), true),
|
||||
(section(json!({"profiles": [{"point": [0, 0]}], "exact_regions": true})), true),
|
||||
(section(json!({"profiles": [0]})), true),
|
||||
(section(json!({"profiles": "none"})), false),
|
||||
(section(json!({"profiles": null})), false),
|
||||
(section(json!({"profiles": []})), false),
|
||||
// Keys the handler never reads in this form: no issue, and the call is as good as without.
|
||||
(member(json!({"profiles": "none", "exact_regions": "yes"})), true),
|
||||
(member(json!({"profiles": null})), true),
|
||||
];
|
||||
for (p, takes) in cases {
|
||||
let (mut s, _) = frame_session(ParamCheck::Off);
|
||||
let outcome = s.execute("frame.member", &p);
|
||||
assert_eq!(outcome.is_ok(), takes, "{p}: {outcome:?}");
|
||||
assert_eq!(issues("frame.member", &p).is_empty(), takes, "{p}: {:?}", issues("frame.member", &p));
|
||||
}
|
||||
|
||||
// One of `profile` and `profile_sketch`: with both or neither the handler refuses before it
|
||||
// reads the section's keys, and the check, which can't see the design, leaves it that.
|
||||
for p in [
|
||||
json!({"sketch": "Frame", "profile": tube(), "profile_sketch": "Section"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "profile_sketch": "Section", "profiles": "none", "exact_regions": 1}),
|
||||
json!({"sketch": "Frame", "profile": null, "profile_sketch": null}),
|
||||
json!({"sketch": "Frame"}),
|
||||
json!({"sketch": "Frame", "profiles": "none"}),
|
||||
] {
|
||||
let (mut s, _) = frame_session(ParamCheck::Off);
|
||||
let error = s.execute("frame.member", &p).unwrap_err().to_string();
|
||||
assert!(error.contains("`profile`") && error.contains("one of them"), "{p}: {error}");
|
||||
assert!(issues("frame.member", &p).is_empty(), "{p}: {:?}", issues("frame.member", &p));
|
||||
}
|
||||
|
||||
// The properties and insert commands.
|
||||
let (mut s, _) = frame_session(ParamCheck::Off);
|
||||
let bar = json!({"kind": "round_bar", "diameter": 10});
|
||||
for (p, takes) in [
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "density": 7850}), true),
|
||||
(json!({"profile": bar, "density": null}), true),
|
||||
(json!({"profile": bar, "density": 1e6}), true),
|
||||
(json!({"profile": bar, "density": 1e6 + 1.0}), false),
|
||||
(json!({"profile": bar, "density": 0}), false),
|
||||
(json!({"profile": bar, "density": "7850"}), false),
|
||||
] {
|
||||
let outcome = s.execute("frame.profile.properties", &p);
|
||||
assert_eq!(outcome.is_ok(), takes, "{p}: {outcome:?}");
|
||||
assert_eq!(issues("frame.profile.properties", &p).is_empty(), takes, "{p}");
|
||||
}
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
for (p, takes) in [
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "at": [3, 4], "anchor": "centroid", "angle": 0.5, "fixed": false}), true),
|
||||
(json!({"profile": bar, "at": {"x": 3, "y": 4}}), true),
|
||||
(json!({"profile": bar, "at": null, "anchor": null, "angle": null, "fixed": null, "sketch": null}), true),
|
||||
(json!({"profile": bar, "angle": -1e6}), true),
|
||||
(json!({"profile": bar, "angle": 1e6 + 1.0}), false),
|
||||
(json!({"profile": bar, "at": [1, 2, 3]}), false),
|
||||
(json!({"profile": bar, "fixed": 0}), false),
|
||||
(json!({"profile": bar, "anchor": "CENTER"}), false),
|
||||
(json!({"profile": bar, "sketch": 1.5}), false),
|
||||
] {
|
||||
let outcome = s.execute("frame.profile.insert", &p);
|
||||
assert_eq!(outcome.is_ok(), takes, "{p}: {outcome:?}");
|
||||
assert_eq!(issues("frame.profile.insert", &p).is_empty(), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With a built-in profile, a `profiles` or `exact_regions` that couldn't be read is no warning;
|
||||
/// with a profile sketch it is, and says what the handler says.
|
||||
#[test]
|
||||
fn a_section_sketchs_keys_count_only_with_a_section_sketch() {
|
||||
let messages = |p: Value| issues("frame.member", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(messages(json!({"sketch": 1, "profile": tube(), "profiles": 7, "exact_regions": []})), Vec::<String>::new());
|
||||
assert_eq!(messages(json!({"sketch": 1, "profile_sketch": 2, "profiles": "none"})), ["`profiles` must be \"all\" or a list"]);
|
||||
assert_eq!(messages(json!({"sketch": 1, "profile_sketch": 2, "exact_regions": "yes"})), ["`exact_regions` must be true or false"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: `sketch` and a profile are required, `profile` and
|
||||
/// `profile_sketch` are alternatives, and values have the shape the handlers read.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
let bar = json!({"kind": "round_bar", "diameter": 10});
|
||||
takes(
|
||||
input_schema_for::<FrameMemberParams>(),
|
||||
&[
|
||||
(json!({"sketch": "Frame", "profile": tube()}), true),
|
||||
(json!({"sketch": 3, "profile_sketch": "Section"}), true),
|
||||
(json!({"sketch": 3, "profile_sketch": 4, "profiles": [{"point": [0, 0]}], "exact_regions": true}), true),
|
||||
(
|
||||
json!({
|
||||
"sketch": "Frame", "lines": ["l1", "l2"], "profile": {"kind": "bar", "width": "w", "height": 5, "corner_radius": 1},
|
||||
"anchor": "top_left", "angle": "90 deg", "mirror": true, "name": "N", "body_name": "Rail",
|
||||
"names": {"l1": "A"}, "participants": ["*"]
|
||||
}),
|
||||
true,
|
||||
),
|
||||
(json!({"profile": tube()}), false),
|
||||
(json!({"sketch": "Frame"}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "profile_sketch": "Section"}), false),
|
||||
(json!({"sketch": "Frame", "profile": {"kind": "hexagon"}}), false),
|
||||
(json!({"sketch": "Frame", "profile": {"kind": "bar", "width": 5}}), false),
|
||||
(json!({"sketch": "Frame", "profile": {"kind": "bar", "width": 5, "height": 5, "wall": 1}}), false),
|
||||
(json!({"sketch": true, "profile": tube()}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "lines": []}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "lines": ["a", "a"]}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "anchor": "middle"}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "angle": true}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "body_names": ["a"]}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<ProfileListParams>(), &[(json!({}), true), (json!({"kind": "bar"}), false)]);
|
||||
takes(
|
||||
input_schema_for::<ProfilePropertiesParams>(),
|
||||
&[
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "density": 7850}), true),
|
||||
(json!({"profile": bar, "density": null}), true),
|
||||
(json!({"profile": bar, "density": 0}), false),
|
||||
(json!({"profile": bar, "density": 1e6 + 1.0}), false),
|
||||
(json!({"density": 7850}), false),
|
||||
(json!({"profile": {"kind": "round_bar"}}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ProfileInsertParams>(),
|
||||
&[
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "at": [1, 2], "anchor": "bottom", "angle": 2.5, "fixed": false, "sketch": "S"}), true),
|
||||
(json!({"profile": bar, "at": {"x": 1, "y": 2}, "sketch": 4}), true),
|
||||
(json!({}), false),
|
||||
(json!({"profile": bar, "at": [1, 2, 3]}), false),
|
||||
(json!({"profile": bar, "angle": 2e6}), false),
|
||||
(json!({"profile": bar, "fixed": "yes"}), false),
|
||||
(json!({"profile": bar, "sketch": 1.5}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The "not blank" patterns are the shared one, which `conforms` doesn't read: pin where they are.
|
||||
#[test]
|
||||
fn the_name_patterns_are_the_blank_pattern() {
|
||||
let blank = crate::schema::types::not_blank_pattern();
|
||||
let member = input_schema_for::<FrameMemberParams>();
|
||||
let member = member.as_value();
|
||||
assert_eq!(member["properties"]["body_name"]["anyOf"][0]["$ref"], json!("#/$defs/MemberName"));
|
||||
assert_eq!(member["$defs"]["MemberName"]["pattern"], json!(blank));
|
||||
assert_eq!(member["$defs"]["MemberNames"]["additionalProperties"]["pattern"], json!(blank));
|
||||
let properties = input_schema_for::<ProfilePropertiesParams>();
|
||||
let properties = properties.as_value();
|
||||
let kinds = properties["$defs"]["ProfileParam"]["anyOf"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(kinds.len(), frame::profile_kinds().len());
|
||||
assert_eq!(kinds[0]["properties"]["width"]["anyOf"][1]["pattern"], json!(blank));
|
||||
}
|
||||
|
||||
/// The warning for what a key gets wrong, and the check that every built-in kind is described.
|
||||
#[test]
|
||||
fn the_check_names_what_is_wrong_with_a_key() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(issues("frame.profile.properties", json!({"profile": {"kind": "hexagon"}})).len(), 1);
|
||||
assert_eq!(
|
||||
issues("frame.profile.properties", json!({"profile": {"kind": "bar", "width": 1, "height": 1, "wall": 1}})),
|
||||
["`profile`: a bar profile has no dimension `wall`"]
|
||||
);
|
||||
assert_eq!(issues("frame.profile.properties", json!({"profile": {"kind": "bar"}})), ["`profile`: a bar profile needs `width`"]);
|
||||
assert_eq!(
|
||||
issues("frame.profile.properties", json!({"profile": {"kind": "round_bar", "diameter": 1}, "density": -3})),
|
||||
["`density` must be a positive number of kg/m³ (at most 1e6)"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("frame.profile.insert", json!({"profile": {"kind": "round_bar", "diameter": 1}, "at": 3})),
|
||||
["`at` must be [x, y] in sketch millimetres"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("frame.profile.insert", json!({"profile": {"kind": "round_bar", "diameter": 1}, "fixed": "no"})),
|
||||
["`fixed` must be true or false"]
|
||||
);
|
||||
assert_eq!(issues("frame.member", json!({"sketch": 1, "profile": tube(), "mirror": "no"})), ["`mirror` must be true or false"]);
|
||||
assert_eq!(issues("frame.member", json!({"sketch": 1, "profile": tube(), "lines": ["a", "a"]})), ["line `a` is listed twice"]);
|
||||
assert_eq!(
|
||||
issues("frame.member", json!({"sketch": 1, "profile": tube(), "body_names": ["a"]})),
|
||||
["unknown key `body_names`; did you mean `body_name`?"]
|
||||
);
|
||||
// The schemas describe every built-in kind with its dimensions, and its example is one.
|
||||
let schema = input_schema_for::<ProfilePropertiesParams>();
|
||||
let schema = schema.as_value();
|
||||
for kind in frame::profile_kinds() {
|
||||
let example = serde_json::to_value(&kind.example).unwrap_or_default();
|
||||
let profile = json!({"profile": example});
|
||||
assert!(conforms(schema, schema, &profile), "{profile}");
|
||||
assert!(crate::schema::issues("frame.profile.properties", &profile).is_empty(), "{profile}");
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// The library's list and properties replies match the output schemas and are what the handlers
|
||||
/// built before the typed replies: the same JSON.
|
||||
#[test]
|
||||
fn profile_library_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let listed = run("frame.profile.list", json!({}));
|
||||
matches(output_schema_for::<ProfileListResult>(), "frame.profile.list", &listed);
|
||||
assert_eq!(listed, json!({"kinds": frame::profile_kinds()}));
|
||||
let kinds = listed["kinds"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(kinds.len(), 7);
|
||||
for kind in &kinds {
|
||||
let r = run("frame.profile.properties", json!({"profile": kind["example"]}));
|
||||
matches(output_schema_for::<ProfilePropertiesResult>(), "frame.profile.properties", &r);
|
||||
let profile: frame::Profile = serde_json::from_value(kind["example"].clone()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let region = profile.region().unwrap_or_else(|e| panic!("{e}"));
|
||||
let sec = frame::section(®ion);
|
||||
assert_eq!(
|
||||
r,
|
||||
json!({
|
||||
"profile": profile.filled(), "area": sec.area, "centroid": sec.centroid, "ixx": sec.ixx, "iyy": sec.iyy, "ixy": sec.ixy,
|
||||
"width": sec.width, "height": sec.height
|
||||
})
|
||||
);
|
||||
}
|
||||
let heavy = run("frame.profile.properties", json!({"profile": tube(), "density": 7850}));
|
||||
matches(output_schema_for::<ProfilePropertiesResult>(), "frame.profile.properties", &heavy);
|
||||
let area = heavy["area"].as_f64().unwrap_or_default();
|
||||
assert_eq!(heavy["mass_per_m"], json!(area * 1e-6 * 7850.0), "{heavy}");
|
||||
assert_eq!(heavy["profile"]["outer_radius"], json!(6.0), "{heavy}");
|
||||
// Dimensions as expressions, in a design with parameters.
|
||||
run("parameters.add", json!({"name": "wall", "expression": "3 mm"}));
|
||||
let by_parameter =
|
||||
run("frame.profile.properties", json!({"profile": {"kind": "rect_tube", "width": "40 mm", "height": 40, "wall": "wall"}, "density": null}));
|
||||
matches(output_schema_for::<ProfilePropertiesResult>(), "frame.profile.properties", &by_parameter);
|
||||
assert_eq!(by_parameter["area"], run("frame.profile.properties", json!({"profile": tube()}))["area"]);
|
||||
|
||||
// A misspelt key still runs in Warn and says so, with a reply that matches.
|
||||
for (id, p) in [("frame.profile.list", json!({"kinds": true})), ("frame.profile.properties", json!({"profile": tube(), "densty": 7850}))] {
|
||||
let warned = run(id, p);
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{warned}");
|
||||
let schema =
|
||||
if id == "frame.profile.list" { output_schema_for::<ProfileListResult>() } else { output_schema_for::<ProfilePropertiesResult>() };
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a profile in every form, in Warn: each reply matches its output schema, carries no
|
||||
/// warning and is what the handler built before.
|
||||
#[test]
|
||||
fn insert_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let made = run("sketch.create", json!({"plane": "XY", "name": "Drawing"}));
|
||||
let id = made["sketch"].clone();
|
||||
let plain = run("frame.profile.insert", json!({"profile": tube()}));
|
||||
matches(output_schema_for::<ProfileInsertResult>(), "frame.profile.insert", &plain);
|
||||
assert_eq!(plain["created"]["curves"].as_array().map(Vec::len), Some(16), "{plain}");
|
||||
assert!(plain["sketch"]["solved"].is_boolean() && plain["sketch"]["dof"].is_u64(), "{plain}");
|
||||
let mut keys: Vec<&String> = plain.as_object().map(|o| o.keys().collect()).unwrap_or_default();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["created", "section", "sketch"]);
|
||||
let mut created: Vec<&String> = plain["created"].as_object().map(|o| o.keys().collect()).unwrap_or_default();
|
||||
created.sort();
|
||||
assert_eq!(created, ["constraints", "curves", "dimensions", "points"]);
|
||||
let section = run("frame.profile.properties", json!({"profile": tube()}));
|
||||
// The section is what the library computes for the profile, not just what the other command says.
|
||||
let profile: frame::Profile = serde_json::from_value(tube()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sec = frame::section(&profile.region().unwrap_or_else(|e| panic!("{e}")));
|
||||
assert_eq!(plain["section"]["area"], json!(sec.area), "{plain}");
|
||||
assert_eq!(plain["section"]["centroid"], json!(sec.centroid), "{plain}");
|
||||
assert_eq!(plain["section"]["profile"], serde_json::to_value(profile.filled()).unwrap_or_default(), "{plain}");
|
||||
assert_eq!(plain["section"], section);
|
||||
let placed = run(
|
||||
"frame.profile.insert",
|
||||
json!({
|
||||
"profile": {"kind": "channel", "height": "60 mm", "width": 45, "web": 5, "flange": 6},
|
||||
"at": [100, 50], "anchor": "bottom_left", "angle": 0.5, "fixed": false, "sketch": "Drawing"
|
||||
}),
|
||||
);
|
||||
matches(output_schema_for::<ProfileInsertResult>(), "frame.profile.insert", &placed);
|
||||
let by_id = run(
|
||||
"frame.profile.insert",
|
||||
json!({"profile": {"kind": "round_bar", "diameter": 20}, "sketch": id, "at": {"x": -50, "y": 5}, "anchor": null}),
|
||||
);
|
||||
matches(output_schema_for::<ProfileInsertResult>(), "frame.profile.insert", &by_id);
|
||||
assert_eq!(by_id["created"]["curves"].as_array().map(Vec::len), Some(1), "{by_id}");
|
||||
// A misspelt key still runs in Warn, and says so, with a reply that matches.
|
||||
let warned = run("frame.profile.insert", json!({"profile": {"kind": "round_bar", "diameter": 5}, "sketch": "Drawing", "ancor": "center"}));
|
||||
assert_eq!(warned["param_warnings"], json!(["unknown key `ancor`; did you mean `anchor`?"]), "{warned}");
|
||||
let schema = output_schema_for::<ProfileInsertResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
|
||||
/// Members in every form, in Warn: each reply matches `FeatureResult` and carries no warning.
|
||||
#[test]
|
||||
fn member_replies_match_the_output_schema() {
|
||||
let (mut s, lines) = frame_session(ParamCheck::Warn);
|
||||
let mut run = |p: Value| s.execute("frame.member", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let first = lines.first().cloned().unwrap_or_default();
|
||||
let second = lines.get(1).cloned().unwrap_or_default();
|
||||
let calls = [
|
||||
json!({"sketch": "Frame", "profile": tube()}),
|
||||
json!({"sketch": "Frame", "profile": {"kind": "angle", "width": 60, "height": 40, "thickness": "5 mm"}, "lines": [first], "anchor": "bottom_left"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "lines": [first, second], "angle": "90 deg", "mirror": true, "name": "Rails", "body_name": "Rail"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "lines": [first], "angle": 45, "names": {first.clone(): "Chosen"}, "participants": "*"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "lines": null}),
|
||||
json!({"sketch": "Frame", "profile_sketch": "Section"}),
|
||||
json!({"sketch": "Frame", "profile_sketch": "Section", "profiles": [{"point": [0, 0]}], "exact_regions": true, "lines": [first], "anchor": "center"}),
|
||||
json!({"sketch": "Frame", "profile_sketch": "Section", "profiles": "all", "mirror": false, "angle": null, "names": null}),
|
||||
];
|
||||
for p in calls {
|
||||
let reply = run(p.clone());
|
||||
matches(output_schema_for::<FeatureResult>(), "frame.member", &reply);
|
||||
assert!(reply["created_bodies"].as_array().is_some_and(|b| !b.is_empty()), "{p}: {reply}");
|
||||
}
|
||||
// By the id of the sketch.
|
||||
let by_id = s_first_sketch_call(&mut s, &first);
|
||||
matches(output_schema_for::<FeatureResult>(), "frame.member", &by_id);
|
||||
|
||||
// A misspelt key still runs in Warn, and says so, with a reply that matches.
|
||||
let warned =
|
||||
s.execute("frame.member", &json!({"sketch": "Frame", "profile": tube(), "lines": [first], "profil": 1})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(warned["param_warnings"], json!(["unknown key `profil`; did you mean `profile`?"]), "{warned}");
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
|
||||
fn s_first_sketch_call(s: &mut Session, line: &str) -> Value {
|
||||
let id = first_sketch(s);
|
||||
s.execute("frame.member", &json!({"sketch": id, "profile": tube(), "lines": [line]})).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
//! Contracts and result types for the commands in `cmd/inspect.rs`.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a body, a sketch or a material exists
|
||||
//! stays the handler's to check. A key the handler refuses when it is left out (`material.assign`'s
|
||||
//! `bodies` and `material`) is optional in the type and required in the schema, so that the
|
||||
//! handler's message, not the check's "is required", is what a caller sees (#12498).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::check::{located, whole};
|
||||
use super::materials::MaterialReply;
|
||||
use crate::params;
|
||||
|
||||
/// Which command to describe.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -50,6 +64,44 @@ pub struct BodyListParams {
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ModelThreadsParams {}
|
||||
|
||||
/// List the timeline features that built each body (#11945).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct BodyFeaturesParams {
|
||||
/// One body, by name (a placed copy's name means the body it shows); every body when left out.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// What `model.body_features` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ModelBodyFeatures {
|
||||
/// The bodies, in the model's order (the one asked for alone).
|
||||
pub bodies: Vec<BodyFeatures>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A body and the features that built it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFeatures {
|
||||
/// The body's name.
|
||||
pub body: String,
|
||||
/// In timeline order: the feature that created it, every later one that changed its
|
||||
/// geometry, and those of the bodies consumed into it (a Combine's tools, a body joined in).
|
||||
pub features: Vec<BodyFeature>,
|
||||
}
|
||||
|
||||
/// A timeline feature.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFeature {
|
||||
/// The feature's id.
|
||||
pub id: u64,
|
||||
/// Its name in the timeline.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// What `model.edges` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ModelEdges {
|
||||
@@ -240,6 +292,831 @@ impl From<&solidcraft_doc::ThreadInfo> for ModelThread {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `keys` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it when it's left out.
|
||||
fn require_keys(schema: &mut Schema, keys: &[&str]) {
|
||||
let mut required = match schema.get("required") {
|
||||
Some(Value::Array(required)) => required.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for key in keys {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
required.push((*key).into());
|
||||
}
|
||||
}
|
||||
schema.insert("required".into(), Value::Array(required));
|
||||
}
|
||||
|
||||
fn bodies_and_material_required(schema: &mut Schema) {
|
||||
require_keys(schema, &["bodies", "material"]);
|
||||
}
|
||||
|
||||
/// The message of a handler's parameter error, for a reader the contract shares.
|
||||
fn message(e: crate::EngineError) -> String {
|
||||
match e {
|
||||
crate::EngineError::BadParams { msg, .. } => msg,
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bodies`: a name or a list of names, read by `params::string_list` itself, with its messages
|
||||
/// (`null` is no names).
|
||||
fn body_names<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<String>>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
params::string_list("inspect", &json!({ "bodies": v }), "bodies").map(Some).map_err(|e| D::Error::custom(whole(message(e))))
|
||||
}
|
||||
|
||||
fn body_names_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": params::MAX_STRING_LIST}, {"type": "null"}]})
|
||||
}
|
||||
|
||||
/// Read the design: its name, units, properties, parameters, timeline, bodies and sketches, the
|
||||
/// selection and the undo and redo depth. Read-only.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DocumentInspectParams {
|
||||
/// Measure each body too (volume, area, centre of mass, box, counts, as `inspect.measure`
|
||||
/// does), in place of its feature. Default: false.
|
||||
#[serde(default)]
|
||||
pub measure: Option<bool>,
|
||||
/// Give each sketch also its plane and its number of points and constraints. Default: false.
|
||||
#[serde(default)]
|
||||
pub sketch_counts: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `document.inspect` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentInspectResult {
|
||||
/// The design's name.
|
||||
pub name: String,
|
||||
/// Its length unit.
|
||||
pub units: String,
|
||||
/// Its metadata object as saved, other tools' keys included.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// The file it is saved in; `null` when it has none.
|
||||
pub path: Option<String>,
|
||||
/// Whether it has changes that are not saved.
|
||||
pub dirty: bool,
|
||||
/// How many times the session's design changed.
|
||||
pub revision: u64,
|
||||
/// The sketch being edited, by feature id; `null` when none.
|
||||
pub active_sketch: Option<u64>,
|
||||
/// The timeline marker: features from this index on are rolled back.
|
||||
pub marker: usize,
|
||||
/// The design's parameters.
|
||||
pub params: Vec<DocumentParam>,
|
||||
/// Its features, in timeline order.
|
||||
pub timeline: Vec<TimelineEntry>,
|
||||
/// Its bodies.
|
||||
pub bodies: Vec<DocumentBody>,
|
||||
/// Its sketches.
|
||||
pub sketches: Vec<DocumentSketch>,
|
||||
/// What is selected, each as the selection commands take it.
|
||||
#[schemars(with = "Vec<Map<String, Value>>")]
|
||||
pub selection: Value,
|
||||
/// How many undo steps there are.
|
||||
pub undo: usize,
|
||||
/// How many redo steps there are.
|
||||
pub redo: usize,
|
||||
/// The parts of the origin that are hidden, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parameter of the design.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentParam {
|
||||
pub name: String,
|
||||
/// What it is defined as.
|
||||
pub expression: String,
|
||||
/// `mm`, `deg` or empty (no unit).
|
||||
pub unit: String,
|
||||
/// What the expression comes to; `null` when it doesn't evaluate.
|
||||
pub value: Option<f64>,
|
||||
/// Whether a dimension or feature made it, not the user.
|
||||
pub model: bool,
|
||||
pub comment: String,
|
||||
/// Why the expression doesn't evaluate; `null` when it does.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A feature of the timeline.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TimelineEntry {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The feature's kind, such as `Extrude`.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub suppressed: bool,
|
||||
/// Whether the timeline marker has rolled it back.
|
||||
pub rolled_back: bool,
|
||||
/// Why it failed; `null` when it did not.
|
||||
pub error: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
/// How long it took to evaluate (ms); `null` when it has no result.
|
||||
pub ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// A body of the design: measured with `measure`, else with the feature that made it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentBody {
|
||||
#[serde(flatten)]
|
||||
pub detail: DocumentBodyDetail,
|
||||
/// Whether it is shown.
|
||||
pub visible: bool,
|
||||
/// Present, and true, when the timeline is rolled back and the body is deleted further on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub removed_later: Option<bool>,
|
||||
}
|
||||
|
||||
/// What a body entry says of the body itself.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum DocumentBodyDetail {
|
||||
/// Without `measure`.
|
||||
Listed {
|
||||
name: String,
|
||||
/// The feature that made it, by id.
|
||||
feature: u64,
|
||||
},
|
||||
/// With `measure`.
|
||||
Measured(Box<BodyMeasurement>),
|
||||
}
|
||||
|
||||
/// One body measured: its name and the measurement or why there is none.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyMeasurement {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub outcome: BodyOutcome,
|
||||
}
|
||||
|
||||
/// A body's measurement.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum BodyOutcome {
|
||||
Measured(BodyFigures),
|
||||
Failed(BodyFailure),
|
||||
}
|
||||
|
||||
/// The measurements of a body.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFigures {
|
||||
/// Whether it encloses a volume.
|
||||
pub closed: bool,
|
||||
/// Its volume (mm³); `null` unless it is closed.
|
||||
pub volume_mm3: Option<f64>,
|
||||
/// Its surface area (mm²).
|
||||
pub area_mm2: f64,
|
||||
/// Its centre of mass at uniform density; `null` unless it is closed.
|
||||
pub center_of_mass: Option<[f64; 3]>,
|
||||
pub bbox: BoundingBox,
|
||||
/// Faces, with split pieces of one analytic surface counted once (a sheet metal body's in
|
||||
/// `inspect.measure`: with its round holes counted as the design shows them).
|
||||
pub faces: i64,
|
||||
pub edges: i64,
|
||||
pub vertices: i64,
|
||||
/// The merged faces by kind of surface.
|
||||
pub face_types: BTreeMap<String, usize>,
|
||||
/// The kernel's own counts, which split closed curves and surfaces.
|
||||
pub kernel: KernelCounts,
|
||||
/// What volume, area and centre come from: `smlib` (exact) or `mesh` (a mesh body's own
|
||||
/// triangles).
|
||||
pub measured_by: String,
|
||||
/// Anything the measurement says about itself.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why a body has no measurement.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFailure {
|
||||
pub error: String,
|
||||
/// `inspect.measure` only, for a sheet metal body: its hole-corrected count, which has
|
||||
/// nothing to correct.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub faces: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edges: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vertices: Option<i64>,
|
||||
}
|
||||
|
||||
/// An axis-aligned box (mm).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BoundingBox {
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
}
|
||||
|
||||
/// A body's topology counts as the kernel has them.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct KernelCounts {
|
||||
pub faces: usize,
|
||||
pub edges: usize,
|
||||
pub vertices: usize,
|
||||
pub shells: usize,
|
||||
}
|
||||
|
||||
/// A sketch of the design, in short.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentSketch {
|
||||
/// Its feature id.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Its remaining degrees of freedom (0: fully constrained).
|
||||
pub dof: usize,
|
||||
pub status: SketchStatus,
|
||||
/// How many curves it has.
|
||||
pub curves: usize,
|
||||
/// How many profiles it closes.
|
||||
pub profiles: usize,
|
||||
/// Whether it is shown.
|
||||
pub visible: bool,
|
||||
/// With `sketch_counts`: where it lies.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<Map<String, Value>>")]
|
||||
pub plane: Option<Value>,
|
||||
/// With `sketch_counts`: how many points it has.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub points: Option<usize>,
|
||||
/// With `sketch_counts`: how many constraints it has.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub constraints: Option<usize>,
|
||||
}
|
||||
|
||||
/// Whether a sketch's constraints solved.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SketchStatus {
|
||||
Solved,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl From<solidcraft_sketch::SolveStatus> for SketchStatus {
|
||||
fn from(s: solidcraft_sketch::SolveStatus) -> Self {
|
||||
match s {
|
||||
solidcraft_sketch::SolveStatus::Solved => SketchStatus::Solved,
|
||||
solidcraft_sketch::SolveStatus::Failed => SketchStatus::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `inspect.measure` selects: bodies, or one or two picked items. With `items`, `bodies` and
|
||||
/// `names_only` are not read, so the check leaves them alone.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MeasureKeys {
|
||||
/// The bodies to measure, by name. Default: all. Not read with `items`.
|
||||
#[serde(default, deserialize_with = "body_names", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "body_names_schema")]
|
||||
pub bodies: Option<Vec<String>>,
|
||||
/// Give each body's name and its definition's and measure nothing. Default: false. Not read
|
||||
/// with `items`.
|
||||
#[serde(default)]
|
||||
pub names_only: Option<bool>,
|
||||
/// One or two selections to measure, instead of bodies: a face or edge `{type, body, point}`
|
||||
/// (`index` is optional), or a body, vertex, sketch curve or profile as `Sel` has it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "items_schema")]
|
||||
pub items: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
fn items_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "array", "items": {"type": "object"}, "minItems": 1, "maxItems": 1000})
|
||||
}
|
||||
|
||||
/// `inspect.measure`'s parameters: [`MeasureKeys`], read as the handler reads them: with `items`
|
||||
/// given (even as `null`) only `items` is read; without it, `bodies` and `names_only`.
|
||||
#[derive(Debug)]
|
||||
pub struct MeasureParams(pub MeasureKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for MeasureParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
if let Some(items) = value.get("items") {
|
||||
let list = items.as_array().ok_or_else(|| D::Error::custom(whole("`items` must be a list of selections")))?;
|
||||
if list.is_empty() || list.len() > 1000 {
|
||||
return Err(D::Error::custom(whole("measure one or two items")));
|
||||
}
|
||||
return Ok(MeasureParams(MeasureKeys { bodies: None, names_only: None, items: Some(list.clone()) }));
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(MeasureParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MeasureParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MeasureParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
MeasureKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `inspect.measure` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasureResult {
|
||||
#[serde(flatten)]
|
||||
pub measured: Measured,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The shapes of `inspect.measure`'s reply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum Measured {
|
||||
/// With `items`.
|
||||
Items(Box<MeasuredItems>),
|
||||
/// With `names_only`.
|
||||
Names(BodyNames),
|
||||
/// Bodies.
|
||||
Bodies(MeasuredBodies),
|
||||
}
|
||||
|
||||
/// Each world body's name and its definition's.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyNames {
|
||||
pub body_count: usize,
|
||||
pub bodies: Vec<BodyName>,
|
||||
}
|
||||
|
||||
/// A body by its name and its definition's.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyName {
|
||||
pub name: String,
|
||||
pub definition: String,
|
||||
}
|
||||
|
||||
/// The bodies measured, with their totals.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasuredBodies {
|
||||
pub body_count: usize,
|
||||
pub bodies: Vec<MeasuredBody>,
|
||||
pub total: MeasureTotal,
|
||||
}
|
||||
|
||||
/// A body measured, with its material and mass.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasuredBody {
|
||||
#[serde(flatten)]
|
||||
pub measurement: BodyMeasurement,
|
||||
/// Its material's name.
|
||||
pub material: String,
|
||||
/// The material's density; only for a body with a volume.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub density_g_cm3: Option<f64>,
|
||||
/// Its mass (g); only for a body with a volume.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mass_g: Option<f64>,
|
||||
/// True for a sheet metal body, whose counts are its own, with round holes as the design
|
||||
/// shows them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sheet_metal: Option<bool>,
|
||||
}
|
||||
|
||||
/// The sum over the bodies measured.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasureTotal {
|
||||
/// How many bodies are left out of the volume and mass because they enclose none.
|
||||
pub open_bodies_skipped: usize,
|
||||
pub volume_mm3: f64,
|
||||
pub area_mm2: f64,
|
||||
pub mass_g: f64,
|
||||
pub faces: u64,
|
||||
pub edges: u64,
|
||||
pub vertices: u64,
|
||||
}
|
||||
|
||||
/// One or two picked items measured: each item, then what its shape gives (for one item) or what
|
||||
/// lies between the two.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct MeasuredItems {
|
||||
/// Each item described: its kind, what it is and its own figures.
|
||||
pub items: Vec<Map<String, Value>>,
|
||||
/// One item that is round: its diameter (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub diameter: Option<Value>,
|
||||
/// Its radius (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub radius: Option<Value>,
|
||||
/// A full circle's circumference (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub circumference: Option<Value>,
|
||||
/// An arc's sweep in degrees; two items: the angle between them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub angle_deg: Option<Value>,
|
||||
/// An arc's length (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub arc_length: Option<Value>,
|
||||
/// One round item's centre.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[f64; 3]>")]
|
||||
pub center: Option<Value>,
|
||||
/// One slot: its `width`, `center_distance`, `overall_length` and the two `centers`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<Map<String, Value>>")]
|
||||
pub slot: Option<Value>,
|
||||
/// One slot: where its first end is centred; two items: the first item's centre.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_from: Option<[f64; 3]>,
|
||||
/// One slot: its second end's centre; two items: the second's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_to: Option<[f64; 3]>,
|
||||
/// Two items: the closest distance between them (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub distance_mm: Option<f64>,
|
||||
/// Two items: where it starts on the first. A coordinate is `null` where it isn't a number: two planes that meet have no
|
||||
/// such point to name (their distance is 0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[Option<f64>; 3]>")]
|
||||
pub from: Option<[f64; 3]>,
|
||||
/// Two items: where it ends on the second. A coordinate is `null` where it isn't a number: two planes that meet have no
|
||||
/// such point to name (their distance is 0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[Option<f64>; 3]>")]
|
||||
pub to: Option<[f64; 3]>,
|
||||
/// Two items: `to` minus `from`. A coordinate is `null` where it isn't a number: two planes that meet have no
|
||||
/// such point to name (their distance is 0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[Option<f64>; 3]>")]
|
||||
pub delta: Option<[f64; 3]>,
|
||||
/// Two items: the closest distance, as `distance_mm`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min: Option<f64>,
|
||||
/// Two items: the farthest distance (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_from: Option<[f64; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_to: Option<[f64; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_delta: Option<[f64; 3]>,
|
||||
/// Two items: the distance between their centres (or from an axis) (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_distance: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_delta: Option<[f64; 3]>,
|
||||
/// Two items: which distance is the one to read, `center` or `min`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub measure: Option<MeasureKind>,
|
||||
}
|
||||
|
||||
/// Which of two items' distances is the one to read.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MeasureKind {
|
||||
Center,
|
||||
Min,
|
||||
}
|
||||
|
||||
/// List every command: its id, label, place on the toolbar, icon, shortcut, parameter prose and
|
||||
/// whether it can run now.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CommandsParams {}
|
||||
|
||||
/// One command of `engine.commands`' list.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CommandEntry {
|
||||
/// The command's id, such as `solid.extrude`.
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// The toolbar tab it is on; empty when it is on none.
|
||||
pub tab: String,
|
||||
/// The panel of that tab.
|
||||
pub panel: String,
|
||||
pub icon: String,
|
||||
pub shortcut: Option<String>,
|
||||
/// The parameter documentation, as prose (`engine.describe` has the schemas).
|
||||
pub params: String,
|
||||
/// Whether it can run in the session's state now.
|
||||
pub enabled: bool,
|
||||
/// Why it can't; `null` when it can.
|
||||
pub disabled_reason: Option<String>,
|
||||
/// For a command that is planned and not built yet: what is planned.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub planned: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::cmd::CommandInfo> for CommandEntry {
|
||||
fn from(c: crate::cmd::CommandInfo) -> Self {
|
||||
CommandEntry {
|
||||
id: c.id.into(),
|
||||
label: c.label.into(),
|
||||
tab: c.tab.into(),
|
||||
panel: c.panel.into(),
|
||||
icon: c.icon.into(),
|
||||
shortcut: c.shortcut.map(Into::into),
|
||||
params: c.params.into(),
|
||||
enabled: c.enabled,
|
||||
disabled_reason: c.disabled_reason,
|
||||
planned: c.planned.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `material.list` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MaterialListParams {}
|
||||
|
||||
/// What `material.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MaterialListResult {
|
||||
/// The built-in materials, then the design's own.
|
||||
pub materials: Vec<ListedMaterial>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A material in the list.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ListedMaterial {
|
||||
/// A built-in material.
|
||||
BuiltIn {
|
||||
name: String,
|
||||
/// Its density (g/cm³).
|
||||
density_g_cm3: f64,
|
||||
},
|
||||
/// One this design defines, with `user` true, its look and its physical card when it has
|
||||
/// one.
|
||||
Design(Box<MaterialReply>),
|
||||
}
|
||||
|
||||
/// A name as `material.assign` reads `material`: text, with the handler's message for anything
|
||||
/// else, `null` included.
|
||||
fn material_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole("`material` must be a material name"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give bodies a physical material (a built-in one, or one made with `material.create`), which
|
||||
/// their mass and density come from. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = bodies_and_material_required)]
|
||||
pub struct MaterialAssignParams {
|
||||
// Optional in the type and required in the schema (`bodies_and_material_required`).
|
||||
/// The bodies, by name: one or a list, at least one, each an existing body.
|
||||
#[serde(default, deserialize_with = "body_names", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "material_bodies_schema")]
|
||||
pub bodies: Option<Vec<String>>,
|
||||
/// The material's name (Steel, Aluminum, ABS Plastic, …, or one made with `material.create`;
|
||||
/// `material.list` lists them). Letter case and spaces at either end don't matter.
|
||||
#[serde(default, deserialize_with = "material_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "material_schema")]
|
||||
pub material: Option<String>,
|
||||
}
|
||||
|
||||
fn material_bodies_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": params::MAX_STRING_LIST}]})
|
||||
}
|
||||
|
||||
fn material_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string"})
|
||||
}
|
||||
|
||||
/// What `material.assign` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MaterialAssignResult {
|
||||
/// The bodies that got the material.
|
||||
pub bodies: Vec<String>,
|
||||
/// The material's name as the design has it.
|
||||
pub material: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A sketch by its feature id (a number) or its name, as `sketch.inspect` reads `sketch`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SketchTarget {
|
||||
Id(u64),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchTarget {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => n.as_u64().map(SketchTarget::Id),
|
||||
Value::String(s) => Some(SketchTarget::Name(s)),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| D::Error::custom(whole("`sketch` must be an id or a name")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchTarget {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchTarget".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A sketch's feature id (a number, never a name) or its name.",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": u64::MAX}, {"type": "string"}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a sketch: its curves, points, constraints, profiles, links and 3D curves, with what is
|
||||
/// determined and what is not. Read-only.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SketchInspectParams {
|
||||
/// The sketch. Default (also `null`): the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.inspect` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchInspected {
|
||||
/// The sketch's feature id.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Where it lies, as saved.
|
||||
#[schemars(with = "Map<String, Value>")]
|
||||
pub plane: Value,
|
||||
pub status: SketchStatus,
|
||||
/// Its remaining degrees of freedom (0: fully constrained).
|
||||
pub dof: usize,
|
||||
pub fully_constrained: bool,
|
||||
/// The constraints still violated after a failed solve.
|
||||
pub failing: Vec<String>,
|
||||
pub curves: Vec<SketchCurve>,
|
||||
pub points: Vec<SketchPoint>,
|
||||
pub constraints: Vec<SketchConstraint>,
|
||||
pub profiles: Vec<SketchProfile>,
|
||||
/// Links to geometry outside the sketch (projections, intersections, includes), as saved.
|
||||
#[schemars(with = "Vec<Map<String, Value>>")]
|
||||
pub links: Value,
|
||||
/// Its 3D curves.
|
||||
pub wires: Vec<SketchWire>,
|
||||
/// Whether it is shown.
|
||||
pub visible: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A curve of a sketch: its id, what it is and how it is held.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchCurve {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub shape: CurveShape,
|
||||
pub construction: bool,
|
||||
/// Whether it is locked (fixed, or linked).
|
||||
pub fixed: bool,
|
||||
/// Whether everything that defines it is determined by constraints.
|
||||
pub fully_constrained: bool,
|
||||
/// For a linked curve: the link that owns it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
/// A curve's kind and its points, by id and (`_at`) by position `[x, y]` in the sketch's plane.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CurveShape {
|
||||
Line {
|
||||
start: String,
|
||||
end: String,
|
||||
start_at: Option<[f64; 2]>,
|
||||
end_at: Option<[f64; 2]>,
|
||||
},
|
||||
Circle {
|
||||
center: String,
|
||||
center_at: Option<[f64; 2]>,
|
||||
radius: f64,
|
||||
},
|
||||
Arc {
|
||||
center: String,
|
||||
start: String,
|
||||
end: String,
|
||||
center_at: Option<[f64; 2]>,
|
||||
start_at: Option<[f64; 2]>,
|
||||
end_at: Option<[f64; 2]>,
|
||||
radius: Option<f64>,
|
||||
},
|
||||
Ellipse {
|
||||
center: String,
|
||||
/// The end of the major axis.
|
||||
major: String,
|
||||
center_at: Option<[f64; 2]>,
|
||||
major_at: Option<[f64; 2]>,
|
||||
minor_radius: f64,
|
||||
},
|
||||
Spline {
|
||||
points: Vec<String>,
|
||||
points_at: Vec<Option<[f64; 2]>>,
|
||||
/// Whether `points` are control points of a B-spline, not fit points.
|
||||
control: bool,
|
||||
degree: u8,
|
||||
periodic: bool,
|
||||
/// A control spline with its own knots (a projected free-form edge).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
knots: Option<Vec<f64>>,
|
||||
/// Its weights, when it has any.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
weights: Option<Vec<f64>>,
|
||||
},
|
||||
Conic {
|
||||
start: String,
|
||||
end: String,
|
||||
apex: String,
|
||||
start_at: Option<[f64; 2]>,
|
||||
end_at: Option<[f64; 2]>,
|
||||
apex_at: Option<[f64; 2]>,
|
||||
rho: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A point of a sketch.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchPoint {
|
||||
pub id: String,
|
||||
/// Its position `[x, y]` in the sketch's plane (mm).
|
||||
pub at: [f64; 2],
|
||||
/// Where that is in the design (mm).
|
||||
pub world: [f64; 3],
|
||||
/// Whether it is locked (fixed, or linked).
|
||||
pub fixed: bool,
|
||||
/// For a linked point: the link that owns it; `null` otherwise.
|
||||
pub link: Option<String>,
|
||||
pub fully_constrained: bool,
|
||||
}
|
||||
|
||||
/// A constraint or dimension: its id, its kind's name and the kind's own fields (`type`, then
|
||||
/// the points and curves it ties, a value…), as saved.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchConstraint {
|
||||
pub id: String,
|
||||
/// The kind in short, such as `Horizontal`.
|
||||
pub name: String,
|
||||
/// For a dimension: the parameter that drives it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub param: Option<String>,
|
||||
/// With `param`: the parameter's expression; `null` when the design has no such parameter.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<String>")]
|
||||
pub expression: Option<Option<String>>,
|
||||
#[serde(flatten)]
|
||||
pub kind: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// A region the sketch closes.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchProfile {
|
||||
pub index: usize,
|
||||
pub area_mm2: f64,
|
||||
/// Its centroid `[x, y]` in the sketch's plane.
|
||||
pub centroid_sketch: [f64; 2],
|
||||
/// The curves of its outer loop, each once, in loop order.
|
||||
pub curves: Vec<String>,
|
||||
/// The curves of each hole's loop.
|
||||
pub holes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// A 3D curve a sketch carries.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchWire {
|
||||
pub id: String,
|
||||
/// How many points its polyline has.
|
||||
pub points: usize,
|
||||
pub start: Option<[f64; 3]>,
|
||||
pub end: Option<[f64; 3]>,
|
||||
/// For a linked curve: the link that owns it; `null` otherwise.
|
||||
pub link: Option<String>,
|
||||
/// For an exact circular arc: its geometry; `null` otherwise.
|
||||
pub arc: Option<WireArcInfo>,
|
||||
}
|
||||
|
||||
/// An exact circular arc of a 3D curve.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct WireArcInfo {
|
||||
pub center: [f64; 3],
|
||||
pub radius: f64,
|
||||
pub start: [f64; 3],
|
||||
pub end: [f64; 3],
|
||||
/// Its sweep (degrees).
|
||||
pub sweep_deg: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inspect_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{BodyListParams, DescribeParams, ModelEdges, ModelFaces, ModelThreads, ModelThreadsParams};
|
||||
use super::{
|
||||
BodyFeaturesParams, BodyListParams, CommandEntry, CommandsParams, DescribeParams, DocumentInspectParams, DocumentInspectResult,
|
||||
MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams, MeasureResult, ModelBodyFeatures, ModelEdges,
|
||||
ModelFaces, ModelThreads, ModelThreadsParams, SketchInspectParams, SketchInspected,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -10,6 +14,7 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DescribeParams>(), &["command"]);
|
||||
assert_keys(input_schema_for::<BodyListParams>(), &["body"]);
|
||||
assert_keys(input_schema_for::<ModelThreadsParams>(), &[]);
|
||||
assert_keys(input_schema_for::<BodyFeaturesParams>(), &["body"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -22,6 +27,7 @@ fn strict_keeps_todays_errors() {
|
||||
("model.edges", json!({"body": "Nope"})),
|
||||
("model.faces", json!({})),
|
||||
("model.faces", json!({"body": "Nope"})),
|
||||
("model.body_features", json!({"body": "Nope"})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -87,3 +93,460 @@ fn describe_returns_both_schemas() {
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DocumentInspectParams>(), &["measure", "sketch_counts"]);
|
||||
assert_keys(input_schema_for::<MeasureParams>(), &["bodies", "items", "names_only"]);
|
||||
assert_keys(input_schema_for::<CommandsParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MaterialListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MaterialAssignParams>(), &["bodies", "material"]);
|
||||
assert_keys(input_schema_for::<SketchInspectParams>(), &["sketch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_of_the_inspect_commands() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("material.assign", json!({})),
|
||||
("material.assign", json!({"bodies": ["A"]})),
|
||||
("material.assign", json!({"bodies": ["A"], "material": 4})),
|
||||
("material.assign", json!({"bodies": ["A"], "material": null})),
|
||||
("material.assign", json!({"bodies": ["A"], "material": ["Steel"]})),
|
||||
("material.assign", json!({"bodies": 5, "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": [5], "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": [], "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": null, "material": "Steel"})),
|
||||
("material.assign", json!({"material": "Steel"})),
|
||||
("material.assign", json!({"bodies": ["Nope"], "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": ["Nope"], "material": "Unobtainium"})),
|
||||
("inspect.measure", json!({"bodies": 5})),
|
||||
("inspect.measure", json!({"bodies": [5]})),
|
||||
("inspect.measure", json!({"bodies": ["Nope"]})),
|
||||
("inspect.measure", json!({"items": null})),
|
||||
("inspect.measure", json!({"items": "x"})),
|
||||
("inspect.measure", json!({"items": {}})),
|
||||
("inspect.measure", json!({"items": []})),
|
||||
("inspect.measure", json!({"items": "x", "bodies": 5})),
|
||||
("inspect.measure", json!({"items": [1], "bodies": 5, "names_only": "yes"})),
|
||||
("inspect.measure", json!({"items": [{"type": "body", "name": "Nope"}]})),
|
||||
("sketch.inspect", json!({"sketch": true})),
|
||||
("sketch.inspect", json!({"sketch": -1})),
|
||||
("sketch.inspect", json!({"sketch": 1.5})),
|
||||
("sketch.inspect", json!({"sketch": [1]})),
|
||||
("sketch.inspect", json!({"sketch": {}})),
|
||||
("sketch.inspect", json!({"sketch": "Nope"})),
|
||||
("sketch.inspect", json!({"sketch": 99999})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Each type reads a key as its handler does: with `items` only `items` is read, without it
|
||||
/// `bodies` and `names_only`; `material.assign` leaves a missing or empty `bodies` and a missing
|
||||
/// `material` to the handler's own message; `sketch` is an id, a name or `null`.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handler_does() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
// inspect.measure: the keys read depend on the form.
|
||||
let pick = json!([{"type": "face", "body": "A", "point": [1, 2, 3]}]);
|
||||
assert_eq!(issues("inspect.measure", json!({})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": ["A"], "names_only": true})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": null, "names_only": null})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": pick})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": pick, "bodies": 5, "names_only": "yes"})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": 5})), ["`bodies` must be a list of names; it is 5"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": ["A", 7]})), ["`bodies` must list names; the entry 2 is 7"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"names_only": "yes"})), ["`names_only` must be true or false"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": null})), ["`items` must be a list of selections"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": "x", "bodies": 5})), ["`items` must be a list of selections"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": []})), ["measure one or two items"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": vec![json!({}); 1001]})), ["measure one or two items"]);
|
||||
// material.assign.
|
||||
assert_eq!(issues("material.assign", json!({"bodies": ["A"], "material": "Steel"})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": "A", "material": " steel "})), none);
|
||||
assert_eq!(issues("material.assign", json!({"material": "Steel"})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": [], "material": "Steel"})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": null})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": 5, "material": "Steel"})), ["`bodies` must be a list of names; it is 5"]);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": ["A"], "material": 4})), ["`material` must be a material name"]);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": ["A"], "material": null})), ["`material` must be a material name"]);
|
||||
// sketch.inspect.
|
||||
for ok in [json!({}), json!({"sketch": null}), json!({"sketch": 3}), json!({"sketch": "Base"}), json!({"sketch": "7"})] {
|
||||
assert_eq!(issues("sketch.inspect", ok.clone()), none, "{ok}");
|
||||
}
|
||||
for bad in [json!(true), json!(-1), json!(1.5), json!([1]), json!({"id": 1})] {
|
||||
assert_eq!(issues("sketch.inspect", json!({ "sketch": bad.clone() })), ["`sketch` must be an id or a name"], "{bad}");
|
||||
}
|
||||
// document.inspect's flags are plain booleans, `null` is left out.
|
||||
assert_eq!(issues("document.inspect", json!({"measure": true, "sketch_counts": null})), none);
|
||||
assert_eq!(issues("document.inspect", json!({"measure": "yes"})), ["`measure` must be true or false"]);
|
||||
// Neither list command takes a key.
|
||||
assert_eq!(issues("engine.commands", json!({"x": 1})), ["unknown key `x`"]);
|
||||
assert_eq!(issues("material.list", json!({"x": 1})), ["unknown key `x`"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: `material.assign` can't do without `bodies` and
|
||||
/// `material`, though its type leaves them to the handler's message; `inspect.measure` takes
|
||||
/// bodies or items.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<MaterialAssignParams>(),
|
||||
&[
|
||||
(json!({"bodies": ["A"], "material": "Steel"}), true),
|
||||
(json!({"bodies": "A", "material": "Steel"}), true),
|
||||
(json!({"material": "Steel"}), false),
|
||||
(json!({"bodies": ["A"]}), false),
|
||||
(json!({"bodies": [], "material": "Steel"}), false),
|
||||
(json!({"bodies": ["A"], "material": 4}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MeasureParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"bodies": ["A"], "names_only": true}), true),
|
||||
(json!({"bodies": "A"}), true),
|
||||
(json!({"bodies": null}), true),
|
||||
(json!({"items": [{"type": "body", "name": "A"}]}), true),
|
||||
(json!({"items": []}), false),
|
||||
(json!({"items": "x"}), false),
|
||||
(json!({"bodies": 5}), false),
|
||||
(json!({"names_only": "yes"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<SketchInspectParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"sketch": 3}), true),
|
||||
(json!({"sketch": "Base"}), true),
|
||||
(json!({"sketch": null}), true),
|
||||
(json!({"sketch": -1}), false),
|
||||
(json!({"sketch": true}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<DocumentInspectParams>(), &[(json!({"measure": true, "sketch_counts": false}), true), (json!({"measure": 1}), false)]);
|
||||
takes(input_schema_for::<CommandsParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A reply matches its output schema and, in Warn, carries `param_warnings` only when told to.
|
||||
fn matches<T: schemars::JsonSchema>(id: &str, reply: &Value, warned: bool) {
|
||||
let schema = output_schema_for::<T>();
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert_eq!(reply.get("param_warnings").is_some(), warned, "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// A design with a box, a cylinder, a sheet metal body, a sketch of every curve kind with a
|
||||
/// dimension, and a sketch that projects a face: returns the first sketch's feature id.
|
||||
fn design(s: &mut Session) -> u64 {
|
||||
run(s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"}));
|
||||
run(s, "solid.cylinder", json!({"radius": 4, "height": 20, "base": [40, 0, 0], "body_name": "Shaft"}));
|
||||
run(s, "material.create", json!({"name": "Alloy", "density_g_cm3": 2.5, "color": "#ff0000"}));
|
||||
run(
|
||||
s,
|
||||
"material.create",
|
||||
json!({"name": "Painted", "density_g_cm3": 1.5, "color": "#00ff00", "pbr": {"metallic": 0.5, "roughness": 0.5, "dielectric_ior": 1.5}}),
|
||||
);
|
||||
run(s, "sheet.manage_rules", json!({"name": "R1", "thickness": "1 mm", "bend_radius": "1 mm", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [60, 0], "p1": [100, 30]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(s, "sketch.create", json!({"plane": "XZ", "name": "Shapes"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 20]]}));
|
||||
run(s, "sketch.circle.center", json!({"center": [10, 10], "radius": 3}));
|
||||
run(s, "sketch.circle.center", json!({"center": [10, 10], "radius": 1}));
|
||||
run(s, "sketch.arc.three_point", json!({"start": [50, 0], "end": [60, 10], "through": [58, 3]}));
|
||||
run(s, "sketch.ellipse", json!({"center": [-30, 0], "major": [-20, 0], "minor_radius": 5}));
|
||||
run(s, "sketch.spline.fit_point", json!({"points": [[0, 40], [10, 50], [20, 40], [30, 50]]}));
|
||||
run(s, "sketch.spline.control_point", json!({"points": [[0, 60], [10, 70], [20, 60], [30, 70]]}));
|
||||
run(s, "sketch.conic", json!({"start": [0, 80], "end": [30, 80], "apex": [15, 100], "rho": 0.4}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["c1"]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
|
||||
run(s, "sketch.construction", json!({"curves": ["l2"]}));
|
||||
let id = run(s, "sketch.inspect", json!({}))["id"].as_u64().unwrap_or_default();
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
|
||||
run(s, "sketch.project", json!({"refs": [{"face": [5, 10, 30], "body": "Block"}]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
id
|
||||
}
|
||||
|
||||
/// Real calls of `document.inspect` in Warn, each form: the replies match the output schema
|
||||
/// without `param_warnings`, and say what the design has.
|
||||
#[test]
|
||||
fn document_inspect_replies_match_its_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
run(&mut s, "sketch.edit", json!({"sketch": shapes}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"measure": true}),
|
||||
json!({"sketch_counts": true}),
|
||||
json!({"measure": true, "sketch_counts": true}),
|
||||
json!({"measure": false, "sketch_counts": null}),
|
||||
] {
|
||||
let r = run(&mut s, "document.inspect", p.clone());
|
||||
matches::<DocumentInspectResult>("document.inspect", &r, false);
|
||||
assert_eq!(r["active_sketch"], json!(shapes), "{r}");
|
||||
assert_eq!(r["bodies"].as_array().map(Vec::len), Some(3), "{p}: {r}");
|
||||
assert_eq!(r["bodies"][0].get("area_mm2").is_some(), p["measure"] == true, "{p}: {r}");
|
||||
assert_eq!(r["bodies"][0].get("feature").is_some(), p["measure"] != true, "{p}: {r}");
|
||||
assert_eq!(r["sketches"][0].get("plane").is_some(), p["sketch_counts"] == true, "{p}: {r}");
|
||||
}
|
||||
let r = run(&mut s, "document.inspect", json!({"measure": true}));
|
||||
assert!(r["params"].as_array().is_some_and(|p| p.iter().any(|x| x["name"].is_string() && x["model"] == true)), "{r}");
|
||||
assert!(r["timeline"].as_array().is_some_and(|t| t.iter().any(|x| x["type"].is_string() && x["ms"].is_number())), "{r}");
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Shaft"], "visible": false}));
|
||||
let r = run(&mut s, "document.inspect", json!({}));
|
||||
matches::<DocumentInspectResult>("document.inspect", &r, false);
|
||||
assert_eq!(r["bodies"][1]["visible"], false, "{r}");
|
||||
// A body that a rolled-back later feature deletes says so.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.remove", json!({"bodies": ["Shaft"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Shapes"}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": shapes}));
|
||||
let r = run(&mut s, "document.inspect", json!({}));
|
||||
matches::<DocumentInspectResult>("document.inspect", &r, false);
|
||||
assert!(r["bodies"].as_array().is_some_and(|b| b.iter().any(|b| b["removed_later"] == true)), "{r}");
|
||||
}
|
||||
|
||||
/// `inspect.measure` in each form: bodies (with sheet metal and a material), names only, one
|
||||
/// item and two, in Warn without warnings. Everything it ignores with `items` is ignored.
|
||||
#[test]
|
||||
fn measure_replies_match_its_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Block"], "material": "Alloy"}));
|
||||
let id = "inspect.measure";
|
||||
let all = run(&mut s, id, json!({}));
|
||||
matches::<MeasureResult>(id, &all, false);
|
||||
assert_eq!(all["body_count"], 3, "{all}");
|
||||
assert_eq!(all["bodies"][0]["material"], "Alloy", "{all}");
|
||||
assert!((all["bodies"][0]["mass_g"].as_f64().unwrap_or_default() - 15.0).abs() < 1e-6, "{all}");
|
||||
assert!(all["bodies"].as_array().is_some_and(|b| b.iter().any(|b| b["sheet_metal"] == true)), "{all}");
|
||||
assert_eq!(all["total"]["open_bodies_skipped"], 0, "{all}");
|
||||
for p in [json!({"bodies": ["Block"]}), json!({"bodies": "Shaft", "names_only": false}), json!({"bodies": null})] {
|
||||
matches::<MeasureResult>(id, &run(&mut s, id, p), false);
|
||||
}
|
||||
let names = run(&mut s, id, json!({"names_only": true}));
|
||||
assert_eq!(names["bodies"][0], json!({"name": "Block", "definition": "Block"}), "{names}");
|
||||
matches::<MeasureResult>(id, &names, false);
|
||||
matches::<MeasureResult>(id, &run(&mut s, id, json!({"bodies": ["Block"], "names_only": true})), false);
|
||||
|
||||
let face = |body: &str, p: [i32; 3]| json!({"type": "face", "body": body, "point": p});
|
||||
let curve = |c: &str| json!({"type": "sketch_curve", "id": c, "sketch": shapes});
|
||||
for items in [
|
||||
json!([face("Block", [5, 10, 30])]),
|
||||
json!([face("Shaft", [44, 0, 10])]),
|
||||
json!([face("Block", [5, 10, 30]), face("Block", [5, 10, 0])]),
|
||||
json!([face("Block", [5, 10, 30]), face("Block", [10, 10, 15])]),
|
||||
json!([face("Shaft", [44, 0, 10]), face("Block", [5, 10, 30])]),
|
||||
json!([{"type": "edge", "body": "Block", "point": [5, 0, 30]}, {"type": "edge", "body": "Block", "point": [5, 20, 30]}]),
|
||||
json!([{"type": "body", "name": "Block"}]),
|
||||
json!([{"type": "body", "name": "Block"}, {"type": "body", "name": "Shaft"}]),
|
||||
json!([{"type": "vertex", "body": "Block", "point": [0, 0, 0]}, {"type": "vertex", "body": "Block", "point": [10, 20, 30]}]),
|
||||
json!([curve("c1")]),
|
||||
json!([curve("a1")]),
|
||||
json!([curve("l1")]),
|
||||
json!([curve("c1"), curve("c2")]),
|
||||
json!([curve("l1"), curve("l2")]),
|
||||
json!([curve("c1"), curve("l1")]),
|
||||
json!([curve("a1"), face("Block", [5, 10, 30])]),
|
||||
] {
|
||||
let r = run(&mut s, id, json!({ "items": items }));
|
||||
matches::<MeasureResult>(id, &r, false);
|
||||
assert!(r["items"].as_array().is_some_and(|i| i.len() == items.as_array().map_or(0, Vec::len)), "{r}");
|
||||
}
|
||||
let one = run(&mut s, id, json!({"items": [curve("c1")]}));
|
||||
assert!(one["diameter"] == 6.0 && one["circumference"].is_number() && one["center"].is_array(), "{one}");
|
||||
let arc = run(&mut s, id, json!({"items": [curve("a1")]}));
|
||||
assert!(arc["arc_length"].is_number() && arc["angle_deg"].is_number() && arc.get("circumference").is_none(), "{arc}");
|
||||
let two = run(&mut s, id, json!({"items": [face("Block", [5, 10, 30]), face("Block", [5, 10, 0])]}));
|
||||
assert!(two["distance_mm"] == 30.0 && two["min"] == 30.0 && two["measure"] == "min" && two["angle_deg"].is_number(), "{two}");
|
||||
let pair = run(&mut s, id, json!({"items": [curve("c1"), curve("c2")]}));
|
||||
assert!(pair["measure"] == "center" && pair["center_distance"].is_number(), "{pair}");
|
||||
// With `items`, `bodies` and `names_only` are not read at all.
|
||||
let r = run(&mut s, id, json!({"items": [face("Block", [5, 10, 30])], "bodies": 7, "names_only": "yes"}));
|
||||
matches::<MeasureResult>(id, &r, false);
|
||||
}
|
||||
|
||||
/// `sketch.inspect` of every curve kind, a dimension and a projected link, by id, by name and as
|
||||
/// the sketch being edited, in Warn without warnings.
|
||||
#[test]
|
||||
fn sketch_inspect_replies_match_its_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
let id = "sketch.inspect";
|
||||
for p in [json!({"sketch": shapes}), json!({"sketch": "Shapes"}), json!({"sketch": "Top"}), json!({"sketch": "Base"})] {
|
||||
matches::<SketchInspected>(id, &run(&mut s, id, p), false);
|
||||
}
|
||||
let r = run(&mut s, id, json!({"sketch": "Shapes"}));
|
||||
let kinds: Vec<&str> = r["curves"].as_array().into_iter().flatten().filter_map(|c| c["type"].as_str()).collect();
|
||||
for kind in ["line", "circle", "arc", "ellipse", "spline", "conic"] {
|
||||
assert!(kinds.contains(&kind), "{kind} in {kinds:?}");
|
||||
}
|
||||
assert!(
|
||||
r["constraints"].as_array().is_some_and(|c| c.iter().any(|c| c["param"].is_string() && c["expression"].is_string() && c["type"].is_string())),
|
||||
"{r}"
|
||||
);
|
||||
assert!(r["profiles"][0]["holes"].is_array() && r["points"][0]["link"].is_null(), "{r}");
|
||||
let top = run(&mut s, id, json!({"sketch": "Top"}));
|
||||
assert!(top["links"][0]["id"].is_string() && top["curves"][0]["link"].is_string(), "{top}");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Shapes"}));
|
||||
for p in [json!({}), json!({"sketch": null}), json!({"sketch": shapes.to_string()})] {
|
||||
let r = run(&mut s, id, p);
|
||||
matches::<SketchInspected>(id, &r, false);
|
||||
assert_eq!(r["name"], "Shapes", "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `material.assign` (a name, a list, the default material) and `material.list` (built-in
|
||||
/// materials and the design's, with and without a look) in Warn without warnings.
|
||||
#[test]
|
||||
fn material_replies_match_their_schemas() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
design(&mut s);
|
||||
let id = "material.assign";
|
||||
let r = run(&mut s, id, json!({"bodies": ["Block"], "material": "alloy"}));
|
||||
assert_eq!(r, json!({"bodies": ["Block"], "material": "Alloy"}));
|
||||
matches::<MaterialAssignResult>(id, &r, false);
|
||||
let r = run(&mut s, id, json!({"bodies": "Shaft", "material": " steel "}));
|
||||
assert_eq!(r["material"], "Steel", "{r}");
|
||||
matches::<MaterialAssignResult>(id, &r, false);
|
||||
matches::<MaterialAssignResult>(id, &run(&mut s, id, json!({"bodies": ["Block", "Shaft"], "material": "Painted"})), false);
|
||||
matches::<MaterialAssignResult>(id, &run(&mut s, id, json!({"bodies": ["Block"], "material": "Steel"})), false);
|
||||
|
||||
let listed = run(&mut s, "material.list", json!({}));
|
||||
matches::<MaterialListResult>("material.list", &listed, false);
|
||||
let materials = listed["materials"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(materials[0].as_object().map(|m| m.len()), Some(2), "{listed}");
|
||||
let mine = |name: &str| materials.iter().find(|m| m["name"] == name).cloned().unwrap_or_default();
|
||||
assert!(mine("Alloy")["user"] == true && mine("Alloy")["appearance"]["color"] == "#ff0000", "{listed}");
|
||||
assert!(mine("Painted")["appearance"]["pbr"]["metallic"] == 0.5, "{listed}");
|
||||
assert!(mine("Steel").get("user").is_none() && mine("Steel").get("appearance").is_none(), "{listed}");
|
||||
run(&mut s, "material.create", json!({"name": "Bare", "density_g_cm3": 1.0}));
|
||||
let listed = run(&mut s, "material.list", json!({}));
|
||||
matches::<MaterialListResult>("material.list", &listed, false);
|
||||
let bare = listed["materials"].as_array().into_iter().flatten().find(|m| m["name"] == "Bare").cloned().unwrap_or_default();
|
||||
assert!(bare["appearance"].is_null() && bare["user"] == true, "{listed}");
|
||||
}
|
||||
|
||||
/// `engine.commands` replies with a list, which carries no `param_warnings`: Strict is where a
|
||||
/// stray key is refused. The list has every command, and each entry matches its schema.
|
||||
#[test]
|
||||
fn the_command_list_matches_its_schema() {
|
||||
let mut strict = Session { param_check: ParamCheck::Strict, ..Session::default() };
|
||||
let list = run(&mut strict, "engine.commands", json!({}));
|
||||
let schema = output_schema_for::<Vec<CommandEntry>>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &list), "{list}");
|
||||
assert_eq!(list.as_array().map(Vec::len), Some(crate::command_specs().len()));
|
||||
assert!(list.as_array().is_some_and(|l| l.iter().any(|c| c["id"] == "engine.commands" && c["enabled"] == true && c["shortcut"].is_null())));
|
||||
assert!(list.as_array().is_some_and(|l| l.iter().any(|c| c["id"] == "inspect.measure" && c["shortcut"] == "I")));
|
||||
let error = strict.execute("engine.commands", &json!({"lst": 1})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("unknown key `lst`"), "{error}");
|
||||
let mut warn = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
assert!(run(&mut warn, "engine.commands", json!({"lst": 1})).is_array());
|
||||
}
|
||||
|
||||
/// A misspelt key runs in Warn and is reported, with a reply that still matches its schema.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
let schemas = |id: &str| match id {
|
||||
"document.inspect" => output_schema_for::<DocumentInspectResult>(),
|
||||
"inspect.measure" => output_schema_for::<MeasureResult>(),
|
||||
"material.assign" => output_schema_for::<MaterialAssignResult>(),
|
||||
"material.list" => output_schema_for::<MaterialListResult>(),
|
||||
_ => output_schema_for::<SketchInspected>(),
|
||||
};
|
||||
for (id, p, suggestion) in [
|
||||
("document.inspect", json!({"mesure": true}), "measure"),
|
||||
("document.inspect", json!({"sketch_count": true}), "sketch_counts"),
|
||||
("inspect.measure", json!({"bodes": ["Block"]}), "bodies"),
|
||||
("inspect.measure", json!({"name_only": true}), "names_only"),
|
||||
("material.assign", json!({"bodies": ["Block"], "materal": "Steel", "material": "Steel"}), "material"),
|
||||
("material.list", json!({"materials": true}), ""),
|
||||
("sketch.inspect", json!({"sketch": shapes, "sketchs": 1}), "sketch"),
|
||||
] {
|
||||
let r = run(&mut s, id, p.clone());
|
||||
let warnings: Vec<&str> = r["param_warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
assert_eq!(warnings.len(), 1, "{id} {p}: {r}");
|
||||
assert!(warnings[0].starts_with("unknown key `") && warnings[0].contains(suggestion), "{id}: {warnings:?}");
|
||||
let schema = schemas(id);
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shapes the real designs above don't reach: a body whose measurement failed (a sheet metal
|
||||
/// one carries its corrected counts) and the null coordinates of two planes that meet.
|
||||
#[test]
|
||||
fn rare_reply_shapes_serialize_as_before_and_match_the_schema() {
|
||||
use super::{BodyFailure, BodyMeasurement, BodyOutcome, Measured, MeasuredItems};
|
||||
let failed = BodyMeasurement {
|
||||
name: "B".into(),
|
||||
outcome: BodyOutcome::Failed(BodyFailure { error: "boom".into(), faces: Some(2), edges: None, vertices: None }),
|
||||
};
|
||||
assert_eq!(serde_json::to_value(&failed).unwrap_or_default(), json!({"name": "B", "error": "boom", "faces": 2}));
|
||||
let items = MeasuredItems { from: Some([f64::NAN, 0.0, 0.0]), distance_mm: Some(0.0), ..MeasuredItems::default() };
|
||||
let reply = serde_json::to_value(MeasureResult { measured: Measured::Items(Box::new(items)), param_warnings: Vec::new() }).unwrap_or_default();
|
||||
assert_eq!(reply["from"], json!([null, 0.0, 0.0]));
|
||||
let schema = output_schema_for::<MeasureResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
|
||||
}
|
||||
|
||||
/// #11945: each body lists the features that built it, in timeline order: its own box, the fillet
|
||||
/// that changed it, and the cutting box a Combine consumed into it with that Combine. Another body
|
||||
/// keeps only its own. Rolled back before the Combine, neither it nor the tool's box are listed.
|
||||
#[test]
|
||||
fn body_features_list_what_built_each_body() {
|
||||
let schema = output_schema_for::<ModelBodyFeatures>();
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let id = |r: Value| r["feature"].as_u64().unwrap_or_else(|| panic!("{r}"));
|
||||
let a = id(run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "A"})));
|
||||
let b =
|
||||
id(run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "operation": "new", "body_name": "B"})));
|
||||
let fillet = id(run(&mut s, "solid.fillet", json!({"body": "A", "edges": [[10, 0, 10]], "radius": 1})));
|
||||
let tool =
|
||||
id(run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 20, "corner": [5, 5, -5], "operation": "new", "body_name": "T"})));
|
||||
let combine = id(run(&mut s, "solid.combine", json!({"target": "A", "tools": ["T"], "operation": "cut"})));
|
||||
|
||||
let listed = |r: &Value, body: &str| -> Vec<u64> {
|
||||
let entry = r["bodies"].as_array().into_iter().flatten().find(|e| e["body"] == body).unwrap_or_else(|| panic!("no {body} in {r}"));
|
||||
entry["features"].as_array().into_iter().flatten().filter_map(|f| f["id"].as_u64()).collect()
|
||||
};
|
||||
let r = run(&mut s, "model.body_features", json!({}));
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
assert_eq!(listed(&r, "A"), vec![a, fillet, tool, combine], "{r}");
|
||||
assert_eq!(listed(&r, "B"), vec![b], "{r}");
|
||||
assert_eq!(r["bodies"].as_array().map(Vec::len), Some(2), "the consumed tool is gone: {r}");
|
||||
assert_eq!(r["bodies"][0]["features"][1]["name"], s.doc.feature(fillet).map(|f| f.name.clone()).unwrap_or_default().as_str());
|
||||
|
||||
// One body, and the Session data the browser reads agree.
|
||||
let one = run(&mut s, "model.body_features", json!({"body": "B"}));
|
||||
assert_eq!((one["bodies"].as_array().map(Vec::len), listed(&one, "B")), (Some(1), vec![b]), "{one}");
|
||||
assert_eq!(s.body_features().get("A"), Some(&vec![a, fillet, tool, combine]));
|
||||
|
||||
run(&mut s, "timeline.rollback", json!({"position": 3}));
|
||||
assert_eq!(s.body_features().get("A"), Some(&vec![a, fillet]));
|
||||
assert_eq!(s.body_features().get("B"), Some(&vec![b]));
|
||||
|
||||
// A misspelt key warns; an unknown body is refused.
|
||||
let warned = run(&mut s, "model.body_features", json!({"bdy": "A"}));
|
||||
assert!(warned.get("param_warnings").is_some(), "{warned}");
|
||||
assert!(s.execute("model.body_features", &json!({"body": "Nope"})).is_err());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,707 @@
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::joints::JointKind;
|
||||
|
||||
use super::{EditJointParams, EditJointResult};
|
||||
use super::{
|
||||
AsBuiltJointParams, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams, DriveJointResult, EditJointParams,
|
||||
InspectJointParams, InspectJointResult, JointAddedResult, JointKindName, JointLimitsParams, ListJointsParams, ListJointsResult,
|
||||
SolveJointsParams, SolveResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{input_schema_for, output_schema_for};
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<EditJointParams>(), &["a", "angle", "b", "flip", "joint", "name", "offset", "quiet", "suppressed", "type"]);
|
||||
assert_keys(
|
||||
input_schema_for::<CreateJointParams>(),
|
||||
&["a", "angle", "b", "flip", "limits", "name", "offset", "quiet", "type", "value", "values"],
|
||||
);
|
||||
assert_keys(input_schema_for::<AsBuiltJointParams>(), &["a", "at", "b", "limits", "name", "quiet", "type", "value", "values"]);
|
||||
assert_keys(input_schema_for::<DriveJointParams>(), &["continuous", "index", "joint", "quiet", "value", "values"]);
|
||||
assert_keys(input_schema_for::<JointLimitsParams>(), &["index", "joint", "max", "min", "quiet"]);
|
||||
assert_keys(input_schema_for::<DeleteJointParams>(), &["joint", "link", "origin", "quiet"]);
|
||||
assert_keys(input_schema_for::<ListJointsParams>(), &["quiet"]);
|
||||
assert_keys(input_schema_for::<InspectJointParams>(), &["joint"]);
|
||||
assert_keys(input_schema_for::<SolveJointsParams>(), &["quiet"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<EditJointResult>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.execute("component.create", &json!({"name": "B", "grounded": false})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.execute("joint.create", &json!({"type": "rigid", "name": "Stack", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}}))
|
||||
.unwrap_or_else(|e| panic!("{e}"));
|
||||
let full = s.execute("joint.edit", &json!({"joint": "Stack", "flip": true})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(conforms(schema, schema, &full), "{full} doesn't match {schema}");
|
||||
let quiet = s.execute("joint.edit", &json!({"joint": "Stack", "quiet": true})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(quiet.get("dof").is_none() && conforms(schema, schema, &quiet), "{quiet}");
|
||||
fn the_commands_publish_their_schemas() {
|
||||
for id in ["joint.create", "joint.as_built", "joint.drive", "joint.limits", "joint.delete", "joint.list", "joint.inspect", "joint.solve"] {
|
||||
let spec = crate::cmd::find_command(id).unwrap_or_else(|| panic!("{id}"));
|
||||
assert!(spec.contract.is_some() && spec.output.is_some(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The replies name a joint's type as the document does.
|
||||
#[test]
|
||||
fn every_joint_type_has_its_reply_name() {
|
||||
let names = output_schema_for::<JointKindName>();
|
||||
let names: Vec<&str> = names.as_value()["oneOf"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(names.as_value()["enum"].as_array().into_iter().flatten())
|
||||
.filter_map(|v| v.get("const").or(Some(v)).and_then(Value::as_str))
|
||||
.collect();
|
||||
for spelling in [
|
||||
"rigid",
|
||||
"revolute",
|
||||
"slider",
|
||||
"cylindrical",
|
||||
"pin_slot",
|
||||
"planar",
|
||||
"ball",
|
||||
"parallel",
|
||||
"perpendicular",
|
||||
"angle",
|
||||
"point_on_plane",
|
||||
"point_on_line",
|
||||
"line_on_plane",
|
||||
] {
|
||||
let kind = JointKind::parse(spelling).unwrap_or_else(|| panic!("{spelling}"));
|
||||
assert_eq!(serde_json::to_value(kind).ok(), Some(json!(spelling)));
|
||||
assert!(names.contains(&spelling), "{spelling} in {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let at = |o: u64, x: f64| json!({"occurrence": o, "point": [x, 0, 0]});
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("joint.create", json!({})),
|
||||
("joint.create", json!({"type": "nope"})),
|
||||
("joint.create", json!({"type": 5})),
|
||||
("joint.create", json!({"type": null})),
|
||||
("joint.create", json!({"type": "rigid"})),
|
||||
("joint.create", json!({"type": "rigid", "a": 5, "b": at(0, 1.0)})),
|
||||
("joint.create", json!({"type": "rigid", "a": null, "b": at(0, 1.0)})),
|
||||
("joint.create", json!({"type": "rigid", "a": {}, "b": at(0, 1.0)})),
|
||||
("joint.create", json!({"type": "rigid", "a": {"frame": 1}, "b": at(0, 1.0)})),
|
||||
("joint.create", json!({"type": "rigid", "a": {"face": [1, 2]}, "b": at(0, 1.0)})),
|
||||
("joint.create", json!({"type": "rigid", "a": at(0, 0.0)})),
|
||||
("joint.create", json!({"type": "rigid", "a": at(0, 0.0), "b": at(0, 1.0), "offset": true})),
|
||||
("joint.create", json!({"type": "rigid", "a": at(0, 0.0), "b": at(0, 1.0), "offset": null})),
|
||||
("joint.create", json!({"type": "rigid", "a": at(0, 0.0), "b": at(0, 1.0), "angle": [1]})),
|
||||
("joint.create", json!({"type": "revolute", "a": at(0, 0.0), "b": at(0, 1.0), "values": [true]})),
|
||||
("joint.create", json!({"type": "revolute", "a": at(0, 0.0), "b": at(0, 1.0), "value": {}})),
|
||||
("joint.create", json!({"type": "revolute", "a": at(0, 0.0), "b": at(0, 1.0), "values": [1e12]})),
|
||||
("joint.create", json!({"type": "revolute", "a": at(0, 0.0), "b": at(0, 1.0), "limits": [[true, 1]]})),
|
||||
("joint.create", json!({"type": "revolute", "a": at(0, 0.0), "b": at(0, 1.0), "limits": [[0, 1e12]]})),
|
||||
("joint.as_built", json!({})),
|
||||
("joint.as_built", json!({"type": "rigid"})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": true, "b": 0})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": 0, "b": null})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": [1], "b": 0})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": 0, "b": 0, "at": null})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": 0, "b": 0, "at": 3})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": 0, "b": 0, "at": {"frame": {}}})),
|
||||
("joint.as_built", json!({"type": "revolute", "a": 0, "b": 0, "limits": [[true, 1]]})),
|
||||
("joint.drive", json!({})),
|
||||
("joint.drive", json!({"joint": true})),
|
||||
("joint.drive", json!({"joint": null})),
|
||||
("joint.drive", json!({"joint": [1]})),
|
||||
("joint.limits", json!({})),
|
||||
("joint.limits", json!({"joint": {}})),
|
||||
("joint.delete", json!({})),
|
||||
("joint.delete", json!({"joint": true})),
|
||||
("joint.delete", json!({"joint": null})),
|
||||
("joint.delete", json!({"origin": "nothing"})),
|
||||
("joint.delete", json!({"link": 4})),
|
||||
("joint.delete", json!({"link": null})),
|
||||
("joint.inspect", json!({"joint": true})),
|
||||
("joint.inspect", json!({"joint": [1]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A design with a grounded plate and a hinged leaf, a joint origin, and a third part joined as
|
||||
/// built, for the calls that need joints to fail on.
|
||||
fn hinged(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
for name in ["LeafA", "LeafB"] {
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
let made = run("component.create", json!({"name": name, "grounded": false}));
|
||||
run("solid.box", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
|
||||
run("solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
|
||||
if name == "LeafA" {
|
||||
run("occurrence.ground", json!({"occurrence": made["occurrence"], "grounded": true}));
|
||||
}
|
||||
}
|
||||
run(
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]},
|
||||
"flip": true, "name": "Hinge", "limits": [[-90, 90]]}),
|
||||
);
|
||||
run("joint.origin", json!({"occurrence": "LeafA", "face": [20, 15, 3], "name": "Top"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// What the handlers refuse, with a joint in the design to refuse it for: strict says the same.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_with_joints() {
|
||||
let cases = [
|
||||
("joint.drive", json!({"joint": "Hinge"})),
|
||||
("joint.drive", json!({"joint": "Hinge", "value": true})),
|
||||
("joint.drive", json!({"joint": "Hinge", "values": [true]})),
|
||||
("joint.drive", json!({"joint": "Hinge", "values": [1e12]})),
|
||||
("joint.drive", json!({"joint": "Hinge", "index": null, "value": 5})),
|
||||
("joint.drive", json!({"joint": "Hinge", "index": "0", "value": 5})),
|
||||
("joint.drive", json!({"joint": "Hinge", "index": -1, "value": 5})),
|
||||
("joint.drive", json!({"joint": "Hinge", "index": 0, "values": [5]})),
|
||||
("joint.drive", json!({"joint": "Hinge", "index": 0, "values": null, "value": 5})),
|
||||
("joint.drive", json!({"joint": "Hinge", "index": 0, "value": null})),
|
||||
("joint.drive", json!({"joint": "Hinge", "index": 0, "value": "1 +"})),
|
||||
("joint.limits", json!({"joint": "Hinge", "min": 1})),
|
||||
("joint.limits", json!({"joint": "Hinge", "max": 1})),
|
||||
("joint.limits", json!({"joint": "Hinge", "min": null, "max": 1})),
|
||||
("joint.limits", json!({"joint": "Hinge", "min": 1, "max": true})),
|
||||
("joint.limits", json!({"joint": "Hinge", "min": true, "max": 1})),
|
||||
("joint.limits", json!({"joint": "Hinge", "min": 1e12, "max": 1})),
|
||||
("joint.limits", json!({"joint": "Hinge", "index": 3, "min": 1, "max": 2})),
|
||||
("joint.delete", json!({"joint": "Hinge", "origin": "Missing"})),
|
||||
("joint.delete", json!({"link": 7, "joint": "Hinge"})),
|
||||
("joint.create", json!({"type": "rigid", "a": "Top", "b": "Top"})),
|
||||
("joint.create", json!({"type": "rigid", "a": "Missing", "b": "Top"})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": "LeafA", "b": "LeafA"})),
|
||||
("joint.as_built", json!({"type": "rigid", "a": "LeafA", "b": "Missing"})),
|
||||
("joint.inspect", json!({"joint": "Missing"})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let off = hinged(ParamCheck::Off).execute(id, &p).err().map(|e| e.to_string());
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(hinged(ParamCheck::Strict).execute(id, &p).err().map(|e| e.to_string()), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn issues(id: &str, p: Value) -> Vec<String> {
|
||||
crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect()
|
||||
}
|
||||
|
||||
/// The types read each key as its handler does, and in the cases the handler reads it.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let none = Vec::<String>::new;
|
||||
let sides = json!({"a": {"occurrence": 0, "point": [0, 0, 0]}, "b": "Origin"});
|
||||
let create = |extra: Value| {
|
||||
let mut p = json!({"type": "revolute"});
|
||||
p.as_object_mut().into_iter().for_each(|o| {
|
||||
o.extend(sides.as_object().cloned().unwrap_or_default());
|
||||
o.extend(extra.as_object().cloned().unwrap_or_default());
|
||||
});
|
||||
issues("joint.create", p)
|
||||
};
|
||||
// The forms of a side, and the key left out, which the handler refuses with its own message.
|
||||
assert_eq!(create(json!({})), none());
|
||||
for side in [
|
||||
json!("Name"),
|
||||
json!({"face": "F1"}),
|
||||
json!({"circle": "F1", "occurrence": "A"}),
|
||||
json!({"face": [0, 0, 0]}),
|
||||
json!({"edge": {"x": 0, "y": 0, "z": 0}, "z": [0, 0, 1]}),
|
||||
json!({"point": [1, 2, 3], "x": [1, 0, 0]}),
|
||||
json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
] {
|
||||
assert_eq!(create(json!({"a": side})), none(), "{side}");
|
||||
}
|
||||
assert_eq!(issues("joint.create", json!({"type": "rigid"})), none());
|
||||
let why = "a joint origin needs face, circle, edge, point or frame [x, y, z], or a face's name";
|
||||
for side in [json!(5), json!(null), json!([1, 2, 3]), json!({}), json!({"face": [1, 2]}), json!({"face": 7})] {
|
||||
assert_eq!(create(json!({"a": side})), [why], "{side}");
|
||||
}
|
||||
assert_eq!(create(json!({"b": {"frame": {"z": [0, 0, 1]}}})), ["`frame` needs origin and z"]);
|
||||
// The type: any spelling `JointKind::parse` takes.
|
||||
for t in ["Revolute", "pin-slot", "POINT ON PLANE", "rotate", "square"] {
|
||||
assert_eq!(issues("joint.create", json!({"type": t, "a": "A", "b": "B"})), none(), "{t}");
|
||||
}
|
||||
assert_eq!(issues("joint.create", json!({"type": "hinge", "a": "A", "b": "B"})).len(), 1);
|
||||
// `value` is read only when `values` is not a list; `values` and `limits` only as lists.
|
||||
assert_eq!(create(json!({"values": [1, "w / 2"], "value": true})), none());
|
||||
assert_eq!(create(json!({"value": true})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(create(json!({"value": "5 mm"})), none());
|
||||
assert_eq!(create(json!({"values": null, "value": 4})), none());
|
||||
assert_eq!(create(json!({"values": [true]})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(create(json!({"values": [1e12]})), ["a joint value must be a number between -1e9 and 1e9"]);
|
||||
assert_eq!(create(json!({"values": 5})), ["`values` must be a list of joint values"]);
|
||||
for limits in [json!([[0, 90]]), json!([null, ["-1 mm", "w"]]), json!([[1]]), json!([[]]), json!([[0, 1, true]]), json!(null), json!([])] {
|
||||
assert_eq!(create(json!({"limits": limits})), none(), "{limits}");
|
||||
}
|
||||
for limits in [json!([[true, 1]]), json!([[0, {}]]), json!([5]), json!({"a": 1}), json!("x")] {
|
||||
assert_eq!(create(json!({"limits": limits})).len(), 1, "{limits}");
|
||||
}
|
||||
// Numbers: `offset` and `angle` read `null` as a value, the flags and the name as absent.
|
||||
assert_eq!(create(json!({"offset": "w", "angle": 30, "flip": true, "name": "J", "quiet": false})), none());
|
||||
assert_eq!(create(json!({"offset": null})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(create(json!({"angle": {}})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(create(json!({"flip": null, "name": null, "quiet": null})), none());
|
||||
assert_eq!(create(json!({"flip": "yes"})), ["`flip` must be true or false"]);
|
||||
assert_eq!(create(json!({"name": 3})), ["`name` must be text"]);
|
||||
assert_eq!(create(json!({"quiet": 1})), ["`quiet` must be true or false"]);
|
||||
assert_eq!(create(json!({"nme": "J"})), ["unknown key `nme`; did you mean `name`?"]);
|
||||
|
||||
// joint.as_built: occurrences by id or name, `at` a joint origin, `values` only by being there.
|
||||
let built = |extra: Value| {
|
||||
let mut p = json!({"type": "rigid", "a": 0, "b": "Part"});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.as_built", p)
|
||||
};
|
||||
assert_eq!(built(json!({})), none());
|
||||
assert_eq!(built(json!({"at": {"face": "F"}, "name": "N", "limits": [[0, 1]], "quiet": true, "values": "anything", "value": {"x": 1}})), none());
|
||||
assert_eq!(built(json!({"a": true})), ["an occurrence must be an id or a name"]);
|
||||
assert_eq!(built(json!({"b": null})), ["an occurrence must be an id or a name"]);
|
||||
assert_eq!(built(json!({"at": null})), [why]);
|
||||
assert_eq!(built(json!({"at": []})), [why]);
|
||||
assert_eq!(issues("joint.as_built", json!({"type": "rigid"})), none());
|
||||
|
||||
// joint.drive: one coordinate with `index`, else `value` or a list of `values`.
|
||||
let drive = |p: Value| {
|
||||
let mut all = json!({"joint": "Hinge"});
|
||||
all.as_object_mut().into_iter().for_each(|o| o.extend(p.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.drive", all)
|
||||
};
|
||||
assert_eq!(drive(json!({"value": 45})), none());
|
||||
assert_eq!(drive(json!({"value": "pi rad", "quiet": true, "continuous": true})), none());
|
||||
assert_eq!(drive(json!({"values": [10, "w"]})), none());
|
||||
assert_eq!(drive(json!({"values": [10], "value": true})), none());
|
||||
assert_eq!(drive(json!({"values": null, "value": 5})), none());
|
||||
assert_eq!(drive(json!({})), none());
|
||||
assert_eq!(drive(json!({"index": 1, "value": 5})), none());
|
||||
assert_eq!(drive(json!({"index": 1, "value": 5, "values": [1]})), ["index goes with value, not values"]);
|
||||
assert_eq!(drive(json!({"index": 1, "values": null})), ["index goes with value, not values"]);
|
||||
assert_eq!(drive(json!({"index": 1})), none());
|
||||
assert_eq!(drive(json!({"index": "1", "value": 5})), ["index is outside this joint's coordinates"]);
|
||||
assert_eq!(drive(json!({"index": null, "value": 5})), ["index is outside this joint's coordinates"]);
|
||||
assert_eq!(drive(json!({"index": -1, "value": 5})), ["index is outside this joint's coordinates"]);
|
||||
assert_eq!(drive(json!({"index": 0, "value": true})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(drive(json!({"value": null})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(drive(json!({"values": [null]})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(drive(json!({"values": "x"})), ["`values` must be a list of joint values"]);
|
||||
assert_eq!(issues("joint.drive", json!({"value": 1})), none());
|
||||
assert_eq!(issues("joint.drive", json!({"joint": null, "value": 1})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(issues("joint.drive", json!({"joint": 3, "value": 1})), none());
|
||||
|
||||
// joint.limits: both or neither, the minimum read first; `index` a number.
|
||||
let limits = |p: Value| {
|
||||
let mut all = json!({"joint": 2});
|
||||
all.as_object_mut().into_iter().for_each(|o| o.extend(p.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.limits", all)
|
||||
};
|
||||
assert_eq!(limits(json!({})), none());
|
||||
assert_eq!(limits(json!({"min": -90, "max": "w / 2", "index": 0, "quiet": true})), none());
|
||||
assert_eq!(limits(json!({"index": -3.5})), none());
|
||||
assert_eq!(limits(json!({"min": 1})), ["give both `min` and `max`, or neither to clear"]);
|
||||
assert_eq!(limits(json!({"max": null})), ["give both `min` and `max`, or neither to clear"]);
|
||||
assert_eq!(limits(json!({"min": null, "max": 1})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(limits(json!({"min": 0, "max": true})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(limits(json!({"index": "0"})), ["`index` must be a number"]);
|
||||
assert_eq!(limits(json!({"index": null})), none());
|
||||
|
||||
// joint.delete: a joint origin's name, else a motion link, else a joint; each mode reads only
|
||||
// its own keys.
|
||||
assert_eq!(issues("joint.delete", json!({"origin": "Top"})), none());
|
||||
assert_eq!(issues("joint.delete", json!({"origin": "Top", "joint": true, "link": "x", "quiet": 5})), none());
|
||||
assert_eq!(issues("joint.delete", json!({"link": 1})), none());
|
||||
assert_eq!(issues("joint.delete", json!({"link": 1, "joint": [1], "quiet": "no"})), none());
|
||||
assert_eq!(issues("joint.delete", json!({"link": 1e12, "joint": "Hinge"})), none());
|
||||
assert_eq!(issues("joint.delete", json!({"joint": "Hinge", "quiet": true})), none());
|
||||
assert_eq!(issues("joint.delete", json!({"origin": null, "link": null, "joint": 4})), none());
|
||||
assert_eq!(issues("joint.delete", json!({})), none());
|
||||
assert_eq!(issues("joint.delete", json!({"joint": true})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(issues("joint.delete", json!({"link": 1e12, "joint": true})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(issues("joint.delete", json!({"joint": 1, "quiet": 5})), ["`quiet` must be true or false"]);
|
||||
assert_eq!(issues("joint.delete", json!({"origin": 5, "joint": 1})), ["`origin` must be the name of a joint origin"]);
|
||||
assert_eq!(issues("joint.delete", json!({"link": "1", "joint": 1})), ["`link` must be a motion link's index, a number"]);
|
||||
|
||||
// joint.list, joint.solve and joint.inspect.
|
||||
for id in ["joint.list", "joint.solve"] {
|
||||
assert_eq!(issues(id, json!({"quiet": true})), none());
|
||||
assert_eq!(issues(id, json!({"quiet": null})), none());
|
||||
assert_eq!(issues(id, json!({"quiet": "x"})), ["`quiet` must be true or false"]);
|
||||
assert_eq!(issues(id, json!({"quet": true})), ["unknown key `quet`; did you mean `quiet`?"]);
|
||||
}
|
||||
assert_eq!(issues("joint.inspect", json!({})), none());
|
||||
assert_eq!(issues("joint.inspect", json!({"joint": null})), none());
|
||||
assert_eq!(issues("joint.inspect", json!({"joint": 4})), none());
|
||||
assert_eq!(issues("joint.inspect", json!({"joint": "Hinge"})), none());
|
||||
assert_eq!(issues("joint.inspect", json!({"joint": false})), ["`joint` must be an id or a name"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: the keys a handler refuses without are required, the
|
||||
/// modes are shown, and the values a handler reads are the ones the schemas take.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
let sides = json!({"a": {"face": [0, 0, 0]}, "b": "Origin"});
|
||||
let mut full = sides.clone();
|
||||
full["type"] = json!("revolute");
|
||||
let with = |extra: Value| {
|
||||
let mut p = full.clone();
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
p
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<CreateJointParams>(),
|
||||
&[
|
||||
(full.clone(), true),
|
||||
(with(json!({"values": [1, "w"], "limits": [[0, 90], null], "flip": true, "offset": "w", "angle": 5, "name": "J", "quiet": true})), true),
|
||||
(with(json!({"value": 3})), true),
|
||||
(
|
||||
json!({"type": "revolute", "a": {"edge": {"x": 0, "y": 0, "z": 0}, "z": {"x": 0, "y": 0, "z": 1}}, "b": {"point": {"x": 1, "y": 2, "z": 3}}}),
|
||||
true,
|
||||
),
|
||||
(json!({"type": "revolute", "a": {"edge": {"x": 0, "y": 0}}, "b": "Origin"}), false),
|
||||
(json!({"a": {"face": [0, 0, 0]}, "b": "Origin"}), false),
|
||||
(json!({"type": "revolute", "b": "Origin"}), false),
|
||||
(json!({"type": "revolute", "a": 5, "b": "Origin"}), false),
|
||||
(with(json!({"values": 5})), false),
|
||||
// The schema can't spell the type's leniency; the type refuses "hinge" (see above).
|
||||
(with(json!({"type": 5})), false),
|
||||
(with(json!({"type": "Pin Slot"})), true),
|
||||
(with(json!({"type": "REVOLUTE"})), true),
|
||||
(with(json!({"offset": true})), false),
|
||||
(with(json!({"limits": [[true, 1]]})), false),
|
||||
(with(json!({"nme": "J"})), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<AsBuiltJointParams>(),
|
||||
&[
|
||||
(json!({"type": "rigid", "a": 0, "b": "Part"}), true),
|
||||
(json!({"type": "rigid", "a": "A", "b": 3, "at": {"circle": [0, 0, 0]}, "limits": [null], "values": [1]}), true),
|
||||
(json!({"type": "rigid", "a": 0}), false),
|
||||
(json!({"type": "rigid", "a": true, "b": 0}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DriveJointParams>(),
|
||||
&[
|
||||
(json!({"joint": "Hinge", "value": 45}), true),
|
||||
(json!({"joint": 1, "values": [1, "w"], "quiet": true, "continuous": true}), true),
|
||||
(json!({"joint": 1, "index": 1, "value": "w"}), true),
|
||||
(json!({"value": 45}), false),
|
||||
(json!({"joint": "Hinge"}), false),
|
||||
(json!({"joint": "Hinge", "index": 0, "values": [1]}), false),
|
||||
(json!({"joint": "Hinge", "index": -1, "value": 1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<JointLimitsParams>(),
|
||||
&[
|
||||
(json!({"joint": "Hinge"}), true),
|
||||
(json!({"joint": 1, "index": 1, "min": -1, "max": "w"}), true),
|
||||
(json!({"index": 1}), false),
|
||||
(json!({"joint": 1, "min": true, "max": 1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DeleteJointParams>(),
|
||||
&[
|
||||
(json!({"joint": "Hinge", "quiet": true}), true),
|
||||
(json!({"origin": "Top"}), true),
|
||||
(json!({"link": 2}), true),
|
||||
(json!({}), false),
|
||||
(json!({"quiet": true}), false),
|
||||
(json!({"joint": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<InspectJointParams>(),
|
||||
&[(json!({}), true), (json!({"joint": "J"}), true), (json!({"joint": 4}), true), (json!({"joint": true}), false)],
|
||||
);
|
||||
takes(input_schema_for::<ListJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
|
||||
takes(input_schema_for::<SolveJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// Hinge leaves, a slider, a screw, a joint origin and two loose parts joined to each other: each
|
||||
/// command, run in Warn in every form it takes, gives a reply that matches its output schema and
|
||||
/// carries no warning.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
for name in ["LeafA", "LeafB", "LeafC", "LeafD", "LeafE"] {
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
let made = run("component.create", json!({"name": name, "grounded": false}));
|
||||
run("solid.box", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
|
||||
run("solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
|
||||
if name == "LeafA" {
|
||||
run("occurrence.ground", json!({"occurrence": made["occurrence"], "grounded": true}));
|
||||
}
|
||||
}
|
||||
let hinge = |a: &str, b: &str, name: &str| json!({"type": "revolute", "a": {"occurrence": a, "circle": [1, 37, 4]}, "b": {"occurrence": b, "circle": [1, 37, 4]}, "flip": true, "name": name});
|
||||
|
||||
// joint.create, in the forms of a side, with a value held inside its limits, and as built.
|
||||
let made = run("joint.create", with_limits(hinge("LeafA", "LeafB", "Hinge"), json!([[-90, 90]])));
|
||||
matches(output_schema_for::<JointAddedResult>(), "joint.create", &made);
|
||||
assert!(made.get("warnings").is_none() && made["name"] == "Hinge" && made.get("dof").is_some(), "{made}");
|
||||
let quiet = run("joint.create", with_extra(hinge("LeafC", "LeafB", "Quiet"), json!({"quiet": true, "value": 30, "limits": [null]})));
|
||||
matches(output_schema_for::<JointAddedResult>(), "joint.create", &quiet);
|
||||
assert!(quiet.get("dof").is_none() && quiet.get("fixed").is_none(), "{quiet}");
|
||||
run("joint.delete", json!({"joint": "Quiet"}));
|
||||
let clamped = run("joint.create", with_extra(hinge("LeafD", "LeafE", "Clamped"), json!({"values": [100], "limits": [[-10, 10]]})));
|
||||
assert!(clamped["warnings"].as_array().is_some_and(|w| w.len() == 1), "{clamped}");
|
||||
matches(output_schema_for::<JointAddedResult>(), "joint.create", &clamped);
|
||||
run("joint.origin", json!({"occurrence": "LeafA", "face": [20, 15, 3], "name": "Top"}));
|
||||
let by_origin = run(
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": "Top", "b": {"occurrence": "LeafC", "face": [20, 15, 0]}, "name": "Stack", "offset": "0 mm", "angle": 0}),
|
||||
);
|
||||
matches(output_schema_for::<JointAddedResult>(), "joint.create", &by_origin);
|
||||
let built = run("joint.as_built", json!({"type": "slider", "a": "LeafB", "b": "LeafC", "name": "Slide", "limits": [[0, 10]], "quiet": false}));
|
||||
matches(output_schema_for::<JointAddedResult>(), "joint.as_built", &built);
|
||||
let at = run("joint.as_built", json!({"type": "rigid", "a": "LeafA", "b": "LeafD", "at": {"point": [0, 0, 0]}, "values": [1]}));
|
||||
matches(output_schema_for::<JointAddedResult>(), "joint.as_built", &at);
|
||||
run("joint.delete", json!({"joint": at["joint"]}));
|
||||
|
||||
// A screw ties Slide's travel to Hinge's turn: a coupling for the list.
|
||||
run("joint.screw", json!({"a": "Hinge", "b": "Slide", "pitch": 2}));
|
||||
|
||||
let list = run("joint.list", json!({}));
|
||||
matches(output_schema_for::<ListJointsResult>(), "joint.list", &list);
|
||||
assert!(list["links"].as_array().is_some_and(|l| l.len() == 1) && list["links"][0]["coupling"]["type"] == "screw", "{list}");
|
||||
assert!(list["joints"].as_array().is_some_and(|j| j.len() == 4) && list["origins"][0]["name"] == "Top", "{list}");
|
||||
assert!(list["floating"].as_array().is_some_and(|f| f.len() == 1), "{list}");
|
||||
let quiet = run("joint.list", json!({"quiet": true}));
|
||||
matches(output_schema_for::<ListJointsResult>(), "joint.list", &quiet);
|
||||
assert!(quiet.get("dof").is_none(), "{quiet}");
|
||||
|
||||
for p in [json!({}), json!({"joint": "Slide"}), json!({"joint": null}), json!({"joint": made["joint"]})] {
|
||||
let inspected = run("joint.inspect", p);
|
||||
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &inspected);
|
||||
}
|
||||
let inspected = run("joint.inspect", json!({"joint": "Hinge"}));
|
||||
assert!(inspected["joints"][0]["links"][0]["role"] == "driver" && inspected["joints"][0]["a"]["references"].as_array().is_some(), "{inspected}");
|
||||
|
||||
// joint.drive: a value, a list, one coordinate, past a limit, a drag frame, a suppressed joint
|
||||
// and one joined to nothing grounded.
|
||||
for p in [
|
||||
json!({"joint": "Hinge", "value": 45}),
|
||||
json!({"joint": "Hinge", "values": [30]}),
|
||||
json!({"joint": "Hinge", "index": 0, "value": "20 deg", "quiet": true}),
|
||||
json!({"joint": "Hinge", "value": 50, "continuous": true}),
|
||||
json!({"joint": "Slide", "value": 3}),
|
||||
] {
|
||||
let driven = run("joint.drive", p);
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &driven);
|
||||
}
|
||||
let held = run("joint.drive", json!({"joint": "Hinge", "value": 200}));
|
||||
assert!(held["held"][0]["why"].as_str().is_some_and(|w| w.contains("limits")), "{held}");
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &held);
|
||||
let linked = run("joint.drive", json!({"joint": "Slide", "value": 5}));
|
||||
assert!(linked["held"][0]["why"].as_str().is_some_and(|w| w.contains("motion link")), "{linked}");
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &linked);
|
||||
let loose = run("joint.drive", json!({"joint": "Clamped", "value": 5}));
|
||||
assert!(loose["unconnected"].is_string() && loose["reason"].is_string(), "{loose}");
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &loose);
|
||||
run("joint.edit", json!({"joint": "Stack", "type": "revolute"}));
|
||||
let turned = run("joint.drive", json!({"joint": "Stack", "value": 1}));
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &turned);
|
||||
run("joint.edit", json!({"joint": "Stack", "suppressed": true}));
|
||||
let hidden = run("joint.drive", json!({"joint": "Stack", "value": 2}));
|
||||
assert!(hidden["reason"].as_str().is_some_and(|r| r.contains("suppressed")), "{hidden}");
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &hidden);
|
||||
|
||||
// joint.limits and joint.solve.
|
||||
for p in [
|
||||
json!({"joint": "Hinge", "min": -45, "max": "45 deg"}),
|
||||
json!({"joint": "Hinge", "index": 0, "min": -10, "max": 10, "quiet": true}),
|
||||
json!({"joint": "Hinge"}),
|
||||
] {
|
||||
let limited = run("joint.limits", p);
|
||||
matches(output_schema_for::<SolveResult>(), "joint.limits", &limited);
|
||||
}
|
||||
for p in [json!({}), json!({"quiet": true})] {
|
||||
let solved = run("joint.solve", p);
|
||||
matches(output_schema_for::<SolveResult>(), "joint.solve", &solved);
|
||||
}
|
||||
|
||||
// joint.delete: a motion link, a joint origin, a joint.
|
||||
let link = run("joint.delete", json!({"link": 0}));
|
||||
assert_eq!(link["deleted_link"], 0, "{link}");
|
||||
matches(output_schema_for::<DeleteJointResult>(), "joint.delete", &link);
|
||||
let origin = run("joint.delete", json!({"origin": "Top", "joint": true, "link": "x"}));
|
||||
assert_eq!((origin["deleted_origin"].clone(), origin["joints"].clone()), (json!("Top"), json!(4)), "{origin}");
|
||||
matches(output_schema_for::<DeleteJointResult>(), "joint.delete", &origin);
|
||||
let deleted = run("joint.delete", json!({"joint": "Stack"}));
|
||||
assert!(deleted["deleted"].is_u64() && deleted.get("dof").is_some(), "{deleted}");
|
||||
matches(output_schema_for::<DeleteJointResult>(), "joint.delete", &deleted);
|
||||
let quiet = run("joint.delete", json!({"joint": "Clamped", "quiet": true}));
|
||||
assert!(quiet.get("dof").is_none() && quiet["deleted"].is_u64(), "{quiet}");
|
||||
matches(output_schema_for::<DeleteJointResult>(), "joint.delete", &quiet);
|
||||
}
|
||||
|
||||
fn with_limits(mut p: Value, limits: Value) -> Value {
|
||||
p["limits"] = limits;
|
||||
p
|
||||
}
|
||||
|
||||
fn with_extra(mut p: Value, extra: Value) -> Value {
|
||||
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn, says so, and the reply still matches its schema.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let mut s = hinged(ParamCheck::Warn);
|
||||
let calls = [
|
||||
("joint.create", json!({"type": "rigid", "a": "Top", "b": {"occurrence": "LeafB", "face": [20, 15, 0]}, "nme": "J"}), "name"),
|
||||
("joint.as_built", json!({"type": "rigid", "a": "LeafA", "b": "LeafB", "nam": "As built"}), "name"),
|
||||
("joint.drive", json!({"joint": "Hinge", "valu": 10, "value": 10}), "value"),
|
||||
("joint.limits", json!({"joint": "Hinge", "mn": 1, "min": -10, "max": 10}), "min"),
|
||||
("joint.list", json!({"quet": true}), "quet"),
|
||||
("joint.inspect", json!({"joynt": "Hinge"}), "joint"),
|
||||
("joint.solve", json!({"quet": true}), "quet"),
|
||||
("joint.delete", json!({"origin": "Top", "oringin": 1}), "origin"),
|
||||
];
|
||||
for (id, p, near) in calls {
|
||||
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let warnings = reply["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains("unknown key") && w.contains(near)), "{id}: {reply}");
|
||||
let out = crate::cmd::find_command(id).and_then(|c| c.output).map(|f| f()).unwrap_or_else(|| panic!("{id}"));
|
||||
assert!(conforms(out.as_value(), out.as_value(), &reply), "{id}: {reply} doesn't match {}", out.as_value());
|
||||
}
|
||||
}
|
||||
|
||||
/// A cam on a revolute joint: `joint.list` and `joint.inspect` give its follower's lift, a table
|
||||
/// that is not whole as `null`, and the replies match their schemas.
|
||||
#[test]
|
||||
fn a_cam_link_shows_its_lift() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let part = |run: &mut dyn FnMut(&str, Value) -> Value, name: &str, corner: [f64; 3]| {
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
let made = run("component.create", json!({"name": name, "grounded": false}));
|
||||
run("solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": corner}));
|
||||
made["occurrence"].clone()
|
||||
};
|
||||
let pin = |run: &mut dyn FnMut(&str, Value) -> Value, kind: &str, name: &str, a: &str, b: &str, p: Vec3, z: Vec3| {
|
||||
let at = |o: &str| json!({"occurrence": o, "point": [p.x, p.y, p.z], "z": [z.x, z.y, z.z]});
|
||||
run("joint.create", json!({"type": kind, "a": at(a), "b": at(b), "flip": true, "name": name}));
|
||||
};
|
||||
let occurrence = part(&mut run, "Base", [-90.0, -90.0, -20.0]);
|
||||
run("occurrence.ground", json!({"occurrence": occurrence, "grounded": true}));
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({"name": "Cam"}));
|
||||
run("sketch.create", json!({"plane": "XY", "name": "CamSketch"}));
|
||||
run("sketch.circle.center", json!({"center": [10, 0], "radius": 30, "id": "c1"}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("solid.extrude", json!({"distance": 5}));
|
||||
pin(&mut run, "revolute", "Cam", "Base", "Cam", Vec3::ZERO, Vec3::Z);
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({"name": "Tappet"}));
|
||||
run("solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [40, -2, 0]}));
|
||||
pin(&mut run, "slider", "Tappet", "Base", "Tappet", Vec3::new(0.0, 0.0, 2.5), Vec3::X);
|
||||
run("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
let list = run("joint.list", json!({}));
|
||||
let schema = output_schema_for::<ListJointsResult>();
|
||||
assert!(list["links"][0]["lift"]["rise"].is_number() && list["links"][0].get("ratio").is_none(), "{list}");
|
||||
assert!(list["links"][0]["coupling"]["type"] == "cam" && conforms(schema.as_value(), schema.as_value(), &list), "{list}");
|
||||
let inspected = run("joint.inspect", json!({"joint": "Tappet"}));
|
||||
let schema = output_schema_for::<InspectJointResult>();
|
||||
assert!(inspected["joints"][0]["links"][0]["role"] == "driven" && conforms(schema.as_value(), schema.as_value(), &inspected), "{inspected}");
|
||||
// A table that is not whole moves nothing, and says `null`.
|
||||
if let Some(l) = s.doc_mut().assembly.links.first_mut() {
|
||||
l.lift.truncate(3);
|
||||
}
|
||||
let broken = s.execute("joint.list", &json!({})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let schema = output_schema_for::<ListJointsResult>();
|
||||
assert!(broken["links"][0]["lift"].is_null() && broken["links"][0].get("lift").is_some(), "{broken}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &broken), "{broken}");
|
||||
}
|
||||
|
||||
/// A slider stopped by contact: the bodies that touched and how far it got, in the reply.
|
||||
#[test]
|
||||
fn a_drive_stopped_by_contact_matches_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let made = run("component.create", json!({"name": name}));
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
if name == "A" {
|
||||
run("occurrence.ground", json!({"occurrence": made["occurrence"], "grounded": true}));
|
||||
}
|
||||
}
|
||||
run("joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
|
||||
run("contact.create", json!({"occurrences": ["A", "B"]}));
|
||||
let free = run("joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
assert!(free["stopped_by_contact"].is_null() && free["stopped_by_loop"].is_null(), "{free}");
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &free);
|
||||
let stopped = run("joint.drive", json!({"joint": "Rail", "value": -30}));
|
||||
assert!(stopped["stopped_by_contact"]["fraction"].is_number() && stopped["stopped_by_contact"]["a"].is_string(), "{stopped}");
|
||||
assert!(stopped["held"][0]["why"].as_str().is_some_and(|w| w.contains("contact")), "{stopped}");
|
||||
matches(output_schema_for::<DriveJointResult>(), "joint.drive", &stopped);
|
||||
}
|
||||
|
||||
/// An origin on an edge names the edge and its two faces; a round origin on an extruded circle
|
||||
/// names the sketch curve its wall was swept from.
|
||||
#[test]
|
||||
fn inspect_names_what_edges_and_swept_faces_refer_to() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let panel = run("component.create", json!({"name": "Panel"}));
|
||||
run("solid.box", json!({"length": 400, "width": 300, "height": 2, "corner": [0, 0, 0]}));
|
||||
run("occurrence.ground", json!({"occurrence": panel["occurrence"], "grounded": true}));
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({"name": "Pin"}));
|
||||
run("sketch.create", json!({"plane": "XY", "name": "PinSketch"}));
|
||||
run("sketch.circle.center", json!({"center": [100, 0], "radius": 5, "id": "c1"}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("solid.extrude", json!({"distance": 20}));
|
||||
run(
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "name": "Edge", "a": {"occurrence": "Panel", "edge": [200, 0, 2]}, "b": {"occurrence": "Pin", "circle": [105, 0, 20]}}),
|
||||
);
|
||||
let inspected = run("joint.inspect", json!({"joint": "Edge"}));
|
||||
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &inspected);
|
||||
let side = |s: &str, role: &str| inspected["joints"][0][s]["references"].as_array().and_then(|r| r.iter().find(|r| r["role"] == role)).cloned();
|
||||
let edge = side("a", "edge").unwrap_or_default();
|
||||
assert!(edge["faces"].as_array().is_some_and(|f| f.len() == 2 && f[0]["feature"]["type"] == "BoxFeature"), "{inspected}");
|
||||
let wall = side("b", "face").unwrap_or_default();
|
||||
assert_eq!((wall["sketch"]["name"].clone(), wall["sketch"]["curve_type"].clone()), (json!("PinSketch"), json!("circle")), "{inspected}");
|
||||
// The extrude goes: the names are kept, and say what is gone.
|
||||
run("timeline.delete", json!({"feature": "Extrude1"}));
|
||||
let gone = run("joint.inspect", json!({"joint": "Edge"}));
|
||||
assert!(gone["joints"][0]["b"]["resolves"] == false && gone["joints"][0]["b"]["error"].is_string(), "{gone}");
|
||||
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &gone);
|
||||
}
|
||||
|
||||
/// The contract looks at every key before the handler looks at the design, so a call with two
|
||||
/// problems may name another one in strict than the handler does (the documented difference), and
|
||||
/// a call the handler ignores a wrong-typed key in is refused (the stricter reading).
|
||||
#[test]
|
||||
fn strict_may_name_another_problem_first_and_refuses_ignored_wrong_types() {
|
||||
let strict = |id: &str, p: Value| hinged(ParamCheck::Strict).execute(id, &p).err().map(|e| e.to_string());
|
||||
let off = |id: &str, p: Value| hinged(ParamCheck::Off).execute(id, &p).err().map(|e| e.to_string());
|
||||
for (id, p) in
|
||||
[("joint.drive", json!({"joint": "nope", "index": "x", "value": 1})), ("joint.limits", json!({"joint": "Hinge", "index": 9, "min": 0}))]
|
||||
{
|
||||
assert!(off(id, p.clone()).is_some() && strict(id, p.clone()).is_some() && off(id, p.clone()) != strict(id, p), "{id}");
|
||||
}
|
||||
for (id, p) in [
|
||||
("joint.limits", json!({"joint": "Hinge", "limits": [5]})),
|
||||
("joint.limits", json!({"joint": "Hinge", "index": "0", "min": 1, "max": 2})),
|
||||
("joint.solve", json!({"quiet": "yes"})),
|
||||
("joint.delete", json!({"joint": "Hinge", "link": "x"})),
|
||||
("joint.delete", json!({"joint": "Hinge", "origin": 5})),
|
||||
] {
|
||||
let works = |level| hinged(level).execute(id, &p).is_ok();
|
||||
assert!(works(ParamCheck::Off) && !works(ParamCheck::Strict), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ pub mod edit;
|
||||
pub mod features;
|
||||
pub mod features_more;
|
||||
pub mod file;
|
||||
pub mod frame;
|
||||
pub mod inspect;
|
||||
pub mod interference;
|
||||
pub mod joints;
|
||||
@@ -103,6 +104,7 @@ pub mod section;
|
||||
pub mod sheet;
|
||||
pub mod silhouette;
|
||||
pub mod sketch;
|
||||
pub mod sketch_3d;
|
||||
pub mod sketch_clipboard;
|
||||
pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
@@ -110,9 +112,12 @@ pub mod sketch_ends;
|
||||
pub mod sketch_equation;
|
||||
pub mod sketch_inspect;
|
||||
pub mod sketch_modify;
|
||||
pub mod sketch_project;
|
||||
pub mod sketch_shapes;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
pub mod surface_match;
|
||||
pub mod timeline_clipboard;
|
||||
pub mod ui_prefs;
|
||||
pub mod view_navigation;
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::check::whole;
|
||||
use solidcraft_geom::Vec3Geom as _;
|
||||
|
||||
use super::check::{located, whole};
|
||||
|
||||
/// Find where each value of a joint can go before the parts it moves touch another body. The
|
||||
/// design is not changed.
|
||||
@@ -625,6 +627,268 @@ pub struct MotionStudyDeleted {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- Exploded views (#12501) ----
|
||||
//
|
||||
// The exploded view handlers read their parameters from the raw JSON, so each key here reads as
|
||||
// its handler reads it and fails with its message. Whether an occurrence or a view exists stays
|
||||
// the handler's to check.
|
||||
|
||||
/// Make an exploded view: occurrences moved apart, shown with `explode.show`. The design keeps
|
||||
/// its placements. One undo step. With `moves`, each occurrence moves as given; without, every
|
||||
/// top-level occurrence is pushed away from the assembly's centre by `scale` times its offset.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExplodeCreateKeys {
|
||||
/// The view's name. Default: "Exploded View" and the first number no view's name has.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// For the automatic view: how far each occurrence is pushed away from the assembly's
|
||||
/// centre, as a multiple of its offset from it (a number from -100 to 100). Default: 1.
|
||||
/// Not read, and so not checked, when `moves` is given.
|
||||
#[serde(default, deserialize_with = "explode_scale", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "explode_scale_schema")]
|
||||
pub scale: Option<f64>,
|
||||
/// The moves, instead of the automatic view (at most 10 000): each an `occurrence` (by id or
|
||||
/// name, or a component's name for its first occurrence) and its `translate`, in mm, as
|
||||
/// [x, y, z] or {x, y, z}, less than 1e7 in length. An occurrence named twice is moved by
|
||||
/// the last.
|
||||
#[serde(default, deserialize_with = "explode_moves", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "explode_moves_schema")]
|
||||
pub moves: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
/// `explode.create`'s parameters: [`ExplodeCreateKeys`], where `scale` is read only without
|
||||
/// `moves`, as the handler does.
|
||||
#[derive(Debug)]
|
||||
pub struct ExplodeCreateParams(pub ExplodeCreateKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ExplodeCreateParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
// `moves`, even `null`, is what the handler goes by: it never reads `scale` then.
|
||||
if let Some(keys) = value.as_object_mut()
|
||||
&& keys.contains_key("moves")
|
||||
{
|
||||
keys.remove("scale");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ExplodeCreateParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ExplodeCreateParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ExplodeCreateParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ExplodeCreateKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// `scale`: a number of at most 100 in size, with the handler's message for anything else,
|
||||
/// `null` included.
|
||||
fn explode_scale<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && x.abs() <= 100.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(whole("`scale` must be a number up to 100")))
|
||||
}
|
||||
|
||||
fn explode_scale_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "For the automatic view: how far each occurrence is pushed away from the assembly's centre, as a multiple of its offset from it (a number from -100 to 100). Default: 1. Not read, and so not checked, when `moves` is given.",
|
||||
"type": "number",
|
||||
"minimum": -100,
|
||||
"maximum": 100
|
||||
})
|
||||
}
|
||||
|
||||
/// Why the handler refuses `item`, one of `moves`, before looking at the design.
|
||||
/// The handler also looks each occurrence up as it goes, so a call with an unknown occurrence in
|
||||
/// an earlier move and a malformed later one gets the handler's "no occurrence" message with the
|
||||
/// check off and this shape message in strict; both refuse the call.
|
||||
fn explode_move_problem(item: &Value) -> Option<&'static str> {
|
||||
if !matches!(item.get("occurrence"), Some(Value::Number(_) | Value::String(_))) {
|
||||
return Some("an occurrence must be an id or a name");
|
||||
}
|
||||
let translate = item.get("translate").and_then(crate::params::vec3).filter(|v| v.len() < 1e7);
|
||||
translate.is_none().then_some("each move needs `translate`: [x, y, z]")
|
||||
}
|
||||
|
||||
/// `moves`: a list of at most 10 000 moves, each checked as [`explode_move_problem`] says. Given,
|
||||
/// `null` included, it must be a list, as the handler reads it.
|
||||
fn explode_moves<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<Value>>, D::Error> {
|
||||
let Value::Array(moves) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`moves` must be a list")));
|
||||
};
|
||||
if moves.len() > MAX_EXPLODE_ITEMS {
|
||||
return Err(D::Error::custom(whole("too many moves")));
|
||||
}
|
||||
if let Some(problem) = moves.iter().find_map(explode_move_problem) {
|
||||
return Err(D::Error::custom(whole(problem)));
|
||||
}
|
||||
Ok(Some(moves))
|
||||
}
|
||||
|
||||
/// The most moves a view takes, and the most views a design has, as `cmd/motion.rs`'s `MAX_ITEMS`.
|
||||
const MAX_EXPLODE_ITEMS: usize = 10_000;
|
||||
|
||||
fn explode_moves_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let c = json!({"type": "number", "exclusiveMinimum": -1e7, "exclusiveMaximum": 1e7});
|
||||
json_schema!({
|
||||
"description": "The moves, instead of the automatic view (at most 10 000): each an `occurrence` (by id or name, or a component's name for its first occurrence) and its `translate`, in mm, as [x, y, z] or {x, y, z}, less than 1e7 in length. An occurrence named twice is moved by the last.",
|
||||
"type": "array",
|
||||
"maxItems": MAX_EXPLODE_ITEMS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["occurrence", "translate"],
|
||||
"properties": {
|
||||
"occurrence": {"type": ["number", "string"]},
|
||||
"translate": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
|
||||
{"type": "object", "properties": {"x": c, "y": c, "z": c}, "required": ["x", "y", "z"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// An exploded view by its id (a number) or its name.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ViewRef(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
v @ (Value::Number(_) | Value::String(_)) => Ok(ViewRef(v)),
|
||||
_ => Err(D::Error::custom(whole("`view` must be an id or a name"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewRef".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An exploded view's id (a number) or its name.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
/// Show an exploded view in the model: the occurrences are drawn moved apart, and the design
|
||||
/// keeps its placements. Without a view (or `none`), show the assembled placement again.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExplodeShowParams {
|
||||
/// The view to show, by id (a number) or name. Default: none, which shows the assembled
|
||||
/// placement; `null` and the text `none` (in any case) mean the same.
|
||||
#[serde(default)]
|
||||
pub view: Option<ViewRef>,
|
||||
}
|
||||
|
||||
/// List the design's exploded views. Nothing is changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExplodeListParams {}
|
||||
|
||||
/// Delete an exploded view. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = view_required)]
|
||||
pub struct ExplodeDeleteParams {
|
||||
// Required in the schema (`view_required`), optional in the type: left out, the handler
|
||||
// refuses it with its own message, not the check's "is required". `null` is refused too.
|
||||
/// The view to delete, by id (a number) or name.
|
||||
#[serde(default, deserialize_with = "some_view", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewRef")]
|
||||
pub view: Option<ViewRef>,
|
||||
}
|
||||
|
||||
/// `view`: an id or a name, with the handler's message for anything else, `null` included.
|
||||
fn some_view<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ViewRef>, D::Error> {
|
||||
ViewRef::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn view_required(schema: &mut Schema) {
|
||||
require(schema, "view");
|
||||
}
|
||||
|
||||
/// One move of an exploded view.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeMoveInfo {
|
||||
/// The occurrence, by id.
|
||||
pub occurrence: u64,
|
||||
/// How far it moves, in mm (world).
|
||||
pub translate: [f64; 3],
|
||||
}
|
||||
|
||||
impl From<&(u64, solidcore_record::Vec3)> for ExplodeMoveInfo {
|
||||
fn from((occurrence, t): &(u64, solidcore_record::Vec3)) -> Self {
|
||||
ExplodeMoveInfo { occurrence: *occurrence, translate: [t.x, t.y, t.z] }
|
||||
}
|
||||
}
|
||||
|
||||
/// What `explode.create` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeCreated {
|
||||
/// The new view's id.
|
||||
pub view: u64,
|
||||
pub name: String,
|
||||
pub moves: Vec<ExplodeMoveInfo>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `explode.show` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeShown {
|
||||
/// The view shown, by id; `null` when the assembled placement is shown.
|
||||
pub view: Option<u64>,
|
||||
/// The view's name; not there when the assembled placement is shown.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `explode.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeViews {
|
||||
pub views: Vec<ExplodedViewInfo>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// An exploded view, as the design saves it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodedViewInfo {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// [occurrence id, translation in mm] pairs.
|
||||
pub moves: Vec<(u64, [f64; 3])>,
|
||||
}
|
||||
|
||||
impl From<&solidcraft_doc::motion::ExplodedView> for ExplodedViewInfo {
|
||||
fn from(view: &solidcraft_doc::motion::ExplodedView) -> Self {
|
||||
let moves = view.moves.iter().map(|(o, t)| (*o, [t.x, t.y, t.z])).collect();
|
||||
ExplodedViewInfo { id: view.id, name: view.name.clone(), moves }
|
||||
}
|
||||
}
|
||||
|
||||
/// What `explode.delete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeDeleted {
|
||||
/// The deleted view's name.
|
||||
pub deleted: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "motion_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,7 +2,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ContactCheck, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetCreated, ContactSetEdited, ContactSets,
|
||||
ContactSwitch, FindLimitsParams, FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted,
|
||||
ContactSwitch, ExplodeCreateParams, ExplodeCreated, ExplodeDeleteParams, ExplodeDeleted, ExplodeListParams, ExplodeShowParams, ExplodeShown,
|
||||
ExplodeViews, FindLimitsParams, FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted,
|
||||
MotionStudyParams, MotionStudySaved,
|
||||
};
|
||||
use crate::Session;
|
||||
@@ -19,6 +20,10 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MotionStudyParams>(), &["keys", "name", "steps", "study"]);
|
||||
assert_keys(input_schema_for::<MotionListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MotionStudyDeleteParams>(), &["study"]);
|
||||
assert_keys(input_schema_for::<ExplodeCreateParams>(), &["moves", "name", "scale"]);
|
||||
assert_keys(input_schema_for::<ExplodeShowParams>(), &["view"]);
|
||||
assert_keys(input_schema_for::<ExplodeListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ExplodeDeleteParams>(), &["view"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,3 +264,203 @@ fn motion_studies_reply_with_their_schemas() {
|
||||
let error = s.execute("motion.study", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`step`"), "{error}");
|
||||
}
|
||||
|
||||
/// The exploded view commands in a design with three occurrences, in Off and in Strict: each call
|
||||
/// the handler refuses is refused with the same message. They need the door's occurrences, so
|
||||
/// the shared helper doesn't fit.
|
||||
#[test]
|
||||
fn strict_keeps_the_exploded_views_errors() {
|
||||
let many: Vec<Value> = (0..10_001).map(|_| move_of(json!("Door"), json!([1, 2, 3]))).collect();
|
||||
for (id, p) in [
|
||||
("explode.create", json!({"moves": null})),
|
||||
("explode.create", json!({"moves": "Door"})),
|
||||
("explode.create", json!({"moves": {"occurrence": "Door", "translate": [1, 2, 3]}})),
|
||||
("explode.create", json!({"moves": many})),
|
||||
("explode.create", json!({"moves": [{}]})),
|
||||
("explode.create", json!({"moves": [true]})),
|
||||
("explode.create", json!({"moves": [move_of(json!(null), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!(true), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!(["Door"]), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Nope"), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [{"occurrence": "Door"}]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!(null))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([1, 2]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!({"x": 1, "y": 2}))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!(["a", 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([1e7, 0, 0]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([8e6, 8e6, 0]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([1, 2, 3])), {"translate": [1, 2, 3]}]})),
|
||||
("explode.create", json!({"scale": "2"})),
|
||||
("explode.create", json!({"scale": null})),
|
||||
("explode.create", json!({"scale": true})),
|
||||
("explode.create", json!({"scale": 100.5})),
|
||||
("explode.create", json!({"scale": -101})),
|
||||
("explode.show", json!({"view": true})),
|
||||
("explode.show", json!({"view": [1]})),
|
||||
("explode.show", json!({"view": {"id": 1}})),
|
||||
("explode.show", json!({"view": "Nope"})),
|
||||
("explode.show", json!({"view": 7})),
|
||||
("explode.delete", json!({})),
|
||||
("explode.delete", json!({"view": null})),
|
||||
("explode.delete", json!({"view": false})),
|
||||
("explode.delete", json!({"view": ["Apart"]})),
|
||||
("explode.delete", json!({"view": "Nope"})),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = door();
|
||||
s.param_check = level;
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn move_of(occurrence: Value, translate: Value) -> Value {
|
||||
json!({"occurrence": occurrence, "translate": translate})
|
||||
}
|
||||
|
||||
/// The exploded view types read each key as the handlers do: `scale` only without `moves`, a move
|
||||
/// by an occurrence's id or name with a point in either form, `view` left out, `null` or `none`
|
||||
/// to show the assembled placement but refused by `explode.delete`, and keys a move carries
|
||||
/// besides its two ignored. Each case is also checked against the schema.
|
||||
#[test]
|
||||
fn the_exploded_view_types_read_the_keys_as_the_handlers_do() {
|
||||
let issues = |id: &str, p: &Value| crate::schema::issues(id, p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let ok = |id: &str, p: Value, takes: bool| assert_eq!(issues(id, &p).is_empty(), takes, "{id} {p}: {:?}", issues(id, &p));
|
||||
let schema_takes = |schema: schemars::Schema, p: Value, takes: bool| {
|
||||
let schema = schema.as_value();
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
};
|
||||
// `scale` is read only when `moves` is not there; `moves` is read when it is there at all.
|
||||
let one = move_of(json!(3), json!({"x": 1, "y": 2, "z": 3}));
|
||||
for (p, takes) in [
|
||||
(json!({}), true),
|
||||
(json!({"name": "Apart", "scale": 2.5}), true),
|
||||
(json!({"scale": -100}), true),
|
||||
(json!({"scale": 101}), false),
|
||||
(json!({"scale": "2"}), false),
|
||||
(json!({"scale": null}), false),
|
||||
(json!({"moves": [], "scale": "ignored"}), true),
|
||||
(json!({"moves": [one.clone()], "scale": null}), true),
|
||||
(json!({"moves": [{"occurrence": "Door", "translate": [0, 0, 5], "note": "ignored"}]}), true),
|
||||
(json!({"moves": null, "scale": 2}), false),
|
||||
(json!({"moves": "Door"}), false),
|
||||
(json!({"moves": [{"occurrence": "Door"}]}), false),
|
||||
(json!({"moves": [{"occurrence": true, "translate": [0, 0, 5]}]}), false),
|
||||
(json!({"moves": [{"occurrence": "Door", "translate": [0, 0, 1e7]}]}), false),
|
||||
] {
|
||||
ok("explode.create", p, takes);
|
||||
}
|
||||
schema_takes(input_schema_for::<ExplodeCreateParams>(), json!({"moves": [one], "name": "N"}), true);
|
||||
schema_takes(input_schema_for::<ExplodeCreateParams>(), json!({"moves": [{"occurrence": "Door"}]}), false);
|
||||
schema_takes(input_schema_for::<ExplodeCreateParams>(), json!({"scale": 101}), false);
|
||||
// Showing: no view, `null` and `none` in any case show the assembled placement.
|
||||
for (p, takes) in [
|
||||
(json!({}), true),
|
||||
(json!({"view": null}), true),
|
||||
(json!({"view": "None"}), true),
|
||||
(json!({"view": "Apart"}), true),
|
||||
(json!({"view": 4}), true),
|
||||
(json!({"view": true}), false),
|
||||
(json!({"view": ["Apart"]}), false),
|
||||
] {
|
||||
ok("explode.show", p.clone(), takes);
|
||||
schema_takes(input_schema_for::<ExplodeShowParams>(), p, takes);
|
||||
}
|
||||
// Deleting needs a view, by id or name.
|
||||
for (p, takes) in [(json!({"view": "Apart"}), true), (json!({"view": 4}), true), (json!({}), false), (json!({"view": null}), false)] {
|
||||
schema_takes(input_schema_for::<ExplodeDeleteParams>(), p, takes);
|
||||
}
|
||||
ok("explode.delete", json!({"view": "Apart"}), true);
|
||||
assert_eq!(issues("explode.delete", &json!({"view": null})), ["`view` must be an id or a name"]);
|
||||
assert_eq!(issues("explode.delete", &json!({})), Vec::<String>::new(), "the handler gives its own message for a missing view");
|
||||
assert_eq!(issues("explode.show", &json!({"view": true})), ["`view` must be an id or a name"]);
|
||||
assert_eq!(issues("explode.create", &json!({"scale": "x"})), ["`scale` must be a number up to 100"]);
|
||||
assert_eq!(issues("explode.create", &json!({"moves": 1})), ["`moves` must be a list"]);
|
||||
assert_eq!(issues("explode.create", &json!({"moves": [{}]})), ["an occurrence must be an id or a name"]);
|
||||
assert_eq!(issues("explode.create", &json!({"moves": [{"occurrence": 1}]})), ["each move needs `translate`: [x, y, z]"]);
|
||||
assert_eq!(issues("explode.list", &json!({"x": 1})).len(), 1);
|
||||
}
|
||||
|
||||
/// Making, showing, listing and deleting exploded views reply as the output schemas say, in Warn
|
||||
/// without warnings, with the JSON the handlers have always returned. A misspelt key gets
|
||||
/// `param_warnings` in Warn, since the handlers ignore unknown keys, and strict refuses it.
|
||||
#[test]
|
||||
fn exploded_views_reply_with_their_schemas() {
|
||||
let mut s = door();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let run = |s: &mut Session, id: &str, p: Value, schema: schemars::Schema| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let auto = run(&mut s, "explode.create", json!({}), output_schema_for::<ExplodeCreated>());
|
||||
assert_eq!(auto["name"], "Exploded View1", "{auto}");
|
||||
assert_eq!(auto["moves"].as_array().map(Vec::len), Some(3), "{auto}");
|
||||
assert!(auto["moves"][0]["occurrence"].is_u64() && auto["moves"][0]["translate"].as_array().is_some_and(|t| t.len() == 3), "{auto}");
|
||||
let scaled = run(&mut s, "explode.create", json!({"name": "Far", "scale": 2.5}), output_schema_for::<ExplodeCreated>());
|
||||
assert_eq!(scaled["name"], "Far", "{scaled}");
|
||||
run(&mut s, "explode.create", json!({"scale": -1}), output_schema_for::<ExplodeCreated>());
|
||||
// `scale` is not read with `moves`, so a bad one is no problem.
|
||||
let by_hand = run(
|
||||
&mut s,
|
||||
"explode.create",
|
||||
json!({"name": "Hand", "scale": "ignored", "moves": [move_of(json!("Door"), json!([0, 0, 50])), move_of(json!("Slide"), json!({"x": 1, "y": 2, "z": 3}))]}),
|
||||
output_schema_for::<ExplodeCreated>(),
|
||||
);
|
||||
let (door_id, slide_id) = (by_hand["moves"][0]["occurrence"].clone(), by_hand["moves"][1]["occurrence"].clone());
|
||||
let expected = json!({
|
||||
"view": by_hand["view"],
|
||||
"name": "Hand",
|
||||
"moves": [{"occurrence": door_id, "translate": [0.0, 0.0, 50.0]}, {"occurrence": slide_id, "translate": [1.0, 2.0, 3.0]}]
|
||||
});
|
||||
assert_eq!(by_hand, expected);
|
||||
run(&mut s, "explode.create", json!({"name": "Empty", "moves": []}), output_schema_for::<ExplodeCreated>());
|
||||
|
||||
let list = run(&mut s, "explode.list", json!({}), output_schema_for::<ExplodeViews>());
|
||||
assert_eq!(list["views"].as_array().map(Vec::len), Some(5), "{list}");
|
||||
// Exactly what the design's own views serialize to, as the handler returned them before.
|
||||
assert_eq!(list["views"], serde_json::to_value(&s.doc.assembly.exploded).unwrap_or_default());
|
||||
assert_eq!(list["views"][3], json!({"id": by_hand["view"], "name": "Hand", "moves": [[door_id, [0.0, 0.0, 50.0]], [slide_id, [1.0, 2.0, 3.0]]]}));
|
||||
|
||||
// Shown by name, by id and, to put the assembled placement back, by nothing, `null` or `none`.
|
||||
let r = run(&mut s, "explode.show", json!({"view": "Hand"}), output_schema_for::<ExplodeShown>());
|
||||
assert_eq!(r, json!({"view": by_hand["view"], "name": "Hand"}));
|
||||
let r = run(&mut s, "explode.show", json!({"view": scaled["view"]}), output_schema_for::<ExplodeShown>());
|
||||
assert_eq!(r, json!({"view": scaled["view"], "name": "Far"}));
|
||||
for p in [json!({}), json!({"view": null}), json!({"view": "None"})] {
|
||||
run(&mut s, "explode.show", json!({"view": "Hand"}), output_schema_for::<ExplodeShown>());
|
||||
let r = run(&mut s, "explode.show", p, output_schema_for::<ExplodeShown>());
|
||||
assert_eq!(r, json!({"view": null}));
|
||||
}
|
||||
|
||||
let r = run(&mut s, "explode.delete", json!({"view": "Hand"}), output_schema_for::<ExplodeDeleted>());
|
||||
assert_eq!(r, json!({"deleted": "Hand"}));
|
||||
let r = run(&mut s, "explode.delete", json!({"view": scaled["view"]}), output_schema_for::<ExplodeDeleted>());
|
||||
assert_eq!(r, json!({"deleted": "Far"}));
|
||||
|
||||
// A misspelt key runs in Warn and says so, with a reply that still matches; strict refuses it.
|
||||
run(&mut s, "explode.create", json!({"name": "Typo"}), output_schema_for::<ExplodeCreated>());
|
||||
for (id, p, schema) in [
|
||||
("explode.create", json!({"nme": "Typo"}), output_schema_for::<ExplodeCreated>()),
|
||||
("explode.show", json!({"veiw": "Exploded View"}), output_schema_for::<ExplodeShown>()),
|
||||
("explode.list", json!({"view": 1}), output_schema_for::<ExplodeViews>()),
|
||||
("explode.delete", json!({"view": "Typo", "name": 1}), output_schema_for::<ExplodeDeleted>()),
|
||||
] {
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()) && conforms(schema, schema, &r), "{id}: {r}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("unknown key"), "{id}: {error}");
|
||||
}
|
||||
let before = s.doc.assembly.exploded.len();
|
||||
s.param_check = ParamCheck::Strict;
|
||||
assert!(s.execute("explode.create", &json!({"nme": "Typo"})).is_err());
|
||||
assert_eq!(s.doc.assembly.exploded.len(), before, "strict ran nothing");
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
//! Contracts and result types for the commands in `cmd/parameters.rs` that manage the design's
|
||||
//! user parameters by name (#11941): `parameters.add`, `.rename`, `.delete`, `.comment`,
|
||||
//! `.favorite` and `.move`.
|
||||
//! Contracts and result types for the commands in `cmd/parameters.rs`: those that manage the
|
||||
//! design's user parameters by name (#11941), `parameters.add`, `.rename`, `.delete`, `.comment`,
|
||||
//! `.favorite` and `.move`, and the rest (#12497): `parameters.change`, `.list`, `.users`,
|
||||
//! `.graph`, `.export`, `.import`, `.complete` and `expr.evaluate`.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a parameter exists, and whether a new
|
||||
//! name, unit or expression is valid, stays the document's to check, with its messages.
|
||||
|
||||
use schemars::{JsonSchema, Schema};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::{ParamRow, ParamUser};
|
||||
|
||||
use super::check::whole;
|
||||
use super::check::{located, whole};
|
||||
|
||||
/// A parameter's name, as `cmd/parameters.rs`'s `param_name` reads `name`: text that isn't blank,
|
||||
/// with its message for anything else. Spaces at either end are dropped.
|
||||
@@ -296,6 +302,854 @@ pub struct ParamMoved {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// #12497: `parameters.change`, `.list`, `.users`, `.graph`, `.export`, `.import`, `.complete`
|
||||
// and `expr.evaluate`.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Adds `key` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it when it's left out.
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
match schema.get_mut("required") {
|
||||
Some(Value::Array(required)) => {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
required.push(key.into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
schema.insert("required".into(), Value::Array(vec![key.into()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` as text: `null` is left out, as `str_` reads it. Anything else is refused, though the
|
||||
/// handler would read it as left out; `message` is the handler's for that case, if it has one.
|
||||
fn text_or_null<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole(message))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `parameters.change` takes, in the form it's read in: see [`ParamChangeParams`].
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = change_forms)]
|
||||
pub struct ParamChangeKeys {
|
||||
/// Several existing parameters at once, `{name: expression}`: they are checked together and
|
||||
/// the design is rebuilt once, all or nothing. Renaming isn't done this way. When given,
|
||||
/// no other key is read.
|
||||
#[serde(default, deserialize_with = "changes_map", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "changes_schema")]
|
||||
pub changes: Option<Map<String, Value>>,
|
||||
/// Delete this user parameter (refused while something uses it). When given as text, no
|
||||
/// other key but `changes` is read.
|
||||
#[serde(default, deserialize_with = "delete_text")]
|
||||
pub delete: Option<String>,
|
||||
/// The parameter to change, by name: a user parameter, a sketch dimension or a feature's
|
||||
/// named input. Spaces at either end are dropped.
|
||||
#[serde(default, deserialize_with = "change_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String", length(min = 1))]
|
||||
pub name: Option<String>,
|
||||
/// The new value: a number, or an expression as text (`25 mm`, `W / 2`). A text
|
||||
/// parameter's expression is its text, quotes round it optional. `name = value` renames
|
||||
/// the parameter to `name` and sets the value (or sets `name` when the value is just
|
||||
/// `name`). Needed unless `new_name`, `favorite` or `comment` is given.
|
||||
#[serde(default, deserialize_with = "expression")]
|
||||
#[schemars(schema_with = "expression_schema")]
|
||||
pub expression: Option<Value>,
|
||||
/// Another name for `expression`, read when `expression` isn't a number or text.
|
||||
#[serde(default, deserialize_with = "expression")]
|
||||
#[schemars(schema_with = "expression_schema")]
|
||||
pub value: Option<Value>,
|
||||
/// The unit: `mm`, `cm`, `m`, `in`, `ft`, `deg`, `rad`, `""` (no unit) or `text`. Default:
|
||||
/// the parameter's own, or for a new one its value's unit, a length in the design's units.
|
||||
#[serde(default, deserialize_with = "unit_text")]
|
||||
pub unit: Option<String>,
|
||||
/// A note to show with the parameter.
|
||||
#[serde(default, deserialize_with = "comment_key")]
|
||||
pub comment: Option<String>,
|
||||
/// List it with the favourites, first, or not.
|
||||
#[serde(default, deserialize_with = "favorite_flag")]
|
||||
pub favorite: Option<bool>,
|
||||
/// Rename the parameter: the expressions, feature inputs and dimensions that use it follow.
|
||||
/// With an `expression` the new value is set too.
|
||||
#[serde(default, deserialize_with = "new_name_key")]
|
||||
pub new_name: Option<String>,
|
||||
}
|
||||
|
||||
fn changes_map<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Map<String, Value>>, D::Error> {
|
||||
let Value::Object(map) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`changes` must be an object of name: expression")));
|
||||
};
|
||||
if map.is_empty() {
|
||||
return Err(D::Error::custom(whole("`changes` is empty")));
|
||||
}
|
||||
for (name, e) in &map {
|
||||
// `expr_arg`: text, or a number.
|
||||
if !matches!(e, Value::String(_) | Value::Number(_)) {
|
||||
return Err(D::Error::custom(whole(format!("`changes.{name}` must be a number or text"))));
|
||||
}
|
||||
}
|
||||
Ok(Some(map))
|
||||
}
|
||||
|
||||
fn changes_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Several existing parameters at once, `{name: expression}`: they are checked together and the design is rebuilt once, all or nothing. Renaming isn't done this way (`name = value` is refused). When given, no other key is read.",
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": {"type": ["number", "string"]}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_text<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`delete` must be text")
|
||||
}
|
||||
|
||||
fn unit_text<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`unit` must be text")
|
||||
}
|
||||
|
||||
fn comment_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`comment` must be text")
|
||||
}
|
||||
|
||||
fn new_name_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`new_name` must be text")
|
||||
}
|
||||
|
||||
fn favorite_flag<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(D::Error::custom(whole("`favorite` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` of a single change, as `param_name` reads it: text that isn't blank, `null` included
|
||||
/// in what is refused.
|
||||
fn change_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
param_name(d).map(Some)
|
||||
}
|
||||
|
||||
/// `parameters.change` takes one of three forms: `changes`, `delete` or `name` with what to
|
||||
/// change about it.
|
||||
fn change_forms(schema: &mut Schema) {
|
||||
let name_with = json!({
|
||||
"required": ["name"],
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"anyOf": [
|
||||
{"required": ["expression"], "properties": {"expression": {"type": ["number", "string"]}}},
|
||||
{"required": ["value"], "properties": {"value": {"type": ["number", "string"]}}},
|
||||
{"required": ["new_name"]},
|
||||
{"required": ["favorite"]},
|
||||
{"required": ["comment"]}
|
||||
]
|
||||
});
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{"required": ["changes"]},
|
||||
{"required": ["delete"], "properties": {"delete": {"type": "string"}}},
|
||||
name_with
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Change a parameter: set its expression, unit, comment or favourite, rename it (references
|
||||
/// follow), delete it, or set several existing parameters at once. The design is rebuilt once
|
||||
/// and the features that changed are named in the reply. One undo step.
|
||||
///
|
||||
/// The handler reads `changes` alone when it is given, else `delete` alone when that is text,
|
||||
/// else the keys of a single change; the keys of a form it doesn't read are left unchecked.
|
||||
#[derive(Debug)]
|
||||
pub struct ParamChangeParams(pub ParamChangeKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ParamChangeParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut() {
|
||||
let only = if keys.contains_key("changes") {
|
||||
Some("changes")
|
||||
} else if matches!(keys.get("delete"), Some(Value::String(_))) {
|
||||
Some("delete")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Otherwise a single change: `changes` isn't there, and `delete` isn't text.
|
||||
if let Some(only) = only {
|
||||
keys.retain(|k, _| k == only || !CHANGE_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
// `value` is read only when `expression` isn't a number or text.
|
||||
else if matches!(keys.get("expression"), Some(Value::String(_) | Value::Number(_))) {
|
||||
keys.remove("value");
|
||||
}
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ParamChangeParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
const CHANGE_KEYS: [&str; 9] = ["changes", "delete", "name", "expression", "value", "unit", "comment", "favorite", "new_name"];
|
||||
|
||||
impl JsonSchema for ParamChangeParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ParamChangeParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ParamChangeKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature that failed to rebuild: its name and error.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FailedFeature {
|
||||
/// The feature's name.
|
||||
pub feature: String,
|
||||
/// Why it failed.
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl FailedFeature {
|
||||
/// The features of `results` that failed.
|
||||
pub fn of(results: &[solidcraft_doc::FeatureResult]) -> Vec<FailedFeature> {
|
||||
results.iter().filter_map(|r| r.error.as_ref().map(|e| FailedFeature { feature: r.name.clone(), error: e.clone() })).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature a configuration rule switched on or off.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SwitchedFeature {
|
||||
/// The feature's id.
|
||||
pub feature: u64,
|
||||
/// Its name; `null` if it is gone.
|
||||
pub name: Option<String>,
|
||||
/// Whether it is suppressed now.
|
||||
pub suppressed: bool,
|
||||
}
|
||||
|
||||
/// A parameter that was renamed.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamRename {
|
||||
/// The old name.
|
||||
pub from: String,
|
||||
/// The new name.
|
||||
pub to: String,
|
||||
/// How many expressions, feature inputs and dimensions were changed to use the new name.
|
||||
pub references_updated: usize,
|
||||
}
|
||||
|
||||
/// What `parameters.change` returns: one of three shapes, by what was asked.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamChanged {
|
||||
#[serde(flatten)]
|
||||
pub body: ParamChangedBody,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The shapes of a `parameters.change` reply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ParamChangedBody {
|
||||
/// A parameter was deleted (`delete`).
|
||||
Deleted {
|
||||
/// The deleted parameter's name.
|
||||
deleted: String,
|
||||
},
|
||||
/// Several parameters were set (`changes`).
|
||||
Many {
|
||||
/// Each changed parameter's value, in mm for a length and degrees for an angle; `null`
|
||||
/// where it can't be worked out.
|
||||
changed: BTreeMap<String, Option<f64>>,
|
||||
/// How many features were rebuilt.
|
||||
recomputed: usize,
|
||||
/// How many features reused an earlier result for the same inputs (a value set back).
|
||||
restored: usize,
|
||||
/// The features that failed to rebuild.
|
||||
errors: Vec<FailedFeature>,
|
||||
/// The features the configuration rules switched, when they did.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
switched: Vec<SwitchedFeature>,
|
||||
},
|
||||
/// One parameter was changed.
|
||||
One(Box<ParamChangedOne>),
|
||||
}
|
||||
|
||||
/// What changing one parameter returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamChangedOne {
|
||||
/// Set when the parameter was renamed (`new_name`, or `name = expression`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub renamed: Option<ParamRename>,
|
||||
/// Set when `name = expression` set the parameter that the changed value's expression named:
|
||||
/// the name asked for.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub changed_through: Option<String>,
|
||||
/// The features the configuration rules switched, when they did.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub switched: Vec<SwitchedFeature>,
|
||||
/// The parameter's name now.
|
||||
pub name: String,
|
||||
/// The expression it has now; `null` when only its name, comment or favourite changed.
|
||||
pub expression: Option<String>,
|
||||
/// A text parameter's text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
/// Its value in mm for a length and degrees for an angle, as in every reply; `null` if it
|
||||
/// can't be worked out.
|
||||
pub value: Option<f64>,
|
||||
/// Its value in its own unit; `null` if it can't be worked out.
|
||||
pub display_value: Option<f64>,
|
||||
/// How many features were rebuilt.
|
||||
pub recomputed: usize,
|
||||
/// How many features reused an earlier result for the same inputs (a value set back).
|
||||
pub restored: usize,
|
||||
/// The features that failed to rebuild.
|
||||
pub errors: Vec<FailedFeature>,
|
||||
}
|
||||
|
||||
/// List the parameters: the user's, the sketch dimensions' and the feature inputs', favourites
|
||||
/// first, with each one's expression, value, uses and users, and any cycles. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamListKeys {
|
||||
/// Only the favourites. When true, `filter` isn't read. Default: false.
|
||||
#[serde(default, deserialize_with = "list_favorites")]
|
||||
pub favorites: Option<bool>,
|
||||
/// Which parameters, ignoring case: `all`, `user`, `sketch` (dimensions), `feature` (inputs),
|
||||
/// `model` (every one but the user's) or `favorites`. Anything else lists all. Default: all.
|
||||
#[serde(default, deserialize_with = "list_filter")]
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
fn list_favorites<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(D::Error::custom(whole("`favorites` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_filter<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`filter` must be text")
|
||||
}
|
||||
|
||||
/// `parameters.list`'s parameters: [`ParamListKeys`], with `filter` left unread when
|
||||
/// `favorites` is true, as the handler leaves it.
|
||||
#[derive(Debug)]
|
||||
pub struct ParamListParams(pub ParamListKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ParamListParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut().filter(|o| o.get("favorites") == Some(&Value::Bool(true))) {
|
||||
keys.remove("filter");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ParamListParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ParamListParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ParamListParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ParamListKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the parameters table.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParameterRow {
|
||||
pub name: String,
|
||||
pub expression: String,
|
||||
/// Its unit; `""` for none, `text` for a text parameter.
|
||||
pub unit: String,
|
||||
/// Its value in mm for a length and degrees for an angle, whatever its own unit; `null` if
|
||||
/// it can't be worked out (a text parameter has none).
|
||||
pub value: Option<f64>,
|
||||
/// Its value in its own unit, as the Parameters window shows it.
|
||||
pub display_value: Option<f64>,
|
||||
/// Why it can't be worked out, when it can't.
|
||||
pub error: Option<String>,
|
||||
pub comment: String,
|
||||
/// `user`, `sketch` (a dimension) or `feature` (a feature's named input).
|
||||
pub source: String,
|
||||
/// For a feature input: the feature.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub feature: Option<String>,
|
||||
/// For a feature input: the input's label.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<String>,
|
||||
pub favorite: bool,
|
||||
/// The names its expression uses.
|
||||
pub uses: Vec<String>,
|
||||
/// The names whose expressions use it.
|
||||
pub used_by: Vec<String>,
|
||||
/// A text parameter's text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ParamRow> for ParameterRow {
|
||||
fn from(r: ParamRow) -> Self {
|
||||
ParameterRow {
|
||||
name: r.name,
|
||||
expression: r.expression,
|
||||
unit: r.unit,
|
||||
value: r.value,
|
||||
display_value: r.display_value,
|
||||
error: r.error,
|
||||
comment: r.comment,
|
||||
source: r.source.to_string(),
|
||||
feature: r.feature,
|
||||
input: r.input.map(str::to_string),
|
||||
favorite: r.favorite,
|
||||
uses: r.uses,
|
||||
used_by: r.used_by,
|
||||
text: r.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `parameters.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamListResult {
|
||||
/// The parameters listed, favourites first.
|
||||
pub parameters: Vec<ParameterRow>,
|
||||
/// How many there are.
|
||||
pub count: usize,
|
||||
/// The names whose expressions refer to themselves, directly or through others.
|
||||
pub cycles: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// List what uses a parameter: other parameters, feature inputs, sketch dimensions,
|
||||
/// configurations, joints, rules and canvases, and the features it drives. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamUsersParams {
|
||||
/// The parameter, by name: a user parameter, a dimension or a feature's named input.
|
||||
#[serde(deserialize_with = "param_name")]
|
||||
#[schemars(length(min = 1))]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Something that uses a parameter.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ParameterUser {
|
||||
/// Another parameter's expression (a stored one or a feature input).
|
||||
Parameter { name: String },
|
||||
/// A feature input (`input` is its label, e.g. `Distance`).
|
||||
Feature { feature: u64, name: String, input: String },
|
||||
/// A sketch dimension (`constraint` is its id).
|
||||
Dimension { feature: u64, name: String, constraint: String },
|
||||
/// A configured column or an expression saved in a configuration row.
|
||||
Configuration { row: Option<String>, column: String },
|
||||
/// A joint's offset, angle or value, or a motion link's gear, rack or screw length (`input`
|
||||
/// says which).
|
||||
Joint { joint: u64, name: String, input: String },
|
||||
/// A sheet metal or plastic rule's value (`kind` says which, `field` which value).
|
||||
Rule { kind: String, name: String, field: String },
|
||||
/// The plane a canvas lies on (`input` is its label, e.g. `Offset`).
|
||||
Canvas { canvas: u64, name: String, input: String },
|
||||
}
|
||||
|
||||
impl From<ParamUser> for ParameterUser {
|
||||
fn from(u: ParamUser) -> Self {
|
||||
match u {
|
||||
ParamUser::Parameter { name } => ParameterUser::Parameter { name },
|
||||
ParamUser::Feature { feature, name, input } => ParameterUser::Feature { feature, name, input },
|
||||
ParamUser::Dimension { feature, name, constraint } => ParameterUser::Dimension { feature, name, constraint },
|
||||
ParamUser::Configuration { row, column } => ParameterUser::Configuration { row, column },
|
||||
ParamUser::Joint { joint, name, input } => ParameterUser::Joint { joint, name, input },
|
||||
ParamUser::Rule { kind, name, field } => ParameterUser::Rule { kind, name, field },
|
||||
ParamUser::Canvas { canvas, name, input } => ParameterUser::Canvas { canvas, name, input },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature a parameter drives.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DrivenFeature {
|
||||
/// The feature's id.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// What `parameters.users` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamUsersResult {
|
||||
/// The parameter's name, spaces at either end dropped.
|
||||
pub name: String,
|
||||
/// What uses it directly.
|
||||
pub users: Vec<ParameterUser>,
|
||||
/// The features it drives, directly or through other parameters.
|
||||
pub drives: Vec<DrivenFeature>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `parameters.graph` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamGraphParams {}
|
||||
|
||||
/// What `parameters.graph` returns: the dependency graph of the parameters.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamGraphResult {
|
||||
/// Every parameter name: the user's, the dimensions' and the feature inputs'.
|
||||
pub nodes: Vec<String>,
|
||||
/// `[user, used]`: the first's expression uses the second; a user may be `Feature:<name>`.
|
||||
pub edges: Vec<[String; 2]>,
|
||||
/// The names whose expressions refer to themselves, directly or through others.
|
||||
pub cycles: Vec<String>,
|
||||
/// The parameters that can't be worked out, with why, by name.
|
||||
pub errors: BTreeMap<String, String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Export the user parameters as CSV or JSON text, and write it to a file when a path is
|
||||
/// given. Changes nothing in the design.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamExportParams {
|
||||
/// `csv` or `json`, ignoring case. Default: `json` when `path` ends in `.json`, else `csv`.
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// A file to write the text to. Default: none is written.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// What `parameters.export` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamExportResult {
|
||||
/// The format used, `csv` or `json`.
|
||||
pub format: String,
|
||||
/// The file written; `null` if none was.
|
||||
pub path: Option<String>,
|
||||
/// The exported text.
|
||||
pub text: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `parameters.import` takes, as the handler reads it: see [`ParamImportParams`].
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = import_forms)]
|
||||
pub struct ParamImportKeys {
|
||||
/// The table as text: CSV with a header (`name`, `expression`, and optionally `unit` and
|
||||
/// `comment`), or JSON `[{name, expression, unit?, comment?}]`. When given, `path` isn't
|
||||
/// read.
|
||||
#[serde(default, deserialize_with = "import_text", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub text: Option<String>,
|
||||
/// A file to read the table from, in the same forms. Read when `text` isn't given.
|
||||
#[serde(default, deserialize_with = "import_text", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// `text` and `path`: text; `null` is left out, as `str_` reads it. Anything else gets the
|
||||
/// message the handler gives when it finds neither.
|
||||
fn import_text<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "give `text` or `path`")
|
||||
}
|
||||
|
||||
/// `parameters.import` needs `text` or `path` as text.
|
||||
fn import_forms(schema: &mut Schema) {
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{"required": ["text"], "properties": {"text": {"type": "string"}}},
|
||||
{"required": ["path"], "properties": {"path": {"type": "string"}}}
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add the parameters in a CSV or JSON table, all or nothing. A name that exists is refused.
|
||||
/// One undo step.
|
||||
///
|
||||
/// `path` is not read when `text` is text, as the handler leaves it.
|
||||
#[derive(Debug)]
|
||||
pub struct ParamImportParams(pub ParamImportKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ParamImportParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut() {
|
||||
match keys.get("text") {
|
||||
Some(Value::String(_)) => {
|
||||
keys.remove("path");
|
||||
}
|
||||
// Not text, so the handler reads `path`: when that works, this is the one thing
|
||||
// wrong; when it doesn't, the handler's message says what is needed.
|
||||
Some(Value::Number(_) | Value::Bool(_) | Value::Array(_) | Value::Object(_))
|
||||
if matches!(keys.get("path"), Some(Value::String(_))) =>
|
||||
{
|
||||
return Err(D::Error::custom(whole("`text` must be text")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ParamImportParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ParamImportParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ParamImportParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ParamImportKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `parameters.import` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamImportResult {
|
||||
/// The names of the parameters added, in order.
|
||||
pub imported: Vec<String>,
|
||||
/// The features that failed to rebuild with them.
|
||||
pub errors: Vec<FailedFeature>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `expr.evaluate` takes, as the handler reads it: see [`ExprEvaluateParams`].
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = expression_required)]
|
||||
pub struct ExprEvaluateKeys {
|
||||
/// The expression: text (`25 mm`, `W / 2`, `name = 3 * W`) or a number. With unit `text`, the
|
||||
/// text a text parameter would hold.
|
||||
// Optional in the type and required in the schema (`expression_required`): left out, the
|
||||
// handler refuses it with its own message.
|
||||
#[serde(default, deserialize_with = "evaluate_expression", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ExpressionText")]
|
||||
pub expression: Option<ExpressionText>,
|
||||
/// The unit the answer is shown in, and what bare numbers mean: `mm`, `cm`, `m`, `in`, `ft`,
|
||||
/// `deg`, `rad`, `""` (none) or `text` (a text parameter's text). Default: none; bare numbers
|
||||
/// follow the design's length unit.
|
||||
#[serde(default, deserialize_with = "evaluate_unit")]
|
||||
pub unit: Option<String>,
|
||||
/// What it must evaluate to: `length`, `angle` or `unitless` (`number` too). Default: the
|
||||
/// unit's kind, or what the expression is. Not read when `unit` is `text`.
|
||||
#[serde(default, deserialize_with = "evaluate_kind")]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// An expression given as text or as a number, as `expr_arg` reads it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExpressionText(pub String);
|
||||
|
||||
impl JsonSchema for ExpressionText {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ExpressionText".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An expression as text, or a number.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_expression<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ExpressionText>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(Some(ExpressionText(s))),
|
||||
Value::Number(n) => n
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite())
|
||||
.map(|v| Some(ExpressionText(format!("{v}"))))
|
||||
.ok_or_else(|| D::Error::custom(whole("`expression` must be text or a number"))),
|
||||
_ => Err(D::Error::custom(whole("`expression` must be text or a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_unit<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`unit` must be text")
|
||||
}
|
||||
|
||||
fn evaluate_kind<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`kind` must be text")
|
||||
}
|
||||
|
||||
fn expression_required(schema: &mut Schema) {
|
||||
require(schema, "expression");
|
||||
}
|
||||
|
||||
/// Evaluate an expression against the design's parameters, without changing anything: its
|
||||
/// value (in mm for a length, radians for an angle), its value in the unit asked for, what it
|
||||
/// names, or why it fails. `name = expression` is worth its expression and says what it would
|
||||
/// define.
|
||||
///
|
||||
/// `kind` is not read when `unit` is `text`, as the handler leaves it.
|
||||
#[derive(Debug)]
|
||||
pub struct ExprEvaluateParams(pub ExprEvaluateKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ExprEvaluateParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut()
|
||||
&& keys.get("unit").and_then(Value::as_str).is_some_and(solidcraft_doc::text_params::is_text_unit)
|
||||
{
|
||||
keys.remove("kind");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ExprEvaluateParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ExprEvaluateParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ExprEvaluateParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ExprEvaluateKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an evaluated expression is.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvalKind {
|
||||
Length,
|
||||
Angle,
|
||||
Unitless,
|
||||
}
|
||||
|
||||
impl From<Kind> for EvalKind {
|
||||
fn from(k: Kind) -> Self {
|
||||
match k {
|
||||
Kind::Length => EvalKind::Length,
|
||||
Kind::Angle => EvalKind::Angle,
|
||||
Kind::Unitless => EvalKind::Unitless,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EvalKind {
|
||||
/// The word for it, as it's serialized.
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
EvalKind::Length => "length",
|
||||
EvalKind::Angle => "angle",
|
||||
EvalKind::Unitless => "unitless",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `expr.evaluate` returns: one of three shapes.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExprEvaluated {
|
||||
#[serde(flatten)]
|
||||
pub body: ExprEvaluatedBody,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The shapes of an `expr.evaluate` reply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ExprEvaluatedBody {
|
||||
/// With unit `text`: the text a text parameter would hold.
|
||||
Text {
|
||||
/// Always true.
|
||||
ok: bool,
|
||||
/// Always `text`.
|
||||
kind: String,
|
||||
/// The text, without the quotes round it.
|
||||
text: String,
|
||||
/// Always empty.
|
||||
references: Vec<String>,
|
||||
},
|
||||
/// The expression evaluated.
|
||||
Value {
|
||||
/// Always true.
|
||||
ok: bool,
|
||||
/// Its value: mm for a length, radians for an angle.
|
||||
value: f64,
|
||||
/// What it is.
|
||||
kind: EvalKind,
|
||||
/// Its value in the unit asked for; with none, degrees for an angle and the value itself
|
||||
/// otherwise.
|
||||
display_value: f64,
|
||||
/// The parameters it names.
|
||||
references: Vec<String>,
|
||||
/// For `name = expression`: the name it would define.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
defines: Option<String>,
|
||||
},
|
||||
/// The expression didn't evaluate.
|
||||
Failed {
|
||||
/// Always false.
|
||||
ok: bool,
|
||||
/// Why.
|
||||
error: String,
|
||||
/// The parameters it names.
|
||||
references: Vec<String>,
|
||||
/// For `name = expression`: the name it would define.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
defines: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Suggest completions for the word at the end of some expression text: parameters (by name,
|
||||
/// anywhere in it), functions, constants and units. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExprCompleteParams {
|
||||
/// The text before the cursor; the word being typed is its trailing letters, digits and
|
||||
/// underscores. Default: empty, which lists every parameter, function and constant.
|
||||
#[serde(default, deserialize_with = "complete_prefix")]
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
fn complete_prefix<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`prefix` must be text")
|
||||
}
|
||||
|
||||
/// What a completion is.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompletionKind {
|
||||
Parameter,
|
||||
Function,
|
||||
Unit,
|
||||
Constant,
|
||||
}
|
||||
|
||||
/// One suggestion.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Completion {
|
||||
/// The text to insert: a parameter's name, a function's name and `(`, a constant or a unit.
|
||||
pub text: String,
|
||||
pub kind: CompletionKind,
|
||||
/// A parameter's expression and value, a function's signature, or a unit's kind.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// What `parameters.complete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExprCompletions {
|
||||
/// The word being completed; empty when the text ends in something else.
|
||||
pub word: String,
|
||||
/// Parameters first (names that start with the word, then the rest; favourites and user
|
||||
/// parameters first, at most 200), then functions, the constant `pi` and units.
|
||||
pub suggestions: Vec<Completion>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parameters_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ParamAddParams, ParamAdded, ParamCommentParams, ParamCommented, ParamDeleted, ParamFavoriteParams, ParamFavorited, ParamMoveParams, ParamMoved,
|
||||
ParamNameParams, ParamRenameParams, ParamRenamed,
|
||||
ExprCompleteParams, ExprCompletions, ExprEvaluateParams, ExprEvaluated, ParamAddParams, ParamAdded, ParamChangeParams, ParamChanged,
|
||||
ParamChangedBody, ParamCommentParams, ParamCommented, ParamDeleted, ParamExportParams, ParamExportResult, ParamFavoriteParams, ParamFavorited,
|
||||
ParamGraphParams, ParamGraphResult, ParamImportParams, ParamImportResult, ParamListParams, ParamListResult, ParamMoveParams, ParamMoved,
|
||||
ParamNameParams, ParamRenameParams, ParamRenamed, ParamUsersParams, ParamUsersResult, SwitchedFeature,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -16,6 +18,17 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ParamCommentParams>(), &["comment", "name"]);
|
||||
assert_keys(input_schema_for::<ParamFavoriteParams>(), &["favorite", "name"]);
|
||||
assert_keys(input_schema_for::<ParamMoveParams>(), &["after", "before", "name"]);
|
||||
assert_keys(
|
||||
input_schema_for::<ParamChangeParams>(),
|
||||
&["changes", "comment", "delete", "expression", "favorite", "name", "new_name", "unit", "value"],
|
||||
);
|
||||
assert_keys(input_schema_for::<ParamListParams>(), &["favorites", "filter"]);
|
||||
assert_keys(input_schema_for::<ParamUsersParams>(), &["name"]);
|
||||
assert_keys(input_schema_for::<ParamGraphParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ParamExportParams>(), &["format", "path"]);
|
||||
assert_keys(input_schema_for::<ParamImportParams>(), &["path", "text"]);
|
||||
assert_keys(input_schema_for::<ExprEvaluateParams>(), &["expression", "kind", "unit"]);
|
||||
assert_keys(input_schema_for::<ExprCompleteParams>(), &["prefix"]);
|
||||
}
|
||||
|
||||
/// The calls fail on a design without parameters, with the handlers' messages or the
|
||||
@@ -49,6 +62,39 @@ fn strict_keeps_todays_errors() {
|
||||
("parameters.move", json!({"name": "W", "before": "A", "after": "B"})),
|
||||
("parameters.move", json!({"name": "W", "before": 5})),
|
||||
("parameters.move", json!({"name": "Nope", "before": "A"})),
|
||||
("parameters.change", json!({})),
|
||||
("parameters.change", json!({"name": "W"})),
|
||||
("parameters.change", json!({"name": " ", "expression": 1})),
|
||||
("parameters.change", json!({"name": "W", "expression": true})),
|
||||
("parameters.change", json!({"name": "W", "expression": null})),
|
||||
("parameters.change", json!({"changes": null})),
|
||||
("parameters.change", json!({"changes": [1]})),
|
||||
("parameters.change", json!({"changes": {}})),
|
||||
("parameters.change", json!({"changes": {"W": true}})),
|
||||
("parameters.change", json!({"changes": {"W": [1]}, "name": "W", "expression": 2})),
|
||||
("parameters.change", json!({"changes": {"Nope": 1}})),
|
||||
("parameters.change", json!({"delete": "Nope"})),
|
||||
("parameters.change", json!({"delete": " "})),
|
||||
("parameters.change", json!({"name": "Nope", "favorite": true})),
|
||||
("parameters.change", json!({"name": "Nope", "new_name": "X"})),
|
||||
("parameters.users", json!({})),
|
||||
("parameters.users", json!({"name": 5})),
|
||||
("parameters.users", json!({"name": "Nope"})),
|
||||
("parameters.export", json!({"format": "xml"})),
|
||||
("parameters.export", json!({"format": "CSV", "path": "/nonexistent-dir/x/y.csv"})),
|
||||
("parameters.import", json!({})),
|
||||
("parameters.import", json!({"text": null, "path": null})),
|
||||
("parameters.import", json!({"text": 5})),
|
||||
("parameters.import", json!({"path": 5})),
|
||||
("parameters.import", json!({"text": "name,expression\n1W,1"})),
|
||||
("parameters.import", json!({"path": "/nonexistent-dir/x/y.csv"})),
|
||||
("expr.evaluate", json!({})),
|
||||
("expr.evaluate", json!({"expression": null})),
|
||||
("expr.evaluate", json!({"expression": true})),
|
||||
("expr.evaluate", json!({"expression": ["1"]})),
|
||||
("expr.evaluate", json!({"expression": "1", "unit": "furlong"})),
|
||||
("expr.evaluate", json!({"expression": "1", "kind": "colour"})),
|
||||
("expr.evaluate", json!({"expression": "1", "unit": "mm", "kind": "colour"})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -144,3 +190,348 @@ fn parameter_edits_reply_with_their_schemas() {
|
||||
let error = warned.execute("parameters.favorite", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`favourite`"), "{error}");
|
||||
}
|
||||
|
||||
/// Whether the contract reports anything for the call, and what: the warnings of Warn.
|
||||
fn issues(id: &str, p: Value) -> Vec<String> {
|
||||
crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect()
|
||||
}
|
||||
|
||||
/// `parameters.change` reads `changes` alone when it is there, `delete` alone when that is text,
|
||||
/// else the keys of one change: a call that works today in any of the forms gets no warning for
|
||||
/// a key of a form it doesn't read, and each form has the handler's messages.
|
||||
#[test]
|
||||
fn change_reads_only_the_keys_of_the_form_it_is_in() {
|
||||
let none = Vec::<String>::new();
|
||||
// The keys of another form are not read: a wrong one is no problem.
|
||||
assert_eq!(
|
||||
issues("parameters.change", json!({"changes": {"W": 1, "H": "2 mm"}, "name": 5, "expression": true, "unit": 1, "favorite": "x"})),
|
||||
none
|
||||
);
|
||||
assert_eq!(issues("parameters.change", json!({"delete": "W", "name": 5, "expression": true, "favorite": "x", "unit": 3})), none);
|
||||
assert_eq!(issues("parameters.change", json!({"changes": {"W": 1}, "delete": 5})), none);
|
||||
// A `delete` that isn't text is read as left out: the single change form it falls to
|
||||
// reports it.
|
||||
assert_eq!(issues("parameters.change", json!({"delete": 5, "name": "W", "expression": 1})), ["`delete` must be text"]);
|
||||
assert_eq!(issues("parameters.change", json!({"delete": null, "name": "W", "expression": 1})), none);
|
||||
// The single change form.
|
||||
for p in [
|
||||
json!({"name": "W", "expression": 5}),
|
||||
json!({"name": " W ", "value": "2 mm", "unit": "mm", "comment": "c", "favorite": true}),
|
||||
json!({"name": "W", "new_name": "X"}),
|
||||
json!({"name": "W", "favorite": false}),
|
||||
json!({"name": "W", "comment": "x"}),
|
||||
json!({"name": "W", "expression": null, "value": 3}),
|
||||
json!({"name": "W", "favorite": null}),
|
||||
] {
|
||||
assert_eq!(issues("parameters.change", p.clone()), none, "{p}");
|
||||
}
|
||||
for (p, message) in [
|
||||
(json!({"name": 5, "expression": 1}), "`name` is required"),
|
||||
(json!({"name": null, "expression": 1}), "`name` is required"),
|
||||
(json!({"name": "W", "expression": true}), "`expression` must be a number or text"),
|
||||
(json!({"name": "W", "new_name": 5}), "`new_name` must be text"),
|
||||
(json!({"name": "W", "expression": 1, "unit": 5}), "`unit` must be text"),
|
||||
(json!({"name": "W", "expression": 1, "comment": 5}), "`comment` must be text"),
|
||||
(json!({"name": "W", "expression": 1, "favorite": "yes"}), "`favorite` must be true or false"),
|
||||
(json!({"changes": null}), "`changes` must be an object of name: expression"),
|
||||
(json!({"changes": ["W"]}), "`changes` must be an object of name: expression"),
|
||||
(json!({"changes": {}}), "`changes` is empty"),
|
||||
(json!({"changes": {"W": 1, "H": null}}), "`changes.H` must be a number or text"),
|
||||
] {
|
||||
assert_eq!(issues("parameters.change", p.clone()), [message], "{p}");
|
||||
}
|
||||
// `value` is read only when `expression` isn't a number or text.
|
||||
assert_eq!(issues("parameters.change", json!({"name": "W", "expression": "5", "value": true})), none);
|
||||
assert_eq!(issues("parameters.change", json!({"name": "W", "expression": null, "value": true})), ["`expression` must be a number or text"]);
|
||||
// A missing key is the handler's to refuse, with its own message.
|
||||
for p in [json!({}), json!({"name": "W"}), json!({"expression": 1})] {
|
||||
assert_eq!(issues("parameters.change", p.clone()), Vec::<String>::new(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `parameters.list` doesn't read `filter` when `favorites` is true; `parameters.import` doesn't
|
||||
/// read `path` when `text` is text; `expr.evaluate` doesn't read `kind` when `unit` is `text`.
|
||||
#[test]
|
||||
fn list_import_and_evaluate_read_keys_only_where_the_handlers_do() {
|
||||
let none = Vec::<String>::new();
|
||||
assert_eq!(issues("parameters.list", json!({"favorites": true, "filter": 5})), none);
|
||||
assert_eq!(issues("parameters.list", json!({"favorites": false, "filter": 5})), ["`filter` must be text"]);
|
||||
assert_eq!(issues("parameters.list", json!({"filter": ["user"]})), ["`filter` must be text"]);
|
||||
assert_eq!(issues("parameters.list", json!({"favorites": "yes"})), ["`favorites` must be true or false"]);
|
||||
assert_eq!(issues("parameters.list", json!({"filter": null, "favorites": null})), none);
|
||||
assert_eq!(issues("parameters.list", json!({"filter": "bogus"})), none);
|
||||
|
||||
assert_eq!(issues("parameters.import", json!({"text": "name,expression\nW,1", "path": 5})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"text": "", "path": []})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"path": "p.csv"})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"text": null, "path": "p.csv"})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"path": 5})), ["give `text` or `path`"]);
|
||||
assert_eq!(issues("parameters.import", json!({"text": 5})), ["give `text` or `path`"]);
|
||||
assert_eq!(issues("parameters.import", json!({"text": 5, "path": "p.csv"})), ["`text` must be text"]);
|
||||
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": "a b", "unit": "text", "kind": 5})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": "a b", "unit": " TEXT ", "kind": "bogus"})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 5, "unit": "mm", "kind": 5})), ["`kind` must be text"]);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 5, "unit": 5})), ["`unit` must be text"]);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 5, "kind": "angle", "unit": null})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 2.5})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": {}})), ["`expression` must be text or a number"]);
|
||||
assert_eq!(issues("parameters.complete", json!({"prefix": 5})), ["`prefix` must be text"]);
|
||||
assert_eq!(issues("parameters.complete", json!({"prefix": null})), none);
|
||||
assert_eq!(issues("parameters.export", json!({"format": "JSON", "path": "x.csv"})), none);
|
||||
assert_eq!(issues("parameters.users", json!({"name": " "})), ["`name` is required"]);
|
||||
assert_eq!(issues("parameters.graph", json!({"nodes": 1})), ["unknown key `nodes`"]);
|
||||
}
|
||||
|
||||
/// The schemas say which forms and keys the handlers need: `parameters.change`'s three forms,
|
||||
/// `parameters.import`'s `text` or `path`, `expr.evaluate`'s `expression`.
|
||||
#[test]
|
||||
fn the_new_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<ParamChangeParams>(),
|
||||
&[
|
||||
(json!({"name": "W", "expression": "10 mm"}), true),
|
||||
(json!({"name": "W", "new_name": "X"}), true),
|
||||
(json!({"name": "W", "favorite": true}), true),
|
||||
(json!({"changes": {"W": 1}}), true),
|
||||
(json!({"delete": "W"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"name": "W"}), false),
|
||||
(json!({"expression": 1}), false),
|
||||
(json!({"delete": 5}), false),
|
||||
(json!({"name": "W", "expression": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ParamImportParams>(),
|
||||
&[(json!({"text": "x"}), true), (json!({"path": "p.csv"}), true), (json!({}), false), (json!({"text": 5}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ExprEvaluateParams>(),
|
||||
&[
|
||||
(json!({"expression": "1 + 2"}), true),
|
||||
(json!({"expression": 3, "unit": "mm", "kind": "length"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"expression": true}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<ParamUsersParams>(), &[(json!({"name": "W"}), true), (json!({}), false), (json!({"name": ""}), false)]);
|
||||
takes(
|
||||
input_schema_for::<ParamListParams>(),
|
||||
&[(json!({}), true), (json!({"favorites": true, "filter": "user"}), true), (json!({"favorites": "x"}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ParamExportParams>(),
|
||||
&[(json!({}), true), (json!({"format": "json", "path": "p.json"}), true), (json!({"path": 1}), false)],
|
||||
);
|
||||
takes(input_schema_for::<ExprCompleteParams>(), &[(json!({}), true), (json!({"prefix": "si"}), true), (json!({"prefix": 1}), false)]);
|
||||
// `conforms` doesn't read nested `anyOf` inside a branch's `properties`; the forms are pinned.
|
||||
let change = input_schema_for::<ParamChangeParams>();
|
||||
assert_eq!(
|
||||
change.as_value()["anyOf"],
|
||||
json!([
|
||||
{"required": ["changes"]},
|
||||
{"required": ["delete"], "properties": {"delete": {"type": "string"}}},
|
||||
{
|
||||
"required": ["name"],
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"anyOf": [
|
||||
{"required": ["expression"], "properties": {"expression": {"type": ["number", "string"]}}},
|
||||
{"required": ["value"], "properties": {"value": {"type": ["number", "string"]}}},
|
||||
{"required": ["new_name"]},
|
||||
{"required": ["favorite"]},
|
||||
{"required": ["comment"]}
|
||||
]
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// #12497: every command of the group, run in every form in Warn, replies as its output schema
|
||||
/// says and without warnings (Warn runs a call the contract refuses anyway, so the absence of
|
||||
/// `param_warnings` is what shows the contract accepts the call).
|
||||
#[test]
|
||||
fn the_parameter_commands_reply_with_their_schemas() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-params-schema-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run("parameters.add", json!({"name": "W", "expression": "10 mm", "favorite": true}));
|
||||
run("parameters.add", json!({"name": "H", "expression": 5, "unit": "mm"}));
|
||||
run("parameters.add", json!({"name": "Label", "expression": "Rev A", "unit": "text"}));
|
||||
run("solid.box", json!({"length": "W", "width": "H * 2", "height": 4, "body_name": "Block"}));
|
||||
|
||||
// Change: one parameter in each of its forms, several at once, a delete.
|
||||
let r = run("parameters.change", json!({"name": "W", "expression": "12 mm", "comment": "wall"}));
|
||||
assert!(r["value"] == 12.0 && r["expression"] == "12 mm" && r.get("renamed").is_none(), "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "W", "value": 14, "unit": "mm", "favorite": false}));
|
||||
assert_eq!(r["value"], 14.0, "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "W", "new_name": "Wall"}));
|
||||
assert!(r["renamed"]["from"] == "W" && r["renamed"]["to"] == "Wall" && r["expression"].is_null(), "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "Wall", "comment": "thin", "favorite": true}));
|
||||
assert!(r["expression"].is_null() && r["name"] == "Wall", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "Wall", "expression": "Wide = 16"}));
|
||||
assert!(r["renamed"]["to"] == "Wide" && r["name"] == "Wide", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "Label", "expression": "Rev B"}));
|
||||
assert_eq!(r["text"], "Rev B", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"changes": {"Wide": 20, "H": "6 mm"}}));
|
||||
assert!(r["changed"]["Wide"] == 20.0 && r["changed"]["H"] == 6.0, "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
// The other forms' keys are not read, so a bad one doesn't warn.
|
||||
let r = run("parameters.change", json!({"changes": {"H": 7}, "name": 5, "expression": true}));
|
||||
assert_eq!(r["changed"]["H"], 7.0, "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
run("parameters.add", json!({"name": "Spare", "expression": 1}));
|
||||
let r = run("parameters.change", json!({"delete": " Spare ", "name": 5}));
|
||||
assert_eq!(r["deleted"], "Spare", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
|
||||
// List: all forms of the filter, favourites first.
|
||||
let all = run("parameters.list", json!({}));
|
||||
assert_eq!(all["count"], all["parameters"].as_array().map_or(0, Vec::len), "{all}");
|
||||
matches(output_schema_for::<ParamListResult>(), "parameters.list", &all);
|
||||
for p in [
|
||||
json!({"favorites": true}),
|
||||
json!({"favorites": true, "filter": 5}),
|
||||
json!({"filter": "USER"}),
|
||||
json!({"filter": "feature"}),
|
||||
json!({"filter": "sketch"}),
|
||||
json!({"filter": "model"}),
|
||||
json!({"filter": "bogus"}),
|
||||
] {
|
||||
let r = run("parameters.list", p);
|
||||
matches(output_schema_for::<ParamListResult>(), "parameters.list", &r);
|
||||
}
|
||||
let favorites = run("parameters.list", json!({"favorites": true}));
|
||||
assert!(favorites["parameters"].as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|r| r["favorite"] == true)), "{favorites}");
|
||||
let feature = run("parameters.list", json!({"filter": "feature"}));
|
||||
assert!(feature["parameters"].as_array().is_some_and(|a| a.iter().all(|r| r["source"] == "feature")), "{feature}");
|
||||
|
||||
// Users and graph.
|
||||
let users = run("parameters.users", json!({"name": " H "}));
|
||||
assert_eq!(users["name"], "H", "{users}");
|
||||
assert!(users["users"].as_array().is_some_and(|u| !u.is_empty()) && users["drives"][0]["name"] == "Box1", "{users}");
|
||||
matches(output_schema_for::<ParamUsersResult>(), "parameters.users", &users);
|
||||
let graph = run("parameters.graph", json!({}));
|
||||
assert!(graph["nodes"].as_array().is_some_and(|n| n.contains(&json!("H"))), "{graph}");
|
||||
matches(output_schema_for::<ParamGraphResult>(), "parameters.graph", &graph);
|
||||
|
||||
// Export in both formats, to text and to a file; import them back.
|
||||
let csv = run("parameters.export", json!({}));
|
||||
assert!(csv["format"] == "csv" && csv["path"].is_null(), "{csv}");
|
||||
matches(output_schema_for::<ParamExportResult>(), "parameters.export", &csv);
|
||||
let json_text = run("parameters.export", json!({"format": "JSON"}));
|
||||
assert_eq!(json_text["format"], "json", "{json_text}");
|
||||
matches(output_schema_for::<ParamExportResult>(), "parameters.export", &json_text);
|
||||
let file = dir.join("parameters.json");
|
||||
let written = run("parameters.export", json!({"path": file}));
|
||||
assert!(written["format"] == "json" && written["path"].is_string(), "{written}");
|
||||
matches(output_schema_for::<ParamExportResult>(), "parameters.export", &written);
|
||||
let mut other = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let imported = other.execute("parameters.import", &json!({"path": file})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(imported["imported"].as_array().is_some_and(|n| n.len() == 3), "{imported}");
|
||||
matches(output_schema_for::<ParamImportResult>(), "parameters.import", &imported);
|
||||
let from_text = other.execute("parameters.import", &json!({"text": "name,expression\nZ,3", "path": 5})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(from_text["imported"], json!(["Z"]), "{from_text}");
|
||||
matches(output_schema_for::<ParamImportResult>(), "parameters.import", &from_text);
|
||||
|
||||
// Evaluate: a length, an angle, a unitless number, a definition, an error, a text.
|
||||
for p in [
|
||||
json!({"expression": "H * 2"}),
|
||||
json!({"expression": 3, "unit": "in"}),
|
||||
json!({"expression": "90 deg"}),
|
||||
json!({"expression": "30", "unit": "deg"}),
|
||||
json!({"expression": "2 * 3", "kind": "unitless"}),
|
||||
json!({"expression": "Wide", "kind": "length"}),
|
||||
json!({"expression": "len = 3 mm"}),
|
||||
json!({"expression": "nope + 1"}),
|
||||
json!({"expression": "Rev \"A\"", "unit": "text", "kind": 5}),
|
||||
] {
|
||||
let r = run("expr.evaluate", p.clone());
|
||||
matches(output_schema_for::<ExprEvaluated>(), "expr.evaluate", &r);
|
||||
assert_eq!(r["ok"], !p["expression"].as_str().is_some_and(|e| e.starts_with("nope")), "{r}");
|
||||
}
|
||||
let r = run("expr.evaluate", json!({"expression": "len = 3 mm"}));
|
||||
assert!(r["defines"] == "len" && r["kind"] == "length" && r["value"] == 3.0, "{r}");
|
||||
let r = run("expr.evaluate", json!({"expression": "'Rev A'", "unit": "text"}));
|
||||
assert!(r["kind"] == "text" && r["text"] == "Rev A" && r["references"] == json!([]), "{r}");
|
||||
let r = run("expr.evaluate", json!({"expression": "nope + 1"}));
|
||||
assert!(r["ok"] == false && r["error"].is_string() && r["references"] == json!(["nope"]), "{r}");
|
||||
|
||||
// Complete: parameters, functions, constants, units.
|
||||
for p in
|
||||
[json!({}), json!({"prefix": "H"}), json!({"prefix": "2 * si"}), json!({"prefix": "p"}), json!({"prefix": "3 m"}), json!({"prefix": null})]
|
||||
{
|
||||
let r = run("parameters.complete", p);
|
||||
matches(output_schema_for::<ExprCompletions>(), "parameters.complete", &r);
|
||||
}
|
||||
let r = run("parameters.complete", json!({"prefix": "3 m"}));
|
||||
assert!(r["suggestions"].as_array().is_some_and(|a| a.iter().any(|c| c["kind"] == "unit" && c["detail"] == "length")), "{r}");
|
||||
let r = run("parameters.complete", json!({"prefix": "p"}));
|
||||
assert!(r["suggestions"].as_array().is_some_and(|a| a.iter().any(|c| c["kind"] == "constant")), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A misspelt key runs in Warn and is reported, with a reply that still matches; strict refuses
|
||||
/// it. Each command of the group.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_for_each_command() {
|
||||
let calls = [
|
||||
("parameters.change", json!({"name": "W", "comment": "c", "favorit": true}), "favorit"),
|
||||
("parameters.list", json!({"favourites": true}), "favourites"),
|
||||
("parameters.users", json!({"name": "W", "nam": 1}), "nam"),
|
||||
("parameters.graph", json!({"node": 1}), "node"),
|
||||
("parameters.export", json!({"formt": "csv"}), "formt"),
|
||||
("parameters.import", json!({"text": "name,expression\nQ,1", "pth": "x"}), "pth"),
|
||||
("expr.evaluate", json!({"expression": "1", "unt": "mm"}), "unt"),
|
||||
("parameters.complete", json!({"prefx": "W"}), "prefx"),
|
||||
];
|
||||
for (id, p, key) in calls {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
s.execute("parameters.add", &json!({"name": "W", "expression": "10 mm"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(r["param_warnings"].to_string().contains(key), "{id}: {r}");
|
||||
let schema = crate::cmd::find_command(id).and_then(|c| c.output).map(|f| f()).unwrap_or_else(|| panic!("{id} has no output schema"));
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r} doesn't match {}", schema.as_value());
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains(&format!("`{key}`")), "{id}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A configuration rule's switch has the shape the schema describes.
|
||||
#[test]
|
||||
fn a_switched_feature_serializes_as_the_schema_says() {
|
||||
let body = ParamChangedBody::Many {
|
||||
changed: [("W".to_string(), Some(1.0))].into(),
|
||||
recomputed: 2,
|
||||
restored: 0,
|
||||
errors: Vec::new(),
|
||||
switched: vec![SwitchedFeature { feature: 3, name: Some("Hole".into()), suppressed: true }],
|
||||
};
|
||||
let reply = serde_json::to_value(ParamChanged { body, param_warnings: Vec::new() }).unwrap_or_default();
|
||||
assert_eq!(
|
||||
reply,
|
||||
json!({"changed": {"W": 1.0}, "recomputed": 2, "restored": 0, "errors": [], "switched": [{"feature": 3, "name": "Hole", "suppressed": true}]})
|
||||
);
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &reply);
|
||||
}
|
||||
|
||||
@@ -338,8 +338,8 @@ pub struct Constrained {
|
||||
pub determined_by: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.horizontal_vertical`, `tangent`, `parallel`, `perpendicular`,
|
||||
/// `collinear`, `concentric` and `midpoint` return.
|
||||
/// What `sketch.constraint.horizontal_vertical`, `parallel`, `perpendicular`, `collinear`,
|
||||
/// `concentric` and `midpoint` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ConstraintResult {
|
||||
#[serde(flatten)]
|
||||
@@ -349,6 +349,35 @@ pub struct ConstraintResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.tangent` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TangentResult {
|
||||
#[serde(flatten)]
|
||||
pub made: TangentMade,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a Tangent call added.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum TangentMade {
|
||||
/// Two curves: one Tangent constraint.
|
||||
Curves(Constrained),
|
||||
/// A curve's end point for `a` or `b` (#12092): the end now lies on the other curve (a
|
||||
/// point-on-curve or, for two end points, a coincident constraint, left out when the end was
|
||||
/// already there) and its curve is tangent there.
|
||||
End {
|
||||
/// The Tangent constraint.
|
||||
constraint: String,
|
||||
/// Every constraint added, the Tangent last.
|
||||
constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
sketch: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.coincident` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CoincidentResult {
|
||||
@@ -493,10 +522,11 @@ macro_rules! two_operands {
|
||||
}
|
||||
|
||||
two_operands! {
|
||||
/// Make two curves, or a curve and a sketch axis, tangent. Two different curves are needed.
|
||||
/// Make two curves, a curve and a sketch axis, or a curve's end point and another curve,
|
||||
/// tangent. Two different curves are needed.
|
||||
TangentParams,
|
||||
a: "A curve's id, or `x` / `y` for the sketch's own axis (a curve with that id wins; an axis becomes a linked construction line, reported in `created`), or a model edge.",
|
||||
b: "The other curve, as `a`.",
|
||||
a: "A curve's id, or `x` / `y` for the sketch's own axis (a curve with that id wins; an axis becomes a linked construction line, reported in `created`), or a model edge, or the end point of a line or an arc (such as `l1.end`): that end then lies on the other curve, and its curve is tangent there (`constraints` lists both).",
|
||||
b: "The other curve or end point, as `a`.",
|
||||
what: "a curve id, or x / y for a sketch axis"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Contracts and result types for the commands in `cmd/sketch_3d.rs`: `sketch.line3d`,
|
||||
//! `sketch.spline3d`, `sketch.point3d` and `sketch.move3d`.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether the sketch has 3D Sketch on, a wire
|
||||
//! exists or a point is in range stays the handler's to check, as does `move3d` being given
|
||||
//! neither `to` nor `by`. A call with several problems may get a different one first in strict
|
||||
//! than with the check off (the check goes key by key, the handlers in their own order).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::Point3;
|
||||
use crate::params;
|
||||
|
||||
/// Draw 3D lines through world points, one line per segment, joined end to end. Needs 3D Sketch
|
||||
/// on for the sketch (`sketch.options` with `sketch_3d: true`). One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = points_required)]
|
||||
pub struct Line3dParams {
|
||||
// Optional in the type and required in the schema (`points_required`): left out, the handler
|
||||
// refuses it with its own message, not the check's "is required".
|
||||
/// The world points [x, y, z] in mm, 2 to 500: one line per segment between neighbours.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "DrawnPoints")]
|
||||
pub points: Option<DrawnPoints>,
|
||||
/// The sketch to draw in: its feature id or its name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Draw a smooth 3D curve through world fit points. Needs 3D Sketch on for the sketch
|
||||
/// (`sketch.options` with `sketch_3d: true`). One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = points_required)]
|
||||
pub struct Spline3dParams {
|
||||
// Optional in the type and required in the schema, as `Line3dParams::points`.
|
||||
/// The world fit points [x, y, z] in mm, 2 to 500, which the curve passes through.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "DrawnPoints")]
|
||||
pub points: Option<DrawnPoints>,
|
||||
/// The sketch to draw in: its feature id or its name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Draw a 3D point at a world point. Needs 3D Sketch on for the sketch (`sketch.options` with
|
||||
/// `sketch_3d: true`). One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = point_required)]
|
||||
pub struct Point3dParams {
|
||||
// Optional in the type and required in the schema, as `Line3dParams::points`.
|
||||
/// The world point [x, y, z] in mm.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "WorldPoint")]
|
||||
pub point: Option<WorldPoint>,
|
||||
/// The sketch to draw in: its feature id or its name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Move one fit point of a drawn 3D curve or point, to a world point or by a world offset. The
|
||||
/// points joined to it move too. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = move_required)]
|
||||
pub struct Move3dParams {
|
||||
// Optional in the type and required in the schema, as `Line3dParams::points`.
|
||||
/// The drawn 3D curve or point to move, by id.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "WireId")]
|
||||
pub wire: Option<WireId>,
|
||||
/// Which fit point of the curve (0 is the first), below 500. Default: 0.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "FitIndex")]
|
||||
pub index: Option<FitIndex>,
|
||||
/// Where the fit point goes, as a world point [x, y, z] in mm. Wins over `by`. The schema
|
||||
/// requires `to` or `by`; the handler refuses a call with neither.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "MoveTarget")]
|
||||
pub to: Option<MoveTarget>,
|
||||
/// How far the fit point moves, as a world offset [dx, dy, dz] in mm. Used when `to` is not
|
||||
/// given.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "MoveTarget")]
|
||||
pub by: Option<MoveTarget>,
|
||||
/// The sketch the curve is in: its feature id or its name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// A key that, once given (`null` included), must parse as `T`, as the handlers read it.
|
||||
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn points_required(schema: &mut Schema) {
|
||||
require(schema, "points");
|
||||
}
|
||||
|
||||
fn point_required(schema: &mut Schema) {
|
||||
require(schema, "point");
|
||||
}
|
||||
|
||||
fn move_required(schema: &mut Schema) {
|
||||
require(schema, "wire");
|
||||
schema.insert("anyOf".into(), json!([{"required": ["to"]}, {"required": ["by"]}]));
|
||||
}
|
||||
|
||||
/// The `points` of `sketch.line3d` and `sketch.spline3d`: a list of 2 to 500 points, as their
|
||||
/// `points` reader takes it, with its messages.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawnPoints(pub Vec<Vec3>);
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawnPoints {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let max = solidcraft_sketch::MAX_FIT_POINTS;
|
||||
let Value::Array(a) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`points` must list [x,y,z] points")));
|
||||
};
|
||||
if a.len() < 2 || a.len() > max {
|
||||
return Err(D::Error::custom(whole(format!("`points` needs 2 to {max} points"))));
|
||||
}
|
||||
a.iter()
|
||||
.map(|v| params::vec3(v).ok_or_else(|| D::Error::custom(whole("a point must be [x, y, z]"))))
|
||||
.collect::<Result<_, _>>()
|
||||
.map(DrawnPoints)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawnPoints {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawnPoints".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "World points [x, y, z] in mm, 2 to 500.",
|
||||
"type": "array",
|
||||
"items": point,
|
||||
"minItems": 2,
|
||||
"maxItems": solidcraft_sketch::MAX_FIT_POINTS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The `point` of `sketch.point3d`: a [`Point3`], with the handler's message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WorldPoint(pub Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for WorldPoint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec3(&Value::deserialize(d)?).map(WorldPoint).ok_or_else(|| D::Error::custom("must be a point [x, y, z]"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for WorldPoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"WorldPoint".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point3>()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `to` and `by` of `sketch.move3d`: a [`Point3`], with the handler's message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MoveTarget(pub Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for MoveTarget {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec3(&Value::deserialize(d)?).map(MoveTarget).ok_or_else(|| D::Error::custom("must be [x,y,z]"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MoveTarget {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MoveTarget".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point3>()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `wire` of `sketch.move3d`: text, as the handler reads it. Whether a drawn curve or point
|
||||
/// has that id is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WireId(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for WireId {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(WireId(s)),
|
||||
_ => Err(D::Error::custom("must be a 3D curve or point id")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for WireId {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"WireId".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// The `index` of `sketch.move3d`: a whole number below 500, as the handler reads it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FitIndex(pub usize);
|
||||
|
||||
impl<'de> Deserialize<'de> for FitIndex {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|v| *v < solidcraft_sketch::MAX_FIT_POINTS)
|
||||
.map(FitIndex)
|
||||
.ok_or_else(|| D::Error::custom("must be a fit point index below 500"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FitIndex {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FitIndex".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "integer", "minimum": 0, "maximum": solidcraft_sketch::MAX_FIT_POINTS - 1})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.line3d` and `sketch.spline3d` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Curves3dResult {
|
||||
/// The ids of the curves drawn: one per segment for a line, one for a spline.
|
||||
pub curves: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.point3d` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Point3dResult {
|
||||
/// The id of the point drawn.
|
||||
pub point: String,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.move3d` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Move3dResult {
|
||||
/// How many fit points moved: the one asked for and the points joined to it.
|
||||
pub moved: usize,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_3d_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,241 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Curves3dResult, Line3dParams, Move3dParams, Move3dResult, Point3dParams, Point3dResult, Spline3dParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<Line3dParams>(), &["points", "sketch"]);
|
||||
assert_keys(input_schema_for::<Spline3dParams>(), &["points", "sketch"]);
|
||||
assert_keys(input_schema_for::<Point3dParams>(), &["point", "sketch"]);
|
||||
assert_keys(input_schema_for::<Move3dParams>(), &["by", "index", "sketch", "to", "wire"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let many: Vec<Value> = (0..501).map(|i| json!([i, 0, 0])).collect();
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.line3d", json!({})),
|
||||
("sketch.line3d", json!({"points": null})),
|
||||
("sketch.line3d", json!({"points": "x"})),
|
||||
("sketch.line3d", json!({"points": {"0": [0, 0, 0]}})),
|
||||
("sketch.line3d", json!({"points": []})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0]]})),
|
||||
("sketch.line3d", json!({"points": many})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [1, 2]]})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], "a"]})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 1]], "sketch": true})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 1]], "sketch": -1})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 1]], "sketch": 1.5})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 1]], "sketch": [1]})),
|
||||
("sketch.spline3d", json!({})),
|
||||
("sketch.spline3d", json!({"points": [[0, 0, 0]]})),
|
||||
("sketch.spline3d", json!({"points": 5})),
|
||||
("sketch.spline3d", json!({"points": [[0, 0, 0], [1, 1]]})),
|
||||
("sketch.point3d", json!({})),
|
||||
("sketch.point3d", json!({"point": null})),
|
||||
("sketch.point3d", json!({"point": [1, 2]})),
|
||||
("sketch.point3d", json!({"point": "1,2,3"})),
|
||||
("sketch.point3d", json!({"point": [1, 2, 3], "sketch": {}})),
|
||||
("sketch.move3d", json!({})),
|
||||
("sketch.move3d", json!({"wire": 3, "to": [0, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": null, "to": [0, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "index": -1, "to": [0, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "index": 500, "to": [0, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "index": 1.5, "to": [0, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "index": "1", "to": [0, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "index": null, "to": [0, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "to": [0, 0]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "to": null})),
|
||||
("sketch.move3d", json!({"wire": "w1", "by": "up"})),
|
||||
("sketch.move3d", json!({"wire": "w1", "by": null})),
|
||||
("sketch.move3d", json!({"wire": "w1", "to": [0, 0, 0], "by": [1]})),
|
||||
("sketch.move3d", json!({"wire": "w1", "to": [0, 0, 0], "sketch": true})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A session with a 3D sketch being edited, and the ids of a drawn line's curves.
|
||||
fn three_d(level: ParamCheck) -> (Session, Vec<String>) {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [("sketch.create", json!({"plane": "XY", "name": "Path"})), ("sketch.options", json!({"sketch_3d": true}))] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
let drawn = s.execute("sketch.line3d", &json!({"points": [[0, 0, 0], [0, 0, 10], [10, 0, 10]]})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let ids = drawn["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(String::from)).collect();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// Calls the handlers take today (every form of each key, `null` for `sketch`) get no issue from
|
||||
/// the contracts; calls they refuse are refused by strict with the same message.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let (_, ids) = three_d(ParamCheck::Off);
|
||||
let wire = ids[0].clone();
|
||||
let cases = [
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [1, 1, 1]]})),
|
||||
("sketch.line3d", json!({"points": [{"x": 0, "y": 0, "z": 0}, [1, 1, 1], [2, 2, 2]], "sketch": null})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [1, 1, 1]], "sketch": "Path"})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [1, 1, 1]], "sketch": 9999})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [1, 1, 1]], "sketch": "no such sketch"})),
|
||||
("sketch.line3d", json!({"points": [[0, 0, 0], [1e9, 1, 1]]})),
|
||||
("sketch.line3d", json!({"points": (0..500).map(|i| json!([i, 0, 0])).collect::<Vec<_>>()})),
|
||||
("sketch.spline3d", json!({"points": [[0, 0, 0], [1, 1, 1], [2, 0, 3]]})),
|
||||
("sketch.spline3d", json!({"points": [[0, 0, 0], [1, 1, 1]], "sketch": null})),
|
||||
("sketch.point3d", json!({"point": [3, 4, 5]})),
|
||||
("sketch.point3d", json!({"point": {"x": 3, "y": 4, "z": 5}, "sketch": "Path"})),
|
||||
("sketch.move3d", json!({"wire": wire, "to": [1, 2, 3]})),
|
||||
("sketch.move3d", json!({"wire": wire, "index": 1, "by": [1, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": wire, "index": 0, "to": [1, 2, 3], "by": [1, 0, 0]})),
|
||||
("sketch.move3d", json!({"wire": wire, "by": {"x": 1, "y": 0, "z": 0}, "sketch": null})),
|
||||
("sketch.move3d", json!({"wire": wire, "by": [1, 0, 0], "to": null})),
|
||||
("sketch.move3d", json!({"wire": wire})),
|
||||
("sketch.move3d", json!({"wire": "no such wire", "to": [1, 2, 3]})),
|
||||
("sketch.move3d", json!({"wire": wire, "index": 7, "to": [1, 2, 3]})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let (mut off, _) = three_d(ParamCheck::Off);
|
||||
let (mut strict, _) = three_d(ParamCheck::Strict);
|
||||
let (mut warn, _) = three_d(ParamCheck::Warn);
|
||||
let today = off.execute(id, &p).map_err(|e| e.to_string());
|
||||
let checked = strict.execute(id, &p).map_err(|e| e.to_string());
|
||||
let warned = warn.execute(id, &p);
|
||||
let issues: Vec<_> = crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect();
|
||||
match &today {
|
||||
// A refusal the handler gives about the document (no such sketch, wire or point).
|
||||
Err(e) if issues.is_empty() => {
|
||||
let document = ["no sketch", "is not an editable drawn 3D curve", "has no drawn point", "give `to` or `by`"];
|
||||
assert!(document.iter().any(|m| e.contains(m)), "{id} {p}: refused for another reason: {e}");
|
||||
assert_eq!(checked.as_ref().err(), Some(e), "{id} {p}");
|
||||
assert!(warned.is_err(), "{id} {p}");
|
||||
}
|
||||
Err(e) => assert_eq!(checked.as_ref().err(), Some(e), "{id} {p}: {issues:?}"),
|
||||
Ok(reply) => {
|
||||
assert!(issues.is_empty(), "{id} {p}: {issues:?}");
|
||||
assert_eq!(checked.as_ref().ok(), Some(reply), "{id} {p}");
|
||||
assert_eq!(warned.ok().as_ref(), Some(reply), "{id} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: the keys they refuse without are required, `to` or
|
||||
/// `by` is needed, and a point is `[x, y, z]` or `{x, y, z}`.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
let two = json!([[0, 0, 0], [1, 1, 1]]);
|
||||
for schema in [input_schema_for::<Line3dParams>(), input_schema_for::<Spline3dParams>()] {
|
||||
takes(
|
||||
schema,
|
||||
&[
|
||||
(json!({"points": two}), true),
|
||||
(json!({"points": [{"x": 0, "y": 0, "z": 0}, [1, 1, 1]], "sketch": "Path"}), true),
|
||||
(json!({"points": two, "sketch": 4}), true),
|
||||
(json!({}), false),
|
||||
(json!({"points": [[0, 0, 0]]}), false),
|
||||
(json!({"points": [[0, 0, 0], [1, 1]]}), false),
|
||||
(json!({"points": two, "sketch": true}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
takes(
|
||||
input_schema_for::<Point3dParams>(),
|
||||
&[
|
||||
(json!({"point": [1, 2, 3]}), true),
|
||||
(json!({"point": {"x": 1, "y": 2, "z": 3}}), true),
|
||||
(json!({}), false),
|
||||
(json!({"point": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<Move3dParams>(),
|
||||
&[
|
||||
(json!({"wire": "w1", "to": [1, 2, 3]}), true),
|
||||
(json!({"wire": "w1", "by": [1, 2, 3], "index": 499}), true),
|
||||
(json!({"wire": "w1", "to": [1, 2, 3], "by": [1, 2, 3], "sketch": "Path"}), true),
|
||||
(json!({"to": [1, 2, 3]}), false),
|
||||
(json!({"wire": "w1"}), false),
|
||||
(json!({"wire": "w1", "to": [1, 2, 3], "index": 500}), false),
|
||||
(json!({"wire": "w1", "to": [1, 2, 3], "index": -1}), false),
|
||||
(json!({"wire": 3, "to": [1, 2, 3]}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// Each command, in Warn: the real reply matches its output schema and carries no warning.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let (mut s, _) = three_d(ParamCheck::Warn);
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let line = run("sketch.line3d", json!({"points": [[0, 5, 0], [0, 5, 10], [10, 5, 10]]}));
|
||||
assert_eq!(line["curves"].as_array().map(Vec::len), Some(2), "{line}");
|
||||
matches(output_schema_for::<Curves3dResult>(), "sketch.line3d", &line);
|
||||
let by_name = run("sketch.line3d", json!({"points": [[0, 6, 0], [0, 6, 10]], "sketch": "Path"}));
|
||||
matches(output_schema_for::<Curves3dResult>(), "sketch.line3d", &by_name);
|
||||
let spline = run("sketch.spline3d", json!({"points": [[0, 8, 0], [5, 9, 5], [10, 8, 10]]}));
|
||||
assert_eq!(spline["curves"].as_array().map(Vec::len), Some(1), "{spline}");
|
||||
matches(output_schema_for::<Curves3dResult>(), "sketch.spline3d", &spline);
|
||||
let point = run("sketch.point3d", json!({"point": {"x": 3, "y": 4, "z": 5}}));
|
||||
assert!(point["point"].is_string(), "{point}");
|
||||
matches(output_schema_for::<Point3dResult>(), "sketch.point3d", &point);
|
||||
let curve = line["curves"][0].clone();
|
||||
for p in [
|
||||
json!({"wire": curve, "index": 1, "by": [0, 0, 5]}),
|
||||
json!({"wire": curve, "to": [1, 5, 1]}),
|
||||
json!({"wire": point["point"], "to": [3, 4, 6], "by": [9, 9, 9]}),
|
||||
] {
|
||||
let moved = run("sketch.move3d", p);
|
||||
assert!(moved["moved"].as_u64().is_some_and(|n| n >= 1), "{moved}");
|
||||
matches(output_schema_for::<Move3dResult>(), "sketch.move3d", &moved);
|
||||
}
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn, and says so, with a reply that still matches.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let (mut s, ids) = three_d(ParamCheck::Warn);
|
||||
let calls = [
|
||||
("sketch.line3d", json!({"points": [[0, 5, 0], [0, 5, 10]], "skech": "Path"})),
|
||||
("sketch.spline3d", json!({"points": [[0, 5, 0], [0, 5, 10]], "skech": "Path"})),
|
||||
("sketch.point3d", json!({"point": [1, 2, 3], "pont": [1, 2, 3]})),
|
||||
("sketch.move3d", json!({"wire": ids[0], "to": [1, 2, 3], "inex": 1})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
let warnings = reply["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains("unknown key")), "{id}: {reply}");
|
||||
let schema = match id {
|
||||
"sketch.point3d" => output_schema_for::<Point3dResult>(),
|
||||
"sketch.move3d" => output_schema_for::<Move3dResult>(),
|
||||
_ => output_schema_for::<Curves3dResult>(),
|
||||
};
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{id}: {reply}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning for what a key gets wrong is the handler's own wording.
|
||||
#[test]
|
||||
fn the_check_names_what_is_wrong_with_a_key() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(issues("sketch.line3d", json!({"points": [[0, 0, 0]]})), ["`points` needs 2 to 500 points"]);
|
||||
assert_eq!(issues("sketch.line3d", json!({"points": [[0, 0, 0], [1]]})), ["a point must be [x, y, z]"]);
|
||||
assert_eq!(issues("sketch.spline3d", json!({"points": 1})), ["`points` must list [x,y,z] points"]);
|
||||
assert_eq!(issues("sketch.point3d", json!({"point": [1]})), ["`point` must be a point [x, y, z]"]);
|
||||
assert_eq!(issues("sketch.move3d", json!({"wire": 1})), ["`wire` must be a 3D curve or point id"]);
|
||||
assert_eq!(issues("sketch.move3d", json!({"wire": "w", "index": 500})), ["`index` must be a fit point index below 500"]);
|
||||
assert_eq!(issues("sketch.move3d", json!({"wire": "w", "by": 1})), ["`by` must be [x,y,z]"]);
|
||||
assert_eq!(issues("sketch.line3d", json!({"points": [[0, 0, 0], [1, 1, 1]], "sketch": true})), ["`sketch` must be an id or a name"]);
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
//! Contracts and result types for the commands in `cmd/sketch_constraints.rs`.
|
||||
//! Contracts and result types for the commands in `cmd/sketch_constraints.rs`: the curvature and
|
||||
//! polygon constraints, the constrainer, Auto Constrain (three commands), Toggle Driven Dimension,
|
||||
//! Toggle Radius / Diameter, the constraint glyphs and Snap Inference.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. What only the design can tell (whether a
|
||||
//! sketch, curve, point or dimension exists) stays the handler's to check. A key the handler
|
||||
//! refuses when it is left out (`a`, `b`, `lines`, `entities`, `constraint`, `at`) is optional in
|
||||
//! the type, so the handler keeps its own message, and required in the schema. A call with several
|
||||
//! problems may get a different one first in strict than with the check off (the check goes key by
|
||||
//! key, the handlers in their own order).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::types::{SketchRef, present};
|
||||
use super::check::whole;
|
||||
use super::config::NameList;
|
||||
use super::motion::require;
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{Point2, SketchRef, present};
|
||||
use crate::params;
|
||||
|
||||
/// Switch a circle's or an arc's dimension between its radius and its diameter.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -44,6 +62,464 @@ pub struct ToggleDiameterResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A curve's id, as `sketch.constraint.curvature` reads `a` and `b`: text, else its message.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CurveId(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for CurveId {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(CurveId(s)),
|
||||
_ => Err(D::Error::custom(whole("expected a curve id"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CurveId {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CurveId".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A curve's id in the sketch, such as `c3`.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimension's constraint id, as `sketch.toggle_driven` reads `constraint`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DimensionId(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for DimensionId {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(DimensionId(s)),
|
||||
_ => Err(D::Error::custom("must be a dimension id")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DimensionId {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DimensionId".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A dimension's constraint id in the sketch.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of `sketch.snap`'s `at`: `[x, y]` or `{x, y}` of numbers (below 1e9), with
|
||||
/// `params::req_vec2`'s message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ClickPoint(pub solidcore_record::Vec2);
|
||||
|
||||
impl<'de> Deserialize<'de> for ClickPoint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec2(&Value::deserialize(d)?).map(ClickPoint).ok_or_else(|| D::Error::custom("must be a point [x, y]"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ClickPoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ClickPoint".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point2>()
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of the refusing-when-left-out keys are listed in the schema's `required`.
|
||||
fn require_keys(schema: &mut Schema, keys: &[&str]) {
|
||||
for key in keys {
|
||||
require(schema, key);
|
||||
}
|
||||
}
|
||||
|
||||
fn curvature_required(schema: &mut Schema) {
|
||||
require_keys(schema, &["a", "b"]);
|
||||
}
|
||||
|
||||
fn polygon_required(schema: &mut Schema) {
|
||||
require_keys(schema, &["lines"]);
|
||||
}
|
||||
|
||||
fn constrainer_required(schema: &mut Schema) {
|
||||
require_keys(schema, &["entities"]);
|
||||
}
|
||||
|
||||
fn driven_required(schema: &mut Schema) {
|
||||
require_keys(schema, &["constraint"]);
|
||||
}
|
||||
|
||||
fn snap_required(schema: &mut Schema) {
|
||||
require_keys(schema, &["at"]);
|
||||
}
|
||||
|
||||
/// Make two curves meet with curvature continuity (G2): they must share an end point.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = curvature_required)]
|
||||
pub struct CurvatureParams {
|
||||
// Optional in the type and required in the schema (`curvature_required`): left out, the
|
||||
// handler refuses it with its own message, not the check's "is required".
|
||||
/// The first curve's id.
|
||||
#[serde(default)]
|
||||
pub a: Option<CurveId>,
|
||||
/// The second curve's id.
|
||||
#[serde(default)]
|
||||
pub b: Option<CurveId>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// `refuse` (any case) refuses the constraint when the others already determine it. Anything
|
||||
/// else keeps it, shown dimmed as redundant. Read only then.
|
||||
#[serde(default)]
|
||||
pub if_redundant: Option<String>,
|
||||
}
|
||||
|
||||
/// Make a closed chain of lines a regular polygon: the sides equal and every corner on one
|
||||
/// (construction) circle, which is added.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = polygon_required)]
|
||||
pub struct PolygonParams {
|
||||
// Optional in the type and required in the schema, as `a` above.
|
||||
/// The ids of the lines of the closed chain: 3 to 200 of them, each a line whose end points
|
||||
/// are shared with its neighbours.
|
||||
#[serde(default)]
|
||||
pub lines: Option<NameList>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Apply the constraint one or two sketch entities nearly have already: horizontal or vertical
|
||||
/// for one line; for two, coincident, point on curve, collinear, parallel, perpendicular, equal,
|
||||
/// concentric or tangent, whichever the geometry is closest to.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = constrainer_required)]
|
||||
pub struct ConstrainerParams {
|
||||
// Optional in the type and required in the schema, as `a` above.
|
||||
/// One or two sketch entity ids (curves or points): one line, or two entities.
|
||||
#[serde(default)]
|
||||
pub entities: Option<NameList>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// `refuse` (any case) refuses the constraint when the others already determine it. Anything
|
||||
/// else keeps it, shown dimmed as redundant. Read only then.
|
||||
#[serde(default)]
|
||||
pub if_redundant: Option<String>,
|
||||
}
|
||||
|
||||
/// Infer the constraints the sketch's geometry nearly has (coincident points, horizontal and
|
||||
/// vertical lines, tangent joints), then dimension what is left until it is fully constrained.
|
||||
/// A constraint that removes no freedom or that the sketch could not solve with is dropped.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AutoConstrainParams {
|
||||
/// How close two points must be to be made coincident, in mm (a number of 0 or less, or of
|
||||
/// 1e9 or more, is read as the default). Default: 0.01.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<f64>,
|
||||
/// How far a line may be from horizontal or vertical, or a joint from tangent, in degrees (a
|
||||
/// number of 0 or less, or of 1e9 or more, is read as the default). Default: 1.
|
||||
#[serde(default)]
|
||||
pub angle_tolerance: Option<f64>,
|
||||
/// Add dimensions (lengths, radii, diameters, distances from the datum). Default: true.
|
||||
#[serde(default)]
|
||||
pub dimensions: Option<bool>,
|
||||
/// The point the position dimensions are measured from: a point id or a name the sketch
|
||||
/// resolves (as `sketch.auto_constrain_from_datum` takes it). Default: the sketch origin.
|
||||
#[serde(default)]
|
||||
pub datum: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Auto Constrain with the position dimensions measured from a datum point instead of the
|
||||
/// origin.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AutoConstrainDatumParams {
|
||||
/// How close two points must be to be made coincident, in mm (a number of 0 or less, or of
|
||||
/// 1e9 or more, is read as the default). Default: 0.01.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<f64>,
|
||||
/// How far a line may be from horizontal or vertical, or a joint from tangent, in degrees (a
|
||||
/// number of 0 or less, or of 1e9 or more, is read as the default). Default: 1.
|
||||
#[serde(default)]
|
||||
pub angle_tolerance: Option<f64>,
|
||||
/// Add dimensions (lengths, radii, diameters, distances from the datum). Default: true.
|
||||
#[serde(default)]
|
||||
pub dimensions: Option<bool>,
|
||||
/// The point the position dimensions are measured from: a point id or a name the sketch
|
||||
/// resolves. Default: the sketch origin.
|
||||
#[serde(default)]
|
||||
pub datum: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Auto Constrain, then Finish Sketch: the sketch is closed when it is done.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct FinishAutoConstrainParams {
|
||||
/// How close two points must be to be made coincident, in mm (a number of 0 or less, or of
|
||||
/// 1e9 or more, is read as the default). Default: 0.01.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<f64>,
|
||||
/// How far a line may be from horizontal or vertical, or a joint from tangent, in degrees (a
|
||||
/// number of 0 or less, or of 1e9 or more, is read as the default). Default: 1.
|
||||
#[serde(default)]
|
||||
pub angle_tolerance: Option<f64>,
|
||||
/// Add dimensions (lengths, radii, diameters, distances from the datum). Default: true.
|
||||
#[serde(default)]
|
||||
pub dimensions: Option<bool>,
|
||||
/// The point the position dimensions are measured from: a point id or a name the sketch
|
||||
/// resolves. Default: the sketch origin.
|
||||
#[serde(default)]
|
||||
pub datum: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Make a dimension driven (it measures, and is no longer a parameter) or driving again (it gets
|
||||
/// a parameter holding its current value).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = driven_required)]
|
||||
pub struct ToggleDrivenParams {
|
||||
// Optional in the type and required in the schema, as `a` above.
|
||||
/// The dimension's constraint id.
|
||||
#[serde(default)]
|
||||
pub constraint: Option<DimensionId>,
|
||||
/// `true` for driven, `false` for driving. Default: the other one from now.
|
||||
#[serde(default)]
|
||||
pub driven: Option<bool>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// List every constraint of a sketch with where its glyph is drawn (sketch coordinates), what it
|
||||
/// is and whether it fails or is redundant, with the sketch's freedom. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct GlyphsParams {
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What a click at a point in the sketch being edited would snap to or infer, best first: a
|
||||
/// point, a line's midpoint, a point on a curve, and horizontal or vertical alignment with the
|
||||
/// previous click. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = snap_required)]
|
||||
pub struct SnapParams {
|
||||
// Optional in the type and required in the schema, as `a` above.
|
||||
/// The click, [x, y] in the sketch's coordinates, in mm.
|
||||
#[serde(default)]
|
||||
pub at: Option<ClickPoint>,
|
||||
/// The previous click, [x, y]: horizontal and vertical alignment with it is offered. Default:
|
||||
/// none.
|
||||
#[serde(default)]
|
||||
pub from: Option<Point2>,
|
||||
/// The snap distance in mm (a number of 0 or less, or of 1e9 or more, is read as the
|
||||
/// default). Default: 1.
|
||||
#[serde(default)]
|
||||
pub radius: Option<f64>,
|
||||
}
|
||||
|
||||
/// What the curvature constraint returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ConstraintAdded {
|
||||
/// The new constraint's id.
|
||||
pub constraint: String,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// `true` when the other constraints already determine it (present only then).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub redundant: bool,
|
||||
/// The constraints that already determine it, as "id (Name)" (present only when redundant).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub determined_by: Option<Vec<String>>,
|
||||
/// What the command added to its sketch, `{points, curves, constraints, dimensions}` (lists
|
||||
/// of ids): the session adds it to the reply of a sketch command, so the handler leaves it out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.constrainer` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ConstrainerResult {
|
||||
/// The new constraint's id.
|
||||
pub constraint: String,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// `true` when the other constraints already determine it (present only then).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub redundant: bool,
|
||||
/// The constraints that already determine it, as "id (Name)" (present only when redundant).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub determined_by: Option<Vec<String>>,
|
||||
/// The kind of constraint applied, such as `Horizontal` or `Tangent`.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
/// What the command added to its sketch, `{points, curves, constraints, dimensions}` (lists
|
||||
/// of ids): the session adds it to the reply of a sketch command, so the handler leaves it out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the polygon constraint returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PolygonResult {
|
||||
/// The new constraints' ids: equal sides, then the corners on the circle.
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// What the command added to its sketch, `{points, curves, constraints, dimensions}` (lists
|
||||
/// of ids): the session adds it to the reply of a sketch command, so the handler leaves it out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the Auto Constrain commands return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AutoConstrainResult {
|
||||
/// The ids of the constraints and dimensions it added.
|
||||
pub added: Vec<String>,
|
||||
/// The degrees of freedom left (0: fully constrained).
|
||||
pub dof: usize,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// What the command added to its sketch, `{points, curves, constraints, dimensions}` (lists
|
||||
/// of ids): the session adds it to the reply of a sketch command, so the handler leaves it out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.toggle_driven` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ToggleDrivenResult {
|
||||
/// The dimension's constraint id (unchanged).
|
||||
pub constraint: String,
|
||||
/// Whether it is driven now.
|
||||
pub driven: bool,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// What the command added to its sketch, `{points, curves, constraints, dimensions}` (lists
|
||||
/// of ids): the session adds it to the reply of a sketch command, so the handler leaves it out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// One constraint of `sketch.glyphs`, with where its glyph goes.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Glyph {
|
||||
/// The constraint's id.
|
||||
pub id: String,
|
||||
/// Its kind, such as `Horizontal` or `Radius`.
|
||||
pub name: String,
|
||||
/// Whether it is a dimension.
|
||||
pub dimension: bool,
|
||||
/// Whether it is driven (it measures; it does not constrain).
|
||||
pub driven: bool,
|
||||
/// A dimension's value (mm, or radians for an angle); `null` for another constraint.
|
||||
pub value: Option<f64>,
|
||||
/// The parameter a driving dimension takes its value from; `null` for none.
|
||||
pub param: Option<String>,
|
||||
/// Where to draw the glyph, [x, y] in sketch coordinates; `null` when it has no place.
|
||||
pub at: Option<[f64; 2]>,
|
||||
/// Whether it is one of the constraints the solver could not satisfy.
|
||||
pub failing: bool,
|
||||
/// Whether the others imply it (it removes no freedom) though it holds: shown dimmed.
|
||||
pub redundant: bool,
|
||||
}
|
||||
|
||||
/// What `sketch.glyphs` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct GlyphsResult {
|
||||
/// The sketch's feature id.
|
||||
pub sketch: u64,
|
||||
/// Its constraints, in order.
|
||||
pub glyphs: Vec<Glyph>,
|
||||
/// The degrees of freedom left.
|
||||
pub dof: usize,
|
||||
/// Whether nothing is left free.
|
||||
pub fully_constrained: bool,
|
||||
/// The ids of the constraints that fail.
|
||||
pub conflicts: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// One thing a click would snap to or infer.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Snap {
|
||||
/// An existing point (an end point, a centre, the origin): the click is made coincident.
|
||||
Coincident {
|
||||
/// The point's id.
|
||||
point: String,
|
||||
/// Where it is, [x, y].
|
||||
at: [f64; 2],
|
||||
},
|
||||
/// The middle of a line.
|
||||
Midpoint {
|
||||
/// The line's id.
|
||||
curve: String,
|
||||
/// The middle, [x, y].
|
||||
at: [f64; 2],
|
||||
},
|
||||
/// The nearest place on a curve.
|
||||
OnCurve {
|
||||
/// The curve's id.
|
||||
curve: String,
|
||||
/// That place, [x, y].
|
||||
at: [f64; 2],
|
||||
},
|
||||
/// Level with the previous click (`from`).
|
||||
Horizontal {
|
||||
/// The click moved to that level, [x, y].
|
||||
at: [f64; 2],
|
||||
},
|
||||
/// Straight above or below the previous click (`from`).
|
||||
Vertical {
|
||||
/// The click moved to that line, [x, y].
|
||||
at: [f64; 2],
|
||||
},
|
||||
}
|
||||
|
||||
/// What `sketch.snap` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SnapResult {
|
||||
/// At most eight, best first: the first is what a click would use.
|
||||
pub snaps: Vec<Snap>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_constraints_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ToggleDiameterParams, ToggleDiameterResult};
|
||||
use super::{
|
||||
AutoConstrainDatumParams, AutoConstrainParams, AutoConstrainResult, ConstrainerParams, ConstrainerResult, ConstraintAdded, CurvatureParams,
|
||||
FinishAutoConstrainParams, GlyphsParams, GlyphsResult, PolygonParams, PolygonResult, SnapParams, SnapResult, ToggleDiameterParams,
|
||||
ToggleDiameterResult, ToggleDrivenParams, ToggleDrivenResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
const AUTO_KEYS: [&str; 5] = ["angle_tolerance", "datum", "dimensions", "sketch", "tolerance"];
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ToggleDiameterParams>(), &["constraint", "diameter", "sketch"]);
|
||||
assert_keys(input_schema_for::<CurvatureParams>(), &["a", "b", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<PolygonParams>(), &["lines", "sketch"]);
|
||||
assert_keys(input_schema_for::<ConstrainerParams>(), &["entities", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<AutoConstrainParams>(), &AUTO_KEYS);
|
||||
assert_keys(input_schema_for::<AutoConstrainDatumParams>(), &AUTO_KEYS);
|
||||
assert_keys(input_schema_for::<FinishAutoConstrainParams>(), &AUTO_KEYS);
|
||||
assert_keys(input_schema_for::<ToggleDrivenParams>(), &["constraint", "driven", "sketch"]);
|
||||
// Snap Inference works in the sketch being edited: it reads no `sketch`.
|
||||
assert_keys(input_schema_for::<GlyphsParams>(), &["sketch"]);
|
||||
assert_keys(input_schema_for::<SnapParams>(), &["at", "from", "radius"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -17,9 +33,195 @@ fn strict_keeps_todays_errors() {
|
||||
("sketch.toggle_diameter", json!({"constraint": null})),
|
||||
("sketch.toggle_diameter", json!({"constraint": "c1"})),
|
||||
("sketch.toggle_diameter", json!({"constraint": "c1", "diameter": true})),
|
||||
// The keys these handlers read before they need a sketch.
|
||||
("sketch.constrainer", json!({"entities": 5})),
|
||||
("sketch.constrainer", json!({"entities": [1]})),
|
||||
("sketch.constrainer", json!({"entities": ["a", 2]})),
|
||||
("sketch.constrainer", json!({})),
|
||||
("sketch.constrainer", json!({"entities": null})),
|
||||
("sketch.constraint.polygon", json!({"lines": true})),
|
||||
("sketch.constraint.polygon", json!({"lines": [{}]})),
|
||||
("sketch.constraint.polygon", json!({})),
|
||||
("sketch.constraint.polygon", json!({"lines": ["l1", "l2"]})),
|
||||
("sketch.toggle_driven", json!({})),
|
||||
("sketch.toggle_driven", json!({"constraint": 5})),
|
||||
("sketch.toggle_driven", json!({"constraint": null})),
|
||||
("sketch.toggle_driven", json!({"constraint": ["k1"]})),
|
||||
("sketch.snap", json!({})),
|
||||
("sketch.snap", json!({"at": 5})),
|
||||
("sketch.snap", json!({"at": [1]})),
|
||||
("sketch.snap", json!({"at": null})),
|
||||
("sketch.snap", json!({"at": [1, "2"]})),
|
||||
("sketch.snap", json!({"at": [1, 2]})),
|
||||
("sketch.glyphs", json!({})),
|
||||
("sketch.auto_constrain", json!({})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A session that is editing a sketch with a line and a circle in it.
|
||||
fn editing(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.line", json!({"points": [[0, 0], [10, 0.5]]})),
|
||||
("sketch.circle.center", json!({"center": [50, 0], "radius": 5})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The same refusals in a sketch being edited, where the handlers go on to read the sketch, the
|
||||
/// curves and the points: strict says what the handler says.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_in_a_sketch() {
|
||||
let cases = [
|
||||
("sketch.constraint.curvature", json!({})),
|
||||
("sketch.constraint.curvature", json!({"a": 5, "b": "c1"})),
|
||||
("sketch.constraint.curvature", json!({"a": "c1"})),
|
||||
("sketch.constraint.curvature", json!({"a": "c1", "b": null})),
|
||||
("sketch.constraint.curvature", json!({"a": "c1", "b": ["c2"]})),
|
||||
("sketch.constraint.curvature", json!({"a": "c1", "b": "c2", "sketch": true})),
|
||||
("sketch.constraint.curvature", json!({"a": "c1", "b": "c2", "sketch": -1})),
|
||||
("sketch.constraint.curvature", json!({"a": "c1", "b": "c2", "sketch": 1.5})),
|
||||
("sketch.constraint.curvature", json!({"a": "c1", "b": "c2", "sketch": [1]})),
|
||||
("sketch.constrainer", json!({"entities": ["c1"], "sketch": {}})),
|
||||
("sketch.constraint.polygon", json!({"lines": ["c1", "c2", "c3"], "sketch": false})),
|
||||
("sketch.toggle_driven", json!({"constraint": "k1", "sketch": -3})),
|
||||
("sketch.glyphs", json!({"sketch": true})),
|
||||
("sketch.glyphs", json!({"sketch": 2.5})),
|
||||
("sketch.auto_constrain", json!({"sketch": ["a"]})),
|
||||
("sketch.auto_constrain_from_datum", json!({"sketch": -1})),
|
||||
("sketch.finish_auto_constrain", json!({"sketch": {}})),
|
||||
];
|
||||
for (index, (id, p)) in cases.iter().enumerate() {
|
||||
let error = |level: ParamCheck| editing(level).execute(id, p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{index} {id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{index} {id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What strict adds on purpose: a value of the wrong type the handlers read as if the key were
|
||||
/// left out (a wrong-typed option or `from`).
|
||||
#[test]
|
||||
fn strict_refuses_options_the_handlers_ignore() {
|
||||
let mut s = editing(ParamCheck::Strict);
|
||||
let line = s.execute("sketch.constrainer", &json!({"entities": ["c1"], "if_redundant": 5}));
|
||||
assert!(line.is_err_and(|e| e.to_string().contains("`if_redundant` must be text")));
|
||||
for (id, p) in [
|
||||
("sketch.auto_constrain", json!({"tolerance": "0.1"})),
|
||||
("sketch.auto_constrain", json!({"angle_tolerance": true})),
|
||||
("sketch.auto_constrain", json!({"dimensions": "no"})),
|
||||
("sketch.auto_constrain", json!({"datum": 3})),
|
||||
("sketch.snap", json!({"at": [1, 1], "from": "x"})),
|
||||
("sketch.snap", json!({"at": [1, 1], "radius": "1"})),
|
||||
] {
|
||||
let e = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("invalid parameters"), "{id} {p}: {e}");
|
||||
s.param_check = ParamCheck::Off;
|
||||
assert!(s.execute(id, &p).is_ok(), "{id} {p} works with the check off");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
}
|
||||
}
|
||||
|
||||
/// The schemas take what the handlers read: the keys they refuse when left out are required.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let check = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value().clone();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(&schema, &schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
check(
|
||||
input_schema_for::<CurvatureParams>(),
|
||||
&[
|
||||
(json!({"a": "c1", "b": "c2"}), true),
|
||||
(json!({"a": "c1", "b": "c2", "sketch": 3, "if_redundant": "refuse"}), true),
|
||||
(json!({"a": "c1"}), false),
|
||||
(json!({"b": "c1"}), false),
|
||||
(json!({"a": 1, "b": "c2"}), false),
|
||||
(json!({"a": "c1", "b": "c2", "sketch": -1}), false),
|
||||
(json!({"a": "c1", "b": "c2", "from": 1}), false),
|
||||
],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<PolygonParams>(),
|
||||
&[(json!({"lines": ["l1", "l2", "l3"]}), true), (json!({"lines": "l1", "sketch": "Sketch1"}), true), (json!({}), false)],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<ConstrainerParams>(),
|
||||
&[(json!({"entities": ["l1"]}), true), (json!({"entities": "p1"}), true), (json!({}), false), (json!({"entities": [1]}), false)],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<ToggleDrivenParams>(),
|
||||
&[(json!({"constraint": "k1"}), true), (json!({"constraint": "k1", "driven": false}), true), (json!({"driven": true}), false)],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<SnapParams>(),
|
||||
&[
|
||||
(json!({"at": [1, 2]}), true),
|
||||
(json!({"at": {"x": 1, "y": 2}, "from": [0, 0], "radius": 2}), true),
|
||||
(json!({}), false),
|
||||
(json!({"at": [1, 2, 3]}), false),
|
||||
(json!({"at": [1, 2], "sketch": 1}), false),
|
||||
],
|
||||
);
|
||||
for schema in
|
||||
[input_schema_for::<AutoConstrainParams>(), input_schema_for::<AutoConstrainDatumParams>(), input_schema_for::<FinishAutoConstrainParams>()]
|
||||
{
|
||||
check(
|
||||
schema,
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"tolerance": 0.1, "angle_tolerance": 2, "dimensions": false, "datum": "p1", "sketch": 4}), true),
|
||||
(json!({"tolerance": "x"}), false),
|
||||
(json!({"dimension": true}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
check(input_schema_for::<GlyphsParams>(), &[(json!({}), true), (json!({"sketch": "S"}), true), (json!({"curve": 1}), false)]);
|
||||
}
|
||||
|
||||
/// Each key reads as its handler reads it: `null` is left out, a number is a whole sketch id,
|
||||
/// and every key the handler takes in only some cases (`if_redundant`, `datum`) is still read.
|
||||
#[test]
|
||||
fn the_types_read_each_key_like_the_handlers() {
|
||||
fn parses<T: serde::de::DeserializeOwned>(v: Value) -> bool {
|
||||
serde_json::from_value::<T>(v).is_ok()
|
||||
}
|
||||
// `null` is a key left out, as every reader reads it.
|
||||
assert!(parses::<CurvatureParams>(json!({"a": null, "b": null, "sketch": null, "if_redundant": null})));
|
||||
assert!(parses::<PolygonParams>(json!({"lines": null, "sketch": null})));
|
||||
assert!(parses::<ConstrainerParams>(json!({"entities": null})));
|
||||
assert!(parses::<ToggleDrivenParams>(json!({"constraint": null, "driven": null})));
|
||||
assert!(parses::<SnapParams>(json!({"at": null, "from": null, "radius": null})));
|
||||
assert!(parses::<GlyphsParams>(json!({"sketch": null})));
|
||||
// A key left out is the handler's to refuse, with its own message.
|
||||
assert!(parses::<CurvatureParams>(json!({})) && parses::<PolygonParams>(json!({})) && parses::<SnapParams>(json!({})));
|
||||
// `sketch`: a whole number or text.
|
||||
for ok in [json!(3), json!("Sketch1"), json!("7")] {
|
||||
assert!(parses::<GlyphsParams>(json!({"sketch": ok})), "{ok}");
|
||||
}
|
||||
for bad in [json!(-1), json!(2.5), json!(true), json!([1]), json!({})] {
|
||||
assert!(!parses::<GlyphsParams>(json!({"sketch": bad})), "{bad}");
|
||||
}
|
||||
// Lists of names, as `string_list` reads them.
|
||||
assert!(parses::<PolygonParams>(json!({"lines": "l1"})) && parses::<PolygonParams>(json!({"lines": ["l1", "l2"]})));
|
||||
assert!(!parses::<PolygonParams>(json!({"lines": [1]})) && !parses::<ConstrainerParams>(json!({"entities": 5})));
|
||||
// Points: `[x, y]` or `{x, y}`.
|
||||
assert!(parses::<SnapParams>(json!({"at": [1, 2]})) && parses::<SnapParams>(json!({"at": {"x": 1, "y": 2}, "from": {"x": 0, "y": 0}})));
|
||||
assert!(!parses::<SnapParams>(json!({"at": [1, 2, 3]})) && !parses::<SnapParams>(json!({"at": [1, 1], "from": [1]})));
|
||||
// Numbers the handlers read as the default (zero, negative) are still numbers.
|
||||
assert!(
|
||||
parses::<SnapParams>(json!({"at": [1, 2], "radius": 0})) && parses::<AutoConstrainParams>(json!({"tolerance": -1, "angle_tolerance": 0}))
|
||||
);
|
||||
// `datum` and the tolerances are read by all three Auto Constrain commands.
|
||||
let p = json!({"datum": "p1", "tolerance": 1, "angle_tolerance": 2, "dimensions": false});
|
||||
assert!(parses::<AutoConstrainParams>(p.clone()) && parses::<AutoConstrainDatumParams>(p.clone()) && parses::<FinishAutoConstrainParams>(p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_diameter_reply_matches_its_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
@@ -46,3 +248,181 @@ fn toggle_diameter_reply_matches_its_schema() {
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, r: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
|
||||
fn keys(r: &Value) -> Vec<&str> {
|
||||
r.as_object().map(|o| o.keys().map(String::as_str).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
/// Every command, in every form its handler takes, replies as its output schema says in Warn
|
||||
/// without a warning; a misspelt key still gets its warning and the reply still matches, and
|
||||
/// strict refuses it.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let run = |s: &mut Session, id: &str, p: Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
r
|
||||
};
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let sketch = s.active_sketch.unwrap_or_default();
|
||||
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
|
||||
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
|
||||
|
||||
// The constrainer: one line; two lines; the same again (redundant), by `sketch` as an id.
|
||||
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a]}));
|
||||
matches(output_schema_for::<ConstrainerResult>(), &r);
|
||||
assert_eq!((r["type"].as_str(), keys(&r)), (Some("Horizontal"), vec!["constraint", "created", "sketch", "type"]), "{r}");
|
||||
assert!(r["sketch"]["solved"] == true && r["sketch"]["dof"].is_u64(), "{r}");
|
||||
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a, b]}));
|
||||
assert_eq!(r["type"], "Parallel", "{r}");
|
||||
let r = run(&mut s, "sketch.constrainer", json!({"entities": a, "sketch": sketch}));
|
||||
matches(output_schema_for::<ConstrainerResult>(), &r);
|
||||
assert_eq!((r["redundant"].clone(), r["determined_by"].is_array()), (json!(true), true), "{r}");
|
||||
assert_eq!(keys(&r), vec!["constraint", "created", "determined_by", "redundant", "sketch", "type"], "{r}");
|
||||
let e = s.execute("sketch.constrainer", &json!({"entities": [a], "if_redundant": "Refuse"})).err().map(|e| e.to_string());
|
||||
assert!(e.is_some_and(|e| e.contains("over-constrain")));
|
||||
|
||||
// Curvature continuity, and polygon.
|
||||
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
|
||||
let sp =
|
||||
ids(&run(&mut s, "sketch.spline.control_point", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
|
||||
let r = run(&mut s, "sketch.constraint.curvature", json!({"a": l, "b": sp, "if_redundant": "keep"}));
|
||||
matches(output_schema_for::<ConstraintAdded>(), &r);
|
||||
assert_eq!(keys(&r), vec!["constraint", "created", "sketch"], "{r}");
|
||||
let r = run(&mut s, "sketch.constraint.curvature", json!({"a": l, "b": sp}));
|
||||
matches(output_schema_for::<ConstraintAdded>(), &r);
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
let quad = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}))["curves"]);
|
||||
let r = run(&mut s, "sketch.constraint.polygon", json!({"lines": quad}));
|
||||
matches(output_schema_for::<PolygonResult>(), &r);
|
||||
assert_eq!((r["constraints"].as_array().map(Vec::len), keys(&r)), (Some(7), vec!["constraints", "created", "sketch"]), "{r}");
|
||||
|
||||
// Glyphs, snaps.
|
||||
let g = run(&mut s, "sketch.glyphs", json!({}));
|
||||
matches(output_schema_for::<GlyphsResult>(), &g);
|
||||
assert!(g["glyphs"].as_array().is_some_and(|l| l.len() > 5) && g["glyphs"][0]["at"].is_array(), "{g}");
|
||||
assert_eq!(keys(&g["glyphs"][0]), vec!["at", "dimension", "driven", "failing", "id", "name", "param", "redundant", "value"], "{g}");
|
||||
assert_eq!(keys(&g), vec!["conflicts", "dof", "fully_constrained", "glyphs", "sketch"], "{g}");
|
||||
let g = run(&mut s, "sketch.glyphs", json!({"sketch": sketch}));
|
||||
matches(output_schema_for::<GlyphsResult>(), &g);
|
||||
for p in [
|
||||
json!({"at": [0.2, 0.1]}),
|
||||
json!({"at": {"x": 5, "y": 0.3}, "radius": 1}),
|
||||
json!({"at": [15.5, 6], "from": [0, 6.2], "radius": 2}),
|
||||
json!({"at": [3.2, 5], "from": [3, 20], "radius": 1.5}),
|
||||
json!({"at": [3, 3], "from": null}),
|
||||
] {
|
||||
let r = run(&mut s, "sketch.snap", p.clone());
|
||||
matches(output_schema_for::<SnapResult>(), &r);
|
||||
assert_eq!(keys(&r), vec!["snaps"], "{r}");
|
||||
}
|
||||
let snaps: Vec<String> = [
|
||||
json!({"at": [0.2, 0.1]}),
|
||||
json!({"at": [5.1, 0.3]}),
|
||||
json!({"at": [3, 5.2]}),
|
||||
json!({"at": [3.4, 50], "from": [3, 51], "radius": 1}),
|
||||
json!({"at": [3.4, 50], "from": [3.9, 53], "radius": 1}),
|
||||
json!({"at": [40, 10.4], "from": [30, 10], "radius": 1}),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|p| {
|
||||
let r = run(&mut s, "sketch.snap", p);
|
||||
matches(output_schema_for::<SnapResult>(), &r);
|
||||
r["snaps"].as_array().cloned().unwrap_or_default().into_iter().filter_map(|x| x["type"].as_str().map(str::to_string)).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
for kind in ["coincident", "midpoint", "on_curve", "horizontal", "vertical"] {
|
||||
assert!(snaps.iter().any(|k| k == kind), "no {kind} in {snaps:?}");
|
||||
}
|
||||
let r = run(&mut s, "sketch.snap", json!({"at": [5.1, 0.3]}));
|
||||
assert!(r["snaps"][0]["curve"].is_string() && r["snaps"][0]["at"].is_array(), "{r}");
|
||||
// The end of `a` (made horizontal above) alone: the coincident snap names that point.
|
||||
let r = run(&mut s, "sketch.snap", json!({"at": [10.2, 0.1], "radius": 0.5}));
|
||||
let first = &r["snaps"][0];
|
||||
assert_eq!((first["type"].as_str(), first["point"].as_str()), (Some("coincident"), Some(format!("{a}.end").as_str())), "{r}");
|
||||
|
||||
// A dimension, driven and driving again.
|
||||
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": [c]}));
|
||||
let g = run(&mut s, "sketch.glyphs", json!({}));
|
||||
let did = g["glyphs"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|x| x["param"] == d["param"])
|
||||
.and_then(|x| x["id"].as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(!did.is_empty(), "{g}");
|
||||
let dimension = g["glyphs"].as_array().into_iter().flatten().find(|x| x["id"] == json!(did)).cloned().unwrap_or_default();
|
||||
assert!(dimension["value"].is_number() && dimension["dimension"] == true, "{dimension}");
|
||||
for p in [json!({"constraint": did}), json!({"constraint": did, "driven": false}), json!({"constraint": did, "driven": true, "sketch": null})] {
|
||||
let r = run(&mut s, "sketch.toggle_driven", p);
|
||||
matches(output_schema_for::<ToggleDrivenResult>(), &r);
|
||||
assert_eq!(keys(&r), vec!["constraint", "created", "driven", "sketch"], "{r}");
|
||||
}
|
||||
let r = s.execute("sketch.toggle_driven", &json!({"constraint": did, "drivn": true})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}");
|
||||
matches(output_schema_for::<ToggleDrivenResult>(), &r);
|
||||
|
||||
// Auto Constrain: with the default options, with every option, from a datum, and finishing.
|
||||
run(&mut s, "sketch.point", json!({"point": [5, 5], "id": "d"}));
|
||||
for (id, p) in [
|
||||
("sketch.auto_constrain", json!({})),
|
||||
("sketch.auto_constrain", json!({"tolerance": 0.05, "angle_tolerance": 2, "dimensions": false, "datum": "origin", "sketch": null})),
|
||||
("sketch.auto_constrain_from_datum", json!({"datum": "d"})),
|
||||
("sketch.auto_constrain_from_datum", json!({})),
|
||||
] {
|
||||
let r = run(&mut s, id, p);
|
||||
matches(output_schema_for::<AutoConstrainResult>(), &r);
|
||||
assert_eq!(keys(&r), vec!["added", "created", "dof", "sketch"], "{r}");
|
||||
}
|
||||
let r = run(&mut s, "sketch.finish_auto_constrain", json!({"datum": "d", "tolerance": 0.02}));
|
||||
matches(output_schema_for::<AutoConstrainResult>(), &r);
|
||||
assert!(s.active_sketch.is_none() && r["dof"] == 0, "{r}");
|
||||
|
||||
// More forms, still without a warning: a sketch by name, `from` as an object, `if_redundant`
|
||||
// on a constraint that is not redundant, Finish with every option.
|
||||
let mut t = editing(ParamCheck::Warn);
|
||||
let tname = t.doc.features.first().map(|f| f.name.clone()).unwrap_or_default();
|
||||
let tline = ids(&run(&mut t, "sketch.line", json!({"points": [[0, 30], [10, 30.5]]}))["curves"])[0].clone();
|
||||
run(&mut t, "sketch.glyphs", json!({"sketch": tname}));
|
||||
run(&mut t, "sketch.snap", json!({"at": [1, 1], "from": {"x": 0, "y": 1}}));
|
||||
run(&mut t, "sketch.constrainer", json!({"entities": [tline], "if_redundant": "refuse", "sketch": tname}));
|
||||
let r = run(&mut t, "sketch.finish_auto_constrain", json!({"sketch": tname, "dimensions": false, "angle_tolerance": 3}));
|
||||
matches(output_schema_for::<AutoConstrainResult>(), &r);
|
||||
|
||||
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
|
||||
let mut s = editing(ParamCheck::Warn);
|
||||
let p = json!({"tolerence": 0.1});
|
||||
let r = s.execute("sketch.auto_constrain", &p).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(
|
||||
r["param_warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("did you mean `tolerance`"))),
|
||||
"{r}"
|
||||
);
|
||||
matches(output_schema_for::<AutoConstrainResult>(), &r);
|
||||
// A new line the constrainer can still act on.
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20.5]]}))["curves"])[0].clone();
|
||||
for (id, p, reply) in [
|
||||
("sketch.snap", json!({"at": [1, 1], "radious": 2}), output_schema_for::<SnapResult>()),
|
||||
("sketch.glyphs", json!({"sketch_id": 1}), output_schema_for::<GlyphsResult>()),
|
||||
("sketch.constrainer", json!({"entities": [line], "if_redundent": "refuse"}), output_schema_for::<ConstrainerResult>()),
|
||||
] {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{id}: {r}");
|
||||
matches(reply, &r);
|
||||
}
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let e = s.execute("sketch.snap", &json!({"at": [1, 1], "radious": 2})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("`radious`") && e.contains("`radius`"), "{e}");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams, SplineReverseResult};
|
||||
use super::{
|
||||
ArcSlotCenterParams, ArcSlotResult, ArcSlotThreePointParams, ArcTangentParams, BlendCurveParams, CenterlineParams, ConicParams,
|
||||
ControlPointSplineParams, DrawResult, EditTextParams, EditTextResult, EllipseParams, FitPointSplineParams, InfiniteLineParams, LineTypeResult,
|
||||
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams,
|
||||
SplineReverseResult, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -81,3 +86,825 @@ fn construction_line_contracts_and_replies_match() {
|
||||
}
|
||||
assert!(s.execute("sketch.construction_line", &json!({"at":[0,0],"mode":"unknown"})).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The creation commands (#12550)
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A session at `level` editing a sketch with two lines meeting at (40, 0), a circle and a text.
|
||||
fn drawing(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 30]]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
|
||||
run(&mut s, "sketch.text", json!({"text": "Hi", "at": [0, 40]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The link of the text `drawing` makes.
|
||||
fn text_link() -> String {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 30]]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
|
||||
run(&mut s, "sketch.text", json!({"text": "Hi", "at": [0, 40]}))["link"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// Like `assert_strict_keeps_todays_errors`, in a sketch being edited: the calls whose refusal
|
||||
/// depends on the design (a point or curve that isn't there), or that the handler reaches only
|
||||
/// after it has found its sketch.
|
||||
fn assert_strict_keeps_todays_errors_in_a_sketch(cases: &[(&str, Value)]) {
|
||||
for (id, p) in cases {
|
||||
let error = |level: ParamCheck| drawing(level).execute(id, p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, r: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
|
||||
fn issues(id: &str, p: Value) -> Vec<String> {
|
||||
crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MidpointLineParams>(), &["construction", "end", "mid", "sketch"]);
|
||||
assert_keys(input_schema_for::<ArcTangentParams>(), &["curve", "end", "sketch", "start"]);
|
||||
assert_keys(input_schema_for::<TwoTangentCircleParams>(), &["curves", "diameter", "near", "radius", "sketch"]);
|
||||
assert_keys(input_schema_for::<ThreeTangentCircleParams>(), &["curves", "near", "sketch"]);
|
||||
assert_keys(input_schema_for::<SlotCenterPointParams>(), &["center", "connect", "end", "sketch", "width"]);
|
||||
assert_keys(input_schema_for::<ArcSlotThreePointParams>(), &["end", "sketch", "start", "through", "width"]);
|
||||
assert_keys(input_schema_for::<ArcSlotCenterParams>(), &["center", "end", "sketch", "start", "sweep", "width"]);
|
||||
assert_keys(input_schema_for::<EllipseParams>(), &["axes", "center", "construction", "major", "minor", "minor_radius", "sketch"]);
|
||||
assert_keys(input_schema_for::<FitPointSplineParams>(), &["construction", "periodic", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<ControlPointSplineParams>(), &["construction", "periodic", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<ConicParams>(), &["apex", "construction", "end", "rho", "sketch", "start"]);
|
||||
assert_keys(input_schema_for::<BlendCurveParams>(), &["a", "b", "continuity", "sketch"]);
|
||||
assert_keys(input_schema_for::<CenterlineParams>(), &["curves", "sketch", "value"]);
|
||||
assert_keys(input_schema_for::<InfiniteLineParams>(), &["curves", "sketch", "value"]);
|
||||
// The texts go into the sketch being edited; `sketch` only picks what `created` compares.
|
||||
assert_keys(
|
||||
input_schema_for::<SketchTextParams>(),
|
||||
&[
|
||||
"align",
|
||||
"angle",
|
||||
"at",
|
||||
"bold",
|
||||
"font",
|
||||
"height",
|
||||
"italic",
|
||||
"letter_spacing",
|
||||
"line_spacing",
|
||||
"path",
|
||||
"reverse",
|
||||
"sketch",
|
||||
"text",
|
||||
"text_expression",
|
||||
],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<EditTextParams>(),
|
||||
&[
|
||||
"align",
|
||||
"angle",
|
||||
"at",
|
||||
"bold",
|
||||
"edits",
|
||||
"entity",
|
||||
"font",
|
||||
"height",
|
||||
"italic",
|
||||
"letter_spacing",
|
||||
"line_spacing",
|
||||
"link",
|
||||
"live",
|
||||
"path",
|
||||
"reverse",
|
||||
"sketch",
|
||||
"text",
|
||||
"text_expression",
|
||||
],
|
||||
);
|
||||
// Every command has a contract and an output schema, and the edit's items too.
|
||||
for id in [
|
||||
"sketch.arc.tangent",
|
||||
"sketch.blend_curve",
|
||||
"sketch.centerline",
|
||||
"sketch.circle.three_tangent",
|
||||
"sketch.circle.two_tangent",
|
||||
"sketch.conic",
|
||||
"sketch.edit_text",
|
||||
"sketch.ellipse",
|
||||
"sketch.infinite",
|
||||
"sketch.line.midpoint",
|
||||
"sketch.slot.arc_center",
|
||||
"sketch.slot.arc_three_point",
|
||||
"sketch.slot.center_point",
|
||||
"sketch.spline.control_point",
|
||||
"sketch.spline.control_point_5",
|
||||
"sketch.spline.fit_point",
|
||||
"sketch.text",
|
||||
] {
|
||||
let c = crate::cmd::find_command(id).unwrap_or_else(|| panic!("{id}"));
|
||||
assert!(c.contract.is_some() && c.output.is_some(), "{id}");
|
||||
}
|
||||
assert_eq!(solidcraft_sketch::MAX_SPLINE_POINTS, 500, "the handler's message and the type's say 500");
|
||||
}
|
||||
|
||||
/// The calls the handlers refuse before they look at a sketch: strict gives their messages.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_before_the_sketch() {
|
||||
let long = "x".repeat(solidcraft_sketch::MAX_TEXT_CHARS + 1);
|
||||
let at = json!([0, 0]);
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.line.midpoint", json!({})),
|
||||
("sketch.line.midpoint", json!({"mid": [1, 1]})),
|
||||
("sketch.line.midpoint", json!({"mid": [1, 1], "end": 5})),
|
||||
("sketch.line.midpoint", json!({"mid": [1, 1], "end": null})),
|
||||
("sketch.line.midpoint", json!({"mid": [1, 1], "end": [1]})),
|
||||
("sketch.arc.tangent", json!({"start": "l1.end"})),
|
||||
("sketch.arc.tangent", json!({"start": "l1.end", "end": true})),
|
||||
("sketch.arc.tangent", json!({"start": "l1.end", "end": null})),
|
||||
("sketch.circle.two_tangent", json!({})),
|
||||
("sketch.circle.two_tangent", json!({"radius": true})),
|
||||
("sketch.circle.two_tangent", json!({"radius": " "})),
|
||||
("sketch.circle.two_tangent", json!({"diameter": "5"})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": "x"})),
|
||||
("sketch.circle.two_tangent", json!({"diameter": 5, "near": null})),
|
||||
("sketch.circle.three_tangent", json!({})),
|
||||
("sketch.circle.three_tangent", json!({"near": [1]})),
|
||||
("sketch.slot.center_point", json!({})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0]})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [5, 0]})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [5, 0], "width": 0})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [5, 0], "width": -2})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [5, 0], "width": "2"})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [5, 0], "width": null})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [5], "width": 2})),
|
||||
("sketch.slot.arc_three_point", json!({})),
|
||||
("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [1, 1]})),
|
||||
("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [1, 1], "end": [2, 0]})),
|
||||
("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [1, 1], "end": [2, 0], "width": 0})),
|
||||
("sketch.slot.arc_center", json!({})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0]})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1, "end": true})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1, "sweep": "x"})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1, "sweep": null})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1, "sweep": 1e9})),
|
||||
("sketch.ellipse", json!({})),
|
||||
("sketch.ellipse", json!({"center": [0, 0]})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0]})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": true})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": [1]})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 0})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": -3})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 1e8})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": "3"})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 3, "axes": "yes"})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": [1, 1], "axes": 0})),
|
||||
("sketch.conic", json!({"rho": 0})),
|
||||
("sketch.conic", json!({"rho": 1})),
|
||||
("sketch.conic", json!({"rho": -2})),
|
||||
("sketch.conic", json!({"rho": 1e-7})),
|
||||
("sketch.spline.fit_point", json!({"periodic": "yes"})),
|
||||
("sketch.spline.fit_point", json!({"periodic": null})),
|
||||
("sketch.spline.fit_point", json!({"periodic": 1, "points": [[0, 0], [1, 1]]})),
|
||||
("sketch.spline.control_point", json!({"periodic": true, "points": [[0, 0], [1, 1]]})),
|
||||
("sketch.spline.control_point", json!({"periodic": null})),
|
||||
("sketch.spline.control_point_5", json!({"periodic": true})),
|
||||
("sketch.blend_curve", json!({"continuity": "g9"})),
|
||||
("sketch.blend_curve", json!({"continuity": "g0"})),
|
||||
("sketch.blend_curve", json!({"continuity": 5})),
|
||||
("sketch.blend_curve", json!({"continuity": true})),
|
||||
("sketch.centerline", json!({})),
|
||||
("sketch.centerline", json!({"curves": []})),
|
||||
("sketch.centerline", json!({"curves": null})),
|
||||
("sketch.centerline", json!({"curves": 5})),
|
||||
("sketch.centerline", json!({"curves": [1]})),
|
||||
("sketch.centerline", json!({"curves": ["l1", true]})),
|
||||
("sketch.infinite", json!({})),
|
||||
("sketch.infinite", json!({"curves": []})),
|
||||
("sketch.infinite", json!({"curves": {"a": 1}})),
|
||||
("sketch.text", json!({})),
|
||||
("sketch.text", json!({"at": null})),
|
||||
("sketch.text", json!({"at": [1], "text": "x"})),
|
||||
("sketch.text", json!({"at": [1, 2, 3], "text": "x"})),
|
||||
("sketch.text", json!({"at": "x", "text": "x"})),
|
||||
("sketch.text", json!({"at": [1, true], "text": "x"})),
|
||||
("sketch.text", json!({"at": ["", 1], "text": "x"})),
|
||||
("sketch.text", json!({"at": {"x": "w", "y": 1}, "text": "x"})),
|
||||
("sketch.text", json!({"at": ["no_such_parameter", 1], "text": "x"})),
|
||||
("sketch.text", json!({"at": at})),
|
||||
("sketch.text", json!({"at": at, "text": ""})),
|
||||
("sketch.text", json!({"at": at, "text": " \n "})),
|
||||
("sketch.text", json!({"at": at, "text": 5})),
|
||||
("sketch.text", json!({"at": at, "text": long})),
|
||||
("sketch.text", json!({"at": at, "text_expression": 5})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "text_expression": "'y'"})),
|
||||
("sketch.text", json!({"at": at, "text": 5, "text_expression": 6})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "height": 0})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "height": 1e5})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "height": true})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "height": " "})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "height": "no_such_parameter"})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "angle": []})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "angle": 1e9})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "font": 5})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "font": "No Such Font Family 12345"})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "bold": "yes"})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "italic": 1})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "reverse": []})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "align": "up"})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "align": 5})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "letter_spacing": "wide"})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "line_spacing": 0})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "line_spacing": "2"})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "path": 5})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The calls that need the design to be refused, or that the handlers refuse once they have
|
||||
/// found their sketch: strict gives their messages.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_in_a_sketch() {
|
||||
let at = json!([0, 0]);
|
||||
// The text `drawing` made.
|
||||
let t = text_link();
|
||||
assert_strict_keeps_todays_errors_in_a_sketch(&[
|
||||
("sketch.line.midpoint", json!({"end": [1, 1]})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": null})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": 5})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": [1]})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": [1, true]})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": ["", 1]})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": ["no_such_parameter", 1]})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": {"ref": 5}})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": {"vertex": [1, 2]}})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": "no_such_point"})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": [1, 1]})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": [3, 3], "sketch": true})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": [3, 3], "sketch": -1})),
|
||||
("sketch.line.midpoint", json!({"end": [1, 1], "mid": [3, 3], "sketch": 4096})),
|
||||
("sketch.arc.tangent", json!({"end": [1, 1]})),
|
||||
("sketch.arc.tangent", json!({"end": [1, 1], "start": null})),
|
||||
("sketch.arc.tangent", json!({"end": [1, 1], "start": 5})),
|
||||
("sketch.arc.tangent", json!({"end": [1, 1], "start": {"at": 5}})),
|
||||
("sketch.arc.tangent", json!({"end": [1, 1], "start": "on:l1:5,0"})),
|
||||
("sketch.arc.tangent", json!({"end": "no_such_point", "start": "l1.end"})),
|
||||
("sketch.arc.tangent", json!({"end": [50, 20], "start": "l1.end", "curve": "no_such_curve"})),
|
||||
("sketch.arc.tangent", json!({"end": [50, 0], "start": "l1.end"})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10]})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10], "curves": null})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10], "curves": ["l1"]})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10], "curves": 5})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10], "curves": [1, 2]})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10], "curves": ["l1", true]})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10], "curves": ["l1", "l1", "l2"]})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 5, "near": [20, 10], "curves": ["l1", "no_such_curve"]})),
|
||||
("sketch.circle.two_tangent", json!({"radius": "no_such_parameter", "near": [20, 10], "curves": ["l1", "l2"]})),
|
||||
("sketch.circle.two_tangent", json!({"radius": 0, "near": [20, 10], "curves": ["l1", "l2"]})),
|
||||
("sketch.circle.three_tangent", json!({"near": [20, 10]})),
|
||||
("sketch.circle.three_tangent", json!({"near": [20, 10], "curves": ["l1", "l2"]})),
|
||||
("sketch.circle.three_tangent", json!({"near": [20, 10], "curves": {}})),
|
||||
("sketch.circle.three_tangent", json!({"near": [20, 10], "curves": ["l1", "l2", "no_such_curve"]})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [0, 0], "width": 2})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [5, 0], "width": 2, "sketch": [1]})),
|
||||
("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [1, 0], "end": [2, 0], "width": 1})),
|
||||
("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [1, 1], "end": [2, 0], "width": 30})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [0, 0], "width": 1, "sweep": 90})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1, "sweep": 0})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [5, 0], "width": 1, "end": [0, 0]})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [0, 0], "minor_radius": 3})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": [20, 0]})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 3, "sketch": 4096})),
|
||||
("sketch.conic", json!({"end": [2, 0], "apex": [1, 1]})),
|
||||
("sketch.conic", json!({"start": null, "end": [2, 0], "apex": [1, 1]})),
|
||||
("sketch.conic", json!({"start": [0, 0], "end": 5, "apex": [1, 1]})),
|
||||
("sketch.conic", json!({"start": [0, 0], "end": [2, 0]})),
|
||||
("sketch.conic", json!({"start": "l1.start", "end": "l1.start", "apex": [1, 1]})),
|
||||
("sketch.conic", json!({"start": [0, 0], "end": [2, 0], "apex": "no_such_point"})),
|
||||
("sketch.conic", json!({"start": [0, 0], "end": [2, 0], "apex": "x:l1+l1:1,1"})),
|
||||
("sketch.spline.fit_point", json!({})),
|
||||
("sketch.spline.fit_point", json!({"points": 5})),
|
||||
("sketch.spline.fit_point", json!({"points": null})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0, 0]]})),
|
||||
("sketch.spline.fit_point", json!({"points": vec![json!([0, 1]); 501]})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0, 0], 5]})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0, 0], [1, 1], {"at": "x"}]})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0, 0], [0, 0]]})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0, 0], [1, 1]], "sketch": true})),
|
||||
("sketch.spline.control_point", json!({"points": [[0, 0], "no_such_point"]})),
|
||||
("sketch.spline.control_point_5", json!({"points": [[0, 0], [1, 1], ["", 3]]})),
|
||||
("sketch.blend_curve", json!({"b": "l2.end"})),
|
||||
("sketch.blend_curve", json!({"a": "l1.end"})),
|
||||
("sketch.blend_curve", json!({"a": null, "b": "l2.end"})),
|
||||
("sketch.blend_curve", json!({"a": "l1.end", "b": 5})),
|
||||
("sketch.blend_curve", json!({"a": "l1.end", "b": "l1.end"})),
|
||||
("sketch.blend_curve", json!({"a": [1, 1], "b": "l2.end"})),
|
||||
("sketch.blend_curve", json!({"a": "no_such_point", "b": "l2.end"})),
|
||||
("sketch.blend_curve", json!({"a": "l1.end", "b": "l2.end", "continuity": "g5"})),
|
||||
("sketch.centerline", json!({"curves": ["no_such_curve"]})),
|
||||
("sketch.centerline", json!({"curves": ["c1"]})),
|
||||
("sketch.infinite", json!({"curves": ["l1", "c1"]})),
|
||||
("sketch.infinite", json!({"curves": ["no_such_curve"]})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "path": "no_such_curve"})),
|
||||
("sketch.text", json!({"at": at, "text": "x", "path": t})),
|
||||
("sketch.edit_text", json!({})),
|
||||
("sketch.edit_text", json!({"link": 5})),
|
||||
("sketch.edit_text", json!({"link": null, "entity": null})),
|
||||
("sketch.edit_text", json!({"link": "no_such_link"})),
|
||||
("sketch.edit_text", json!({"entity": "no_such_curve"})),
|
||||
("sketch.edit_text", json!({"entity": "l1"})),
|
||||
("sketch.edit_text", json!({"link": t, "text": 5})),
|
||||
("sketch.edit_text", json!({"link": t, "text": ""})),
|
||||
("sketch.edit_text", json!({"link": t, "text": "x", "text_expression": "'y'"})),
|
||||
("sketch.edit_text", json!({"link": t, "text_expression": 5})),
|
||||
("sketch.edit_text", json!({"link": t, "at": [1]})),
|
||||
("sketch.edit_text", json!({"link": t, "at": "x"})),
|
||||
("sketch.edit_text", json!({"link": t, "height": -1})),
|
||||
("sketch.edit_text", json!({"link": t, "height": []})),
|
||||
("sketch.edit_text", json!({"link": t, "angle": {}})),
|
||||
("sketch.edit_text", json!({"link": t, "font": false})),
|
||||
("sketch.edit_text", json!({"link": t, "bold": "no"})),
|
||||
("sketch.edit_text", json!({"link": t, "align": "middle"})),
|
||||
("sketch.edit_text", json!({"link": t, "letter_spacing": true})),
|
||||
("sketch.edit_text", json!({"link": t, "line_spacing": -1})),
|
||||
("sketch.edit_text", json!({"link": t, "path": 3})),
|
||||
("sketch.edit_text", json!({"edits": []})),
|
||||
("sketch.edit_text", json!({"edits": "t1"})),
|
||||
("sketch.edit_text", json!({"edits": {"link": t}})),
|
||||
("sketch.edit_text", json!({"edits": [5]})),
|
||||
("sketch.edit_text", json!({"edits": [{}]})),
|
||||
("sketch.edit_text", json!({"edits": [{"link": t}, {"link": "no_such_link"}]})),
|
||||
("sketch.edit_text", json!({"edits": [{"link": t, "text": 5}]})),
|
||||
("sketch.edit_text", json!({"edits": [{"link": t, "at": [1, 2, 3]}]})),
|
||||
("sketch.edit_text", json!({"edits": vec![json!({"link": t}); 1001]})),
|
||||
("sketch.edit_text", json!({"edits": [{"link": t, "bold": 1}], "live": false})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A key the handler reads leniently (a wrong type read as left out) is a mistake the check
|
||||
/// reports: the one place where strict refuses a call that runs today, as documented.
|
||||
#[test]
|
||||
fn wrong_types_the_handlers_ignore_are_reported() {
|
||||
for (id, p, message) in [
|
||||
("sketch.conic", json!({"start": [0, 0], "end": [2, 0], "apex": [1, 1], "rho": "x"}), "`rho` must be a number between 0 and 1"),
|
||||
("sketch.line.midpoint", json!({"mid": [0, 0], "end": [2, 0], "construction": "yes"}), "`construction` must be true or false"),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [2, 0], "width": 1, "connect": 1}), "`connect` must be true or false"),
|
||||
("sketch.arc.tangent", json!({"start": "l1.end", "end": [2, 0], "curve": 5}), "`curve` must be text"),
|
||||
("sketch.centerline", json!({"curves": ["l1"], "value": 1}), "`value` must be true or false"),
|
||||
("sketch.edit_text", json!({"link": "t1", "live": 1}), "`live` must be true or false"),
|
||||
] {
|
||||
assert_eq!(issues(id, p), [message], "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys a handler reads only in some cases are checked only in those cases: a call that works
|
||||
/// never gets a warning for the rest.
|
||||
#[test]
|
||||
fn keys_read_only_in_some_cases_are_checked_only_then() {
|
||||
let ok = |id: &str, p: Value| assert_eq!(issues(id, p.clone()), Vec::<String>::new(), "{id} {p}");
|
||||
let near = json!([20, 10]);
|
||||
// radius wins over diameter, whatever the diameter is; without a radius, a diameter does.
|
||||
ok("sketch.circle.two_tangent", json!({"radius": 5, "diameter": "wide", "near": near, "curves": ["l1", "l2"]}));
|
||||
ok("sketch.circle.two_tangent", json!({"radius": "r / 2", "diameter": null, "near": near, "curves": ["l1", "l2"]}));
|
||||
ok("sketch.circle.two_tangent", json!({"radius": true, "diameter": 10, "near": near, "curves": ["l1", "l2"]}));
|
||||
ok("sketch.circle.two_tangent", json!({"diameter": 10, "near": near, "curves": ["l1", "l2"]}));
|
||||
assert_eq!(
|
||||
issues("sketch.circle.two_tangent", json!({"diameter": "wide", "near": near, "curves": ["l1", "l2"]})),
|
||||
["needs `radius` or `diameter`"]
|
||||
);
|
||||
// sweep wins over end, however end is written; without a sweep, end is read.
|
||||
let slot = |extra: Value| {
|
||||
let mut p = json!({"center": [0, 0], "start": [5, 0], "width": 1});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
p
|
||||
};
|
||||
ok("sketch.slot.arc_center", slot(json!({"sweep": 90, "end": "nowhere"})));
|
||||
ok("sketch.slot.arc_center", slot(json!({"sweep": 90, "end": null})));
|
||||
ok("sketch.slot.arc_center", slot(json!({"end": [0, 5]})));
|
||||
assert_eq!(issues("sketch.slot.arc_center", slot(json!({"end": "nowhere"}))), ["`end` must be a point [x, y]"]);
|
||||
assert_eq!(issues("sketch.slot.arc_center", slot(json!({"sweep": null, "end": [0, 5]}))), ["`sweep` must be finite degrees"]);
|
||||
// minor_radius wins over minor, and a minor_radius that is no number is not read when there is a minor.
|
||||
let ellipse = |extra: Value| {
|
||||
let mut p = json!({"center": [0, 0], "major": [10, 0]});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
p
|
||||
};
|
||||
ok("sketch.ellipse", ellipse(json!({"minor_radius": 3, "minor": "nowhere"})));
|
||||
ok("sketch.ellipse", ellipse(json!({"minor_radius": "wide", "minor": [1, 1]})));
|
||||
ok("sketch.ellipse", ellipse(json!({"minor_radius": null, "minor": [1, 1]})));
|
||||
ok("sketch.ellipse", ellipse(json!({"minor": {"x": 1, "y": 1}})));
|
||||
assert_eq!(issues("sketch.ellipse", ellipse(json!({"minor_radius": "wide", "minor": "x"}))), ["needs `minor` [x,y] or `minor_radius`"]);
|
||||
// Outside `edits` a text edit reads its own keys; with `edits` it reads only `edits` (and `live`).
|
||||
ok("sketch.edit_text", json!({"edits": [{"link": "t1"}], "link": 5, "text": 7, "at": "no", "bold": "yes"}));
|
||||
ok("sketch.edit_text", json!({"edits": null, "link": "t1", "text": "x"}));
|
||||
assert_eq!(issues("sketch.edit_text", json!({"edits": [{"entity": "c1", "bold": "yes"}]})), ["`bold` must be true or false"]);
|
||||
// A link that isn't text is read as left out when there is an entity, and the entity as left out when there is a link.
|
||||
ok("sketch.edit_text", json!({"link": 5, "entity": "l1"}));
|
||||
ok("sketch.edit_text", json!({"link": "t1", "entity": 5}));
|
||||
assert_eq!(issues("sketch.edit_text", json!({"link": 5, "entity": 5})), ["give `link` or `entity`"]);
|
||||
// The texts of a single edit read their own `at` as null: kept.
|
||||
ok("sketch.edit_text", json!({"link": "t1", "at": null, "height": null, "text": null}));
|
||||
// A text on a path: `at` is checked all the same, as the handler does.
|
||||
assert_eq!(issues("sketch.text", json!({"text": "x", "at": null})), [crate::cmd::AT_MUST]);
|
||||
ok("sketch.text", json!({"text": "x", "at": [0, 0], "sketch": 4}));
|
||||
ok("sketch.edit_text", json!({"link": "t1", "sketch": "S"}));
|
||||
}
|
||||
|
||||
/// Every form of every creation command runs in warn without warnings, replies as its output
|
||||
/// schema says, and a misspelt key gets its warning while the reply still matches.
|
||||
#[test]
|
||||
fn creation_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "6 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
for points in [[[0, 0], [40, 0]], [[40, 0], [40, 30]], [[40, 30], [0, 30]]] {
|
||||
run(&mut s, "sketch.line", json!({"points": points}));
|
||||
}
|
||||
let run_ok = |s: &mut Session, id: &str, p: Value| {
|
||||
let r = run(s, id, p.clone());
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
r
|
||||
};
|
||||
let drawn = |s: &mut Session, id: &str, p: Value| {
|
||||
let r = run_ok(s, id, p);
|
||||
matches(output_schema_for::<DrawResult>(), &r);
|
||||
assert_eq!(
|
||||
r.as_object().map(|o| o.keys().cloned().collect::<std::collections::BTreeSet<_>>()),
|
||||
Some(["constraints", "created", "curves", "sketch"].map(String::from).into()),
|
||||
"{r}"
|
||||
);
|
||||
r
|
||||
};
|
||||
let near = json!([30, 10]);
|
||||
for p in [
|
||||
json!({"mid": [100, 5], "end": [110, 5]}),
|
||||
json!({"mid": [100, 15], "end": [110, 15], "construction": true}),
|
||||
json!({"mid": "l1.start", "end": [10, 10], "construction": null, "sketch": null}),
|
||||
json!({"mid": {"ref": "l1.end"}, "end": {"x": 50, "y": 10}}),
|
||||
json!({"mid": {"at": [100, 25]}, "end": [110, 25]}),
|
||||
json!({"mid": ["101 mm", "26"], "end": [111, 26]}),
|
||||
json!({"mid": "mid:l1", "end": [20, 10]}),
|
||||
json!({"mid": "on:l3:10,30", "end": [10, 20]}),
|
||||
] {
|
||||
drawn(&mut s, "sketch.line.midpoint", p);
|
||||
}
|
||||
drawn(&mut s, "sketch.arc.tangent", json!({"start": "l2.end", "end": [60, 40], "curve": "l2"}));
|
||||
drawn(&mut s, "sketch.circle.two_tangent", json!({"radius": 5, "near": near, "curves": ["l1", "l2"]}));
|
||||
drawn(&mut s, "sketch.circle.two_tangent", json!({"radius": "tall", "near": near, "curves": ["l1", "l2"]}));
|
||||
drawn(&mut s, "sketch.circle.two_tangent", json!({"diameter": 8, "near": near, "curves": ["l1", "l2"]}));
|
||||
drawn(&mut s, "sketch.circle.two_tangent", json!({"radius": 4, "diameter": "ignored", "near": near, "curves": ["l1", "l2"]}));
|
||||
drawn(&mut s, "sketch.circle.three_tangent", json!({"near": [25, 15], "curves": ["l1", "l2", "l3"]}));
|
||||
drawn(&mut s, "sketch.slot.center_point", json!({"center": [100, 100], "end": [110, 100], "width": 4}));
|
||||
drawn(&mut s, "sketch.slot.center_point", json!({"center": [100, 120], "end": [110, 120], "width": 4, "connect": false, "sketch": null}));
|
||||
for p in [
|
||||
json!({"start": [100, 0], "through": [110, 10], "end": [120, 0], "width": 2}),
|
||||
json!({"start": [130, 0], "through": [140, 10], "end": [150, 0], "width": 2, "sketch": null}),
|
||||
] {
|
||||
let r = run_ok(&mut s, "sketch.slot.arc_three_point", p);
|
||||
matches(output_schema_for::<ArcSlotResult>(), &r);
|
||||
assert!(r["centre_arc"].is_string(), "{r}");
|
||||
}
|
||||
for p in [
|
||||
json!({"center": [200, 0], "start": [210, 0], "end": [200, 10], "width": 2}),
|
||||
json!({"center": [200, 30], "start": [210, 30], "sweep": 90, "width": 2}),
|
||||
json!({"center": [200, 60], "start": [210, 60], "sweep": 90, "end": "nowhere", "width": 2}),
|
||||
] {
|
||||
let r = run_ok(&mut s, "sketch.slot.arc_center", p);
|
||||
matches(output_schema_for::<ArcSlotResult>(), &r);
|
||||
assert!(r["centre_arc"].is_string(), "{r}");
|
||||
}
|
||||
for p in [
|
||||
json!({"center": [300, 0], "major": [310, 0], "minor_radius": 4}),
|
||||
json!({"center": [300, 20], "major": [310, 20], "minor": [302, 24], "axes": false}),
|
||||
json!({"center": [300, 40], "major": [310, 40], "minor": [302, 44], "axes": null, "construction": true}),
|
||||
json!({"center": [300, 60], "major": [310, 60], "minor_radius": 4, "minor": "nowhere"}),
|
||||
json!({"center": [300, 80], "major": [310, 80], "minor_radius": "wide", "minor": {"x": 302, "y": 84}}),
|
||||
] {
|
||||
drawn(&mut s, "sketch.ellipse", p);
|
||||
}
|
||||
drawn(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 50], [10, 60], [20, 50]]}));
|
||||
drawn(&mut s, "sketch.spline.fit_point", json!({"points": [[30, 50], [40, 60], [50, 50], [45, 45]], "periodic": true, "construction": true}));
|
||||
drawn(&mut s, "sketch.spline.fit_point", json!({"points": ["l1.start", {"at": [10, 90]}, ["20 mm", 80]], "periodic": false}));
|
||||
drawn(&mut s, "sketch.spline.control_point", json!({"points": [[0, 110], [10, 120], [20, 110], [30, 120]], "periodic": false}));
|
||||
drawn(&mut s, "sketch.spline.control_point_5", json!({"points": [[0, 130], [10, 140], [20, 130], [30, 140], [40, 130], [50, 140]]}));
|
||||
drawn(&mut s, "sketch.conic", json!({"start": [0, 150], "end": [20, 150], "apex": [10, 160]}));
|
||||
drawn(&mut s, "sketch.conic", json!({"start": [30, 150], "end": [50, 150], "apex": [40, 160], "rho": 0.3, "construction": true}));
|
||||
drawn(&mut s, "sketch.blend_curve", json!({"a": "l1.start", "b": "l3.end"}));
|
||||
drawn(&mut s, "sketch.blend_curve", json!({"a": {"ref": "l1.end"}, "b": "l2.end", "continuity": " G2 "}));
|
||||
drawn(&mut s, "sketch.blend_curve", json!({"a": "l1.start", "b": "l2.end", "continuity": "", "sketch": null}));
|
||||
for (id, p) in [
|
||||
("sketch.centerline", json!({"curves": ["l1"], "value": true})),
|
||||
("sketch.centerline", json!({"curves": "l1", "value": null})),
|
||||
("sketch.infinite", json!({"curves": ["l2", "l3"], "value": true, "sketch": null})),
|
||||
("sketch.infinite", json!({"curves": "l2"})),
|
||||
] {
|
||||
let r = run_ok(&mut s, id, p);
|
||||
matches(output_schema_for::<LineTypeResult>(), &r);
|
||||
assert_eq!(
|
||||
r.as_object().map(|o| o.keys().cloned().collect::<std::collections::BTreeSet<_>>()),
|
||||
Some(["changed", "created", "sketch"].map(String::from).into()),
|
||||
"{r}"
|
||||
);
|
||||
}
|
||||
// Sketch text: its forms, and its edits.
|
||||
let text = |s: &mut Session, p: Value| {
|
||||
let r = run_ok(s, "sketch.text", p);
|
||||
matches(output_schema_for::<TextResult>(), &r);
|
||||
r
|
||||
};
|
||||
let first = text(&mut s, json!({"text": "Hello\nthere", "at": [0, 200]}));
|
||||
assert!(first["guide"].is_null() && first.get("exprs").is_none(), "{first}");
|
||||
let link = first["link"].as_str().unwrap_or_default().to_string();
|
||||
text(
|
||||
&mut s,
|
||||
json!({"text": "Styled", "at": {"x": 50, "y": 200}, "height": 8, "angle": 10, "font": "", "bold": false, "italic": true, "reverse": false, "align": "Center", "letter_spacing": 0.5, "line_spacing": 1.2, "path": ""}),
|
||||
);
|
||||
text(&mut s, json!({"text": null, "text_expression": "'A' + 'B'", "at": [0, 220], "bold": null, "align": null, "path": null}));
|
||||
let expr = text(&mut s, json!({"text": "Param", "at": ["1 + 1", 230], "height": "tall", "angle": "30 deg"}));
|
||||
// An expression that names no parameter is the number it comes to.
|
||||
assert_eq!(expr["exprs"], json!({"height": "tall"}), "{expr}");
|
||||
let on_path = text(&mut s, json!({"text": "Curve", "at": [0, 0], "path": "l1", "align": " RIGHT ", "reverse": true}));
|
||||
assert_eq!(on_path["guide"], json!("l1"), "{on_path}");
|
||||
let other = text(&mut s, json!({"text": "Other", "at": [0, 260]}));
|
||||
let outline = other["curves"][0].clone();
|
||||
for p in [
|
||||
json!({"link": link, "text": "Changed"}),
|
||||
json!({"link": link, "at": [5, 205], "height": "tall", "angle": null, "font": null, "line_spacing": null}),
|
||||
json!({"entity": outline, "italic": true, "live": false}),
|
||||
json!({"link": link, "entity": 5, "text": "Same", "edits": null}),
|
||||
json!({"edits": [{"link": link, "text": "One"}, {"entity": outline, "height": 9}], "link": 5, "text": 7, "at": "ignored"}),
|
||||
] {
|
||||
let r = run_ok(&mut s, "sketch.edit_text", p.clone());
|
||||
matches(output_schema_for::<EditTextResult>(), &r);
|
||||
assert!(r["edits"].is_array() == p.get("edits").is_some_and(|e| !e.is_null()), "{r}");
|
||||
}
|
||||
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
|
||||
let misspelt: [(&str, Value); 17] = [
|
||||
("sketch.line.midpoint", json!({"mid": [100, 45], "end": [110, 45], "constrution": true})),
|
||||
("sketch.arc.tangent", json!({"start": "l2.end", "end": [62, 42], "curv": "l2"})),
|
||||
("sketch.circle.two_tangent", json!({"radious": 3, "diameter": 6, "near": near, "curves": ["l1", "l2"]})),
|
||||
("sketch.circle.three_tangent", json!({"nere": 1, "near": [25, 15], "curves": ["l1", "l2", "l3"]})),
|
||||
("sketch.slot.center_point", json!({"center": [100, 140], "end": [110, 140], "widht": 1, "width": 4})),
|
||||
("sketch.slot.arc_three_point", json!({"start": [160, 0], "through": [170, 10], "end": [180, 0], "width": 2, "throgh": 1})),
|
||||
("sketch.slot.arc_center", json!({"center": [200, 90], "start": [210, 90], "sweep": 90, "width": 2, "swep": 1})),
|
||||
("sketch.ellipse", json!({"center": [300, 100], "major": [310, 100], "minor_radius": 4, "axs": false})),
|
||||
("sketch.spline.fit_point", json!({"points": [[60, 50], [70, 60], [80, 50]], "periodc": true})),
|
||||
("sketch.spline.control_point", json!({"points": [[60, 110], [70, 120], [80, 110]], "point": 1})),
|
||||
("sketch.spline.control_point_5", json!({"points": [[60, 130], [70, 140], [80, 130], [90, 140], [100, 130], [110, 140]], "constructon": 1})),
|
||||
("sketch.conic", json!({"start": [60, 150], "end": [80, 150], "apex": [70, 160], "rhoo": 0.4})),
|
||||
("sketch.blend_curve", json!({"a": "l1.start", "b": "l3.end", "continuty": "g1"})),
|
||||
("sketch.centerline", json!({"curves": ["l1"], "vlaue": true})),
|
||||
("sketch.infinite", json!({"curves": ["l2"], "vlaue": true})),
|
||||
("sketch.text", json!({"text": "Oops", "at": [0, 240], "hieght": 3})),
|
||||
("sketch.edit_text", json!({"link": link, "txt": "x"})),
|
||||
];
|
||||
for (id, p) in &misspelt {
|
||||
let r = s.execute(id, p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(
|
||||
r["param_warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|m| m.contains("unknown key"))),
|
||||
"{id}: {r}"
|
||||
);
|
||||
}
|
||||
s.param_check = ParamCheck::Strict;
|
||||
for (id, p) in &misspelt {
|
||||
let error = s.execute(id, p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("unknown key"), "{id}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each command's schemas describe it: a summary, and a description on every key.
|
||||
#[test]
|
||||
fn creation_schemas_are_described() {
|
||||
for id in [
|
||||
"sketch.arc.tangent",
|
||||
"sketch.blend_curve",
|
||||
"sketch.centerline",
|
||||
"sketch.circle.three_tangent",
|
||||
"sketch.circle.two_tangent",
|
||||
"sketch.conic",
|
||||
"sketch.edit_text",
|
||||
"sketch.ellipse",
|
||||
"sketch.infinite",
|
||||
"sketch.line.midpoint",
|
||||
"sketch.slot.arc_center",
|
||||
"sketch.slot.arc_three_point",
|
||||
"sketch.slot.center_point",
|
||||
"sketch.spline.control_point",
|
||||
"sketch.spline.control_point_5",
|
||||
"sketch.spline.fit_point",
|
||||
"sketch.text",
|
||||
] {
|
||||
let c = crate::cmd::find_command(id).unwrap_or_else(|| panic!("{id}"));
|
||||
let input = c.contract.map(|c| c.input_schema()).unwrap_or_else(|| panic!("{id}"));
|
||||
let input = input.as_value();
|
||||
assert!(input["description"].as_str().is_some_and(|d| d.len() > 20), "{id}: no summary");
|
||||
for (key, property) in input["properties"].as_object().into_iter().flatten() {
|
||||
assert!(property.get("description").is_some() || property.get("$ref").is_some(), "{id}: `{key}` has no description");
|
||||
}
|
||||
let output = (c.output.unwrap_or_else(|| panic!("{id}")))();
|
||||
assert!(
|
||||
output.as_value().get("properties").is_some() || output.as_value().get("oneOf").is_some() || output.as_value().get("anyOf").is_some(),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The schemas ask for what the handlers can't do without, and take what they read.
|
||||
#[test]
|
||||
fn the_creation_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<TwoTangentCircleParams>(),
|
||||
&[
|
||||
(json!({"radius": 5, "near": [1, 2], "curves": ["l1", "l2"]}), true),
|
||||
(json!({"diameter": 5, "near": {"x": 1, "y": 2}, "curves": ["l1", "l2"]}), true),
|
||||
(json!({"diameter": 5, "near": [1, 2], "curves": "l1"}), false),
|
||||
(json!({"radius": "r / 2", "near": [1, 2], "curves": ["l1", "l2"], "sketch": 3}), true),
|
||||
(json!({"near": [1, 2], "curves": ["l1", "l2"]}), false),
|
||||
(json!({"radius": 5, "curves": ["l1", "l2"]}), false),
|
||||
(json!({"radius": 5, "near": [1, 2]}), false),
|
||||
(json!({"radius": 5, "near": [1, 2], "curves": ["l1"]}), false),
|
||||
(json!({"radius": 5, "near": [1, 2], "curves": ["l1", "l2", "l3"]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ArcSlotCenterParams>(),
|
||||
&[
|
||||
(json!({"center": [0, 0], "start": [5, 0], "width": 1, "sweep": 90}), true),
|
||||
(json!({"center": [0, 0], "start": [5, 0], "width": 1, "end": [0, 5]}), true),
|
||||
(json!({"center": [0, 0], "start": [5, 0], "width": 1}), false),
|
||||
(json!({"center": [0, 0], "start": [5, 0], "sweep": 90}), false),
|
||||
(json!({"center": [0, 0], "start": [5, 0], "width": 0, "sweep": 90}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<EllipseParams>(),
|
||||
&[
|
||||
(json!({"center": [0, 0], "major": [5, 0], "minor_radius": 2}), true),
|
||||
(json!({"center": [0, 0], "major": [5, 0], "minor": [1, 1], "axes": false}), true),
|
||||
(json!({"center": [0, 0], "major": [5, 0]}), false),
|
||||
(json!({"center": [0, 0], "minor_radius": 2}), false),
|
||||
(json!({"center": [0, 0], "major": [5, 0], "minor_radius": 0}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ConicParams>(),
|
||||
&[
|
||||
(json!({"start": [0, 0], "end": "l1.end", "apex": {"vertex": [1, 2, 3]}, "rho": 0.2}), true),
|
||||
(json!({"start": ["w / 2", 0], "end": {"ref": "p1"}, "apex": "mid:l1"}), true),
|
||||
(json!({"start": [0, 0], "end": [1, 0]}), false),
|
||||
(json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<FitPointSplineParams>(),
|
||||
&[
|
||||
(json!({"points": [[0, 0], [1, 1]], "periodic": true}), true),
|
||||
(json!({"points": [[0, 0]]}), false),
|
||||
(json!({"points": vec![json!([0, 0]); 501]}), false),
|
||||
(json!({"periodic": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ControlPointSplineParams>(),
|
||||
&[(json!({"points": [[0, 0], [1, 1]], "periodic": false}), true), (json!({"points": [[0, 0]]}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<BlendCurveParams>(),
|
||||
&[
|
||||
(json!({"a": "l1.end", "b": "l2.start", "continuity": "G3"}), true),
|
||||
(json!({"a": "l1.end", "b": "l2.start", "continuity": ""}), true),
|
||||
(json!({"a": "l1.end"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<CenterlineParams>(),
|
||||
&[
|
||||
(json!({"curves": "l1"}), true),
|
||||
(json!({"curves": ["l1", "l2"], "value": false}), true),
|
||||
(json!({"curves": []}), false),
|
||||
(json!({}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<SketchTextParams>(),
|
||||
&[
|
||||
(json!({"text": "x", "at": [0, 0]}), true),
|
||||
(json!({"text_expression": "'x'", "at": ["w / 2", "5"]}), true),
|
||||
(json!({"text": "x", "at": {"x": 1, "y": 2}, "height": "h", "angle": "30 deg", "align": "CENTRE", "path": ""}), true),
|
||||
(json!({"text": "x"}), false),
|
||||
(json!({"at": [0, 0]}), false),
|
||||
(json!({"text": "x", "at": [0, 0], "height": 0}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<EditTextParams>(),
|
||||
&[
|
||||
(json!({"link": "j1", "text": "y"}), true),
|
||||
(json!({"entity": "n1", "at": [1, 2]}), true),
|
||||
(json!({"edits": [{"link": "j1"}, {"entity": "n1", "height": 4}], "live": false}), true),
|
||||
(json!({"edits": []}), false),
|
||||
(json!({"edits": [{"text": "y"}]}), false),
|
||||
(json!({"text": "y"}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The point forms the types take are the forms the handlers read: `sketch.line.midpoint` snaps
|
||||
/// its points, `sketch.blend_curve` does not.
|
||||
#[test]
|
||||
fn point_forms_match_the_point_readers() {
|
||||
let refused =
|
||||
|e: &crate::EngineError| e.to_string().contains("a point must be [x, y]") || e.to_string().contains("a point's coordinates must be");
|
||||
let forms = [
|
||||
json!([1, 2]),
|
||||
json!({"x": 1, "y": 2}),
|
||||
json!([1e9, 2]),
|
||||
json!([1, "2"]),
|
||||
json!(["1 mm", "2"]),
|
||||
json!(["1", true]),
|
||||
json!([true, "2"]),
|
||||
json!(["", 2]),
|
||||
json!([" ", 2]),
|
||||
json!(["x".repeat(4097), 2]),
|
||||
json!([1e9, "2"]),
|
||||
json!([null, "2"]),
|
||||
json!([1, 2, 3]),
|
||||
json!([1]),
|
||||
json!([]),
|
||||
json!("l1.end"),
|
||||
json!(""),
|
||||
json!("mid:l1"),
|
||||
json!("vertex:1,2,3"),
|
||||
json!({"ref": "l1.end"}),
|
||||
json!({"ref": 5}),
|
||||
json!({"ref": 5, "at": [1, 2]}),
|
||||
json!({"ref": "l1.end", "at": 5}),
|
||||
json!({"at": [1, 2]}),
|
||||
json!({"at": {"x": 1, "y": 2}}),
|
||||
json!({"at": [1, "2"]}),
|
||||
json!({"at": 5}),
|
||||
json!({"vertex": [1, 2, 3]}),
|
||||
json!({"vertex": [1, 2]}),
|
||||
json!({"vertex": [1, 2], "at": [1, 2]}),
|
||||
json!({"on_edge": {"x": 1, "y": 2, "z": 3}}),
|
||||
json!({"on_edge": "x"}),
|
||||
json!({}),
|
||||
json!({"x": 1}),
|
||||
json!(5),
|
||||
json!(true),
|
||||
json!(null),
|
||||
json!([[1, 2]]),
|
||||
];
|
||||
for v in forms {
|
||||
let mut s = drawing(ParamCheck::Off);
|
||||
let snapped = s.execute("sketch.line.midpoint", &json!({"mid": v, "end": [7, 7]}));
|
||||
assert_eq!(crate::cmd::point_arg_form(&v, true).is_ok(), !snapped.as_ref().err().is_some_and(refused), "mid {v}: {snapped:?}");
|
||||
let mut s = drawing(ParamCheck::Off);
|
||||
let plain = s.execute("sketch.blend_curve", &json!({"a": v, "b": "l2.end"}));
|
||||
assert_eq!(crate::cmd::point_arg_form(&v, false).is_ok(), !plain.as_ref().err().is_some_and(refused), "a {v}: {plain:?}");
|
||||
// The type says the same.
|
||||
assert_eq!(serde_json::from_value::<super::SnapPoint>(v.clone()).is_ok(), crate::cmd::point_arg_form(&v, true).is_ok(), "{v}");
|
||||
assert_eq!(serde_json::from_value::<super::SketchPoint>(v.clone()).is_ok(), crate::cmd::point_arg_form(&v, false).is_ok(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A text edit made `live` goes to the live runner, which the check doesn't wrap yet: it still
|
||||
/// replies as the output schema says, and says nothing about its keys.
|
||||
#[test]
|
||||
fn a_live_text_edit_replies_as_its_schema_says() {
|
||||
let mut s = drawing(ParamCheck::Warn);
|
||||
let link = text_link();
|
||||
let r = run(&mut s, "sketch.edit_text", json!({"link": link, "height": 7, "live": true, "unknown": 1}));
|
||||
matches(output_schema_for::<EditTextResult>(), &r);
|
||||
assert!(r.get("param_warnings").is_none() && r["link"] == json!(link), "{r}");
|
||||
}
|
||||
|
||||
/// The patterns that spell what a handler reads ignoring case and blanks.
|
||||
#[test]
|
||||
fn the_case_blind_patterns_say_what_the_handlers_read() {
|
||||
assert_eq!(
|
||||
super::word_pattern(&["left", "right"]),
|
||||
format!("^[{0}]*([Ll][Ee][Ff][Tt]|[Rr][Ii][Gg][Hh][Tt])[{0}]*$", crate::schema::types::whitespace_class())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
//! Contracts and result types for the commands in `cmd/sketch_project.rs`: `sketch.project`,
|
||||
//! `sketch.intersect`, `sketch.include_3d`, `sketch.project_to_surface`,
|
||||
//! `sketch.intersection_curve`, `sketch.spun_profile`, `sketch.isoparametric_curve`,
|
||||
//! `sketch.fit_curves_to_section`, `sketch.break_link` and `sketch.auto_project`.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. What only the design can tell (a body, sketch or
|
||||
//! curve that exists, an axis name, whether a reference projects to anything) stays the handler's
|
||||
//! to check: a reference given as text, or as an axis name, passes here and is looked up there, and
|
||||
//! so does a reference to a sketch, whose `curve` or `point` the handler checks once it has found
|
||||
//! the sketch.
|
||||
//! A call with several problems may get a different one first in strict than with the check off
|
||||
//! (the check goes key by key, the handlers in their own order).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
use super::types::Point3;
|
||||
use crate::cmd::{RefForm, ref_form};
|
||||
use crate::params;
|
||||
|
||||
/// What a reference to model geometry may look like, for the descriptions.
|
||||
const REF_FORMS: &str = "a reference: `{\"edge\": [x, y, z], \"body\"?}` (the edge through that point), `{\"face\": [x, y, z], \"body\"?}` (the edges of the face), `{\"vertex\": [x, y, z], \"body\"?}`, `{\"body\": name}` (its silhouette or outline), `{\"sketch\": id or name, \"curve\": id}` or `{\"sketch\": id or name, \"point\": id}` (a curve or point of another sketch), `\"origin\"` or `{\"origin\": …}`, `{\"axis\": \"X\"|\"Y\"|\"Z\"|a construction axis's name}`, `{\"plane\": \"XY\"|\"XZ\"|\"YZ\"|a plane's name}`; text alone is `origin`, X, Y, Z (any case) or a body's name";
|
||||
|
||||
/// The most references one `refs` takes into account: the handler reads no more.
|
||||
const MAX_REFS: usize = 1000;
|
||||
|
||||
/// Whether text is what `cmd/sketch_project.rs` reads as the origin or an origin axis, which is
|
||||
/// settled without the design; any other text may be a body's name.
|
||||
fn is_origin_or_axis(name: &str) -> bool {
|
||||
["origin", "x", "y", "z"].iter().any(|word| name.eq_ignore_ascii_case(word))
|
||||
}
|
||||
|
||||
/// The handler's message for a reference that says nothing a design could be asked about, in its
|
||||
/// own words, or `None` when the form is fine. A sketch reference with neither `curve` nor `point`
|
||||
/// is fine here: the handler refuses it only once it has looked the sketch up, and says "no sketch"
|
||||
/// first when there is none.
|
||||
fn ref_problem(v: &Value) -> Option<String> {
|
||||
ref_form(v).err()
|
||||
}
|
||||
|
||||
/// One reference of `sketch.project`, `sketch.intersect` and `sketch.include_3d`, kept as given
|
||||
/// for the handler.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProjectRef(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for ProjectRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match ref_problem(&v) {
|
||||
Some(why) => Err(D::Error::custom(whole(why))),
|
||||
None => Ok(ProjectRef(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ProjectRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProjectRef".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
let with_body = |key: &str| {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {key: point, "body": {"type": "string", "description": "The body's name. Default: any body. Text only: the handler reads anything else as left out, which the schema does not take."}},
|
||||
"required": [key]
|
||||
})
|
||||
};
|
||||
json_schema!({
|
||||
"description": REF_FORMS,
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
with_body("edge"),
|
||||
with_body("face"),
|
||||
with_body("vertex"),
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "curve": {"type": "string"}},
|
||||
"required": ["sketch", "curve"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "point": {"type": "string"}},
|
||||
"required": ["sketch", "point"]
|
||||
},
|
||||
{"type": "object", "properties": {"body": {"type": "string"}}, "required": ["body"]},
|
||||
{"type": "object", "properties": {"axis": {"type": "string"}}, "required": ["axis"]},
|
||||
{"type": "object", "properties": {"plane": {"type": "string"}}, "required": ["plane"]},
|
||||
{"type": "object", "required": ["origin"]}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The references of `refs` (or `ref`): a list, or one reference. Only the first 1000 of a list
|
||||
/// count, as the handler reads no more.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProjectRefs(pub Vec<Value>);
|
||||
|
||||
impl ProjectRefs {
|
||||
/// The references `v` lists, or the handler's message for them. A value that is not a list is
|
||||
/// one reference.
|
||||
fn read(v: &Value) -> Result<ProjectRefs, String> {
|
||||
let list: Vec<&Value> = match v {
|
||||
Value::Array(a) => a.iter().take(MAX_REFS).collect(),
|
||||
one => vec![one],
|
||||
};
|
||||
if list.is_empty() {
|
||||
return Err("`refs` is empty".into());
|
||||
}
|
||||
if let Some(why) = list.iter().find_map(|r| ref_problem(r)) {
|
||||
return Err(why);
|
||||
}
|
||||
Ok(ProjectRefs(list.into_iter().cloned().collect()))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ProjectRefs {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProjectRefs".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let one = generator.subschema_for::<ProjectRef>();
|
||||
json_schema!({
|
||||
"description": "One reference or a non-empty list of them (the first 1000 count).",
|
||||
"anyOf": [one, {"type": "array", "items": one, "minItems": 1}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `refs`, or `ref` when `refs` is absent (the handler reads `ref` only then, `null` included).
|
||||
fn read_refs(o: &Map<String, Value>) -> Result<(Option<ProjectRefs>, Option<ProjectRefs>), String> {
|
||||
match (o.get("refs"), o.get("ref")) {
|
||||
(Some(v), _) => Ok((Some(ProjectRefs::read(v)?), None)),
|
||||
(None, Some(v)) => Ok((None, Some(ProjectRefs::read(v)?))),
|
||||
(None, None) => Ok((None, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `projection_link`: a boolean, and `null` is refused too (the message is the same for both
|
||||
/// commands that take it).
|
||||
fn read_link_flag(o: &Map<String, Value>) -> Result<Option<bool>, String> {
|
||||
match o.get("projection_link") {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err("`projection_link` must be a boolean".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `refs` and `ref` are one of them required: left out, the handler refuses with its own message.
|
||||
fn refs_required(schema: &mut Schema) {
|
||||
schema.insert("anyOf".into(), json!([{"required": ["refs"]}, {"required": ["ref"]}]));
|
||||
}
|
||||
|
||||
/// Bring model edges, faces, bodies, other sketches' curves and points, the origin, an axis or a
|
||||
/// plane into the active sketch as linked reference geometry: fixed, drawn purple and updated
|
||||
/// when the upstream geometry changes. What the sketch already shows (the border of its face, an
|
||||
/// edge projected before) is skipped and listed in `already_projected`; a call that adds nothing is
|
||||
/// refused. One undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = refs_required)]
|
||||
pub struct ProjectParams {
|
||||
/// What to project: one reference or a list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProjectRefs")]
|
||||
pub refs: Option<ProjectRefs>,
|
||||
/// The same as `refs`, used only when `refs` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(rename = "ref", with = "ProjectRefs")]
|
||||
pub reference: Option<ProjectRefs>,
|
||||
/// Keep the geometry linked to its source (true), or make it normal sketch geometry at once
|
||||
/// (false: the replies' `link` is then null). Default: true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub projection_link: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ProjectParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
// The handler reads `projection_link` before the references.
|
||||
let projection_link = read_link_flag(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(ProjectParams { refs, reference, projection_link })
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersect the sketch plane with model bodies, faces, edges and other sketches' curves, and
|
||||
/// bring the section curves (or points) into the active sketch as linked reference geometry. One
|
||||
/// undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = refs_required)]
|
||||
pub struct IntersectParams {
|
||||
/// What to intersect with the sketch plane: one reference or a list (`{body}`, `{face}`,
|
||||
/// `{edge}` or `{sketch, curve}` are the forms that make sense here).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProjectRefs")]
|
||||
pub refs: Option<ProjectRefs>,
|
||||
/// The same as `refs`, used only when `refs` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(rename = "ref", with = "ProjectRefs")]
|
||||
pub reference: Option<ProjectRefs>,
|
||||
/// Keep the geometry linked to its source (true), or make it normal sketch geometry at once
|
||||
/// (false: the reply's `link` is then null). Default: true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub projection_link: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for IntersectParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
let projection_link = read_link_flag(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(IntersectParams { refs, reference, projection_link })
|
||||
}
|
||||
}
|
||||
|
||||
/// Include model edges, vertices, and other sketches' curves and points in the active sketch as
|
||||
/// linked geometry; curves out of the sketch plane are flattened onto it. Takes the references of
|
||||
/// `sketch.project`, and always keeps the link. One undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = refs_required)]
|
||||
pub struct Include3dParams {
|
||||
/// What to include: one reference or a list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProjectRefs")]
|
||||
pub refs: Option<ProjectRefs>,
|
||||
/// The same as `refs`, used only when `refs` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(rename = "ref", with = "ProjectRefs")]
|
||||
pub reference: Option<ProjectRefs>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Include3dParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(Include3dParams { refs, reference })
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that, once given (`null` included), must parse as `T`, as the handlers read it.
|
||||
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// A point `[x, y, z]` or `{x, y, z}` with the handler's own message.
|
||||
macro_rules! point_with_message {
|
||||
($(#[$doc:meta])* $name:ident, $message:literal) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct $name(pub solidcore_record::Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec3(&Value::deserialize(d)?).map($name).ok_or_else(|| D::Error::custom(whole($message)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for $name {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
stringify!($name).into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point3>()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
point_with_message!(
|
||||
/// `sketch.project_to_surface`'s `face`, with the handler's message.
|
||||
TargetFacePoint,
|
||||
"`face` must be a point [x,y,z] on the target face"
|
||||
);
|
||||
point_with_message!(
|
||||
/// `sketch.isoparametric_curve`'s `face`, with the handler's message.
|
||||
IsoFacePoint,
|
||||
"`face` must be a point [x,y,z] on the face"
|
||||
);
|
||||
|
||||
/// A curve to project onto a surface: `{sketch, curve}`, read as the handler reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SurfaceCurve(pub Value);
|
||||
|
||||
impl SurfaceCurve {
|
||||
fn problem(v: &Value) -> Option<String> {
|
||||
const EACH: &str = "each entry must be {sketch, curve}";
|
||||
match ref_form(v) {
|
||||
Err(why) => Some(why),
|
||||
// What the handler says of a sketch reference comes after it has found the sketch.
|
||||
Ok(RefForm::Sketch { .. }) => None,
|
||||
// Whether text names a body, or an axis a construction axis, only the design knows;
|
||||
// either way it is not a curve.
|
||||
Ok(RefForm::Name(name)) if !is_origin_or_axis(name) => None,
|
||||
Ok(RefForm::Axis(name)) if !is_origin_or_axis(name) => None,
|
||||
Ok(_) => Some(EACH.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `curves` of `sketch.project_to_surface`: 1 to 500 of them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SurfaceCurves(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for SurfaceCurves {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let list = match Value::deserialize(d)? {
|
||||
Value::Array(list) => list,
|
||||
_ => return Err(D::Error::custom(whole("`curves` must list {sketch, curve}"))),
|
||||
};
|
||||
if list.is_empty() || list.len() > 500 {
|
||||
return Err(D::Error::custom(whole("`curves` must list 1…500 curves")));
|
||||
}
|
||||
if let Some(why) = list.iter().find_map(SurfaceCurve::problem) {
|
||||
return Err(D::Error::custom(whole(why)));
|
||||
}
|
||||
Ok(SurfaceCurves(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SurfaceCurves {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SurfaceCurves".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "From 1 to 500 curves of other sketches, each {\"sketch\": id or name, \"curve\": curve id}.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "curve": {"type": "string"}},
|
||||
"required": ["sketch", "curve"]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Project curves of other sketches along the active sketch's normal onto a face of the model,
|
||||
/// as linked 3D curves. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = surface_required)]
|
||||
pub struct ProjectToSurfaceParams {
|
||||
// Optional in the type and required in the schema (`surface_required`): left out, the handler
|
||||
// refuses it with its own message, not the check's "is required".
|
||||
/// The curves to project: 1 to 500 of `{"sketch": id or name, "curve": id}`, curves of other
|
||||
/// sketches.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SurfaceCurves")]
|
||||
pub curves: Option<SurfaceCurves>,
|
||||
/// A point [x, y, z] in mm on the target face.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "TargetFacePoint")]
|
||||
pub face: Option<TargetFacePoint>,
|
||||
/// The face's body. Default: any body the point is on. Text only; anything else is read as
|
||||
/// left out.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
fn surface_required(schema: &mut Schema) {
|
||||
require(schema, "curves");
|
||||
require(schema, "face");
|
||||
}
|
||||
|
||||
/// One side of `sketch.intersection_curve`: a body, a face, or a curve of another sketch.
|
||||
macro_rules! intersection_side {
|
||||
($(#[$doc:meta])* $name:ident, $key:literal) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct $name(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let wrong = || D::Error::custom(whole(concat!("`", $key, "` must be a body, a face or a curve of another sketch")));
|
||||
match ref_form(&v) {
|
||||
Err(why) => Err(D::Error::custom(whole(why))),
|
||||
// A sketch reference is judged by the handler once it has found the sketch.
|
||||
Ok(RefForm::Body(_) | RefForm::Face { .. } | RefForm::Sketch { .. }) => Ok($name(v)),
|
||||
// Text is a body's name, or the origin or an axis; an axis name is looked up.
|
||||
Ok(RefForm::Name(name)) if !is_origin_or_axis(name) => Ok($name(v)),
|
||||
Ok(RefForm::Axis(name)) if !is_origin_or_axis(name) => Ok($name(v)),
|
||||
Ok(_) => Err(wrong()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for $name {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
stringify!($name).into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "A body `{\"body\": name}` (or its name as text), a face `{\"face\": [x, y, z], \"body\"?}` (the face through that point), or a curve of another sketch `{\"sketch\": id or name, \"curve\": id}`, which stands for its extrusion along that sketch's normal.",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "object", "properties": {"body": {"type": "string"}}, "required": ["body"]},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"face": point, "body": {"type": "string"}},
|
||||
"required": ["face"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "curve": {"type": "string"}},
|
||||
"required": ["sketch", "curve"]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
intersection_side!(
|
||||
/// `sketch.intersection_curve`'s `a`.
|
||||
IntersectionSideA,
|
||||
"a"
|
||||
);
|
||||
intersection_side!(
|
||||
/// `sketch.intersection_curve`'s `b`.
|
||||
IntersectionSideB,
|
||||
"b"
|
||||
);
|
||||
|
||||
/// Add where two pieces of model geometry meet to the active sketch, as a linked 3D curve. One
|
||||
/// undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = sides_required)]
|
||||
pub struct IntersectionCurveParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// The first side: a body, a face or a curve of another sketch.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IntersectionSideA")]
|
||||
pub a: Option<IntersectionSideA>,
|
||||
/// The second side, of the same forms.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IntersectionSideB")]
|
||||
pub b: Option<IntersectionSideB>,
|
||||
}
|
||||
|
||||
fn sides_required(schema: &mut Schema) {
|
||||
require(schema, "a");
|
||||
require(schema, "b");
|
||||
}
|
||||
|
||||
/// A body's name, with the handler's message for text it is not given as.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BodyName(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for BodyName {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(name) => Ok(BodyName(name)),
|
||||
_ => Err(D::Error::custom(whole("`body` must name a body"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for BodyName {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"BodyName".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A body's name.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// `sketch.spun_profile`'s `axis`: X, Y or Z (any case), or the id of a line of the active sketch.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SpunAxis(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SpunAxis {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(axis) => Ok(SpunAxis(axis)),
|
||||
_ => Err(D::Error::custom(whole("`axis` must be a sketch line or X|Y|Z"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SpunAxis {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SpunAxis".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The axis to revolve about: X, Y or Z (any case: the origin's axis), or the id of a line of the active sketch.",
|
||||
"type": "string"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the outline of a body, revolved about an axis, to the active sketch as a linked 3D curve
|
||||
/// in the sketch plane. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = spun_required)]
|
||||
pub struct SpunProfileParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// The body to revolve.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "BodyName")]
|
||||
pub body: Option<BodyName>,
|
||||
/// The axis to revolve about: X, Y or Z, or the id of a line of the active sketch.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SpunAxis")]
|
||||
pub axis: Option<SpunAxis>,
|
||||
}
|
||||
|
||||
fn spun_required(schema: &mut Schema) {
|
||||
require(schema, "body");
|
||||
require(schema, "axis");
|
||||
}
|
||||
|
||||
/// `sketch.isoparametric_curve`'s `direction`: `u` or `v` (any case), or a direction [x, y, z].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct IsoDirection(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for IsoDirection {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let word = matches!(&v, Value::String(d) if d.eq_ignore_ascii_case("u") || d.eq_ignore_ascii_case("v"));
|
||||
if word || params::vec3(&v).is_some() { Ok(IsoDirection(v)) } else { Err(D::Error::custom(whole("`direction` must be u, v or [x,y,z]"))) }
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for IsoDirection {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"IsoDirection".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "u or v (any case): along the face's parametric direction; or a direction [x, y, z].",
|
||||
"anyOf": [{"type": "string", "enum": ["u", "v", "U", "V"]}, point]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the curve of constant u or v through a point of a face to the active sketch, as a linked
|
||||
/// 3D curve. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = face_required)]
|
||||
pub struct IsoCurveParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// A point [x, y, z] in mm on the face: the curve passes through it.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IsoFacePoint")]
|
||||
pub face: Option<IsoFacePoint>,
|
||||
/// The face's body. Default: any body the point is on. Text only; anything else is read as
|
||||
/// left out.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// Which way the curve runs: u or v, or a direction [x, y, z]. Default: u.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IsoDirection")]
|
||||
pub direction: Option<IsoDirection>,
|
||||
}
|
||||
|
||||
fn face_required(schema: &mut Schema) {
|
||||
require(schema, "face");
|
||||
}
|
||||
|
||||
/// Fit lines and arcs to the section of a mesh or solid body with the sketch plane, and add them
|
||||
/// to the active sketch as normal (unlinked) geometry. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = body_required)]
|
||||
pub struct FitCurvesParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// The body to cut with the sketch plane.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "BodyName")]
|
||||
pub body: Option<BodyName>,
|
||||
}
|
||||
|
||||
fn body_required(schema: &mut Schema) {
|
||||
require(schema, "body");
|
||||
}
|
||||
|
||||
/// `sketch.break_link`'s `entities`: one id or a list of ids, as `params::string_list` reads them.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct EntityIds(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for EntityIds {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match params::string_list("", &json!({"entities": v}), "entities") {
|
||||
Ok(ids) => Ok(EntityIds(ids)),
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
|
||||
Err(other) => Err(D::Error::custom(whole(other))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for EntityIds {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"EntityIds".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The id of a curve or point, or a list of ids (at most 10000).",
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": 10000}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn linked sketch geometry into normal sketch geometry, by its link or by any of its curves
|
||||
/// and points. Give `link` or `entities`. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = link_or_entities)]
|
||||
pub struct SketchBreakLinkParams {
|
||||
/// A link's id (`j1`). Text only; anything else is read as left out.
|
||||
#[serde(default)]
|
||||
pub link: Option<String>,
|
||||
/// The ids of linked curves or points: each one's whole link is broken. Default: none.
|
||||
#[serde(default)]
|
||||
pub entities: Option<EntityIds>,
|
||||
}
|
||||
|
||||
/// The handler refuses a call with neither key (or an empty list), with its own message.
|
||||
fn link_or_entities(schema: &mut Schema) {
|
||||
schema.insert("anyOf".into(), json!([{"required": ["link"]}, {"required": ["entities"]}]));
|
||||
}
|
||||
|
||||
/// Choose whether a sketch created on a face projects that face's edges into it at once, or
|
||||
/// toggle the choice. A setting the session keeps: not an undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AutoProjectParams {
|
||||
/// True to project, false not to. Default: the opposite of what it is now. Anything but true
|
||||
/// or false is read as left out.
|
||||
#[serde(default)]
|
||||
pub value: Option<bool>,
|
||||
}
|
||||
|
||||
/// One link a projection made, and what it brought into the sketch.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectedLink {
|
||||
/// The link's id (`j1`); null when `projection_link` was false: the geometry is normal sketch
|
||||
/// geometry then.
|
||||
pub link: Option<String>,
|
||||
/// The ids of the curves it made.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the points it made.
|
||||
pub points: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.project`, `sketch.intersect` and `sketch.include_3d` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectResult {
|
||||
/// One entry for each reference that added something, in order.
|
||||
pub links: Vec<ProjectedLink>,
|
||||
/// What was left out because the sketch shows it already (`sketch.project` only).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub already_projected: Vec<String>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The link one command made: what `sketch.intersection_curve`, `sketch.spun_profile` and
|
||||
/// `sketch.isoparametric_curve` return, and each of the `links` of `sketch.project_to_surface`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct LinkMade {
|
||||
/// The link's id (`j1`).
|
||||
pub link: String,
|
||||
/// The ids of the curves it made.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the points it made.
|
||||
pub points: Vec<String>,
|
||||
/// The ids of the wires it made.
|
||||
pub wires: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.intersection_curve`, `sketch.spun_profile` and `sketch.isoparametric_curve`
|
||||
/// return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchLinkResult {
|
||||
#[serde(flatten)]
|
||||
pub made: LinkMade,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.project_to_surface` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectToSurfaceResult {
|
||||
/// One link for each curve, in order.
|
||||
pub links: Vec<LinkMade>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.fit_curves_to_section` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FitSectionResult {
|
||||
/// The ids of the curves added.
|
||||
pub curves: Vec<String>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.break_link` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchBreakLinkResult {
|
||||
/// The ids of the links that were broken.
|
||||
pub broken: Vec<String>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.auto_project` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AutoProjectResult {
|
||||
/// Whether a sketch created on a face projects its edges now.
|
||||
pub auto_project: bool,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,514 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
AutoProjectParams, AutoProjectResult, FitCurvesParams, FitSectionResult, Include3dParams, IntersectParams, IntersectionCurveParams,
|
||||
IsoCurveParams, ProjectParams, ProjectRef, ProjectResult, ProjectToSurfaceParams, ProjectToSurfaceResult, SketchBreakLinkParams,
|
||||
SketchBreakLinkResult, SketchLinkResult, SpunProfileParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
/// A box and a tall cylinder through it (two bodies), a finished sketch `Src` with a line `source`,
|
||||
/// and a sketch being edited, on the XY plane at height 5.
|
||||
fn scene(level: ParamCheck) -> Session {
|
||||
scene_on(level, "XY")
|
||||
}
|
||||
|
||||
/// [`scene`] with the sketch being edited on `plane`.
|
||||
fn scene_on(level: ParamCheck, plane: &str) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "Block"})),
|
||||
("solid.cylinder", json!({"base": [10, 10, -5], "radius": 4, "height": 30, "operation": "new", "body_name": "Pin"})),
|
||||
("sketch.create", json!({"plane": "XY", "offset": 20, "name": "Src"})),
|
||||
("sketch.line", json!({"points": [[2, 2], [18, 2]], "ids": ["source"]})),
|
||||
("sketch.line", json!({"points": [[2, 4], [18, 8]], "ids": ["other"]})),
|
||||
("sketch.finish", json!({})),
|
||||
("sketch.create", json!({"plane": plane, "offset": 5})),
|
||||
] {
|
||||
if let Err(e) = s.execute(id, &p) {
|
||||
panic!("{id} {p}: {e}");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn error(level: ParamCheck, id: &str, p: &Value) -> Option<String> {
|
||||
scene(level).execute(id, p).err().map(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ProjectParams>(), &["projection_link", "ref", "refs"]);
|
||||
assert_keys(input_schema_for::<IntersectParams>(), &["projection_link", "ref", "refs"]);
|
||||
assert_keys(input_schema_for::<Include3dParams>(), &["ref", "refs"]);
|
||||
assert_keys(input_schema_for::<ProjectToSurfaceParams>(), &["body", "curves", "face"]);
|
||||
assert_keys(input_schema_for::<IntersectionCurveParams>(), &["a", "b"]);
|
||||
assert_keys(input_schema_for::<SpunProfileParams>(), &["axis", "body"]);
|
||||
assert_keys(input_schema_for::<IsoCurveParams>(), &["body", "direction", "face"]);
|
||||
assert_keys(input_schema_for::<FitCurvesParams>(), &["body"]);
|
||||
assert_keys(input_schema_for::<SketchBreakLinkParams>(), &["entities", "link"]);
|
||||
assert_keys(input_schema_for::<AutoProjectParams>(), &["value"]);
|
||||
}
|
||||
|
||||
/// Strict gives the handlers' messages for the calls they refuse for a key. These fail before the
|
||||
/// handler looks at the sketch being edited, so they hold with none open.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.project", json!({})),
|
||||
("sketch.project", json!({"refs": []})),
|
||||
("sketch.project", json!({"ref": []})),
|
||||
("sketch.project", json!({"refs": [], "ref": "origin"})),
|
||||
("sketch.project", json!({"projection_link": "no", "refs": ["origin"]})),
|
||||
("sketch.project", json!({"projection_link": null, "refs": ["origin"]})),
|
||||
("sketch.project", json!({"projection_link": 1})),
|
||||
("sketch.intersect", json!({})),
|
||||
("sketch.intersect", json!({"refs": []})),
|
||||
("sketch.intersect", json!({"projection_link": "no", "refs": [{"body": "Block"}]})),
|
||||
("sketch.include_3d", json!({})),
|
||||
("sketch.include_3d", json!({"refs": []})),
|
||||
("sketch.project_to_surface", json!({})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2]})),
|
||||
("sketch.project_to_surface", json!({"face": null})),
|
||||
("sketch.project_to_surface", json!({"face": "top"})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3]})),
|
||||
("sketch.spun_profile", json!({})),
|
||||
("sketch.spun_profile", json!({"body": 5, "axis": "Z"})),
|
||||
("sketch.spun_profile", json!({"body": null})),
|
||||
("sketch.spun_profile", json!({"body": "Block"})),
|
||||
("sketch.spun_profile", json!({"body": "Block", "axis": 3})),
|
||||
("sketch.spun_profile", json!({"body": "Block", "axis": null})),
|
||||
("sketch.isoparametric_curve", json!({})),
|
||||
("sketch.isoparametric_curve", json!({"face": [1, 2]})),
|
||||
("sketch.isoparametric_curve", json!({"face": {"x": 1, "y": 2}})),
|
||||
("sketch.fit_curves_to_section", json!({})),
|
||||
("sketch.fit_curves_to_section", json!({"body": 7})),
|
||||
("sketch.fit_curves_to_section", json!({"body": null})),
|
||||
("sketch.fit_curves_to_section", json!({"body": ["Block"]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The same for the calls that fail only once the handler has a sketch to work in: what it
|
||||
/// refuses in a reference or a list, with the sketch being edited open.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_in_a_sketch() {
|
||||
let cases = [
|
||||
("sketch.project", json!({"refs": null})),
|
||||
("sketch.project", json!({"refs": [true]})),
|
||||
("sketch.project", json!({"refs": [5]})),
|
||||
("sketch.project", json!({"refs": [null]})),
|
||||
("sketch.project", json!({"refs": [[1, 2, 3]]})),
|
||||
("sketch.project", json!({"refs": [{}]})),
|
||||
("sketch.project", json!({"refs": [{"edge": [1, 2]}]})),
|
||||
("sketch.project", json!({"refs": [{"edge": "x"}, {"plane": 3}]})),
|
||||
("sketch.project", json!({"refs": ["origin", {"sketch": [1], "curve": "source"}]})),
|
||||
("sketch.project", json!({"refs": [{"sketch": true}]})),
|
||||
("sketch.project", json!({"refs": [{"sketch": "Src"}]})),
|
||||
("sketch.project", json!({"refs": [{"sketch": "Src", "curve": 4}]})),
|
||||
("sketch.project", json!({"ref": {"axis": 5}})),
|
||||
("sketch.project", json!({"ref": 5})),
|
||||
("sketch.intersect", json!({"refs": [{"vertex": [1, 2]}]})),
|
||||
("sketch.intersect", json!({"ref": {"sketch": "Src", "point": 1}})),
|
||||
("sketch.include_3d", json!({"refs": [1.5]})),
|
||||
("sketch.include_3d", json!({"refs": {"colour": "red"}})),
|
||||
("sketch.include_3d", json!({"refs": null})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": 5})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": null})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": {"sketch": "Src", "curve": "source"}})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": []})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": vec![json!({"sketch": "Src", "curve": "source"}); 501]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"edge": [1, 2, 3]}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"sketch": "Src", "point": "p1"}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"body": "Block"}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": ["origin"]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"origin": true}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"nothing": 1}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"sketch": "Src"}]})),
|
||||
("sketch.intersection_curve", json!({})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": null, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": 5, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"nothing": 1}})),
|
||||
("sketch.intersection_curve", json!({"a": "origin", "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": "x"})),
|
||||
("sketch.intersection_curve", json!({"a": {"edge": [1, 2, 3]}, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"vertex": [1, 2, 3]}})),
|
||||
("sketch.intersection_curve", json!({"a": {"plane": "XY"}, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"sketch": "Src", "point": "p1"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"sketch": "Src"}})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": null})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": "w"})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": [1, 2]})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": 3})),
|
||||
("sketch.break_link", json!({})),
|
||||
("sketch.break_link", json!({"entities": []})),
|
||||
("sketch.break_link", json!({"entities": 5})),
|
||||
("sketch.break_link", json!({"entities": [1]})),
|
||||
("sketch.break_link", json!({"entities": ["a", true]})),
|
||||
("sketch.break_link", json!({"entities": {"a": 1}})),
|
||||
("sketch.break_link", json!({"link": "j9"})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let off = error(ParamCheck::Off, id, &p);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict, id, &p), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict says the same wherever the handler's message comes from the contract's reading: every
|
||||
/// shape of reference is read by the type as the handler reads it, so the contract refuses it
|
||||
/// exactly when the handler does for its form (a sketch that exists and is another sketch's).
|
||||
#[test]
|
||||
fn the_reference_type_reads_like_the_handler() {
|
||||
let forms = [
|
||||
json!("origin"),
|
||||
json!("ORIGIN"),
|
||||
json!("x"),
|
||||
json!("Z"),
|
||||
json!("Block"),
|
||||
json!("nothing"),
|
||||
json!({"edge": [10, 0, 5]}),
|
||||
json!({"edge": {"x": 10, "y": 0, "z": 5}, "body": "Block"}),
|
||||
json!({"edge": "bad", "body": "Block"}),
|
||||
json!({"edge": [1, 2], "face": [10, 10, 10]}),
|
||||
json!({"edge": [1, 2, 3, 4]}),
|
||||
json!({"face": [10, 10, 10], "body": 4}),
|
||||
json!({"vertex": [0, 0, 10]}),
|
||||
json!({"vertex": [1e9, 0, 0]}),
|
||||
json!({"sketch": "Src", "curve": "source"}),
|
||||
json!({"sketch": "Src", "point": "p1"}),
|
||||
json!({"sketch": "Src", "curve": "source", "point": "p1"}),
|
||||
json!({"sketch": "Src", "curve": 7, "point": "p1"}),
|
||||
json!({"sketch": "Src"}),
|
||||
json!({"sketch": "Src", "curve": null}),
|
||||
json!({"sketch": 1.5}),
|
||||
json!({"sketch": true}),
|
||||
json!({"sketch": null}),
|
||||
json!({"sketch": ["Src"]}),
|
||||
json!({"sketch": {"name": "Src"}, "body": "Block"}),
|
||||
json!({"body": "Block"}),
|
||||
json!({"body": 5}),
|
||||
json!({"body": "Block", "axis": "Z"}),
|
||||
json!({"axis": "X"}),
|
||||
json!({"axis": "x"}),
|
||||
json!({"axis": 5}),
|
||||
json!({"plane": "XY"}),
|
||||
json!({"plane": null}),
|
||||
json!({"origin": null}),
|
||||
json!({"origin": [0, 0, 0]}),
|
||||
json!({}),
|
||||
json!([]),
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!(5),
|
||||
json!({"nothing": 1}),
|
||||
];
|
||||
for form in forms {
|
||||
let issues = issues("sketch.include_3d", &json!({"refs": [form]}));
|
||||
let off = error(ParamCheck::Off, "sketch.include_3d", &json!({"refs": [form]}));
|
||||
let strict = error(ParamCheck::Strict, "sketch.include_3d", &json!({"refs": [form]}));
|
||||
let typed = serde_json::from_value::<ProjectRef>(form.clone());
|
||||
// The type, the check and the handler agree on what is refused for its form.
|
||||
assert_eq!(typed.is_err(), !issues.is_empty(), "{form}");
|
||||
if typed.is_err() {
|
||||
assert_eq!(strict, off, "{form}");
|
||||
} else {
|
||||
let message = off.unwrap_or_default();
|
||||
// What the handler refuses of a form the type accepts needs the design.
|
||||
for shape in ["cannot project", "`sketch` must be", "`refs` is empty"] {
|
||||
assert!(!message.contains(shape), "{form}: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A single reference is read as a list of one, and `ref` is read only without `refs`.
|
||||
let count = |p: Value| issues("sketch.project", &p).len();
|
||||
assert_eq!(count(json!({"refs": "origin"})), 0);
|
||||
assert_eq!(count(json!({"refs": {"origin": 1}})), 0);
|
||||
assert_eq!(count(json!({"ref": "origin"})), 0);
|
||||
assert_eq!(count(json!({"refs": ["origin"], "ref": 5})), 0, "`ref` is not read when `refs` is given");
|
||||
assert_eq!(count(json!({"refs": null, "ref": "origin"})), 1, "`refs` is read even when null");
|
||||
assert_eq!(count(json!({"ref": 5})), 1);
|
||||
assert_eq!(count(json!({"refs": []})), 1);
|
||||
assert_eq!(count(json!({"refs": [], "ref": "origin"})), 1);
|
||||
// Only the first 1000 references are read.
|
||||
let mut many = vec![json!("origin"); 1000];
|
||||
many.push(json!(5));
|
||||
assert_eq!(count(json!({"refs": many})), 0);
|
||||
many.insert(0, json!(5));
|
||||
assert_eq!(count(json!({"refs": many})), 1);
|
||||
// `include_3d` takes no `projection_link`: the handler never reads it, so strict names it.
|
||||
assert_eq!(issues("sketch.include_3d", &json!({"refs": ["origin"], "projection_link": false})).len(), 1);
|
||||
assert_eq!(issues("sketch.project", &json!({"refs": ["origin"], "projection_link": false})).len(), 0);
|
||||
}
|
||||
|
||||
/// What the contracts take and refuse in the schemas as well as the types.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let check = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value().clone();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(&schema, &schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
for schema in [input_schema_for::<ProjectParams>(), input_schema_for::<IntersectParams>()] {
|
||||
check(
|
||||
schema,
|
||||
&[
|
||||
(json!({"refs": [{"edge": [1, 2, 3], "body": "Block"}, {"face": [1, 2, 3]}, "origin", {"axis": "X"}]}), true),
|
||||
(json!({"refs": {"sketch": 2, "curve": "c1"}, "projection_link": false}), true),
|
||||
(json!({"ref": {"body": "Block"}}), true),
|
||||
(json!({"refs": [{"sketch": "Src", "point": "p1"}, {"plane": "XY"}, {"vertex": {"x": 1, "y": 2, "z": 3}}]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"projection_link": true}), false),
|
||||
(json!({"refs": []}), false),
|
||||
(json!({"refs": [{"edge": [1, 2]}]}), false),
|
||||
(json!({"refs": ["origin"], "projection_link": "yes"}), false),
|
||||
(json!({"refs": ["origin"], "projection_lnk": true}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
check(
|
||||
input_schema_for::<Include3dParams>(),
|
||||
&[(json!({"refs": ["origin"]}), true), (json!({"refs": ["origin"], "projection_link": true}), false)],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<ProjectToSurfaceParams>(),
|
||||
&[
|
||||
(json!({"face": [0, 0, 20], "curves": [{"sketch": 4, "curve": "source"}]}), true),
|
||||
(json!({"face": [0, 0, 20], "curves": [{"sketch": "Src", "curve": "source"}], "body": "Block"}), true),
|
||||
(json!({"face": [0, 0, 20]}), false),
|
||||
(json!({"curves": [{"sketch": 4, "curve": "source"}]}), false),
|
||||
(json!({"face": [0, 0, 20], "curves": []}), false),
|
||||
(json!({"face": [0, 0, 20], "curves": [{"sketch": 4}]}), false),
|
||||
],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<IntersectionCurveParams>(),
|
||||
&[
|
||||
(json!({"a": {"body": "Block"}, "b": {"face": [1, 2, 3], "body": "Pin"}}), true),
|
||||
(json!({"a": {"sketch": "Src", "curve": "source"}, "b": "Pin"}), true),
|
||||
(json!({"a": {"body": "Block"}}), false),
|
||||
(json!({"a": {"body": "Block"}, "b": {"edge": [1, 2, 3]}}), false),
|
||||
],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<SpunProfileParams>(),
|
||||
&[
|
||||
(json!({"body": "Pin", "axis": "Z"}), true),
|
||||
(json!({"body": "Pin"}), false),
|
||||
(json!({"axis": "Z"}), false),
|
||||
(json!({"body": 1, "axis": "Z"}), false),
|
||||
],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<IsoCurveParams>(),
|
||||
&[
|
||||
(json!({"face": [1, 2, 3]}), true),
|
||||
(json!({"face": [1, 2, 3], "body": "Block", "direction": "v"}), true),
|
||||
(json!({"face": [1, 2, 3], "direction": [0, 1, 0]}), true),
|
||||
(json!({"direction": "u"}), false),
|
||||
(json!({"face": [1, 2, 3], "direction": "w"}), false),
|
||||
],
|
||||
);
|
||||
check(input_schema_for::<FitCurvesParams>(), &[(json!({"body": "Pin"}), true), (json!({}), false)]);
|
||||
check(
|
||||
input_schema_for::<SketchBreakLinkParams>(),
|
||||
&[
|
||||
(json!({"link": "j1"}), true),
|
||||
(json!({"entities": "c1"}), true),
|
||||
(json!({"entities": ["c1", "p2"], "link": "j1"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"entities": [1]}), false),
|
||||
],
|
||||
);
|
||||
check(input_schema_for::<AutoProjectParams>(), &[(json!({}), true), (json!({"value": false}), true), (json!({"value": "no"}), false)]);
|
||||
}
|
||||
|
||||
/// Every command replies as its output schema says, in Warn without warnings, and in the same JSON
|
||||
/// as with the check off.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
fn matches(schema: schemars::Schema, r: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
let calls: Vec<(&str, Value, fn() -> schemars::Schema)> = vec![
|
||||
("sketch.project", json!({"refs": [{"edge": [10, 0, 10]}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": [{"vertex": [0, 0, 10]}, {"face": [10, 10, 10], "body": "Block"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": {"edge": [20, 10, 10], "body": "Block"}, "projection_link": true}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"ref": {"edge": [10, 20, 10]}, "projection_link": false}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": ["origin"]}), output_schema_for::<ProjectResult>),
|
||||
(
|
||||
"sketch.project",
|
||||
json!({"refs": [{"axis": "X"}, {"plane": "XZ"}, {"sketch": "Src", "curve": "source"}]}),
|
||||
output_schema_for::<ProjectResult>,
|
||||
),
|
||||
("sketch.project", json!({"refs": [{"sketch": "Src", "point": "source.start"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": [{"edge": "bad", "body": "Pin"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": ["Pin"], "ref": 5}), output_schema_for::<ProjectResult>),
|
||||
("sketch.intersect", json!({"refs": [{"body": "Block"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.intersect", json!({"refs": {"face": [0, 10, 5], "body": "Block"}, "projection_link": false}), output_schema_for::<ProjectResult>),
|
||||
("sketch.intersect", json!({"ref": {"edge": [0, 0, 5]}}), output_schema_for::<ProjectResult>),
|
||||
("sketch.include_3d", json!({"refs": [{"edge": [20, 10, 10]}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.include_3d", json!({"ref": {"vertex": [0, 0, 10]}}), output_schema_for::<ProjectResult>),
|
||||
(
|
||||
"sketch.project_to_surface",
|
||||
json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}, {"sketch": "Src", "curve": "other"}]}),
|
||||
output_schema_for::<ProjectToSurfaceResult>,
|
||||
),
|
||||
(
|
||||
"sketch.project_to_surface",
|
||||
json!({"face": {"x": 10, "y": 10, "z": 10}, "body": "Block", "curves": [{"sketch": 3, "curve": "source"}]}),
|
||||
output_schema_for::<ProjectToSurfaceResult>,
|
||||
),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"body": "Pin"}}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.intersection_curve", json!({"a": {"face": [10, 10, 10], "body": "Block"}, "b": "Pin"}), output_schema_for::<SketchLinkResult>),
|
||||
(
|
||||
"sketch.intersection_curve",
|
||||
json!({"a": {"sketch": "Src", "curve": "source"}, "b": {"body": "Block"}}),
|
||||
output_schema_for::<SketchLinkResult>,
|
||||
),
|
||||
("sketch.spun_profile", json!({"body": "Pin", "axis": "Z"}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.spun_profile", json!({"body": "Pin", "axis": "x"}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10]}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "body": "Block", "direction": "V"}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.isoparametric_curve", json!({"face": {"x": 10, "y": 10, "z": 10}, "direction": [1, 0, 0]}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.fit_curves_to_section", json!({"body": "Block"}), output_schema_for::<FitSectionResult>),
|
||||
("sketch.auto_project", json!({}), output_schema_for::<AutoProjectResult>),
|
||||
("sketch.auto_project", json!({"value": true}), output_schema_for::<AutoProjectResult>),
|
||||
];
|
||||
for (id, p, output) in calls {
|
||||
// The spun profile needs a plane the cylinder's axis lies in.
|
||||
let plane = if id == "sketch.spun_profile" { "XZ" } else { "XY" };
|
||||
let a = scene_on(ParamCheck::Off, plane).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let b = scene_on(ParamCheck::Warn, plane).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(b.get("param_warnings").is_none(), "{id} {p}: {b}");
|
||||
assert_eq!(a, b, "{id} {p}");
|
||||
matches(output(), &b);
|
||||
}
|
||||
let run = |p: Value| scene(ParamCheck::Warn).execute("sketch.project", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
// The shapes a caller sees: the link, its curves and points; null where the link is not kept.
|
||||
let r = run(json!({"refs": [{"edge": [10, 0, 10]}]}));
|
||||
assert_eq!(r["links"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let l = &r["links"][0];
|
||||
assert!(l["link"].is_string() && l["curves"].is_array() && l["points"].is_array(), "{r}");
|
||||
assert_eq!(l.as_object().map(|o| o.len()), Some(3), "{r}");
|
||||
assert!(r.get("already_projected").is_none(), "no `already_projected` when all was projected: {r}");
|
||||
assert!(r["created"]["curves"].is_array(), "the engine reports what a sketch command created: {r}");
|
||||
let r = run(json!({"refs": [{"edge": [10, 0, 10]}], "projection_link": false}));
|
||||
assert!(r["links"][0]["link"].is_null() && r["links"][0]["curves"][0].is_string(), "{r}");
|
||||
matches(output_schema_for::<ProjectResult>(), &r);
|
||||
// Projecting what the sketch shows already says so, and the reply still matches.
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
s.execute("sketch.project", &json!({"refs": [{"edge": [10, 0, 10]}]})).unwrap_or_default();
|
||||
let r = s.execute("sketch.project", &json!({"refs": [{"edge": [10, 0, 10]}, {"edge": [10, 20, 10]}]})).unwrap_or_default();
|
||||
assert_eq!(r["already_projected"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
matches(output_schema_for::<ProjectResult>(), &r);
|
||||
// A `value` that is not true or false is read as left out, and the check says so.
|
||||
let r = scene(ParamCheck::Warn).execute("sketch.auto_project", &json!({"value": "maybe"})).unwrap_or_default();
|
||||
assert_eq!(r["param_warnings"], json!(["`value` must be true or false"]), "{r}");
|
||||
matches(output_schema_for::<AutoProjectResult>(), &r);
|
||||
// The shape of the others.
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let r = s.execute("sketch.isoparametric_curve", &json!({"face": [10, 10, 10]})).unwrap_or_default();
|
||||
let mut keys: Vec<_> = r.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["created", "curves", "link", "points", "wires"], "{r}");
|
||||
let r = s.execute("sketch.auto_project", &json!({"value": true})).unwrap_or_default();
|
||||
assert_eq!(r["auto_project"], json!(true));
|
||||
let r = s.execute("sketch.auto_project", &json!({})).unwrap_or_default();
|
||||
assert_eq!(r["auto_project"], json!(false), "no value toggles");
|
||||
let r = s.execute("sketch.fit_curves_to_section", &json!({"body": "Block"})).unwrap_or_default();
|
||||
assert!(r["curves"].is_array(), "{r}");
|
||||
}
|
||||
|
||||
/// Breaking links: by `link`, by `entities` of both kinds (a curve, a point), and with both.
|
||||
#[test]
|
||||
fn break_link_replies_without_warnings() {
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let linked = |s: &mut Session, p: Value| {
|
||||
let r = s.execute("sketch.project", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let l = &r["links"][0];
|
||||
let text = |v: &Value| v.as_str().unwrap_or_default().to_string();
|
||||
(text(&l["link"]), text(&l["curves"][0]), text(&l["points"][0]))
|
||||
};
|
||||
let (l1, _, _) = linked(&mut s, json!({"refs": [{"edge": [10, 0, 10]}]}));
|
||||
let (_, c2, _) = linked(&mut s, json!({"refs": [{"edge": [10, 20, 10]}]}));
|
||||
let (l3, _, _) = linked(&mut s, json!({"refs": [{"edge": [20, 10, 10]}]}));
|
||||
let (_, _, p4) = linked(&mut s, json!({"refs": [{"vertex": [0, 0, 10]}]}));
|
||||
assert!(!c2.is_empty() && !p4.is_empty());
|
||||
let schema = output_schema_for::<SketchBreakLinkResult>();
|
||||
for p in [json!({"link": l1}), json!({"entities": c2}), json!({"entities": [p4], "link": l3})] {
|
||||
let r = s.execute("sketch.break_link", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
assert_eq!(r["broken"].as_array().map(Vec::len), Some(p.as_object().map_or(0, |o| o.len())), "{p}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A key spelt wrong runs in warn and says so (the reply still matches), and strict refuses it.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn_and_refused_in_strict() {
|
||||
let cases = [
|
||||
("sketch.project", json!({"refs": ["origin"], "projection_lnk": false})),
|
||||
("sketch.intersect", json!({"refs": [{"body": "Block"}], "projectionlink": false})),
|
||||
("sketch.include_3d", json!({"refs": [{"edge": [20, 10, 10]}], "projection_link": false})),
|
||||
("sketch.project_to_surface", json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}], "bdy": "Block"})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"body": "Pin"}, "c": 1})),
|
||||
("sketch.spun_profile", json!({"body": "Pin", "axis": "Z", "axes": "Y"})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "directon": "v"})),
|
||||
("sketch.fit_curves_to_section", json!({"body": "Block", "bodies": []})),
|
||||
("sketch.auto_project", json!({"vlaue": false})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let plane = if id == "sketch.spun_profile" { "XZ" } else { "XY" };
|
||||
let r = scene_on(ParamCheck::Warn, plane).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let warnings = r["param_warnings"].as_array().unwrap_or_else(|| panic!("{id} {p}: {r}"));
|
||||
assert_eq!(warnings.len(), 1, "{id} {p}: {r}");
|
||||
assert!(warnings[0].as_str().is_some_and(|w| w.contains("unknown key")), "{id}: {r}");
|
||||
let error = error(ParamCheck::Strict, id, &p).unwrap_or_default();
|
||||
assert!(error.contains("unknown key"), "{id}: {error}");
|
||||
}
|
||||
let r = scene(ParamCheck::Warn).execute("sketch.auto_project", &json!({"vlaue": false})).unwrap_or_default();
|
||||
assert_eq!(r["param_warnings"], json!(["unknown key `vlaue`"]), "{r}");
|
||||
// The reply with its warnings still matches its schema.
|
||||
let schema = output_schema_for::<AutoProjectResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
}
|
||||
|
||||
/// Wrong-typed values the handlers read as left out: the typed field names them in warn (and
|
||||
/// strict refuses), `null` is left out and gets no warning, and the reply is the same as with the
|
||||
/// check off.
|
||||
#[test]
|
||||
fn values_read_as_left_out_are_reported_but_change_nothing() {
|
||||
let cases = [
|
||||
("sketch.project_to_surface", json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}], "body": 5}), true),
|
||||
("sketch.project_to_surface", json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}], "body": null}), false),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "body": 5}), true),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "body": null}), false),
|
||||
("sketch.auto_project", json!({"value": null}), false),
|
||||
("sketch.auto_project", json!({"value": 1}), true),
|
||||
];
|
||||
for (id, p, warns) in cases {
|
||||
let off = scene(ParamCheck::Off).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let mut warn = scene(ParamCheck::Warn).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(warn.get("param_warnings").is_some(), warns, "{id} {p}: {warn}");
|
||||
if let Some(o) = warn.as_object_mut() {
|
||||
o.remove("param_warnings");
|
||||
}
|
||||
assert_eq!(off, warn, "{id} {p}");
|
||||
}
|
||||
// break_link: `link` of the wrong type is left out, so `entities` alone does the work.
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let r = s.execute("sketch.project", &json!({"refs": [{"edge": [10, 0, 10]}]})).unwrap_or_default();
|
||||
let curve = r["links"][0]["curves"][0].clone();
|
||||
let r = s.execute("sketch.break_link", &json!({"link": 5, "entities": [curve]})).unwrap_or_default();
|
||||
assert_eq!(r["broken"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert_eq!(r["param_warnings"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let r = scene(ParamCheck::Warn).execute("sketch.break_link", &json!({"link": null}));
|
||||
assert!(r.is_err(), "nothing to break");
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! Contracts and result types for the drawing commands in `cmd/sketch.rs`: a line, a point, the
|
||||
//! circles and the arcs (#12953).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
|
||||
use super::check::whole;
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{LengthExpr, Point2, Point3, present};
|
||||
use crate::params;
|
||||
|
||||
/// What the coordinates of a point typed as text must be.
|
||||
const POINT_MUST: &str = "a point's coordinates must be numbers or expressions over the design's parameters";
|
||||
/// The most points a line takes.
|
||||
const MAX_LINE_POINTS: usize = 10_000;
|
||||
|
||||
/// The forms of a point argument: [`SketchPoint`] and [`SnapPoint`] differ in the snaps.
|
||||
fn point_arg_schema(generator: &mut SchemaGenerator, snaps: bool) -> Schema {
|
||||
let point = generator.subschema_for::<Point2>();
|
||||
let coordinate = generator.subschema_for::<LengthExpr>();
|
||||
let mut forms = vec![
|
||||
json!(point),
|
||||
json!({
|
||||
"description": "[x, y] where a coordinate may be an expression over the design's parameters, such as [\"w / 2\", 5]: a point that follows the parameters.",
|
||||
"type": "array", "items": coordinate, "minItems": 2, "maxItems": 2
|
||||
}),
|
||||
json!({"description": "An existing point's id such as \"l1.end\" or \"p3\", or a snap such as \"mid:l1\".", "type": "string"}),
|
||||
json!({"description": "An existing point by id.", "type": "object", "properties": {"ref": {"type": "string"}}, "required": ["ref"]}),
|
||||
json!({"description": "A point by place.", "type": "object", "properties": {"at": point}, "required": ["at"]}),
|
||||
];
|
||||
let description = if snaps {
|
||||
let model = generator.subschema_for::<Point3>();
|
||||
forms.push(json!({"description": "A model vertex [x, y, z] in mm, projected into the sketch.", "type": "object", "properties": {"vertex": model}, "required": ["vertex"]}));
|
||||
forms.push(json!({"description": "A point on a model edge [x, y, z] in mm, projected into the sketch.", "type": "object", "properties": {"on_edge": model}, "required": ["on_edge"]}));
|
||||
"A sketch point: [x, y] in mm (a coordinate may be an expression), an existing point's id (\"l1.end\"), {ref} or {at}, a snap as text (\"mid:<line>\", \"on:<curve>:x,y\", \"x:<a>+<b>:x,y\", \"vertex:x,y,z\"), or a model {vertex} or {on_edge}."
|
||||
} else {
|
||||
"A sketch point: [x, y] in mm (a coordinate may be an expression over the design's parameters), an existing point's id such as \"l1.end\", or {ref} or {at}."
|
||||
};
|
||||
json_schema!({"description": description, "anyOf": forms})
|
||||
}
|
||||
|
||||
/// A point argument as `sketch::parg` reads it: a place `[x, y]` (a coordinate may be an
|
||||
/// expression), an existing point's id, `{ref}` or `{at}`. Kept as given, for the handler, which
|
||||
/// has the design to find a point and evaluate an expression.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SketchPoint(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchPoint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
crate::cmd::point_arg_form(&v, false).map(|()| SketchPoint(v)).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchPoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchPoint".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
point_arg_schema(generator, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// A point argument of a command that snaps its points (`sketch_project::snap_points`): a
|
||||
/// [`SketchPoint`], or a model `{vertex}` or `{on_edge}`. Snaps given as text are text.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SnapPoint(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for SnapPoint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
crate::cmd::point_arg_form(&v, true).map(|()| SnapPoint(v)).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SnapPoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SnapPoint".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
point_arg_schema(generator, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// A point as `params::req_vec2` reads it: `[x, y]` or `{x, y}` of numbers, with no expression, id
|
||||
/// or snap (the 2-point circle's `p0` and `p1`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PlainPoint(pub Vec2);
|
||||
|
||||
impl<'de> Deserialize<'de> for PlainPoint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec2(&Value::deserialize(d)?).map(PlainPoint).ok_or_else(|| D::Error::custom("must be a point [x, y]"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PlainPoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PlainPoint".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point2>()
|
||||
}
|
||||
}
|
||||
|
||||
/// The points of a line: a list of [`SnapPoint`]s, 2 to 10 000.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LinePoints(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for LinePoints {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let Value::Array(list) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`points` must be a list")));
|
||||
};
|
||||
if list.len() < 2 || list.len() > MAX_LINE_POINTS {
|
||||
return Err(D::Error::custom(whole("a line needs 2 or more points")));
|
||||
}
|
||||
for v in &list {
|
||||
crate::cmd::point_arg_form(v, true).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
}
|
||||
Ok(LinePoints(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for LinePoints {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"LinePoints".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<SnapPoint>();
|
||||
json_schema!({
|
||||
"description": "The points the line runs through, in order: 2 to 10 000. Each is a SnapPoint.",
|
||||
"type": "array", "items": point, "minItems": 2, "maxItems": MAX_LINE_POINTS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` is a snap that `sketch.point` takes as one, and if so whether it is well formed
|
||||
/// (what the design makes of it, such as a line that is not there, stays the handler's).
|
||||
fn snap_form(v: &Value) -> Option<bool> {
|
||||
let text = v.as_str();
|
||||
let snapped =
|
||||
text.is_some_and(|t| ["mid:", "on:", "vertex:"].iter().any(|k| t.starts_with(k))) || v.get("vertex").is_some() || v.get("on_edge").is_some();
|
||||
if !snapped {
|
||||
return None;
|
||||
}
|
||||
let numbers = |t: &str| -> Vec<f64> { t.split(',').filter_map(|x| x.trim().parse().ok()).collect() };
|
||||
let finite3 = |n: &[f64]| matches!(n, [x, y, z] if x.is_finite() && y.is_finite() && z.is_finite());
|
||||
let vertex =
|
||||
v.get("vertex").and_then(params::vec3).is_some() || text.and_then(|t| t.strip_prefix("vertex:")).is_some_and(|t| finite3(&numbers(t)));
|
||||
let edge = v.get("on_edge").and_then(params::vec3).is_some();
|
||||
let mid = text.is_some_and(|t| t.starts_with("mid:"));
|
||||
let on = text
|
||||
.and_then(|t| t.strip_prefix("on:"))
|
||||
.and_then(|t| t.rsplit_once(':'))
|
||||
.is_some_and(|(_, xy)| matches!(numbers(xy)[..], [x, y] if x.is_finite() && y.is_finite()));
|
||||
Some(vertex || edge || mid || on)
|
||||
}
|
||||
|
||||
/// `sketch.point`'s `point`: `[x, y]` (numbers, or text expressions over the design's parameters),
|
||||
/// `{x, y}`, or a snap that makes the point. An existing point's id is not taken.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PointOrSnap(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for PointOrSnap {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match snap_form(&v) {
|
||||
Some(true) => return Ok(PointOrSnap(v)),
|
||||
Some(false) => {
|
||||
return Err(D::Error::custom(whole(format!(
|
||||
"`point` {v} is not a snap it takes: \"mid:<line>\", \"on:<curve>:x,y\", \"vertex:x,y,z\", {{vertex: [x,y,z]}} or {{on_edge: [x,y,z]}}"
|
||||
))));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some([x, y]) = v.as_array().map(Vec::as_slice)
|
||||
&& (x.is_string() || y.is_string())
|
||||
{
|
||||
// The coordinates `sketch::expr_point` reads: a finite number, or text that isn't blank.
|
||||
let coordinate = |c: &Value| match c {
|
||||
Value::Number(n) => n.as_f64().and_then(params::finite).is_some(),
|
||||
Value::String(e) => !e.trim().is_empty() && e.len() <= 4096,
|
||||
_ => false,
|
||||
};
|
||||
return if coordinate(x) && coordinate(y) { Ok(PointOrSnap(v)) } else { Err(D::Error::custom(whole(POINT_MUST))) };
|
||||
}
|
||||
if params::vec2(&v).is_some() { Ok(PointOrSnap(v)) } else { Err(D::Error::custom("must be a point [x, y]")) }
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PointOrSnap {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PointOrSnap".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point2>();
|
||||
let coordinate = generator.subschema_for::<LengthExpr>();
|
||||
let model = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "A point to make: [x, y] in mm (a coordinate may be an expression over the design's parameters: the point then follows them), or a snap that makes it and holds it: \"mid:<line>\" (the line's midpoint), \"on:<curve>:x,y\" (on the curve, near x,y), \"vertex:x,y,z\" or {vertex: [x,y,z]} (a model vertex, projected) or {on_edge: [x,y,z]} (a point on a model edge, projected). An existing point's id is not taken.",
|
||||
"anyOf": [
|
||||
point,
|
||||
{"type": "array", "items": coordinate, "minItems": 2, "maxItems": 2},
|
||||
{"type": "string", "pattern": "^(mid:|on:|vertex:)"},
|
||||
{"type": "object", "properties": {"vertex": model}, "required": ["vertex"]},
|
||||
{"type": "object", "properties": {"on_edge": model}, "required": ["on_edge"]}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An id a drawing command gives its curve or point, as `named_id` reads it: text that can be
|
||||
/// written into a face name whole.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawId(pub String);
|
||||
|
||||
/// `id`, or an entry of `ids`, as `named_id` refuses it (the entry's position is not named).
|
||||
fn id_problem(id: &str) -> Option<String> {
|
||||
(!solidcraft_doc::persistent_name::text_fits_name(id))
|
||||
.then(|| format!("the id `{id}` cannot be written into a name whole; give an id without `|`, `,`, `~`, `#` or `]`"))
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawId {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let Value::String(id) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom("must be text"));
|
||||
};
|
||||
match id_problem(&id) {
|
||||
Some(why) => Err(D::Error::custom(whole(why))),
|
||||
None => Ok(DrawId(id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawId {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawId".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An id for the new geometry, such as \"base\". It may not hold `|`, `,`, `~`, `#` or `]`, which a face name made from it would read as its own.",
|
||||
"type": "string"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's `ids`, as `params::string_list` and `named_id` read them: a list of ids (one id is a
|
||||
/// list of one), at most 10 000.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawIds(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawIds {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let ids = match params::string_list("", &json!({"ids": v}), "ids") {
|
||||
Ok(ids) => ids,
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => return Err(D::Error::custom(whole(msg))),
|
||||
Err(e) => return Err(D::Error::custom(whole(e))),
|
||||
};
|
||||
if let Some(why) = ids.iter().find_map(|id| id_problem(id)) {
|
||||
return Err(D::Error::custom(whole(why)));
|
||||
}
|
||||
Ok(DrawIds(ids))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawIds {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawIds".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Ids for the lines, in order: the first for the first segment and so on. One id is a list of one. Segments past the end of the list get an id of the sketch's own. An id may not hold `|`, `,`, `~`, `#` or `]`.",
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": 10000}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A circle's `radius` or `diameter`, as `radius_arg` and `circle_size` read it: a number, or text
|
||||
/// that is an expression over the design's parameters (a dimension driven by them). Whether it is
|
||||
/// positive, and whether an expression evaluates, is the handler's to check.
|
||||
fn size_form(v: &Value, key: &str) -> Result<(), String> {
|
||||
match v {
|
||||
Value::Number(n) if n.as_f64().and_then(params::finite).is_some() => Ok(()),
|
||||
Value::String(_) if params::expr(&json!({ "v": v }), "v").is_some() => Ok(()),
|
||||
Value::String(_) => Err(format!("`{key}` {}", params::expr_problem(Some(v)))),
|
||||
_ => Err(format!("`{key}` must be a number")),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! circle_size {
|
||||
($name:ident, $key:literal, $doc:literal) => {
|
||||
#[doc = $doc]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct $name(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
size_form(&v, $key).map(|()| $name(v)).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for $name {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
stringify!($name).into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<LengthExpr>()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
circle_size!(CircleRadius, "radius", "A circle's `radius`: a number or an expression.");
|
||||
circle_size!(CircleDiameter, "diameter", "A circle's `diameter`: a number or an expression.");
|
||||
|
||||
/// An arc's `sweep`, as `params::num` reads it: a number below 1e9 in size. That it is between
|
||||
/// -360 and 360 and not 0 is the handler's to check.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sweep(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for Sweep {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?.as_f64().and_then(params::finite).map(Sweep).ok_or_else(|| D::Error::custom("must be a number of degrees"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Sweep {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Sweep".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The angle the arc turns through, in degrees: positive counter-clockwise from `start`, negative clockwise. Not 0, and less than 360 in size.",
|
||||
"type": "number", "exclusiveMinimum": -360, "exclusiveMaximum": 360
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a line through two or more points, or a closed polyline.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct LineParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The points to run through, in order: one segment between each two. A point that is an
|
||||
/// existing point's id (or `{ref}`) shares that point, so the line starts or ends there.
|
||||
pub points: LinePoints,
|
||||
/// Close the line with a segment from the last point back to the first. Default: false.
|
||||
#[serde(default)]
|
||||
pub closed: Option<bool>,
|
||||
/// Draw construction geometry, which is not part of a profile. Default: false.
|
||||
#[serde(default)]
|
||||
pub construction: Option<bool>,
|
||||
/// Ids for the segments, in order. Default: ids of the sketch's own.
|
||||
#[serde(default)]
|
||||
pub ids: Option<DrawIds>,
|
||||
/// Add a horizontal or vertical constraint to a segment that is drawn exactly so, and a
|
||||
/// tangent to the arc or a perpendicular to the line that it starts from. Default: false.
|
||||
#[serde(default)]
|
||||
pub infer: Option<bool>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// Add a point to the sketch, or a point made by a snap that holds it.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PointParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// Where the point goes, or the snap that makes it.
|
||||
pub point: PointOrSnap,
|
||||
/// An id for the point. Default: one of the sketch's own. Ignored when `point` is a snap.
|
||||
#[serde(default)]
|
||||
pub id: Option<DrawId>,
|
||||
/// Make the new point coincident with an existing point it lands exactly on (the origin
|
||||
/// too). Default: true. Ignored when `point` is a snap.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// Draw a circle from its centre and its radius or diameter.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CircleCenterParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The centre. A point's id or `{ref}` makes that point the centre, so the circle follows it.
|
||||
pub center: SnapPoint,
|
||||
/// The radius, in mm: a number, or an expression over the design's parameters, which makes
|
||||
/// the radius a dimension driven by them. Wins over `diameter`. Give one of the two.
|
||||
#[serde(default)]
|
||||
pub radius: Option<CircleRadius>,
|
||||
/// The diameter, in mm, as `radius` is given. Used when `radius` is not.
|
||||
#[serde(default)]
|
||||
pub diameter: Option<CircleDiameter>,
|
||||
/// A point the circle passes through, kept on it. With a placed centre and an existing
|
||||
/// point, that point and `radius` both fix the radius.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SnapPoint")]
|
||||
pub through: Option<SnapPoint>,
|
||||
/// An id for the circle. Default: one of the sketch's own.
|
||||
#[serde(default)]
|
||||
pub id: Option<DrawId>,
|
||||
/// Draw construction geometry, which is not part of a profile. Default: false.
|
||||
#[serde(default)]
|
||||
pub construction: Option<bool>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// Draw a circle from the two ends of a diameter.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CircleTwoPointParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// One end of the diameter, as [x, y] or {x, y} in mm. An expression, a point's id or a
|
||||
/// snap is not taken.
|
||||
pub p0: PlainPoint,
|
||||
/// The other end of the diameter. Not the same point as `p0`.
|
||||
pub p1: PlainPoint,
|
||||
/// Draw construction geometry, which is not part of a profile. Default: false.
|
||||
#[serde(default)]
|
||||
pub construction: Option<bool>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// Draw a circle through three points.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CircleThreePointParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// A point on the circle. One that is an existing point's id stays on the circle.
|
||||
pub p0: SnapPoint,
|
||||
/// A second point on the circle.
|
||||
pub p1: SnapPoint,
|
||||
/// A third point on the circle. The three may not be in a line.
|
||||
pub p2: SnapPoint,
|
||||
/// Draw construction geometry, which is not part of a profile. Default: false.
|
||||
#[serde(default)]
|
||||
pub construction: Option<bool>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// Draw an arc from its two ends and a point on it.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ArcThreePointParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// One end of the arc.
|
||||
pub start: SnapPoint,
|
||||
/// The other end of the arc.
|
||||
pub end: SnapPoint,
|
||||
/// A point the arc passes through, between the ends. One that is an existing point's id
|
||||
/// stays on the arc. The three may not be in a line.
|
||||
pub through: SnapPoint,
|
||||
/// An id for the arc. Default: one of the sketch's own.
|
||||
#[serde(default)]
|
||||
pub id: Option<DrawId>,
|
||||
/// Draw construction geometry, which is not part of a profile. Default: false.
|
||||
#[serde(default)]
|
||||
pub construction: Option<bool>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// Draw an arc round a centre, from a start point to an end point or through a sweep.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ArcCenterPointParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The centre of the arc.
|
||||
pub center: SnapPoint,
|
||||
/// Where the arc starts. Its distance from `center` is the radius.
|
||||
pub start: SnapPoint,
|
||||
/// A point in the direction of the arc's end from `center`: the arc ends on the ray through
|
||||
/// it, at the radius. Give `end` or `sweep`; `sweep` wins.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SnapPoint")]
|
||||
pub end: Option<SnapPoint>,
|
||||
/// With `end`: go clockwise from `start` to `end`. Default: false, counter-clockwise.
|
||||
#[serde(default)]
|
||||
pub clockwise: Option<bool>,
|
||||
/// The angle to turn through from `start`, in degrees, instead of `end`.
|
||||
#[serde(default)]
|
||||
pub sweep: Option<Sweep>,
|
||||
/// An id for the arc. Default: one of the sketch's own.
|
||||
#[serde(default)]
|
||||
pub id: Option<DrawId>,
|
||||
/// Draw construction geometry, which is not part of a profile. Default: false.
|
||||
#[serde(default)]
|
||||
pub construction: Option<bool>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// What a command added to its sketch: the ids that the sketch has now and did not before.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct Created {
|
||||
/// Point ids.
|
||||
pub points: Vec<String>,
|
||||
/// Curve ids.
|
||||
pub curves: Vec<String>,
|
||||
/// Constraint ids, dimensions not among them.
|
||||
pub constraints: Vec<String>,
|
||||
/// Dimension ids.
|
||||
pub dimensions: Vec<String>,
|
||||
/// The ids of 3D curves, when the command made any.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wires: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a drawing command returns for a line, a circle or an arc.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DrawResult {
|
||||
/// The ids of the curves drawn, in order.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the constraints added besides the curves' own: a coincident for a point that
|
||||
/// landed on an existing one, a point on the curve for a point that was given by id, a radius
|
||||
/// for a size given as an expression, what `infer` added.
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// Everything the call added to the sketch: `{points, curves, constraints, dimensions}`, and
|
||||
/// `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Created>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.point` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PointResult {
|
||||
/// The id of the point made.
|
||||
pub point: String,
|
||||
/// The ids of the constraints added: a coincident with the existing point it landed on.
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// Everything the call added to the sketch: `{points, curves, constraints, dimensions}`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Created>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_shapes_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,217 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, DrawResult, LineParams, PointParams,
|
||||
PointResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
/// A session in a new sketch that holds one line, `l1`, from [0, 0] to [10, 0].
|
||||
fn session(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["l1"]}));
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<LineParams>(), &["closed", "connect", "construction", "ids", "infer", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<PointParams>(), &["connect", "id", "point", "sketch"]);
|
||||
assert_keys(input_schema_for::<CircleCenterParams>(), &["center", "connect", "construction", "diameter", "id", "radius", "sketch", "through"]);
|
||||
assert_keys(input_schema_for::<CircleTwoPointParams>(), &["connect", "construction", "p0", "p1", "sketch"]);
|
||||
assert_keys(input_schema_for::<CircleThreePointParams>(), &["connect", "construction", "p0", "p1", "p2", "sketch"]);
|
||||
assert_keys(input_schema_for::<ArcThreePointParams>(), &["connect", "construction", "end", "id", "sketch", "start", "through"]);
|
||||
assert_keys(
|
||||
input_schema_for::<ArcCenterPointParams>(),
|
||||
&["center", "clockwise", "connect", "construction", "end", "id", "sketch", "start", "sweep"],
|
||||
);
|
||||
}
|
||||
|
||||
/// Calls the handler refuses fail in strict with the same message. (The shared helper finishes
|
||||
/// its sketch first; these need one being edited.)
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let cases = [
|
||||
("sketch.line", json!({"points": 5})),
|
||||
("sketch.line", json!({"points": [[0, 0]]})),
|
||||
("sketch.line", json!({"points": [[0, 0], "zz"]})),
|
||||
("sketch.line", json!({"points": [[0, 0], [1]]})),
|
||||
("sketch.line", json!({"points": [[0, 0], [true, "a"]]})),
|
||||
("sketch.line", json!({"points": [[0, 0], ["", 1]]})),
|
||||
("sketch.line", json!({"points": [[0, 0], [5, 5]], "ids": 5})),
|
||||
("sketch.line", json!({"points": [[0, 0], [5, 5]], "ids": [1]})),
|
||||
("sketch.line", json!({"points": [[0, 0], [5, 5]], "ids": ["a|b"]})),
|
||||
("sketch.line", json!({"points": [[0, 0], [5, 5]], "ids": "a,b"})),
|
||||
("sketch.line", json!({"points": [[0, 0], {"vertex": "x"}]})),
|
||||
("sketch.point", json!({"point": "l1.end"})),
|
||||
("sketch.point", json!({"point": [1]})),
|
||||
("sketch.point", json!({"point": ["", 2]})),
|
||||
("sketch.point", json!({"point": [1, true]})),
|
||||
("sketch.point", json!({"point": "mid:nope"})),
|
||||
("sketch.point", json!({"point": {"vertex": "x"}})),
|
||||
("sketch.point", json!({"point": "on:l1:x"})),
|
||||
("sketch.point", json!({"point": "vertex:1,2"})),
|
||||
("sketch.point", json!({"point": [1, 2], "id": "a|b"})),
|
||||
("sketch.circle.center", json!({"center": [0, 0]})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": true})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": " "})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 1e12})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 0})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "diameter": [1]})),
|
||||
("sketch.circle.center", json!({"center": 5, "radius": 1})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 1, "through": null})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 1, "id": "a]"})),
|
||||
("sketch.circle.two_point", json!({"p0": "l1.end", "p1": [1, 1]})),
|
||||
("sketch.circle.two_point", json!({"p0": [0, 0], "p1": ["1", 1]})),
|
||||
("sketch.circle.two_point", json!({"p0": [0, 0], "p1": [0, 0]})),
|
||||
("sketch.circle.three_point", json!({"p0": [0, 0], "p1": [1, 0], "p2": [2, 0]})),
|
||||
("sketch.circle.three_point", json!({"p0": [0, 0], "p1": 5, "p2": [2, 3]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 0]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": "zz"})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 5], "id": "a|b"})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "sweep": 0})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "sweep": 400})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "end": true})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "end": null})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0]})),
|
||||
];
|
||||
for (id, p) in &cases {
|
||||
let error = |level| session(level).execute(id, p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every form each command takes is accepted by the contract: run in warn, the reply has no
|
||||
/// warnings and matches the output schema.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema_and_forms_are_accepted() {
|
||||
let draw = output_schema_for::<DrawResult>();
|
||||
let draw = draw.as_value();
|
||||
let point = output_schema_for::<PointResult>();
|
||||
let point = point.as_value();
|
||||
let calls = [
|
||||
("sketch.line", json!({"points": [[0, 5], [10, 5]]})),
|
||||
("sketch.line", json!({"points": ["l1.end", [20, 0]], "infer": true})),
|
||||
("sketch.line", json!({"points": [{"ref": "l1.start"}, {"at": [1, 2]}], "construction": true})),
|
||||
("sketch.line", json!({"points": [[30, 0], ["5 mm", 3], {"x": 40, "y": 9}], "closed": true, "ids": "solo", "connect": false})),
|
||||
("sketch.line", json!({"points": [[50, 0], [60, 0]], "ids": ["a", "b"], "sketch": null})),
|
||||
("sketch.circle.center", json!({"center": [50, 50], "radius": 2})),
|
||||
("sketch.circle.center", json!({"center": [50, 60], "diameter": 4, "id": "ring", "construction": true})),
|
||||
("sketch.circle.center", json!({"center": [50, 70], "radius": "3 mm", "connect": false})),
|
||||
("sketch.circle.center", json!({"center": ["1 mm", 80], "diameter": "6 mm", "through": "l1.end"})),
|
||||
("sketch.circle.two_point", json!({"p0": [70, 0], "p1": [80, 0]})),
|
||||
("sketch.circle.two_point", json!({"p0": {"x": 70, "y": 10}, "p1": {"x": 80, "y": 10}, "construction": false})),
|
||||
("sketch.circle.three_point", json!({"p0": [90, 0], "p1": [100, 10], "p2": [110, 0]})),
|
||||
("sketch.circle.three_point", json!({"p0": "l1.start", "p1": {"at": [0, -10]}, "p2": [-10, 0]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 20], "end": [10, 20], "through": [5, 25], "id": "arc_one"})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 30], "end": [10, 30], "through": [5, 35], "construction": true})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 40], "start": [5, 40], "end": [0, 45]})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 50], "start": [5, 50], "end": [0, 55], "clockwise": true, "id": "arc_two"})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 60], "start": [5, 60], "sweep": 90})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 70], "start": [5, 70], "sweep": -45, "end": [0, 75]})),
|
||||
];
|
||||
let point_calls = [
|
||||
json!({"point": [30, 30]}),
|
||||
json!({"point": {"x": 31, "y": 31}, "id": "pt"}),
|
||||
json!({"point": ["10 mm", 40], "connect": false}),
|
||||
json!({"point": "mid:l1"}),
|
||||
json!({"point": "on:l1:5,0"}),
|
||||
];
|
||||
let mut s = session(ParamCheck::Warn);
|
||||
for (id, p) in &calls {
|
||||
let r = run(&mut s, id, p.clone());
|
||||
assert!(conforms(draw, draw, &r), "{id} {p}: {r} doesn't match {draw}");
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
assert!(r["created"]["curves"].is_array(), "{r}");
|
||||
}
|
||||
for p in &point_calls {
|
||||
let r = run(&mut s, "sketch.point", p.clone());
|
||||
assert!(conforms(point, point, &r), "{p}: {r} doesn't match {point}");
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
}
|
||||
// A misspelt key still runs in warn and is named.
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[0, 90], [10, 90]], "constructon": true}));
|
||||
assert!(conforms(draw, draw, &r), "{r} doesn't match {draw}");
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}");
|
||||
let r = run(&mut s, "sketch.point", json!({"point": [3, 95], "constructon": true}));
|
||||
assert!(conforms(point, point, &r), "{r} doesn't match {point}");
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}");
|
||||
}
|
||||
|
||||
/// The same calls give the same JSON with the check off and in strict.
|
||||
#[test]
|
||||
fn the_check_does_not_change_the_reply() {
|
||||
let calls = [
|
||||
("sketch.line", json!({"points": [[0, 5], [10, 5], [10, 15]], "closed": true, "ids": ["a", "b", "c"]})),
|
||||
("sketch.point", json!({"point": [3, 3]})),
|
||||
("sketch.point", json!({"point": "mid:l1"})),
|
||||
("sketch.circle.center", json!({"center": [50, 50], "radius": 2, "id": "c1"})),
|
||||
("sketch.circle.two_point", json!({"p0": [70, 0], "p1": [80, 0]})),
|
||||
("sketch.circle.three_point", json!({"p0": [90, 0], "p1": [100, 10], "p2": [110, 0]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 20], "end": [10, 20], "through": [5, 25]})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 40], "start": [5, 40], "sweep": 90})),
|
||||
];
|
||||
let (mut off, mut strict) = (session(ParamCheck::Off), session(ParamCheck::Strict));
|
||||
for (id, p) in &calls {
|
||||
let (a, b) = (run(&mut off, id, p.clone()), run(&mut strict, id, p.clone()));
|
||||
assert_eq!(a, b, "{id} {p}");
|
||||
assert!(a["created"]["points"].is_array() && a["sketch"]["dof"].is_number(), "{a}");
|
||||
}
|
||||
// Key order and spelling of a reply, as the handlers wrote it before the types.
|
||||
let r = run(&mut off, "sketch.point", json!({"point": [4, 4]}));
|
||||
assert_eq!(
|
||||
r.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()),
|
||||
Some(vec!["constraints".into(), "created".into(), "point".into(), "sketch".into()])
|
||||
);
|
||||
}
|
||||
|
||||
/// Values of the wrong type that a handler ignores or reads as left out are refused by the
|
||||
/// contract: that is what warn reports and strict refuses.
|
||||
#[test]
|
||||
fn wrong_typed_keys_are_reported() {
|
||||
let base = [
|
||||
("sketch.line", json!({"points": [[0, 0], [5, 5]]})),
|
||||
("sketch.point", json!({"point": [1, 1]})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 1})),
|
||||
("sketch.circle.two_point", json!({"p0": [0, 0], "p1": [5, 5]})),
|
||||
("sketch.circle.three_point", json!({"p0": [0, 0], "p1": [5, 5], "p2": [10, 0]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 5]})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "sweep": 90})),
|
||||
];
|
||||
for (id, p) in &base {
|
||||
assert!(issues(id, p).is_empty(), "{id} {p}");
|
||||
for key in ["connect", "construction", "closed", "infer", "clockwise"] {
|
||||
let mut bad = p.clone();
|
||||
bad[key] = json!("yes");
|
||||
if input_schema_has(id, key) {
|
||||
assert_eq!(issues(id, &bad).len(), 1, "{id} {key}: {:?}", issues(id, &bad));
|
||||
}
|
||||
}
|
||||
let mut bad = p.clone();
|
||||
bad["sketch"] = json!(true);
|
||||
assert_eq!(issues(id, &bad)[0].message, "`sketch` must be an id or a name");
|
||||
}
|
||||
let message = |id: &str, p: Value| issues(id, &p).first().map(|i| i.message.clone());
|
||||
assert_eq!(message("sketch.point", json!({"point": [1, 1], "id": 5})).as_deref(), Some("`id` must be text"));
|
||||
assert_eq!(
|
||||
message("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "sweep": "90"})).as_deref(),
|
||||
Some("`sweep` must be a number of degrees")
|
||||
);
|
||||
assert_eq!(message("sketch.circle.center", json!({"center": [0, 0], "radius": 1, "through": {"vertex": [1, 2, 3]}})), None);
|
||||
assert_eq!(message("sketch.point", json!({"point": {"on_edge": [1, 2, 3]}})), None);
|
||||
assert_eq!(message("sketch.point", json!({"point": "vertex:1,2,3"})), None);
|
||||
assert_eq!(message("sketch.line", json!({"points": [{"vertex": [1, 2, 3]}, {"on_edge": [1, 2, 3]}]})), None);
|
||||
}
|
||||
|
||||
fn input_schema_has(id: &str, key: &str) -> bool {
|
||||
crate::cmd::find_command(id).and_then(|c| c.contract).is_some_and(|c| c.allowed_keys().contains(key))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
|
||||
use super::{
|
||||
CoincidentParams, CoincidentResult, CollinearParams, ConcentricParams, ConstraintResult, EqualParams, EqualResult, FixParams, FixResult,
|
||||
HorizontalVerticalParams, MidpointParams, Operand, ParallelParams, PerpendicularParams, PointOperand, SketchKey, SymmetryParams, SymmetryResult,
|
||||
TangentParams,
|
||||
TangentParams, TangentResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -342,9 +342,16 @@ fn replies_match_the_output_schemas_and_real_calls_get_no_warnings() {
|
||||
let r = assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "c3", "b": "c4"}));
|
||||
assert!(r["constraints"].as_array().is_some_and(|c| c.len() == 2) && r.get("constraint").is_some(), "{r}");
|
||||
|
||||
// Tangent, parallel, perpendicular, collinear.
|
||||
// Tangent: two curves, and a line's end point and a circle (#12092), which adds two
|
||||
// constraints.
|
||||
let mut s = new();
|
||||
let r = assert_clean::<TangentResult>(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
|
||||
assert!(r.get("constraints").is_none(), "{r}");
|
||||
let r = assert_clean::<TangentResult>(&mut s, "sketch.constraint.tangent", json!({"a": "l3.end", "b": "c2"}));
|
||||
assert!(r["constraints"].as_array().is_some_and(|c| c.len() == 2) && r.get("constraint").is_some(), "{r}");
|
||||
|
||||
// Parallel, perpendicular, collinear.
|
||||
let mut s = new();
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.parallel", json!({"a": "l2", "b": "l3"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.parallel", json!({"a": "l4", "b": "x"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.perpendicular", json!({"a": "l5", "b": "l6"}));
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Contracts and result types for the commands in `cmd/clipboard.rs` and `cmd/script.rs`:
|
||||
//! `timeline.copy`, `timeline.paste`, `timeline.add` and `timeline.export_script` (#12502).
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message; where a reader is shared with the handler
|
||||
//! (`copy_keys`, `component_path_form`, `step_params`) the type calls it. Whether a feature, a
|
||||
//! component or a file exists stays the handler's to check. A key the handler refuses when it is
|
||||
//! left out (`features`, `feature`) is optional in the type and required in the schema, so that the
|
||||
//! handler's message, not the check's "is required", is what a caller sees. A call with several
|
||||
//! problems may get a different one first in strict than with the check off (the check goes key by
|
||||
//! key, the handlers in their own order).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
use super::check::whole;
|
||||
use super::component_link::ComponentRef;
|
||||
use super::motion::require;
|
||||
use super::types::not_blank_pattern;
|
||||
use crate::EngineError;
|
||||
use crate::params;
|
||||
|
||||
/// The most features one `timeline.copy` lists, as the handler reads them.
|
||||
const MAX_COPY: usize = 1000;
|
||||
/// The most occurrence ids in a `component_path`, as the handler reads them.
|
||||
const MAX_PATH: usize = 64;
|
||||
|
||||
/// The message of a handler's refusal, to pass on as it is.
|
||||
fn handler_message(e: EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => whole(msg),
|
||||
other => whole(other),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- timeline.copy ----
|
||||
|
||||
/// The features `timeline.copy` lists: one name, or a list of names and ids, in any mix.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FeatureKeys(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for FeatureKeys {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let wrapped = json!({ "features": Value::deserialize(d)? });
|
||||
crate::cmd::copy_keys(&wrapped).map(|keys| FeatureKeys(keys.to_vec())).map_err(|e| D::Error::custom(handler_message(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FeatureKeys {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FeatureKeys".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let key = json!({"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0}]});
|
||||
json_schema!({
|
||||
"description": "Features by name or id: one name, or a list of names and ids in any mix.",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": key, "minItems": 1, "maxItems": MAX_COPY}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy features to the session's clipboard, and a snapshot of them for pasting into another
|
||||
/// design. The features stay where they are; the clipboard replaces what was copied before. Not an
|
||||
/// undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = features_required)]
|
||||
pub struct CopyParams {
|
||||
// Optional in the type and required in the schema (`features_required`).
|
||||
/// The features to copy, by name or id: one name, or 1 to 1000 names and ids. Pasted in
|
||||
/// timeline order whatever the order given.
|
||||
#[serde(default, deserialize_with = "features", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "FeatureKeys")]
|
||||
pub features: Option<FeatureKeys>,
|
||||
}
|
||||
|
||||
/// `features`, with the handler's message for anything else, `null` and an empty list included.
|
||||
fn features<'de, D: Deserializer<'de>>(d: D) -> Result<Option<FeatureKeys>, D::Error> {
|
||||
FeatureKeys::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn features_required(schema: &mut Schema) {
|
||||
require(schema, "features");
|
||||
}
|
||||
|
||||
/// What `timeline.copy` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CopyResult {
|
||||
/// The names of the features copied, in timeline order.
|
||||
pub copied: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- timeline.paste ----
|
||||
|
||||
/// A `translate` as `timeline.paste` reads it: `[x, y, z]` or `{x, y, z}` of numbers in mm, each
|
||||
/// under 1e9 in size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PasteTranslation(pub Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for PasteTranslation {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec3(&Value::deserialize(d)?).map(PasteTranslation).ok_or_else(|| D::Error::custom(whole("`translate` must be [x, y, z]")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PasteTranslation {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PasteTranslation".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let c = json!({"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9});
|
||||
json_schema!({
|
||||
"description": "A distance in mm, as [x, y, z] or {\"x\", \"y\", \"z\"}.",
|
||||
"anyOf": [
|
||||
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
|
||||
{"type": "object", "properties": {"x": c, "y": c, "z": c}, "required": ["x", "y", "z"]}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A `component_path` as `timeline.paste` reads it: 0 to 64 occurrence ids.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PastePath(pub Vec<u64>);
|
||||
|
||||
impl<'de> Deserialize<'de> for PastePath {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::component_path_form(&Value::deserialize(d)?)
|
||||
.map(PastePath)
|
||||
.ok_or_else(|| D::Error::custom(whole("`component_path` must list 0..64 occurrence ids")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PastePath {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PastePath".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The occurrence ids from the top of the design down to a placement.",
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 0},
|
||||
"maxItems": MAX_PATH
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_component<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ComponentRef>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => Ok(Some(ComponentRef(n.to_string()))),
|
||||
Value::String(s) => Ok(Some(ComponentRef(s))),
|
||||
_ => Err(D::Error::custom(whole("`component` must be a component id or name"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_path<'de, D: Deserializer<'de>>(d: D) -> Result<Option<PastePath>, D::Error> {
|
||||
PastePath::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn paste_translate<'de, D: Deserializer<'de>>(d: D) -> Result<Option<PasteTranslation>, D::Error> {
|
||||
PasteTranslation::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// `from_design` is true or false; `null` is refused, as the handler does.
|
||||
fn from_design<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(D::Error::custom(whole("`from_design` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste the copied features as independent copies at the timeline marker: sketches get their own
|
||||
/// dimension parameters, features their own names, and references inside the copied set point at
|
||||
/// the copies. Body transfers are left out. One undo step. With `from_design`, only sketch
|
||||
/// features are pasted, from the snapshot another design's copy left. Every key reads the same
|
||||
/// either way.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PasteParams {
|
||||
/// Move the copies' geometry by this much, in mm. Default: not moved.
|
||||
#[serde(default, deserialize_with = "paste_translate", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PasteTranslation")]
|
||||
pub translate: Option<PasteTranslation>,
|
||||
/// The component the copies go into, by id or name. Default: the active one.
|
||||
#[serde(default, deserialize_with = "paste_component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
/// The placement of that component the copies are authored in, as occurrence ids. Default: the
|
||||
/// active placement for the active component, else the component's only placement.
|
||||
#[serde(default, deserialize_with = "paste_path", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PastePath")]
|
||||
pub component_path: Option<PastePath>,
|
||||
/// True pastes the sketch features of the clipboard snapshot of another design, with their
|
||||
/// parameters. Default: false, the features copied in this design.
|
||||
#[serde(default, deserialize_with = "from_design", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub from_design: Option<bool>,
|
||||
}
|
||||
|
||||
/// A feature a paste made.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Pasted {
|
||||
/// The new feature, by id.
|
||||
pub id: u64,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// The name of the feature it is a copy of.
|
||||
pub from: String,
|
||||
}
|
||||
|
||||
/// A pasted feature that did not rebuild.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PasteError {
|
||||
/// The pasted feature, by id.
|
||||
pub feature: u64,
|
||||
/// Why it did not rebuild.
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// What `timeline.paste` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PasteResult {
|
||||
/// The features made, in timeline order.
|
||||
pub pasted: Vec<Pasted>,
|
||||
/// The pasted features that did not rebuild.
|
||||
pub errors: Vec<PasteError>,
|
||||
/// What the paste left out or kept (body transfers, external face attachments).
|
||||
pub notices: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- timeline.add ----
|
||||
|
||||
/// The `feature` of `timeline.add`: an object, as a design file or `timeline.inspect` holds a
|
||||
/// feature.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RecordedFeature(pub Map<String, Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for RecordedFeature {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Object(o) => Ok(RecordedFeature(o)),
|
||||
_ => Err(D::Error::custom(whole("`feature` must be a feature object, as a design file or timeline.inspect has it"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RecordedFeature {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RecordedFeature".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A feature as the design file and timeline.inspect hold it: {type, name?, id?, …}. \
|
||||
The name is kept even when another feature has it; the id is kept, and refused when another \
|
||||
feature has it. Whether the rest is a feature is the command's to check.",
|
||||
"type": "object"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The model parameters of `timeline.add`: read by the handler's own reader.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StepParams(pub Vec<solidcraft_doc::Param>);
|
||||
|
||||
impl<'de> Deserialize<'de> for StepParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
crate::cmd::step_params("timeline.add", Some(&value)).map(StepParams).map_err(|e| D::Error::custom(handler_message(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for StepParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"StepParams".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let text = json!({"type": ["string", "null"]});
|
||||
json_schema!({
|
||||
"description": "Model parameters the feature's sketch dimensions use.",
|
||||
"type": "array",
|
||||
"maxItems": solidcraft_sketch::MAX_CONSTRAINTS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "The parameter's name."},
|
||||
"expression": {
|
||||
"description": "Its value: a number or an expression that is not blank.",
|
||||
"anyOf": [{"type": "string", "pattern": not_blank_pattern()}, {"type": "number"}]
|
||||
},
|
||||
"unit": text.clone(),
|
||||
"comment": text
|
||||
},
|
||||
"required": ["name", "expression"]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn recorded<'de, D: Deserializer<'de>>(d: D) -> Result<Option<RecordedFeature>, D::Error> {
|
||||
RecordedFeature::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn feature_required(schema: &mut Schema) {
|
||||
require(schema, "feature");
|
||||
}
|
||||
|
||||
/// Append one recorded feature to the timeline, as the design file holds it, so every feature
|
||||
/// kind replays exactly: the same ids, names, parameter names and stored edge and face names (the
|
||||
/// steps `timeline.export_script` writes). It goes at the timeline marker. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = feature_required)]
|
||||
pub struct AddParams {
|
||||
// Optional in the type and required in the schema (`feature_required`).
|
||||
/// The feature, as a design file or `timeline.inspect` has it.
|
||||
#[serde(default, deserialize_with = "recorded", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "RecordedFeature")]
|
||||
pub feature: Option<RecordedFeature>,
|
||||
/// The model parameters its sketch dimensions use, each {name, expression, unit?, comment?}.
|
||||
/// Default: none.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "StepParams")]
|
||||
pub params: Option<StepParams>,
|
||||
}
|
||||
|
||||
/// What `timeline.add` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AddResult {
|
||||
/// The new feature, by id.
|
||||
pub feature: u64,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// Why it did not build, or null.
|
||||
pub error: Option<String>,
|
||||
/// A warning from building it, or null.
|
||||
pub warning: Option<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- timeline.export_script ----
|
||||
|
||||
fn script_path<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole("`path` must be a file path"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the design's history as a command script that rebuilds it (`solidcraft-cli eval` and
|
||||
/// `run` replay it): one step per setting and feature, and a list of what the script leaves out.
|
||||
/// A design with components or joints is refused for now. The design is not changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExportScriptParams {
|
||||
/// A file to write the script to, as JSON. Default: none, the script is only returned.
|
||||
#[serde(default, deserialize_with = "script_path", skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// One step of a command script.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ScriptStep {
|
||||
/// The command the step runs.
|
||||
pub command: String,
|
||||
/// Its parameters.
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
/// Where a script came from and how to replay it. Fields stay in alphabetical order: a script file
|
||||
/// is written from this type, and its keys have always been sorted.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ScriptAbout {
|
||||
/// The design's name.
|
||||
pub design: String,
|
||||
/// The design file format the `timeline.add` steps hold features in.
|
||||
pub format: String,
|
||||
/// How to replay the script.
|
||||
pub replay: String,
|
||||
/// The version of SolidCraft that wrote it.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// A command script. Fields stay in alphabetical order (see [`ScriptAbout`]).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Script {
|
||||
pub about: ScriptAbout,
|
||||
/// The steps that rebuild the design on an empty one.
|
||||
#[schemars(with = "Vec<ScriptStep>")]
|
||||
pub commands: Vec<Value>,
|
||||
}
|
||||
|
||||
/// What `timeline.export_script` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExportScriptResult {
|
||||
/// What the script leaves out (appearances, folders, views, configurations, print-face
|
||||
/// assignments…).
|
||||
pub omitted: Vec<String>,
|
||||
/// The file written, or null.
|
||||
pub path: Option<String>,
|
||||
/// The script.
|
||||
pub script: Script,
|
||||
/// How many steps it has.
|
||||
pub steps: usize,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "timeline_clipboard_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,410 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{AddParams, AddResult, CopyParams, CopyResult, ExportScriptParams, ExportScriptResult, PasteParams, PasteResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
fn warn_session() -> Session {
|
||||
Session { param_check: ParamCheck::Warn, ..Session::default() }
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A dimensioned sketch named S, extruded into Body, for the commands to work on.
|
||||
fn plate(s: &mut Session) {
|
||||
run(s, "parameters.change", json!({"name": "base", "expression": "5 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "base * 4"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "S", "distance": 5, "name": "E"}));
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
fn issues(id: &str, p: Value) -> Vec<String> {
|
||||
crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<CopyParams>(), &["features"]);
|
||||
assert_keys(input_schema_for::<PasteParams>(), &["component", "component_path", "from_design", "translate"]);
|
||||
assert_keys(input_schema_for::<AddParams>(), &["feature", "params"]);
|
||||
assert_keys(input_schema_for::<ExportScriptParams>(), &["path"]);
|
||||
}
|
||||
|
||||
/// Strict gives the handlers' own messages for what they already refuse (a missing `features` or
|
||||
/// `feature` included: the handlers' words, not "is required").
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let key = |k: Value| json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1", "corner": [0, 0, 0]}, "params": k});
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("timeline.copy", json!({})),
|
||||
("timeline.copy", json!({"features": null})),
|
||||
("timeline.copy", json!({"features": []})),
|
||||
("timeline.copy", json!({"features": 3})),
|
||||
("timeline.copy", json!({"features": true})),
|
||||
("timeline.copy", json!({"features": [true]})),
|
||||
("timeline.copy", json!({"features": ["S", -1]})),
|
||||
("timeline.copy", json!({"features": ["S", 1.5]})),
|
||||
("timeline.copy", json!({"features": vec!["S"; 1001]})),
|
||||
("timeline.copy", json!({"features": vec!["S"; 10_001]})),
|
||||
("timeline.copy", json!({"features": ["Nope"]})),
|
||||
("timeline.paste", json!({"from_design": null})),
|
||||
("timeline.paste", json!({"from_design": 1})),
|
||||
("timeline.paste", json!({"from_design": "true"})),
|
||||
("timeline.add", json!({})),
|
||||
("timeline.add", json!({"feature": null})),
|
||||
("timeline.add", json!({"feature": [1, 2, 3]})),
|
||||
("timeline.add", json!({"feature": "box"})),
|
||||
("timeline.add", key(json!("q"))),
|
||||
("timeline.add", key(json!({"name": "q"}))),
|
||||
("timeline.add", key(json!(vec![json!({"name": "q", "expression": "1"}); 40_001]))),
|
||||
("timeline.add", key(json!([1]))),
|
||||
("timeline.add", key(json!([{"expression": "1"}]))),
|
||||
("timeline.add", key(json!([{"name": 4, "expression": "1"}]))),
|
||||
("timeline.add", key(json!([{"name": "q"}]))),
|
||||
("timeline.add", key(json!([{"name": "q", "expression": " "}]))),
|
||||
("timeline.add", key(json!([{"name": "q", "expression": true}]))),
|
||||
("timeline.add", key(json!([{"name": "q", "expression": "1", "unit": 3}]))),
|
||||
("timeline.add", key(json!([{"name": "q", "expression": "1", "comment": []}]))),
|
||||
("timeline.export_script", json!({"path": 7})),
|
||||
("timeline.export_script", json!({"path": true})),
|
||||
("timeline.export_script", json!({"path": ["a"]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A paste with something to paste (in this design, or from another one): strict gives the
|
||||
/// handler's message for each key it refuses. Without anything copied the handler says so before
|
||||
/// it reads the other keys.
|
||||
#[test]
|
||||
fn paste_strict_keeps_todays_errors() {
|
||||
let mut source = Session::default();
|
||||
plate(&mut source);
|
||||
run(&mut source, "timeline.copy", json!({"features": ["S"]}));
|
||||
let cases = [
|
||||
json!({"translate": [1, 2]}),
|
||||
json!({"translate": null}),
|
||||
json!({"translate": "1,2,3"}),
|
||||
json!({"translate": [1e9, 0, 0]}),
|
||||
json!({"component": true}),
|
||||
json!({"component": null}),
|
||||
json!({"component_path": "0"}),
|
||||
json!({"component_path": null}),
|
||||
json!({"component_path": [-1]}),
|
||||
json!({"component_path": vec![0; 65]}),
|
||||
];
|
||||
for design in [false, true] {
|
||||
for case in &cases {
|
||||
let mut p = case.clone();
|
||||
p["from_design"] = json!(design);
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
plate(&mut s);
|
||||
s.foreign_copy = source.foreign_copy.clone();
|
||||
run(&mut s, "timeline.copy", json!({"features": ["S"]}));
|
||||
s.foreign_copy = source.foreign_copy.clone();
|
||||
s.execute("timeline.paste", &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some_and(|e| !e.contains("nothing copied")), "{p} should fail on the key");
|
||||
assert_eq!(error(ParamCheck::Strict), error(ParamCheck::Off), "{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The types read each key as the handlers do: what the handler's reader takes the type takes,
|
||||
/// and a refusal has the handler's message.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let ok = |id: &str, p: Value| assert_eq!(issues(id, p.clone()), Vec::<String>::new(), "{p}");
|
||||
let refused = |id: &str, p: Value, message: &str| assert_eq!(issues(id, p.clone()), [message], "{p}");
|
||||
|
||||
// timeline.copy: a name, or names and ids in any mix; null is not absent.
|
||||
ok("timeline.copy", json!({"features": "S"}));
|
||||
ok("timeline.copy", json!({"features": ["S", 3, "E"]}));
|
||||
ok("timeline.copy", json!({"features": vec!["S"; 1000]}));
|
||||
refused("timeline.copy", json!({"features": []}), "`features` must list 1…1000 features");
|
||||
refused("timeline.copy", json!({"features": null}), "`features` must list 1…1000 features");
|
||||
refused("timeline.copy", json!({"features": vec!["S"; 1001]}), "`features` must list 1…1000 features");
|
||||
refused("timeline.copy", json!({"features": 3}), "`features` must be a list of names or ids; it is 3");
|
||||
refused("timeline.copy", json!({"features": ["S", true]}), "`features` must list names or ids; the entry 2 is true");
|
||||
|
||||
// timeline.paste: the same keys with or without from_design.
|
||||
for design in [json!({}), json!({"from_design": false}), json!({"from_design": true})] {
|
||||
let with = |k: &str, v: Value| {
|
||||
let mut p = design.clone();
|
||||
p[k] = v;
|
||||
p
|
||||
};
|
||||
ok("timeline.paste", design.clone());
|
||||
ok("timeline.paste", with("translate", json!([1, 2.5, -3])));
|
||||
ok("timeline.paste", with("translate", json!({"x": 1, "y": 2, "z": 3})));
|
||||
ok("timeline.paste", with("component", json!("Plate")));
|
||||
ok("timeline.paste", with("component", json!(4)));
|
||||
ok("timeline.paste", with("component_path", json!([])));
|
||||
ok("timeline.paste", with("component_path", json!([1, 2])));
|
||||
refused("timeline.paste", with("translate", json!([1, 2])), "`translate` must be [x, y, z]");
|
||||
refused("timeline.paste", with("translate", json!(null)), "`translate` must be [x, y, z]");
|
||||
refused("timeline.paste", with("component", json!(null)), "`component` must be a component id or name");
|
||||
refused("timeline.paste", with("component", json!([1])), "`component` must be a component id or name");
|
||||
refused("timeline.paste", with("component_path", json!(null)), "`component_path` must list 0..64 occurrence ids");
|
||||
refused("timeline.paste", with("component_path", json!([1.5])), "`component_path` must list 0..64 occurrence ids");
|
||||
refused("timeline.paste", with("component_path", json!(vec![0; 65])), "`component_path` must list 0..64 occurrence ids");
|
||||
}
|
||||
ok("timeline.paste", json!({"component_path": vec![0; 64]}));
|
||||
refused("timeline.paste", json!({"from_design": null}), "`from_design` must be true or false");
|
||||
refused("timeline.paste", json!({"from_design": 0}), "`from_design` must be true or false");
|
||||
|
||||
// timeline.add: the feature is an object; params are read by the handler's own reader.
|
||||
let feature = json!({"type": "box", "length": "1", "width": "1", "height": "1", "corner": [0, 0, 0]});
|
||||
ok("timeline.add", json!({"feature": feature}));
|
||||
ok("timeline.add", json!({"feature": feature, "params": null}));
|
||||
ok("timeline.add", json!({"feature": feature, "params": []}));
|
||||
ok("timeline.add", json!({"feature": feature, "params": [{"name": "q", "expression": 5}]}));
|
||||
ok("timeline.add", json!({"feature": feature, "params": [{"name": "q", "expression": "5", "unit": null, "comment": null, "extra": 1}]}));
|
||||
ok("timeline.add", json!({"feature": feature, "params": [{"name": " q ", "expression": " 5 ", "unit": "mm", "comment": "c"}]}));
|
||||
let message = "`feature` must be a feature object, as a design file or timeline.inspect has it";
|
||||
refused("timeline.add", json!({"feature": 1}), message);
|
||||
refused("timeline.add", json!({"feature": null}), message);
|
||||
refused("timeline.add", json!({"feature": feature, "params": {}}), "`params` must be a list of parameters");
|
||||
refused("timeline.add", json!({"feature": feature, "params": [{}]}), "each parameter needs a `name`");
|
||||
refused("timeline.add", json!({"feature": feature, "params": [{"name": "q"}]}), "parameter `q` needs an `expression`");
|
||||
refused(
|
||||
"timeline.add",
|
||||
json!({"feature": feature, "params": [{"name": "q", "expression": "1", "unit": 1}]}),
|
||||
"parameter `q`: `unit` must be text",
|
||||
);
|
||||
refused(
|
||||
"timeline.add",
|
||||
json!({"feature": feature, "params": [{"name": "q", "expression": "1", "comment": 1}]}),
|
||||
"parameter `q`: `comment` must be text",
|
||||
);
|
||||
// The feature's own keys are the design file's to check, not the contract's.
|
||||
ok("timeline.add", json!({"feature": {"type": "warp_drive", "anything": [1, 2]}}));
|
||||
|
||||
// timeline.export_script: text, or null for no file.
|
||||
ok("timeline.export_script", json!({}));
|
||||
ok("timeline.export_script", json!({"path": null}));
|
||||
ok("timeline.export_script", json!({"path": "script.json"}));
|
||||
refused("timeline.export_script", json!({"path": 1}), "`path` must be a file path");
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: the keys a handler refuses without are required, and
|
||||
/// each value is the form its reader takes.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<CopyParams>(),
|
||||
&[
|
||||
(json!({"features": "S"}), true),
|
||||
(json!({"features": ["S", 2]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"features": []}), false),
|
||||
(json!({"features": 2}), false),
|
||||
(json!({"features": [true]}), false),
|
||||
(json!({"features": [-1]}), false),
|
||||
(json!({"features": vec!["S"; 1001]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<PasteParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"translate": [1, 2, 3], "component": "P", "component_path": [1], "from_design": true}), true),
|
||||
(json!({"translate": {"x": 1, "y": 2, "z": 3}, "component": 2}), true),
|
||||
(json!({"translate": [1, 2]}), false),
|
||||
(json!({"translate": [1e9, 0, 0]}), false),
|
||||
(json!({"component": true}), false),
|
||||
(json!({"component_path": [-1]}), false),
|
||||
(json!({"component_path": vec![0; 65]}), false),
|
||||
(json!({"from_design": null}), false),
|
||||
(json!({"from_design": "yes"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<AddParams>(),
|
||||
&[
|
||||
(json!({"feature": {"type": "box"}}), true),
|
||||
(json!({"feature": {"type": "box"}, "params": [{"name": "q", "expression": 5, "unit": "mm", "comment": null}]}), true),
|
||||
(json!({"feature": {"type": "box"}, "params": [{"name": "q", "expression": "5"}]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"feature": []}), false),
|
||||
(json!({"feature": {"type": "box"}, "params": [{"name": "q"}]}), false),
|
||||
(json!({"feature": {"type": "box"}, "params": {"name": "q"}}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ExportScriptParams>(),
|
||||
&[(json!({}), true), (json!({"path": "a.json"}), true), (json!({"path": 3}), false), (json!({"file": "a.json"}), false)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy (a name, an id, both), paste (as it is, moved, into a component, from another design),
|
||||
/// in Warn: each real reply matches its output schema and carries no warning.
|
||||
#[test]
|
||||
fn copy_and_paste_replies_match_the_output_schema() {
|
||||
let mut s = warn_session();
|
||||
plate(&mut s);
|
||||
let sketch_id = s.doc.find_feature("S").map(|f| f.id).unwrap_or_default();
|
||||
|
||||
for features in [json!("S"), json!(["S"]), json!([sketch_id]), json!(["S", "E"]), json!(["E", sketch_id])] {
|
||||
let copied = run(&mut s, "timeline.copy", json!({"features": features}));
|
||||
assert!(copied["copied"].as_array().is_some_and(|c| !c.is_empty()), "{copied}");
|
||||
matches(output_schema_for::<CopyResult>(), "timeline.copy", &copied);
|
||||
}
|
||||
assert_eq!(run(&mut s, "timeline.copy", json!({"features": ["E", "S"]}))["copied"], json!(["S", "E"]), "timeline order");
|
||||
|
||||
let pasted = run(&mut s, "timeline.paste", json!({}));
|
||||
assert_eq!(pasted["pasted"].as_array().map(Vec::len), Some(2), "{pasted}");
|
||||
assert_eq!(pasted["pasted"][0]["from"], "S", "{pasted}");
|
||||
assert_eq!(pasted["pasted"][0]["name"], "S (2)", "{pasted}");
|
||||
assert!(pasted["pasted"][0]["id"].is_u64(), "{pasted}");
|
||||
assert_eq!(pasted["errors"], json!([]), "{pasted}");
|
||||
assert_eq!(pasted["notices"], json!([]), "{pasted}");
|
||||
matches(output_schema_for::<PasteResult>(), "timeline.paste", &pasted);
|
||||
|
||||
for p in [json!({"translate": [30, 0, 0]}), json!({"translate": {"x": 0, "y": 30, "z": 0}, "from_design": false})] {
|
||||
let moved = run(&mut s, "timeline.paste", p);
|
||||
matches(output_schema_for::<PasteResult>(), "timeline.paste", &moved);
|
||||
}
|
||||
|
||||
// Into a component, by name and by id, with its placement.
|
||||
let made = run(&mut s, "component.create", json!({"name": "Plate", "grounded": false}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
let (component, occurrence) = (made["component"].clone(), made["occurrence"].clone());
|
||||
let into = run(&mut s, "timeline.paste", json!({"component": "Plate", "component_path": [occurrence.clone()]}));
|
||||
matches(output_schema_for::<PasteResult>(), "timeline.paste", &into);
|
||||
let by_id = run(&mut s, "timeline.paste", json!({"component": component, "translate": [0, 0, 20]}));
|
||||
matches(output_schema_for::<PasteResult>(), "timeline.paste", &by_id);
|
||||
|
||||
// From another design: its copy is in the snapshot, pasted with the same keys.
|
||||
let mut source = warn_session();
|
||||
plate(&mut source);
|
||||
run(&mut source, "timeline.copy", json!({"features": ["S"]}));
|
||||
let mut target = warn_session();
|
||||
target.foreign_copy = source.foreign_copy.clone();
|
||||
let foreign = run(&mut target, "timeline.paste", json!({"from_design": true, "translate": [5, 0, 0], "component_path": []}));
|
||||
assert_eq!(foreign["pasted"][0]["from"], "S", "{foreign}");
|
||||
assert_eq!(foreign["errors"], json!([]), "{foreign}");
|
||||
assert_eq!(foreign["notices"], json!([]), "{foreign}");
|
||||
matches(output_schema_for::<PasteResult>(), "timeline.paste", &foreign);
|
||||
|
||||
// A copy of a feature that is not a sketch, by id.
|
||||
let mut notices = warn_session();
|
||||
run(&mut notices, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
|
||||
let nothing = run(&mut notices, "timeline.copy", json!({"features": [1]}));
|
||||
matches(output_schema_for::<CopyResult>(), "timeline.copy", ¬hing);
|
||||
}
|
||||
|
||||
/// Add every step of a design's script in Warn, with its parameters: each `timeline.add` reply
|
||||
/// matches the output schema without warnings, a feature that does not build says why, and the
|
||||
/// script is returned and written as before.
|
||||
#[test]
|
||||
fn add_and_export_replies_match_the_output_schema() {
|
||||
let mut s = warn_session();
|
||||
plate(&mut s);
|
||||
let exported = run(&mut s, "timeline.export_script", json!({}));
|
||||
matches(output_schema_for::<ExportScriptResult>(), "timeline.export_script", &exported);
|
||||
assert_eq!(exported["path"], Value::Null, "{exported}");
|
||||
assert_eq!(exported["omitted"], json!([]), "{exported}");
|
||||
assert_eq!(exported["steps"].as_u64(), exported["script"]["commands"].as_array().map(|c| c.len() as u64), "{exported}");
|
||||
let mut about: Vec<_> = exported["script"]["about"].as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
|
||||
about.sort();
|
||||
assert_eq!(about, ["design", "format", "replay", "version"], "{exported}");
|
||||
assert!(exported["script"]["commands"].as_array().is_some_and(|c| c.iter().all(|c| c["command"].is_string() && c.get("params").is_some())));
|
||||
|
||||
let mut t = warn_session();
|
||||
let mut added = 0;
|
||||
for step in exported["script"]["commands"].as_array().into_iter().flatten() {
|
||||
let reply = run(&mut t, step["command"].as_str().unwrap_or_default(), step["params"].clone());
|
||||
if step["command"] == "timeline.add" {
|
||||
added += 1;
|
||||
assert!(reply["error"].is_null() && reply["warning"].is_null(), "{reply}");
|
||||
matches(output_schema_for::<AddResult>(), "timeline.add", &reply);
|
||||
}
|
||||
}
|
||||
assert!(added >= 2, "a sketch and an extrude");
|
||||
assert!(
|
||||
exported["script"]["commands"].as_array().is_some_and(|c| c.iter().any(|c| c["params"]["params"].is_array())),
|
||||
"a step brings parameters"
|
||||
);
|
||||
|
||||
// Parameters as a number, with text and null in the other keys; a feature that doesn't build.
|
||||
let mut u = warn_session();
|
||||
let reply = run(
|
||||
&mut u,
|
||||
"timeline.add",
|
||||
json!({"feature": {"type": "box", "length": "q", "width": "5", "height": "5", "corner": [0, 0, 0]}, "params": [{"name": "q", "expression": 8, "unit": null, "comment": "a size"}]}),
|
||||
);
|
||||
assert_eq!(reply["name"], "Box1", "{reply}");
|
||||
matches(output_schema_for::<AddResult>(), "timeline.add", &reply);
|
||||
let broken = run(&mut u, "timeline.add", json!({"feature": {"type": "box", "length": "0", "width": "5", "height": "5", "corner": [0, 0, 0]}}));
|
||||
assert!(broken["error"].is_string() || broken["warning"].is_string(), "{broken}");
|
||||
matches(output_schema_for::<AddResult>(), "timeline.add", &broken);
|
||||
|
||||
// With a path the script is written, and the reply names the file.
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-timeline-schema-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
let file = dir.join("script.json");
|
||||
let written = run(&mut s, "timeline.export_script", json!({"path": file}));
|
||||
let text = std::fs::read_to_string(&file).unwrap_or_default();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(serde_json::from_str::<Value>(&text).ok(), Some(written["script"].clone()));
|
||||
// The file's text is the pretty-printed script with its keys sorted, as it has always been.
|
||||
let sorted = serde_json::to_string_pretty(&written["script"]).unwrap_or_default();
|
||||
assert_eq!(text, sorted);
|
||||
assert_eq!(written["path"], json!(file), "{written}");
|
||||
matches(output_schema_for::<ExportScriptResult>(), "timeline.export_script", &written);
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn, and says so, with a reply that matches its schema.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let mut s = warn_session();
|
||||
plate(&mut s);
|
||||
let sketch = json!({"type": "box", "length": "1", "width": "1", "height": "1", "corner": [0, 0, 0]});
|
||||
let cases = [
|
||||
("timeline.copy", json!({"features": ["S"], "featurs": 1}), output_schema_for::<CopyResult>()),
|
||||
("timeline.paste", json!({"translat": [1, 0, 0]}), output_schema_for::<PasteResult>()),
|
||||
("timeline.add", json!({"feature": sketch, "param": []}), output_schema_for::<AddResult>()),
|
||||
("timeline.export_script", json!({"paht": "x.json"}), output_schema_for::<ExportScriptResult>()),
|
||||
];
|
||||
for (id, p, schema) in cases {
|
||||
if id == "timeline.paste" {
|
||||
run(&mut s, "timeline.copy", json!({"features": ["S"]}));
|
||||
}
|
||||
let reply = run(&mut s, id, p);
|
||||
let warnings = reply["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains("unknown key")), "{id}: {reply}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{id}: {reply}");
|
||||
}
|
||||
// In strict the call is refused before it runs.
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let refused = s.execute("timeline.export_script", &json!({"paht": "x.json"}));
|
||||
assert!(refused.is_err_and(|e| e.to_string().contains("unknown key `paht`")));
|
||||
}
|
||||
|
||||
/// The expression of a parameter that isn't blank once trimmed: the pattern is the one the
|
||||
/// shared check builds from `trim`'s whitespace.
|
||||
#[test]
|
||||
fn a_blank_expression_is_refused_by_the_pattern() {
|
||||
let schema = input_schema_for::<AddParams>();
|
||||
let pattern = schema.as_value()["$defs"]["StepParams"]["items"]["properties"]["expression"]["anyOf"][0]["pattern"].clone();
|
||||
assert_eq!(pattern, json!(crate::schema::types::not_blank_pattern()));
|
||||
}
|
||||
@@ -2,30 +2,15 @@
|
||||
# (coverage_tests.rs). This list only shrinks: when you give a command its contract and output
|
||||
# type, remove its line. Never add one: a new command comes with both schemas. Sorted, one id
|
||||
# per line.
|
||||
component.activate
|
||||
component.capture_position
|
||||
component.create
|
||||
component.delete
|
||||
component.from_bodies
|
||||
component.list
|
||||
component.move_bodies
|
||||
component.move_sketches
|
||||
component.paste_new
|
||||
component.properties
|
||||
component.rename
|
||||
component.revert_position
|
||||
component.unlock
|
||||
doc.open
|
||||
doc.recover
|
||||
doc.recovery_discard
|
||||
doc.recovery_list
|
||||
document.inspect
|
||||
engine.commands
|
||||
explode.create
|
||||
explode.delete
|
||||
explode.list
|
||||
explode.show
|
||||
expr.evaluate
|
||||
face.delete
|
||||
file.export
|
||||
file.insert_mesh
|
||||
@@ -35,30 +20,15 @@ file.save
|
||||
file.save_as
|
||||
file.save_mesh
|
||||
file.slicers
|
||||
frame.member
|
||||
frame.profile.insert
|
||||
frame.profile.list
|
||||
frame.profile.properties
|
||||
inspect.measure
|
||||
joint.as_built
|
||||
joint.cam
|
||||
joint.create
|
||||
joint.delete
|
||||
joint.drive
|
||||
joint.gear
|
||||
joint.inspect
|
||||
joint.limits
|
||||
joint.list
|
||||
joint.motion_link
|
||||
joint.origin
|
||||
joint.rack_pinion
|
||||
joint.reach
|
||||
joint.rigid_group
|
||||
joint.screw
|
||||
joint.solve
|
||||
joint.sweep
|
||||
material.assign
|
||||
material.list
|
||||
mesh.convert
|
||||
motion.export_animation
|
||||
motion.interference
|
||||
@@ -71,13 +41,6 @@ occurrence.drag
|
||||
occurrence.ground
|
||||
occurrence.move
|
||||
occurrence.properties
|
||||
parameters.change
|
||||
parameters.complete
|
||||
parameters.export
|
||||
parameters.graph
|
||||
parameters.import
|
||||
parameters.list
|
||||
parameters.users
|
||||
render.aging.describe
|
||||
render.backends
|
||||
render.cancel
|
||||
@@ -95,25 +58,7 @@ sheet.export_bend_table
|
||||
sheet.export_dxf
|
||||
sheet.flat_pattern
|
||||
sheet.path_flange
|
||||
sketch.arc.center_point
|
||||
sketch.arc.tangent
|
||||
sketch.arc.three_point
|
||||
sketch.auto_constrain
|
||||
sketch.auto_constrain_from_datum
|
||||
sketch.auto_project
|
||||
sketch.blend_curve
|
||||
sketch.break_link
|
||||
sketch.centerline
|
||||
sketch.chain
|
||||
sketch.circle.center
|
||||
sketch.circle.three_point
|
||||
sketch.circle.three_tangent
|
||||
sketch.circle.two_point
|
||||
sketch.circle.two_tangent
|
||||
sketch.conic
|
||||
sketch.constrainer
|
||||
sketch.constraint.curvature
|
||||
sketch.constraint.polygon
|
||||
sketch.construction
|
||||
sketch.create
|
||||
sketch.delete
|
||||
@@ -122,52 +67,21 @@ sketch.dimension_text
|
||||
sketch.drag
|
||||
sketch.drag_end
|
||||
sketch.draw.dimensioned
|
||||
sketch.edit_text
|
||||
sketch.ellipse
|
||||
sketch.export_dxf
|
||||
sketch.finish_auto_constrain
|
||||
sketch.fit_curves_to_section
|
||||
sketch.glyphs
|
||||
sketch.include_3d
|
||||
sketch.infinite
|
||||
sketch.insert_dxf
|
||||
sketch.insert_svg
|
||||
sketch.inspect
|
||||
sketch.intersect
|
||||
sketch.intersection_curve
|
||||
sketch.isoparametric_curve
|
||||
sketch.line
|
||||
sketch.line.midpoint
|
||||
sketch.line3d
|
||||
sketch.move3d
|
||||
sketch.move_point
|
||||
sketch.options
|
||||
sketch.point
|
||||
sketch.point3d
|
||||
sketch.polygon.circumscribed
|
||||
sketch.polygon.edge
|
||||
sketch.polygon.inscribed
|
||||
sketch.project
|
||||
sketch.project_to_surface
|
||||
sketch.rectangle.center
|
||||
sketch.rectangle.rounded
|
||||
sketch.rectangle.three_point
|
||||
sketch.rectangle.two_point
|
||||
sketch.redefine
|
||||
sketch.slot.arc_center
|
||||
sketch.slot.arc_three_point
|
||||
sketch.slot.center_point
|
||||
sketch.slot.center_to_center
|
||||
sketch.slot.overall
|
||||
sketch.snap
|
||||
sketch.solve
|
||||
sketch.spline.control_point
|
||||
sketch.spline.control_point_5
|
||||
sketch.spline.fit_point
|
||||
sketch.spline3d
|
||||
sketch.spun_profile
|
||||
sketch.text
|
||||
sketch.toggle_driven
|
||||
solid.align
|
||||
solid.chamfer
|
||||
solid.coil
|
||||
@@ -205,20 +119,11 @@ surface.thicken
|
||||
surface.trim
|
||||
surface.unstitch
|
||||
surface.untrim
|
||||
timeline.add
|
||||
timeline.copy
|
||||
timeline.export_script
|
||||
timeline.group
|
||||
timeline.move_many
|
||||
timeline.move_to_group
|
||||
timeline.paste
|
||||
timeline.redefine
|
||||
timeline.rename_group
|
||||
timeline.reorder
|
||||
timeline.reorder_many
|
||||
timeline.ungroup
|
||||
view.apply
|
||||
view.delete
|
||||
view.list
|
||||
view.rename
|
||||
view.save
|
||||
|
||||
+62
-9
@@ -397,8 +397,23 @@ fn polyline(verts: &[(Vec2, f64)], closed: bool, out: &mut Vec<Geom2>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write 2D geometry (millimetres) as an R12-style ASCII DXF.
|
||||
pub fn write_dxf(geom: &[Geom2]) -> String {
|
||||
/// Options for writing an R12-style ASCII DXF.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DxfWriteOptions {
|
||||
/// DXF $INSUNITS: 1 in, 2 ft, 4 mm (default), 5 cm, 6 m.
|
||||
pub insunits: i32,
|
||||
/// Default layer name (default "0").
|
||||
pub layer: String,
|
||||
}
|
||||
|
||||
impl Default for DxfWriteOptions {
|
||||
fn default() -> Self {
|
||||
DxfWriteOptions { insunits: 4, layer: "0".to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Write 2D geometry with per-entity layer names as an R12-style ASCII DXF.
|
||||
pub fn write_dxf_entities(entities: &[(&str, Geom2)], insunits: i32) -> String {
|
||||
let mut s = String::new();
|
||||
let mut w = |c: i32, v: &str| {
|
||||
s.push_str(&format!("{c}\n{v}\n"));
|
||||
@@ -406,22 +421,24 @@ pub fn write_dxf(geom: &[Geom2]) -> String {
|
||||
w(0, "SECTION");
|
||||
w(2, "HEADER");
|
||||
w(9, "$INSUNITS");
|
||||
w(70, "4");
|
||||
let insunits_str = insunits.to_string();
|
||||
w(70, &insunits_str);
|
||||
w(0, "ENDSEC");
|
||||
w(0, "SECTION");
|
||||
w(2, "ENTITIES");
|
||||
let f = |x: f64| format!("{x:.9}");
|
||||
for g in geom {
|
||||
for (layer, g) in entities {
|
||||
let lyr = if layer.trim().is_empty() { "0" } else { layer.trim() };
|
||||
match g {
|
||||
Geom2::Point(p) => {
|
||||
w(0, "POINT");
|
||||
w(8, "0");
|
||||
w(8, lyr);
|
||||
w(10, &f(p.x));
|
||||
w(20, &f(p.y));
|
||||
}
|
||||
Geom2::Line(a, b) => {
|
||||
w(0, "LINE");
|
||||
w(8, "0");
|
||||
w(8, lyr);
|
||||
w(10, &f(a.x));
|
||||
w(20, &f(a.y));
|
||||
w(11, &f(b.x));
|
||||
@@ -429,14 +446,14 @@ pub fn write_dxf(geom: &[Geom2]) -> String {
|
||||
}
|
||||
Geom2::Circle(c, r) => {
|
||||
w(0, "CIRCLE");
|
||||
w(8, "0");
|
||||
w(8, lyr);
|
||||
w(10, &f(c.x));
|
||||
w(20, &f(c.y));
|
||||
w(40, &f(*r));
|
||||
}
|
||||
Geom2::Arc { c, a, b } => {
|
||||
w(0, "ARC");
|
||||
w(8, "0");
|
||||
w(8, lyr);
|
||||
w(10, &f(c.x));
|
||||
w(20, &f(c.y));
|
||||
w(40, &f(c.dist(*a)));
|
||||
@@ -445,7 +462,7 @@ pub fn write_dxf(geom: &[Geom2]) -> String {
|
||||
}
|
||||
Geom2::Text { text, at, height, angle } => {
|
||||
w(0, "TEXT");
|
||||
w(8, "0");
|
||||
w(8, lyr);
|
||||
w(10, &f(at.x));
|
||||
w(20, &f(at.y));
|
||||
w(40, &f(*height));
|
||||
@@ -461,6 +478,18 @@ pub fn write_dxf(geom: &[Geom2]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Write 2D geometry as an R12-style ASCII DXF with options.
|
||||
pub fn write_dxf_opts(geom: &[Geom2], opts: &DxfWriteOptions) -> String {
|
||||
let layer = if opts.layer.trim().is_empty() { "0" } else { opts.layer.as_str() };
|
||||
let entities: Vec<(&str, Geom2)> = geom.iter().cloned().map(|g| (layer, g)).collect();
|
||||
write_dxf_entities(&entities, opts.insunits)
|
||||
}
|
||||
|
||||
/// Write 2D geometry (millimetres) as an R12-style ASCII DXF.
|
||||
pub fn write_dxf(geom: &[Geom2]) -> String {
|
||||
write_dxf_opts(geom, &DxfWriteOptions::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -643,4 +672,28 @@ mod tests {
|
||||
let g = read_dxf(&array).unwrap_or_default();
|
||||
assert!(g.is_empty(), "an empty block has no geometry to place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_dxf_opts_and_entities_round_trip() {
|
||||
let geom = vec![Geom2::Line(Vec2::ZERO, Vec2::new(25.4, 0.0)), Geom2::Circle(Vec2::new(12.7, 12.7), 6.35)];
|
||||
|
||||
// Default write_dxf: byte-identical check with write_dxf_opts default
|
||||
let d0 = write_dxf(&geom);
|
||||
let d1 = write_dxf_opts(&geom, &DxfWriteOptions::default());
|
||||
assert_eq!(d0, d1);
|
||||
assert!(d0.contains("\n8\n0\n"));
|
||||
assert!(d0.contains("\n70\n4\n"));
|
||||
|
||||
// Custom layer and inch units (insunits = 1)
|
||||
let opts = DxfWriteOptions { insunits: 1, layer: "CUT".to_string() };
|
||||
let d_inch = write_dxf_opts(&geom, &opts);
|
||||
assert!(d_inch.contains("\n8\nCUT\n"));
|
||||
assert!(d_inch.contains("\n70\n1\n"));
|
||||
|
||||
// Per-entity layers with write_dxf_entities
|
||||
let ents = vec![("OUTLINE", Geom2::Line(Vec2::ZERO, Vec2::new(10.0, 0.0))), ("CUT", Geom2::Circle(Vec2::new(5.0, 5.0), 2.0))];
|
||||
let d_roles = write_dxf_entities(&ents, 4);
|
||||
assert!(d_roles.contains("\n8\nOUTLINE\n"));
|
||||
assert!(d_roles.contains("\n8\nCUT\n"));
|
||||
}
|
||||
}
|
||||
|
||||
+157
-3
@@ -68,6 +68,12 @@ pub enum DxfShape {
|
||||
Polyline {
|
||||
points: Vec<Vec2>,
|
||||
},
|
||||
/// A lightweight polyline (AutoCAD `LWPOLYLINE`) with bulges for arc segments.
|
||||
/// Each entry in `verts` is `(position, bulge)`, where bulge is `tan(sweep / 4)`.
|
||||
LwPolyline {
|
||||
verts: Vec<(Vec2, f64)>,
|
||||
closed: bool,
|
||||
},
|
||||
Circle {
|
||||
c: Vec2,
|
||||
r: f64,
|
||||
@@ -153,6 +159,54 @@ pub fn loop_shapes(lp: &Loop2) -> Vec<DxfShape> {
|
||||
lp.segs.iter().map(seg_shape).collect()
|
||||
}
|
||||
|
||||
/// The closed loop as one AutoCAD `LWPOLYLINE` entity, with circular arc segments represented
|
||||
/// by their DXF bulge (tan of a quarter of the included angle). Free-form curves are sampled as lines.
|
||||
pub fn loop_lwpolyline_scaled(lp: &Loop2, scale: f64) -> DxfShape {
|
||||
let mut verts: Vec<(Vec2, f64)> = Vec::new();
|
||||
for s in &lp.segs {
|
||||
match *s {
|
||||
Seg2::Line { a, .. } => {
|
||||
verts.push((a * scale, 0.0));
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
if (sweep.abs() - TAU).abs() < 1e-9 {
|
||||
// Full circle: split into two 180° semicircles.
|
||||
let b = if sweep >= 0.0 { 1.0 } else { -1.0 };
|
||||
let p0 = (center + Vec2::from_angle(start) * radius) * scale;
|
||||
let p1 = (center + Vec2::from_angle(start + std::f64::consts::PI) * radius) * scale;
|
||||
verts.push((p0, b));
|
||||
verts.push((p1, b));
|
||||
} else if sweep.abs() > std::f64::consts::PI + 1e-9 {
|
||||
// Arc larger than 180°: split into two pieces.
|
||||
let half = sweep * 0.5;
|
||||
let b = (half / 4.0).tan();
|
||||
let p0 = (center + Vec2::from_angle(start) * radius) * scale;
|
||||
let p1 = (center + Vec2::from_angle(start + half) * radius) * scale;
|
||||
verts.push((p0, b));
|
||||
verts.push((p1, b));
|
||||
} else {
|
||||
let b = (sweep / 4.0).tan();
|
||||
let p0 = (center + Vec2::from_angle(start) * radius) * scale;
|
||||
verts.push((p0, b));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let poly = s.polyline(0.01);
|
||||
let count = poly.len().saturating_sub(1);
|
||||
for p in poly.iter().take(count) {
|
||||
verts.push((*p * scale, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DxfShape::LwPolyline { verts, closed: true }
|
||||
}
|
||||
|
||||
/// [`loop_lwpolyline_scaled`] at scale 1.0 (millimetres).
|
||||
pub fn loop_lwpolyline(lp: &Loop2) -> DxfShape {
|
||||
loop_lwpolyline_scaled(lp, 1.0)
|
||||
}
|
||||
|
||||
/// A parameter reduced to [0, 2π); one within 1e-12 of a full turn is 0.
|
||||
fn wrap(t: f64) -> f64 {
|
||||
let r = t.rem_euclid(TAU);
|
||||
@@ -221,6 +275,30 @@ impl DxfShape {
|
||||
match self {
|
||||
DxfShape::Line(a, b) => out.extend([*a, *b]),
|
||||
DxfShape::Polyline { points } => out.extend(points.iter().copied()),
|
||||
DxfShape::LwPolyline { verts, closed } => {
|
||||
for (i, (p0, b)) in verts.iter().enumerate() {
|
||||
out.push(*p0);
|
||||
if b.abs() > 1e-12 {
|
||||
let next_idx = if i + 1 < verts.len() {
|
||||
Some(i + 1)
|
||||
} else if *closed {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(next) = next_idx
|
||||
&& let Some((p1, _)) = verts.get(next)
|
||||
{
|
||||
let chord = *p1 - *p0;
|
||||
let mid = (*p0 + *p1) * 0.5;
|
||||
let arc_mid = mid + chord.perp() * (*b * 0.5);
|
||||
if arc_mid.x.is_finite() && arc_mid.y.is_finite() {
|
||||
out.push(arc_mid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DxfShape::Circle { c, r } => out.extend([*c - Vec2::new(*r, *r), *c + Vec2::new(*r, *r)]),
|
||||
DxfShape::Arc { c, r, a0, a1 } => {
|
||||
let sweep = (a1 - a0).rem_euclid(360.0);
|
||||
@@ -349,6 +427,11 @@ fn text_value(s: &str) -> Result<String> {
|
||||
/// (layer `0` is always there). A TEXT character beyond U+FFFF returns an error naming the
|
||||
/// character, its position, the entity and its layer; no drawing is returned on failure.
|
||||
pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<String> {
|
||||
write_dxf_drawing_units(layers, entities, 4)
|
||||
}
|
||||
|
||||
/// [`write_dxf_drawing`], with explicit DXF `$INSUNITS` (1 in, 2 ft, 4 mm, 5 cm, 6 m).
|
||||
pub fn write_dxf_drawing_units(layers: &[DxfLayer], entities: &[DxfEntity], insunits: i32) -> Result<String> {
|
||||
let mut pts = Vec::new();
|
||||
for e in entities {
|
||||
e.shape.sample(&mut pts);
|
||||
@@ -507,7 +590,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
for (index, e) in entities.iter().enumerate() {
|
||||
let kind = match &e.shape {
|
||||
DxfShape::Line(..) => "LINE",
|
||||
DxfShape::Polyline { .. } => "LWPOLYLINE",
|
||||
DxfShape::Polyline { .. } | DxfShape::LwPolyline { .. } => "LWPOLYLINE",
|
||||
DxfShape::Circle { .. } => "CIRCLE",
|
||||
DxfShape::Arc { .. } => "ARC",
|
||||
DxfShape::Ellipse { .. } => "ELLIPSE",
|
||||
@@ -531,6 +614,19 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
o.num(20, p.y);
|
||||
}
|
||||
}
|
||||
DxfShape::LwPolyline { verts, closed } => {
|
||||
o.code(100, "AcDbPolyline");
|
||||
o.code(90, verts.len());
|
||||
// Autodesk DXF reference: bit 1 closed, bit 128 (PLINEGEN) continues dash pattern.
|
||||
o.code(70, 128 + if *closed { 1 } else { 0 });
|
||||
for (p, b) in verts {
|
||||
o.num(10, p.x);
|
||||
o.num(20, p.y);
|
||||
if b.abs() > 1e-12 {
|
||||
o.num(42, *b);
|
||||
}
|
||||
}
|
||||
}
|
||||
DxfShape::Line(a, b) => {
|
||||
o.code(100, "AcDbLine");
|
||||
o.point(10, *a);
|
||||
@@ -632,9 +728,9 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
head.code(9, "$HANDSEED");
|
||||
head.code(5, format!("{:X}", o.next));
|
||||
head.code(9, "$INSUNITS");
|
||||
head.code(70, 4);
|
||||
head.code(70, insunits);
|
||||
head.code(9, "$MEASUREMENT");
|
||||
head.code(70, 1);
|
||||
head.code(70, if insunits == 1 || insunits == 2 { 0 } else { 1 });
|
||||
head.code(9, "$EXTMIN");
|
||||
head.point(10, lo);
|
||||
head.code(9, "$EXTMAX");
|
||||
@@ -1128,4 +1224,62 @@ mod tests {
|
||||
assert_eq!(&after("$EXTMIN")[..2], &[(10, "27.0".to_string()), (20, "6.0".to_string())]);
|
||||
assert_eq!(&after("$EXTMAX")[..2], &[(10, "30.0".to_string()), (20, "18.0".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lwpolyline_with_bulge_and_units_round_trip() {
|
||||
// Closed loop with a line and a 90° arc segment (bulge tan(pi / 8)).
|
||||
let sweep = FRAC_PI_2;
|
||||
let bulge = (sweep / 4.0).tan();
|
||||
let loop2 = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(0.0, 0.0) },
|
||||
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 10.0) },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: FRAC_PI_2, sweep: -FRAC_PI_2 },
|
||||
],
|
||||
};
|
||||
let shape = loop_lwpolyline(&loop2);
|
||||
let DxfShape::LwPolyline { ref verts, closed } = shape else { panic!("expected LwPolyline") };
|
||||
assert!(closed);
|
||||
assert_eq!(verts.len(), 3);
|
||||
// Third segment is clockwise (-90°), bulge is negative
|
||||
assert!((verts[2].1 - (-bulge)).abs() < 1e-12, "bulge was {}", verts[2].1);
|
||||
|
||||
// Write with inch units (insunits = 1)
|
||||
let dxf = write_dxf_drawing_units(
|
||||
&[DxfLayer::new("CUT", 4)],
|
||||
&[DxfEntity { layer: "CUT".into(), shape: loop_lwpolyline_scaled(&loop2, 1.0 / 25.4) }],
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(dxf.contains("LWPOLYLINE"));
|
||||
assert!(dxf.contains("\n 8\nCUT\n") || dxf.contains("\n8\nCUT\n"));
|
||||
assert!(dxf.contains("\n 70\n1\n") || dxf.contains("\n70\n1\n")); // $INSUNITS 1
|
||||
|
||||
// Read back via read_dxf: should scale from inches back to mm
|
||||
let geom = read_dxf(&dxf).unwrap();
|
||||
assert_eq!(geom.len(), 3);
|
||||
// Arcs and lines should be recovered in millimetres
|
||||
let Some((c, a, b)) = geom.iter().find_map(|g| match *g {
|
||||
Geom2::Arc { c, a, b } => Some((c, a, b)),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("arc not recovered");
|
||||
};
|
||||
assert!(c.dist(Vec2::ZERO) < 1e-6);
|
||||
assert!((c.dist(a) - 10.0).abs() < 1e-6);
|
||||
assert!((c.dist(b) - 10.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lwpolyline_open_sampling_does_not_wrap_around() {
|
||||
// Unclosed polyline with 2 vertices: (0, 0) and (10, 0), with a bulge on the last vertex.
|
||||
// For an unclosed polyline, the last vertex cannot form an arc segment to vertex 0.
|
||||
let shape = DxfShape::LwPolyline { verts: vec![(Vec2::new(0.0, 0.0), 0.0), (Vec2::new(10.0, 0.0), 1.0)], closed: false };
|
||||
let mut pts = Vec::new();
|
||||
shape.sample(&mut pts);
|
||||
// Only the two vertices should be sampled, not an arc chord back to (0, 0)
|
||||
assert_eq!(pts.len(), 2);
|
||||
assert_eq!(pts[0], Vec2::new(0.0, 0.0));
|
||||
assert_eq!(pts[1], Vec2::new(10.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,11 @@ mod threemf;
|
||||
pub mod urdf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
|
||||
pub use dxf::{DxfWriteOptions, read_dxf, write_dxf, write_dxf_entities, write_dxf_opts};
|
||||
pub use dxf_out::{
|
||||
DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_lwpolyline, loop_lwpolyline_scaled, loop_shapes, seg_shape, write_dxf_drawing,
|
||||
write_dxf_drawing_units,
|
||||
};
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, PREVIOUS_APP_IDS, adopt_legacy_dir, legacy_flatpak_paths};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
|
||||
@@ -161,7 +161,16 @@ fn smlib_body(record: &solidcore_record::brep::Brep) -> std::result::Result<(Bod
|
||||
let (body, body_order) = crate::native::body_from_smlib_ordered(&brep).map_err(failed)?;
|
||||
// SMLib's face i is the record's `smlib_order` face and the body's `body_order` face (an
|
||||
// empty order is the same one).
|
||||
let (Some(smlib_order), Some(body_order)) = (smlib_order, body_order) else { return Err(renumbered()) };
|
||||
let Some(body_order) = body_order else { return Err(renumbered()) };
|
||||
// SMLib's export may number the edges and vertices otherwise than the record (a surface model
|
||||
// whose open shell uses faces against their surfaces beside closed shells: SMLib walks those
|
||||
// faces as their surfaces face). An import still lists SMLib's faces in the record's order;
|
||||
// that is taken where each face is checked to be the record's face it would be.
|
||||
let smlib_order = match smlib_order {
|
||||
Some(order) => order,
|
||||
None if faces_in_record_order(record, body.record(), &body_order.faces) => Default::default(),
|
||||
None => return Err(renumbered()),
|
||||
};
|
||||
let at = |order: &[usize], i: usize| if order.is_empty() { Some(i) } else { order.get(i).copied() };
|
||||
let faces = brep.counts().map_err(|e| failed(KernelError::Failed(format!("SMLib: {e}"))))?[0];
|
||||
let mut of_record = vec![usize::MAX; body.face_count()];
|
||||
@@ -177,6 +186,44 @@ fn smlib_body(record: &solidcore_record::brep::Brep) -> std::result::Result<(Bod
|
||||
Ok((body, of_record))
|
||||
}
|
||||
|
||||
/// Whether SMLib's face `i` of a body made from `record` (the body's face `body_order[i]`, or `i`
|
||||
/// for an empty order) is the record's face `i`, face by face: the same kind of surface, the same
|
||||
/// loops of the same lengths, each loop through the same corners (within the vertices' and the
|
||||
/// records' tolerances, whichever way and from wherever SMLib walks it).
|
||||
fn faces_in_record_order(record: &solidcore_record::brep::Brep, body: &solidcore_record::brep::Brep, body_order: &[usize]) -> bool {
|
||||
use solidcore_record::brep::{Brep, Loop};
|
||||
if record.faces.len() != body.faces.len() || !(body_order.is_empty() || body_order.len() == body.faces.len()) {
|
||||
return false;
|
||||
}
|
||||
// Each loop's corners: the start of each of its edges as the loop walks them, with its tolerance.
|
||||
let corners = |b: &Brep, l: &Loop| -> Option<Vec<([f64; 3], f64)>> {
|
||||
let vertex = |v: u32| b.vertices.get(usize::try_from(v).ok()?).map(|x| (x.point, x.tolerance));
|
||||
match l {
|
||||
Loop::Vertex { vertex: v } => Some(vec![vertex(*v)?]),
|
||||
Loop::Edges { edges } => edges
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let edge = b.edges.get(usize::try_from(e.edge).ok()?)?;
|
||||
vertex(if e.forward { edge.vertices[0] } else { edge.vertices[1] })
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
};
|
||||
let slack = record.tolerance + body.tolerance;
|
||||
(0..record.faces.len()).all(|i| {
|
||||
let at = if body_order.is_empty() { Some(i) } else { body_order.get(i).copied() };
|
||||
let (Some(a), Some(b)) = (record.faces.get(i), at.and_then(|k| body.faces.get(k))) else { return false };
|
||||
if std::mem::discriminant(&a.surface) != std::mem::discriminant(&b.surface) || a.loops.len() != b.loops.len() {
|
||||
return false;
|
||||
}
|
||||
a.loops.iter().zip(&b.loops).all(|(x, y)| {
|
||||
let (Some(p), Some(q)) = (corners(record, x), corners(body, y)) else { return false };
|
||||
p.len() == q.len()
|
||||
&& p.iter().all(|(u, tu)| q.iter().any(|(w, tw)| (0..3).map(|k| (u[k] - w[k]).powi(2)).sum::<f64>().sqrt() <= tu + tw + slack))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The face of its one operand each face of `body` is, from its history.
|
||||
fn kept_faces(body: &Body) -> Option<Vec<usize>> {
|
||||
let h = body.history()?;
|
||||
|
||||
@@ -662,6 +662,69 @@ fn a_surface_models_closed_shell_beside_a_sheet_is_a_solid_of_its_own() {
|
||||
assert!(imp.bodies.iter().all(|b| b.color == Some([1.0, 0.5, 0.0])));
|
||||
}
|
||||
|
||||
/// A surface model whose shells are all OPEN_SHELLs, one of them closed all the same (a unit
|
||||
/// cube) beside a sheet of two faces, the first used against its plane (as SolidWorks 2011 wrote
|
||||
/// a vendor part): SMLib's export walks that face as its plane faces and numbers the edges and
|
||||
/// vertices otherwise than the reader, its faces still in the reader's order. The model comes in
|
||||
/// as one body of all its faces, each the file's face it was; it once came in as nothing ("SMLib
|
||||
/// renumbered the solid's faces"), and the file with it.
|
||||
#[test]
|
||||
fn a_surface_model_of_open_shells_one_closed_beside_a_turned_sheet_imports() {
|
||||
let mut t = String::from(HEAD);
|
||||
t += &cube_entities().replace("#100=CLOSED_SHELL", "#100=OPEN_SHELL");
|
||||
// Two unit squares at z = 5 sharing the edge x = 1, both facing +z: the first on a plane
|
||||
// facing -z used against it, the second on a plane facing +z.
|
||||
let corners = [(0, 0), (1, 0), (2, 0), (2, 1), (1, 1), (0, 1)];
|
||||
for (i, (x, y)) in corners.iter().enumerate() {
|
||||
t += &format!("#{}=CARTESIAN_POINT('',({x}.,{y}.,5.));\n#{}=VERTEX_POINT('',#{});\n", 800 + i, 810 + i, 800 + i);
|
||||
}
|
||||
let edges = [(0, 1), (1, 4), (4, 5), (5, 0), (1, 2), (2, 3), (3, 4)];
|
||||
for (k, (a, b)) in edges.iter().enumerate() {
|
||||
let (p, q) = (corners[*a], corners[*b]);
|
||||
t += &format!("#{}=DIRECTION('',({}.,{}.,0.));\n#{}=VECTOR('',#{},1.);\n", 900 + 2 * k, q.0 - p.0, q.1 - p.1, 901 + 2 * k, 900 + 2 * k);
|
||||
t += &format!(
|
||||
"#{}=LINE('',#{},#{});\n#{}=EDGE_CURVE('',#{},#{},#{},.T.);\n",
|
||||
820 + k,
|
||||
800 + a,
|
||||
901 + 2 * k,
|
||||
830 + k,
|
||||
810 + a,
|
||||
810 + b,
|
||||
820 + k
|
||||
);
|
||||
}
|
||||
let faces: [(&[(usize, bool)], i32, &str); 2] =
|
||||
[(&[(0, true), (1, true), (2, true), (3, true)], -1, ".F."), (&[(4, true), (5, true), (6, true), (1, false)], 1, ".T.")];
|
||||
for (f, (lp, nz, sense)) in faces.iter().enumerate() {
|
||||
let base = 940 + 20 * f;
|
||||
let oes: Vec<String> = lp
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, (e, fwd))| {
|
||||
t += &format!("#{}=ORIENTED_EDGE('',*,*,#{},{});\n", base + j, 830 + e, if *fwd { ".T." } else { ".F." });
|
||||
format!("#{}", base + j)
|
||||
})
|
||||
.collect();
|
||||
t += &format!("#{}=EDGE_LOOP('',({}));\n#{}=FACE_OUTER_BOUND('',#{},.T.);\n", base + 5, oes.join(","), base + 6, base + 5);
|
||||
t += &format!("#{}=CARTESIAN_POINT('',(0.,0.,5.));\n#{}=DIRECTION('',(0.,0.,{nz}.));\n", base + 7, base + 8);
|
||||
t += &format!("#{}=AXIS2_PLACEMENT_3D('',#{},#{},$);\n#{}=PLANE('',#{});\n", base + 9, base + 7, base + 8, base + 10, base + 9);
|
||||
t += &format!("#{}=ADVANCED_FACE('',(#{}),#{},{sense});\n", base + 11, base + 6, base + 10);
|
||||
}
|
||||
t += "#130=OPEN_SHELL('',(#951,#971));\n#110=SHELL_BASED_SURFACE_MODEL('Bracket',(#130,#100));\n";
|
||||
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
|
||||
t += "#60=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#110),#20);\n";
|
||||
t += TAIL;
|
||||
let imp = step_import(&t).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
let b = &imp.bodies[0];
|
||||
assert_eq!((b.source_item, b.file_faces, b.body.face_count()), (110, 8, 8), "{:?}", imp.warnings);
|
||||
assert!(b.refused.is_none(), "{:?}", b.refused);
|
||||
// Each face is the file's face it was: the sheet's first, then the cube's.
|
||||
assert_eq!(b.face_origin, [951, 971, 411, 431, 451, 471, 491, 511]);
|
||||
let m = measure(&b.body).unwrap();
|
||||
assert!(rel(m.area, 8.0) < 1e-9, "area {}", m.area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_written_part_in_inches_with_colour() {
|
||||
let mut t = String::from(HEAD);
|
||||
|
||||
@@ -3150,10 +3150,25 @@ fn entry_row(
|
||||
let patterns_open = pattern_key.is_some_and(|key| is_open(app, key, start_open));
|
||||
let rect = row_rect(ui);
|
||||
let revealed = folder == "bodies" && body_reveal(app).is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b));
|
||||
if !ui.is_rect_visible(rect) && !patterns_open && !revealed {
|
||||
// Features under bodies (#11945): the body row folds open on the features that built it.
|
||||
let built_key = (folder == "bodies"
|
||||
&& app.preferences.browser_body_features
|
||||
&& !crate::render_workspace::active(app)
|
||||
&& matches!(e.sel, Some(Sel::Body { .. })))
|
||||
.then(|| format!("{}/features", e.row_key));
|
||||
let built_open = built_key.as_deref().is_some_and(|key| is_open(app, key, false));
|
||||
if !ui.is_rect_visible(rect) && !patterns_open && !revealed && !built_open {
|
||||
ui.allocate_space(rect.size());
|
||||
return;
|
||||
}
|
||||
let built: Vec<u64> = match &built_key {
|
||||
Some(_) => {
|
||||
prepare_model(app);
|
||||
app.tree.model.as_mut().map(|model| model.body_features(&app.session, &e.key)).unwrap_or_default()
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let built_key = built_key.filter(|_| !built.is_empty());
|
||||
let (selected, mark) = lit_row(acts.lit.entry(app, e));
|
||||
let visible = e.is_visible(app);
|
||||
let locked = match &e.sel {
|
||||
@@ -3173,7 +3188,7 @@ fn entry_row(
|
||||
ui.scope_id().with(("item", &e.row_key)),
|
||||
&Row {
|
||||
depth,
|
||||
fold: pattern_key.map(|_| patterns_open),
|
||||
fold: pattern_key.map(|_| patterns_open).or(built_key.as_ref().map(|_| built_open)),
|
||||
eye: Some(visible),
|
||||
icon: e.icon,
|
||||
kind_tip: e.tip,
|
||||
@@ -3205,6 +3220,10 @@ fn entry_row(
|
||||
&& let Some(key) = pattern_key
|
||||
{
|
||||
toggle(app, key, start_open);
|
||||
} else if r.fold
|
||||
&& let Some(key) = &built_key
|
||||
{
|
||||
toggle(app, key, false);
|
||||
}
|
||||
if folder == "bodies"
|
||||
&& body_reveal(app).is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b))
|
||||
@@ -3276,6 +3295,9 @@ fn entry_row(
|
||||
if patterns_open && let Some(Sel::Feature { id }) = &e.sel {
|
||||
crate::sketch_pattern_tree::rows(app, ui, *id, depth + 1, "browser", path.map(|path| (comp, path)), !visible || parent_hidden);
|
||||
}
|
||||
if built_open && built_key.is_some() {
|
||||
features::body_rows(app, ui, acts, comp, path.unwrap_or_default(), &e.row_key, &built, depth + 1);
|
||||
}
|
||||
let Some(resp) = &r.resp else { return };
|
||||
// Drag: the selected items of this folder when this one is selected, else just this one.
|
||||
if resp.drag_started()
|
||||
|
||||
@@ -137,7 +137,7 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path:
|
||||
.filter_map(|(i, f)| (f.component == component && (compact || !matches!(f.kind, FeatureKind::Sketch { .. }))).then_some(i))
|
||||
.collect(),
|
||||
};
|
||||
let here = Here { component, path, scope: &scope };
|
||||
let here = Here { component, path, scope: &scope, body_row: None };
|
||||
// The feature that made what is under the cursor or picked in the viewport, as the history
|
||||
// lights it, and in the history layout what a selected history row lights.
|
||||
let from_view = [crate::timeline::hovered_feature(app), crate::timeline::picked_feature(app)];
|
||||
@@ -206,6 +206,32 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path:
|
||||
drag_rows(app, ui, &plan, drag_id, dragged, &drops, resp.rect.center().y);
|
||||
}
|
||||
|
||||
/// The features that built a body, one level in under its row (#11945): the feature rows the
|
||||
/// Features folder draws, without dragging, each lit as there when the view lights it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn body_rows(
|
||||
app: &mut SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
acts: &mut Actions,
|
||||
component: u64,
|
||||
path: &[u64],
|
||||
body_row: &str,
|
||||
features: &[u64],
|
||||
depth: usize,
|
||||
) {
|
||||
let scope = if path.is_empty() { format!("c{component}") } else { occurrence_key(path) };
|
||||
let here = Here { component, path, scope: &scope, body_row: Some(body_row) };
|
||||
let from_view = [crate::timeline::hovered_feature(app), crate::timeline::picked_feature(app)];
|
||||
// A feature that built several bodies is listed under each: its rows need ids of their own.
|
||||
ui.push_id(("body_features", body_row), |ui| {
|
||||
for id in features {
|
||||
let Some(index) = app.session.doc.features.iter().position(|f| f.id == *id) else { continue };
|
||||
let mark = if from_view.contains(&Some(*id)) { Mark::Here } else { Mark::None };
|
||||
let _ = feature_row(app, ui, acts, &here, index, depth, None, mark, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A place in the browser's History where dragged rows can land: its height, the timeline
|
||||
/// position they move to and the folder they join (`None`: the timeline root).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -220,6 +246,9 @@ struct Here<'a> {
|
||||
component: u64,
|
||||
path: &'a [u64],
|
||||
scope: &'a str,
|
||||
/// The body row a feature row is listed under (#11945): its row-order key is then its own,
|
||||
/// `<body row>/s:<id>`, so a feature listed under two bodies is a range end at each row.
|
||||
body_row: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// The component's history rows, `indices` its features' timeline positions: with Sketches:
|
||||
@@ -277,7 +306,8 @@ fn feature_row(
|
||||
// stays the face or edge.
|
||||
let by_geometry = !app.session.selection.contains(&Sel::Feature { id }) && crate::timeline::picked_feature(app) == Some(id);
|
||||
let selected = app.session.selection.contains(&Sel::Feature { id }) || by_geometry;
|
||||
app.tree.remember(&format!("f:{id}"));
|
||||
let order_key = here.body_row.map_or_else(|| format!("f:{id}"), |row| format!("{row}/s:{id}"));
|
||||
app.tree.remember(&order_key);
|
||||
let action = sketch.map(|_| ("sketch", "Edit Profile Sketch"));
|
||||
// A sketch row has the eye its Sketches folder row has. A feature's eye shows and hides the
|
||||
// bodies it made (#5099); a component listed on its own stands for all of its placements.
|
||||
@@ -343,7 +373,7 @@ fn feature_row(
|
||||
// active before comes back when the editor ends (#12720).
|
||||
app.edit_feature_in(id, Some((component, path)));
|
||||
} else if r.clicked {
|
||||
click_select(app, ui, format!("f:{id}"), Some(Sel::Feature { id }));
|
||||
click_select(app, ui, order_key, Some(Sel::Feature { id }));
|
||||
}
|
||||
if let Some(at) = r.secondary {
|
||||
acts.menu = Some((at, if is_sketch { Target::Sketch { id } } else { Target::Feature { id } }));
|
||||
@@ -1342,4 +1372,81 @@ mod tests {
|
||||
assert_eq!(h.app.session.doc.marker, Some(2), "the marker drag still rolls back");
|
||||
assert_eq!(order(&h), [ids[2], ids[0], ids[1]], "rolling back moves no feature");
|
||||
}
|
||||
|
||||
/// #11945: with Features under bodies on, a body row folds open on the features that built it,
|
||||
/// in timeline order and only those; a click on one selects it. Off, the default, body rows
|
||||
/// have no fold arrow and list nothing.
|
||||
#[test]
|
||||
fn body_rows_fold_open_on_the_features_that_built_them() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":20,"width":20,"height":10,"name":"Base","body_name":"Plate"})).unwrap();
|
||||
h.app
|
||||
.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[50,0,0],"operation":"new","name":"Block","body_name":"Other"}))
|
||||
.unwrap();
|
||||
let round =
|
||||
h.app.run("solid.fillet", json!({"body":"Plate","edges":[[10,0,10]],"radius":1,"name":"Round"})).unwrap()["feature"].as_u64().unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(fold_of("Plate"), None, "off by default: no fold arrow on a body");
|
||||
assert_eq!(count("Round"), 0);
|
||||
|
||||
assert_eq!(h.call("ui.prefs", json!({"browser_body_features": true}))["ok"], true);
|
||||
h.frames(2);
|
||||
assert_eq!(fold_of("Plate"), Some(false), "the body starts folded");
|
||||
assert_eq!(count("Round"), 0);
|
||||
h.step(&json!({"click": {"handle": "fold:Plate"}})).unwrap();
|
||||
h.frames(2);
|
||||
let rows = drawn_rows();
|
||||
let top = |label: &str| rows.iter().find(|(l, ..)| l == label).map(|row| row.1.top()).unwrap();
|
||||
assert_eq!((count("Base"), count("Round"), count("Block")), (1, 1, 0), "only what built Plate");
|
||||
assert!(top("Plate") < top("Base") && top("Base") < top("Round") && top("Round") < top("Other"), "under Plate, in timeline order");
|
||||
|
||||
let at = rows.iter().find(|(l, ..)| l == "Round").map(|row| row.1.center()).unwrap();
|
||||
h.step(&json!({"click": [at.x, at.y]})).unwrap();
|
||||
assert_eq!(h.app.session.selection, [Sel::Feature { id: round }]);
|
||||
|
||||
h.call("ui.prefs", json!({"browser_body_features": false}));
|
||||
h.frames(2);
|
||||
assert_eq!((fold_of("Plate"), count("Round")), (None, 0), "off again: as before");
|
||||
}
|
||||
|
||||
/// #11945: a feature that changed two bodies is listed under each; Shift-clicking its second
|
||||
/// row takes the range from the anchor to that row, not to its first row under the other body.
|
||||
#[test]
|
||||
fn a_feature_under_two_bodies_is_a_range_end_at_each_row() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":20,"width":20,"height":10,"name":"Base","body_name":"Plate"})).unwrap();
|
||||
let block = h
|
||||
.app
|
||||
.run("solid.box", json!({"length":5,"width":5,"height":10,"corner":[50,0,0],"operation":"new","name":"Block","body_name":"Other"}))
|
||||
.unwrap()["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
// A groove along the top of both bodies, splitting neither.
|
||||
let groove = h
|
||||
.app
|
||||
.run(
|
||||
"solid.box",
|
||||
json!({"length":70,"width":2,"height":10,"corner":[-5,1,5],"operation":"cut","targets":["Plate","Other"],"name":"Groove"}),
|
||||
)
|
||||
.unwrap()["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(h.call("ui.prefs", json!({"browser_body_features": true}))["ok"], true);
|
||||
h.frames(2);
|
||||
for body in ["Plate", "Other"] {
|
||||
h.step(&json!({"click": {"handle": format!("fold:{body}")}})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
assert_eq!(count("Groove"), 2, "listed under both bodies");
|
||||
let rows = drawn_rows();
|
||||
let at = |label: &str, last: bool| {
|
||||
let mut hits = rows.iter().filter(|(l, ..)| l == label);
|
||||
let hit = if last { hits.next_back() } else { hits.next() };
|
||||
hit.map(|row| row.1.center()).unwrap()
|
||||
};
|
||||
let (block_at, groove_at) = (at("Block", false), at("Groove", true));
|
||||
h.step(&json!({"click": [block_at.x, block_at.y]})).unwrap();
|
||||
h.step(&json!({"click": [groove_at.x, groove_at.y], "shift": true})).unwrap();
|
||||
assert_eq!(h.app.session.selection, [Sel::Feature { id: block }, Sel::Feature { id: groove }], "only the rows under Other");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(super) struct Model {
|
||||
placements: HashMap<Vec<u64>, Option<Arc<OccurrencePlacement>>>,
|
||||
/// Each placed body's placement path, by the body's world name (built on first use).
|
||||
body_paths: Option<HashMap<String, Vec<u64>>>,
|
||||
/// The features that built each body, by its model name (worked out on first use, #11945).
|
||||
body_features: Option<BTreeMap<String, Vec<u64>>>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
@@ -67,9 +69,16 @@ impl Model {
|
||||
children,
|
||||
placements: HashMap::new(),
|
||||
body_paths: None,
|
||||
body_features: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The features that built the body with this model name, in timeline order
|
||||
/// ([`Session::body_features`]), worked out once per model.
|
||||
pub fn body_features(&mut self, session: &Session, body: &str) -> Vec<u64> {
|
||||
self.body_features.get_or_insert_with(|| session.body_features()).get(body).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn matches(&self, session: &Session) -> bool {
|
||||
Arc::ptr_eq(&self.doc, &session.doc) && Arc::ptr_eq(&self.state, &session.model.state()) && self.pending_moves == session.pending_moves
|
||||
}
|
||||
|
||||
@@ -615,12 +615,13 @@ impl DialogComputation {
|
||||
}
|
||||
}
|
||||
|
||||
fn adopt_dialog(app: &mut SolveApp, prepared: solidcraft_engine::ComputedCommands) -> Result<(), String> {
|
||||
fn adopt_dialog(app: &mut SolveApp, prepared: solidcraft_engine::ComputedCommands, d: &Dialog) -> Result<(), String> {
|
||||
if app.file_load.busy() || app.file_picker_busy() {
|
||||
return Err("a file operation is in progress".into());
|
||||
}
|
||||
let replies = app.session.accept_commands(prepared).map_err(|e| e.to_string())?;
|
||||
app.big_operation = true;
|
||||
let made = crate::hole_place::holes_made(&replies);
|
||||
for (id, reply) in replies {
|
||||
let stop = (id == "joint.drive").then(|| crate::dialogs_assembly::drive_stop(&reply)).flatten();
|
||||
app.command_completed(&id, Ok(reply))?;
|
||||
@@ -628,6 +629,7 @@ fn adopt_dialog(app: &mut SolveApp, prepared: solidcraft_engine::ComputedCommand
|
||||
app.set_status(why, false);
|
||||
}
|
||||
}
|
||||
crate::hole_place::group_made(app, d, made);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4138,7 +4140,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Err(error) => d.refuse(app, error),
|
||||
}
|
||||
} else {
|
||||
let run = result.and_then(|prepared| adopt_dialog(app, prepared));
|
||||
let run = result.and_then(|prepared| adopt_dialog(app, prepared, &d));
|
||||
match run {
|
||||
Ok(()) => {
|
||||
cancel = !apply_again;
|
||||
@@ -4888,16 +4890,18 @@ pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
}
|
||||
if d.previews() {
|
||||
let prepared = DialogComputation::new(app, d)?.compute()?;
|
||||
return adopt_dialog(app, prepared);
|
||||
return adopt_dialog(app, prepared, d);
|
||||
}
|
||||
let commands = apply_commands(app, d)?;
|
||||
if let Some((_, marker)) = d.editing {
|
||||
// Record the actual pre-edit timeline state in the command's undo snapshot.
|
||||
roll_marker(app, marker)?;
|
||||
}
|
||||
let mut replies = Vec::new();
|
||||
for (cmd, params) in commands {
|
||||
match app.run(&cmd, params) {
|
||||
Ok(reply) => {
|
||||
replies.push((cmd.clone(), reply.clone()));
|
||||
// A drive that stopped short says where, and why.
|
||||
if cmd == "joint.drive"
|
||||
&& let Some(why) = crate::dialogs_assembly::drive_stop(&reply)
|
||||
@@ -4914,6 +4918,8 @@ pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let made = crate::hole_place::holes_made(&replies);
|
||||
crate::hole_place::group_made(app, d, made);
|
||||
if let Kind::Section { offset, flip, hatch } = &d.kind
|
||||
&& let Some(cut) = app.session.section
|
||||
{
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
//!
|
||||
//! Create Hole › Several holes on one face (`hole_several`): a pick elsewhere on a face that already
|
||||
//! has a hole adds another hole there (rather than moving it), and a pick on a hole takes it out.
|
||||
//! Create Hole › Group holes made together (`hole_group`): the holes one Hole dialog makes, two or
|
||||
//! more, go into a feature folder in History, closed at first.
|
||||
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use solidcraft_engine::geom::prelude::*;
|
||||
@@ -349,6 +351,42 @@ pub(crate) fn pick_several(app: &SolveApp, d: &mut Dialog, sel: &Sel) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The features the Hole commands among `replies` made, in order.
|
||||
pub(crate) fn holes_made(replies: &[(String, serde_json::Value)]) -> Vec<u64> {
|
||||
replies.iter().filter(|(id, _)| id == "solid.hole").filter_map(|(_, reply)| reply.get("feature").and_then(serde_json::Value::as_u64)).collect()
|
||||
}
|
||||
|
||||
/// With Group holes made together on (Preferences), the holes one Hole dialog made (two or more)
|
||||
/// go into a feature folder in History, closed at first: opened, each hole is edited on its own.
|
||||
/// The folder sits where the holes went (inside the folder they were added to, if any).
|
||||
pub(crate) fn group_made(app: &mut SolveApp, d: &Dialog, made: Vec<u64>) {
|
||||
if !app.preferences.hole_group || d.editing.is_some() || !matches!(d.kind, Kind::Hole { .. }) || made.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let parent = made.first().and_then(|first| app.session.doc.timeline_groups.iter().find(|g| g.features.contains(first))).map(|g| g.id);
|
||||
let made_count = made.len();
|
||||
match app.run("timeline.group", serde_json::json!({"name": "Holes", "features": made, "parent": parent})) {
|
||||
Ok(reply) => {
|
||||
if let Some(id) = reply.get("group").and_then(serde_json::Value::as_u64) {
|
||||
crate::timeline_folders::set_open(app, id, false);
|
||||
}
|
||||
// Keep one Undo step for all holes from this dialog, with folder creation after it.
|
||||
let undo = &mut app.session.undo;
|
||||
let first_hole =
|
||||
undo.len().checked_sub(made_count + 1).filter(|&first| undo[first..undo.len() - 1].iter().all(|step| step.label == "Hole"));
|
||||
if let Some(first_hole) = first_hole {
|
||||
let folder_step = undo.pop();
|
||||
if let Some(folder_step) = folder_step {
|
||||
drop(undo.drain(first_hole + 1..));
|
||||
undo.push(folder_step);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The holes stand; only the folder is missing.
|
||||
Err(error) => app.set_status(error, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The hovered face's snap marks, and the face places' handles when holes move by dragging.
|
||||
/// Called after the canvas draws the dialog's arrows, so a press on a hole's centre takes the
|
||||
/// hole, not the depth arrow.
|
||||
|
||||
@@ -288,7 +288,7 @@ fn editing_a_hole_drags_it_to_a_new_place() {
|
||||
fn snaps_and_moves_are_off_by_default_and_picks_behave_as_before() {
|
||||
let mut h = block_as_installed();
|
||||
assert!(
|
||||
!h.app.preferences.hole_snaps && !h.app.preferences.hole_moves && !h.app.preferences.hole_several,
|
||||
!h.app.preferences.hole_snaps && !h.app.preferences.hole_moves && !h.app.preferences.hole_several && !h.app.preferences.hole_group,
|
||||
"a new installation places holes as it always did"
|
||||
);
|
||||
h.step(&json!({"start": "solid.hole"})).unwrap();
|
||||
@@ -622,3 +622,54 @@ fn edit_holes_on_a_folder_changes_what_was_edited_in_every_hole_and_keeps_the_re
|
||||
assert!(crate::hole_folder::holes(&h.app, mixed).is_none());
|
||||
assert!(h.step(&json!({"call": "ui.editFolder", "params": {"folder": "Mixed"}})).is_err());
|
||||
}
|
||||
|
||||
/// Single holes on the block's top and front faces, made with OK; the new Hole features' ids.
|
||||
fn holes_on_two_faces(h: &mut Harness) -> Vec<u64> {
|
||||
let before = h.app.session.doc.features.len();
|
||||
h.step(&json!({"start": "solid.hole"})).unwrap();
|
||||
for p in [Vec3::new(30.0, 20.0, 20.0), Vec3::new(25.0, 0.0, 10.0)] {
|
||||
let at = screen(h, p);
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
}
|
||||
h.step(&json!({"click": {"handle": "dialog:ok"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error));
|
||||
h.app.session.doc.features[before..].iter().filter(|f| matches!(f.kind, FeatureKind::Hole { .. })).map(|f| f.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holes_made_together_share_a_closed_folder_when_preferences_say_so() {
|
||||
let mut h = block_as_installed();
|
||||
h.step(&json!({"call": "ui.view", "params": {"view": "iso"}})).unwrap();
|
||||
h.settle();
|
||||
// Off (the default): each hole is its own entry in History, as before.
|
||||
assert_eq!(holes_on_two_faces(&mut h).len(), 2);
|
||||
assert!(h.app.session.doc.timeline_groups.is_empty(), "no folder");
|
||||
// On: the two holes go into one folder, closed at first.
|
||||
crate::prefs::set(&mut h.app, &json!({"hole_group": true})).unwrap();
|
||||
let made = holes_on_two_faces(&mut h);
|
||||
assert_eq!(made.len(), 2);
|
||||
let [folder] = h.app.session.doc.timeline_groups.as_slice() else { panic!("one folder: {:?}", h.app.session.doc.timeline_groups) };
|
||||
assert_eq!((folder.name.as_str(), &folder.features, folder.parent), ("Holes", &made, None));
|
||||
assert!(!crate::timeline_folders::is_open(&h.app, folder.id, true), "closed at first, in both Histories");
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(h.app.session.doc.timeline_groups.is_empty(), "first Undo removes the folder");
|
||||
assert_eq!(h.app.session.doc.features.iter().filter(|f| made.contains(&f.id)).count(), 2, "holes remain");
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.features.iter().filter(|f| made.contains(&f.id)).count(), 0, "second Undo removes both holes");
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
// One hole alone, or a hole edited, stays out of any new folder.
|
||||
h.step(&json!({"start": "solid.hole"})).unwrap();
|
||||
let at = screen(&mut h, Vec3::new(5.0, 25.0, 20.0));
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
h.step(&json!({"click": {"handle": "dialog:ok"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error));
|
||||
h.step(&json!({"call": "ui.editFeature", "params": {"feature": made[0]}})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click": {"handle": "dialog:ok"}})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(h.app.session.doc.timeline_groups.len(), 1, "still the one folder");
|
||||
assert_eq!(h.app.session.doc.timeline_groups[0].features, made, "holding the same holes");
|
||||
}
|
||||
|
||||
@@ -1378,6 +1378,9 @@ preference_settings! {
|
||||
/// A Play History button in the History controls, between Previous and Next (#3688). Off
|
||||
/// gives the four controls History had before; right-clicking the controls toggles it too.
|
||||
history_play_button: bool = true, PAGE_LAYOUT;
|
||||
/// A History folder whose features are all of one kind (Holes, say) shows their icon instead of
|
||||
/// the folder icon, in both Histories. Off by default: every folder shows the folder icon.
|
||||
history_folder_kind_icon: bool = false, PAGE_LAYOUT;
|
||||
/// On, two or more joints show as one button with their count in the History, and a click
|
||||
/// shows each one (#8884). Off, the default, shows each joint as the History always has.
|
||||
history_group_joints: bool = false, PAGE_LAYOUT;
|
||||
@@ -1423,6 +1426,9 @@ preference_settings! {
|
||||
/// Every component placement in the browser starts open, as before #9274, instead of closed
|
||||
/// until the user opens it. Off by default.
|
||||
browser_components_open: bool = false, PAGE_LAYOUT;
|
||||
/// Each body row in the Browser folds open on the timeline features that built it, in
|
||||
/// timeline order (#11945). Off by default: body rows have no fold arrow, as before.
|
||||
browser_body_features: bool = false, PAGE_LAYOUT;
|
||||
/// Folders in the Browser start closed, and placements too whatever "Components start open"
|
||||
/// says, and they open only on request (#8768): with their arrow, Expand All or Find in
|
||||
/// Browser, which opens just the rows down to its body and closes again the ones the last find
|
||||
@@ -1490,6 +1496,9 @@ preference_settings! {
|
||||
/// and a pick on a hole takes it out (`hole_place`). Off by default: a face takes one Single
|
||||
/// hole, and a second pick on it takes the hole out (or moves it, with `hole_moves`).
|
||||
hole_several: bool = false, PAGE_TOOLS => check_id("prefs.design_tools.hole_several");
|
||||
/// The holes one Hole command makes (two or more) go into a feature folder in History, closed
|
||||
/// at first (`hole_place`). Off by default: each hole is its own entry in History, as before.
|
||||
hole_group: bool = false, PAGE_TOOLS => check_id("prefs.design_tools.hole_group");
|
||||
/// The browser's Origin folder starts hidden in designs that don't keep its eye themselves.
|
||||
hide_origin: bool = false, PAGE_DESIGN => check_tr("ui.hide_origin_by_default");
|
||||
/// Handles on the preview of a feature placed on a face (Rest, Boss, Snap Fit): a marker that
|
||||
@@ -6385,6 +6394,7 @@ mod tests {
|
||||
("timeline_under_browser", json!(true), PAGE_LAYOUT),
|
||||
("history_sketches", json!("under_feature"), PAGE_LAYOUT),
|
||||
("history_play_button", json!(false), PAGE_LAYOUT),
|
||||
("history_folder_kind_icon", json!(true), PAGE_LAYOUT),
|
||||
("history_group_joints", json!(true), PAGE_LAYOUT),
|
||||
("history_bar_groups", json!(true), PAGE_LAYOUT),
|
||||
("browser_float", json!(true), PAGE_LAYOUT),
|
||||
@@ -6405,6 +6415,7 @@ mod tests {
|
||||
("hole_snaps", json!(true), PAGE_TOOLS),
|
||||
("hole_moves", json!(true), PAGE_TOOLS),
|
||||
("hole_several", json!(true), PAGE_TOOLS),
|
||||
("hole_group", json!(true), PAGE_TOOLS),
|
||||
("extrude_arrow_face_stops", json!(false), PAGE_TOOLS),
|
||||
("extrude_auto_to_object", json!(false), PAGE_TOOLS),
|
||||
("extrude_view_isometric", json!(true), PAGE_TOOLS),
|
||||
|
||||
@@ -458,6 +458,25 @@ pub(super) fn layout(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
&mut app.preferences.browser_eye_left,
|
||||
"browser_eye_left",
|
||||
);
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.browser_body_features"),
|
||||
app.tr("prefs.browser_body_features_note"),
|
||||
&mut app.preferences.browser_body_features,
|
||||
"browser_body_features",
|
||||
);
|
||||
});
|
||||
// A folder's icon from its features (#12514), in a row of its own: one more box in the
|
||||
// row above widens it past the page at narrow windows.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_16, t.space_6);
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.history_folder_kind_icon"),
|
||||
app.tr("prefs.history_folder_kind_icon_note"),
|
||||
&mut app.preferences.history_folder_kind_icon,
|
||||
"history_folder_kind_icon",
|
||||
);
|
||||
});
|
||||
});
|
||||
ui_kit::settings_section(ui, "dialogs", app.tr("prefs.dialogs"), |ui| {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
),
|
||||
(setting::hole_moves, "prefs.design_tools.hole_moves_help", "hole_moves", "Move holes by clicking or dragging"),
|
||||
(setting::hole_several, "prefs.design_tools.hole_several_help", "hole_several", "Several holes on one face"),
|
||||
(setting::hole_group, "prefs.design_tools.hole_group_help", "hole_group", "Group holes made together"),
|
||||
] {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting(app, ui).backing_on_hover_text(app.tr(help));
|
||||
|
||||
@@ -1561,6 +1561,7 @@ mod tests {
|
||||
("Snap to face and edge centres", "Snap to face and edge centres", crate::prefs::PAGE_TOOLS, "prefs:hole_snaps"),
|
||||
("Move holes by clicking or dragging", "Move holes by clicking or dragging", crate::prefs::PAGE_TOOLS, "prefs:hole_moves"),
|
||||
("Several holes on one face", "prefs.design_tools.hole_several", crate::prefs::PAGE_TOOLS, "prefs:hole_several"),
|
||||
("Group holes made together", "prefs.design_tools.hole_group", crate::prefs::PAGE_TOOLS, "prefs:hole_group"),
|
||||
("Show moved parts' old place as a ghost", "prefs.design_tools.ghost", crate::prefs::PAGE_TOOLS, "prefs:drive_joints_ghost"),
|
||||
("Near-axis rotation step (°)", "joint_move.coarse", crate::prefs::PAGE_TOOLS, "prefs:joint_rotation_coarse"),
|
||||
("Far-axis rotation step (°)", "joint_move.fine", crate::prefs::PAGE_TOOLS, "prefs:joint_rotation_fine"),
|
||||
|
||||
@@ -212,6 +212,36 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon a History folder takes from its features when Preferences says so: theirs, when every
|
||||
/// feature in it (and in the folders inside it) has the same icon and it holds no joints.
|
||||
pub(crate) fn folder_kind_icon(app: &SolveApp, id: u64) -> Option<&'static str> {
|
||||
if !app.preferences.history_folder_kind_icon {
|
||||
return None;
|
||||
}
|
||||
let doc = &app.session.doc;
|
||||
let mut pending = vec![id];
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut icon = None;
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let group = doc.timeline_groups.iter().find(|group| group.id == id)?;
|
||||
if !group.joints.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for feature in &group.features {
|
||||
let this = icon_of(&doc.feature(*feature)?.kind);
|
||||
if icon.is_some_and(|icon| icon != this) {
|
||||
return None;
|
||||
}
|
||||
icon = Some(this);
|
||||
}
|
||||
pending.extend(doc.timeline_groups.iter().filter(|child| child.parent == Some(id)).map(|child| child.id));
|
||||
}
|
||||
icon
|
||||
}
|
||||
|
||||
fn construction_plane_icon(plane: &PlaneRef) -> &'static str {
|
||||
match plane {
|
||||
PlaneRef::AtAngle { .. } => "plane_angle",
|
||||
@@ -1262,6 +1292,38 @@ mod tests {
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
#[test]
|
||||
fn a_folder_of_one_kind_of_feature_takes_its_icon_when_preferences_say_so() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Block"})).unwrap();
|
||||
for x in [8, 16, 24, 32] {
|
||||
h.app.run("solid.hole", json!({"position": [x, 10, 20], "diameter": 4, "name": format!("H{x}")})).unwrap();
|
||||
}
|
||||
let group = |h: &mut Harness, p: serde_json::Value| h.app.run("timeline.group", p).unwrap()["group"].as_u64().unwrap();
|
||||
let inner = group(&mut h, json!({"name": "Inner", "features": ["H8", "H16"]}));
|
||||
// Holes, and a folder of holes inside: all one kind.
|
||||
let holes = group(&mut h, json!({"name": "Holes", "features": ["H24"], "groups": [inner]}));
|
||||
let mixed = group(&mut h, json!({"name": "Mixed", "features": ["Block"]}));
|
||||
let hole = super::icon_of(&h.app.session.doc.find_feature("H8").unwrap().kind);
|
||||
// Off (the default): every folder keeps the folder icon.
|
||||
assert!(!h.app.preferences.history_folder_kind_icon);
|
||||
assert_eq!(super::folder_kind_icon(&h.app, holes), None);
|
||||
crate::prefs::set(&mut h.app, &json!({"history_folder_kind_icon": true})).unwrap();
|
||||
assert_eq!(super::folder_kind_icon(&h.app, holes), Some(hole));
|
||||
assert_eq!(super::folder_kind_icon(&h.app, inner), Some(hole));
|
||||
assert_eq!(super::folder_kind_icon(&h.app, mixed), Some(super::icon_of(&h.app.session.doc.find_feature("Block").unwrap().kind)));
|
||||
// A box among the holes: back to the folder icon.
|
||||
h.app.run("timeline.group", json!({"name": "Both", "groups": [holes, mixed]})).unwrap();
|
||||
let both = h.app.session.doc.timeline_groups.iter().find(|g| g.name == "Both").unwrap().id;
|
||||
assert_eq!(super::folder_kind_icon(&h.app, both), None);
|
||||
// Both Histories draw it without trouble.
|
||||
for left in [false, true] {
|
||||
h.app.preferences.timeline_left = left;
|
||||
h.frames(3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joint_folder_hides_fifty_joint_tiles_in_both_history_layouts() {
|
||||
use crate::scenario::handle_at;
|
||||
|
||||
@@ -250,8 +250,9 @@ pub(crate) fn folder_chip(
|
||||
let state = history.inside(folder.start, folder.end, folder.open);
|
||||
let picked = app.tree.picked_timeline_groups.contains(&folder.id)
|
||||
|| (!folder.open && history.folders.joints.get(&folder.id).is_some_and(|j| j.iter().any(|j| app.tree.picked_joints.contains(j))));
|
||||
let icon = crate::timeline::folder_kind_icon(app, folder.id).unwrap_or("feature_group");
|
||||
let chip = HistoryGroupChip {
|
||||
icon: "feature_group",
|
||||
icon,
|
||||
name: &name,
|
||||
count,
|
||||
open: folder.open,
|
||||
|
||||
@@ -1128,7 +1128,8 @@ fn vertical_group_row(
|
||||
painter.multiply_opacity(0.42);
|
||||
}
|
||||
let icon = Rect::from_center_size(pos2(rect.left() + 27.5 + offset, rect.center().y), vec2(17.0, 17.0));
|
||||
icons::paint_icon(&painter, icon, "feature_group", f32::from(t.icon.a()) / 255.0);
|
||||
let kind = crate::timeline::folder_kind_icon(app, group.id).unwrap_or("feature_group");
|
||||
icons::paint_icon(&painter, icon, kind, f32::from(t.icon.a()) / 255.0);
|
||||
if suppressed {
|
||||
painter.line_segment([icon.left_bottom(), icon.right_top()], Stroke::new(1.5, t.text_dim));
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) {
|
||||
}
|
||||
}
|
||||
Kind::Pick(n) => {
|
||||
let hits = pick(app, proj, pos);
|
||||
let hits = curve_operands(tool.cmd, pick(app, proj, pos));
|
||||
// Symmetry's third pick may be an origin axis lying along the sketch's own x or y
|
||||
// (#1075); the command names those `x` / `y`.
|
||||
let axis_ok = tool.cmd == "sketch.constraint.symmetry" && tool.picks.len() == 2;
|
||||
@@ -895,6 +895,20 @@ fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
let _ = app.run(t.cmd, params);
|
||||
}
|
||||
|
||||
/// Parallel, Perpendicular, Equal and Collinear constrain two curves, and their commands take no
|
||||
/// point. So a click on a curve's end point picks the curve under it, not the point, and a body's
|
||||
/// edge is taken rather than its vertex (#12092). Tangent also takes a curve's end point: that
|
||||
/// end then lies on the other curve. A body's vertex ends no sketch curve, so Tangent takes the
|
||||
/// edge there too.
|
||||
fn curve_operands(cmd: &str, hits: Vec<Hit>) -> Vec<Hit> {
|
||||
let points = match cmd {
|
||||
"sketch.constraint.collinear" | "sketch.constraint.equal" | "sketch.constraint.parallel" | "sketch.constraint.perpendicular" => false,
|
||||
"sketch.constraint.tangent" => true,
|
||||
_ => return hits,
|
||||
};
|
||||
hits.into_iter().filter(|h| !matches!(h, Hit::Vertex { .. }) && (points || !matches!(h, Hit::SketchPoint { .. }))).collect()
|
||||
}
|
||||
|
||||
/// Take back a pending spline point before undo or Backspace reaches the sketch.
|
||||
pub fn undo_spline_point(app: &mut SolveApp) -> bool {
|
||||
let Some(t) = app.tool.as_mut() else { return false };
|
||||
|
||||
@@ -160,6 +160,48 @@ fn ambiguous_selection_preserves_tool_first_workflow() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #12092: a tool that constrains two curves takes the line when the click lands on its end
|
||||
/// point. It used to pass the point on, and the command refused it ("unknown sketch curve
|
||||
/// `l1.end`"). Tangent keeps the end: the line's end then moves onto the circle, tangent there.
|
||||
#[test]
|
||||
fn a_click_on_a_line_end_picks_the_line_for_two_curve_constraints() {
|
||||
for command in ["parallel", "perpendicular", "equal", "collinear"] {
|
||||
let mut h = lines();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"start":format!("sketch.constraint.{command}")}, {"click":{"sketch":[40,10]}}, {"click":{"sketch":[40,35]}}])).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "{command}");
|
||||
assert_line_geometry(&h, command);
|
||||
}
|
||||
let mut h = Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,20],[30,20]],"infer":false}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[50,0],"radius":10}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"start":"sketch.constraint.tangent"},
|
||||
{"click":{"sketch":[30,20]}},
|
||||
{"click":{"sketch":[60,0]}}
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "tangent: {:?}", h.app.status);
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Tangent { .. })), "tangent");
|
||||
// The line now touches the circle: its distance from the centre is the radius.
|
||||
let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap();
|
||||
let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap();
|
||||
let circle = sk.curves.iter().position(|c| matches!(c.kind, solidcraft_engine::sketch::CurveKind::Circle { .. })).unwrap();
|
||||
let centre = sk.point(sk.resolve_point(&format!("{}.center", sk.curves[circle].id)).unwrap()).unwrap();
|
||||
let radius = sk.radius(circle).unwrap();
|
||||
let distance = (b - a).normalized().unwrap().cross(centre - a).abs();
|
||||
assert!((distance - radius).abs() < 1e-6, "line {a:?}..{b:?} is {distance} from the centre, radius {radius}");
|
||||
// The clicked end lies on the circle, where the line touches it.
|
||||
assert!((b.dist(centre) - radius).abs() < 1e-6, "the line's end {b:?} is {} from the centre, radius {radius}", b.dist(centre));
|
||||
// Only that end moved: the line turned about its other end, and the circle stayed.
|
||||
assert!(a.x.abs() < 1e-9 && (a.y - 20.0).abs() < 1e-9, "the line's other end moved to {a:?}");
|
||||
assert!((centre.x - 50.0).abs() < 1e-9 && centre.y.abs() < 1e-9 && (radius - 10.0).abs() < 1e-9, "the circle moved: {centre:?}, radius {radius}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_preselection_preserves_document_and_error() {
|
||||
let mut h = lines();
|
||||
|
||||
@@ -61,9 +61,10 @@ handler and everything it passes `p` to:
|
||||
that take picks;
|
||||
- `live`, for a command with `.live(…)` (today `sketch.drag` and `sketch.edit_text`): a call
|
||||
with `live: true` goes to the live runner, which `checked!` doesn't wrap, so live calls aren't
|
||||
checked yet. Leave those two on the list until the engine checks the live path too. A new
|
||||
command that needs a live runner still gets its contract (declare `live` as an optional
|
||||
`bool`); say in its pull request that its live calls aren't checked.
|
||||
checked yet. Leave `sketch.drag` on the list until the engine checks the live path too;
|
||||
`sketch.edit_text` has its contract (with `live` declared as an optional `bool`) and its live
|
||||
calls aren't checked. A new command that needs a live runner gets its contract the same way;
|
||||
say in its pull request that its live calls aren't checked.
|
||||
|
||||
A key the handler reads only in some situations (thin-wall keys with `thin: true`) is still a
|
||||
field; its description says when it applies. Contracts don't check combinations of keys
|
||||
|
||||
@@ -895,6 +895,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
|
||||
- `ground_shadow_follows_model` (Graphics, default false): raised models cast their ground shadow on the origin ground plane; true restores the shadow below the lowest visible body. Both modes keep the shadow below bodies extending beneath the origin, and follow the selected up axis.
|
||||
- `hidden_toolbar_tabs` (default `[]`: all existing tabs, including Sheet Metal and Weldments, stay visible; lists tabs to hide, each once, e.g. `["SURFACE", "SHEET METAL"]`; hidden tabs stay hidden regardless of the design's features; [ribbon layout](ribbon-layout.md))
|
||||
- `history_sketches` (`in_order` (default), `under_feature`: the History at the left lists a sketch under the first feature built from it, #6489; the timeline at the bottom keeps the creation order)
|
||||
- `hole_group` (default false: the holes one Hole command makes, two or more, go into a feature folder in History, closed at first)
|
||||
- `hole_moves` (default false: a placed Single hole has a drag handle (`hole:<n>`), and a pick on a face that has a hole moves it there)
|
||||
- `hole_several` (default false: a face takes several Single holes; a pick elsewhere on a face that has a hole adds another, and a pick on a hole takes it out)
|
||||
- `hole_snaps` (default false: a Single hole snaps to its face's middle and round edges' centres)
|
||||
|
||||
@@ -32,6 +32,7 @@ Source today: Interface page (`prefs/interface.rs`, `prefs/navbar.rs`).
|
||||
| Sketches in History | Segmented: In order / Under feature | `Prefs.history_sketches` (`HistorySketches`) | In order | none | none | |
|
||||
| Bodies folders | Segmented: Always / Several / Never | `Prefs.browser_bodies_folders` (`BodiesFolders`) | Several | none | none | |
|
||||
| Play History button | Checkbox | `Prefs.history_play_button` | `true` | none | none | |
|
||||
| Folder icon from its features | Checkbox | `Prefs.history_folder_kind_icon` | `false` | "A folder whose features are all of one kind shows their icon." | none | |
|
||||
| Origin and bodies on top | Checkbox | `Prefs.timeline_origin_bodies` | `false` | "Vertical history only." | none | |
|
||||
| Eye beside the fold arrow | Checkbox | `Prefs.browser_eye_left` | `false` | none | none | |
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Each group is a 120x64 figure at the left of its checkboxes, with a group name u
|
||||
| Hole: Snap to centres | Checkbox | `Prefs.hole_snaps` | `false` | "The middle of a face and the centres of its round edges." | `tool-hole` | Today's label "Snap to face and edge centres". |
|
||||
| Hole: Move by click or drag | Checkbox | `Prefs.hole_moves` | `false` | "Picking the face again moves the hole instead of removing it." | `tool-hole` | Today's label "Move holes by clicking or dragging". |
|
||||
| Hole: Several per face | Checkbox | `Prefs.hole_several` | `false` | "Picking elsewhere on a face adds another hole; picking a hole removes it." | `tool-hole` | Label "Several holes on one face". |
|
||||
| Hole: Group holes made together | Checkbox | `Prefs.hole_group` | `false` | "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own." | `tool-hole` | Label "Group holes made together". |
|
||||
| Extrude: Extrude to a picked target | Checkbox | `Prefs.extrude_auto_to_object` | `true` | "A face, vertex or parallel plane switches the extent to To Object." | `tool-extrude` | |
|
||||
| Extrude: Choose the operation automatically | Checkbox | `Prefs.auto_operation` | `true` | "Join, New Body or Cut, from what the material touches." | `tool-extrude` | |
|
||||
| Extrude: Turn the view to isometric when Extrude opens | Checkbox | `Prefs.extrude_view_isometric` | `false` | none | `tool-extrude` | Retained newer control, directly after automatic operation in the same group. |
|
||||
|
||||
@@ -589,6 +589,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
| List Body Features | `model.body_features` | – |
|
||||
| List Edges | `model.edges` | – |
|
||||
| List Faces | `model.faces` | – |
|
||||
| List Threads | `model.threads` | – |
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ ordinary `execute` tool: `render.preflight`, `render.start`, `render.status`,
|
||||
in the session until explicit PNG export. See [render jobs](render-jobs.md).
|
||||
|
||||
For sandboxed plugins and parametric timeline
|
||||
features that also use `run(command, args)`, see [plugins.md](plugins.md). Desktop MCP in this
|
||||
features that also use `run(command, args)`, see [RFC 0002](rfcs/0002-plugins.md). Desktop MCP in this
|
||||
document remains the reference implementation today.
|
||||
|
||||
## Start it
|
||||
|
||||
+2
-141
@@ -1,142 +1,3 @@
|
||||
# Plugins (design)
|
||||
# Plugins
|
||||
|
||||
Design companion to [#1221](https://github.com/bherbruck/solidcraft/issues/1221): sandboxed
|
||||
WebAssembly plugins with a WIT interface, the same `run(command, args)` JSON as
|
||||
[MCP](mcp.md) and the [control protocol](control-protocol.md).
|
||||
|
||||
This document adds **parametric timeline features**: a generic host-owned feature shell that
|
||||
plugins fill with parameters, display metadata, and rebuild logic, without a second timeline
|
||||
model or a private geometry API.
|
||||
|
||||
## Goals (unchanged from #1221)
|
||||
|
||||
- Sandboxed by default: manifest permissions, fuel/memory limits on desktop, broker enforcement on web.
|
||||
- Any language that compiles to Wasm components; Rust first.
|
||||
- **One command surface:** plugins change the model only through engine commands (undoable, journaled).
|
||||
- Versioned WIT world (`solidcraft:plugin@1.0.0`).
|
||||
|
||||
## Two plugin shapes
|
||||
|
||||
| Shape | Timeline | Rebuild | Milestone |
|
||||
|-------|----------|---------|-----------|
|
||||
| **One-shot** | Optional; often no persistent feature | N/A | 1 (first ship) |
|
||||
| **Parametric** | One **`Plugin` feature row** per plugin instance | Host calls plugin on param/timeline rebuild | 2 |
|
||||
|
||||
One-shot plugins call `execute(params)` once; they may emit ordinary native features (sketch,
|
||||
extrude, …) inside that transaction. Parametric plugins still use `run()` inside rebuild; the
|
||||
difference is that the document **records** a single feature whose params the user edits later.
|
||||
|
||||
## Generic feature shell (host-owned)
|
||||
|
||||
Add one variant to the parametric document (name illustrative):
|
||||
|
||||
```text
|
||||
FeatureKind::Plugin {
|
||||
plugin_id: String, // "gear_generator"
|
||||
api_version: u32, // matches WIT world
|
||||
params: Value, // JSON owned by the plugin schema
|
||||
display: PluginDisplay, // timeline + browser presentation
|
||||
provenance: PluginProvenance, // optional: manifest hash, build id
|
||||
}
|
||||
```
|
||||
|
||||
**`PluginDisplay`** (stored in the file, not recomputed from plugin code at open):
|
||||
|
||||
| Field | Role |
|
||||
|-------|------|
|
||||
| `label` | Primary timeline / browser text |
|
||||
| `icon_id` | Host icon registry name (same system as [`feature_icon`](https://github.com/bherbruck/solidcraft/blob/main/crates/ui-egui/src/workspace.rs)) |
|
||||
| `tooltip` | Hover text, `inspect` type string |
|
||||
| `type_name` | short feature type name for power users |
|
||||
|
||||
The timeline renders **any** `Plugin` feature through one code path: read `display`, draw icon +
|
||||
label, wire suppress/rename/delete/reorder like built-in features. No per-plugin egui in milestone 2.
|
||||
|
||||
Optional later: **`parameter_schema()`** in WIT returns `{ id, label, kind, min, max, … }` so the
|
||||
host renders sliders and dimension rows without custom UI per plugin.
|
||||
|
||||
## WIT entry points (phase 2 additions)
|
||||
|
||||
Milestone 1 (#1221): `manifest()`, `execute(params) -> result<string, string>`.
|
||||
|
||||
Milestone 2 parametric:
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `evaluate(params, ctx) -> result<string, string>` | Rebuild: plugin runs `run()` commands; result is success JSON or error |
|
||||
| `parameter_schema() -> string` | Optional JSON schema for generic parameter UI |
|
||||
| `fallback_policy() -> string` | Declares missing-plugin behaviour (see below) |
|
||||
|
||||
`ctx` is host-provided read-only hints (configuration name, units, feature id); plugins must not
|
||||
rely on it for correctness.
|
||||
|
||||
Inside **`evaluate`**, the plugin uses the same **`run(command, args)`** import as one-shot mode.
|
||||
The host applies commands to the current eval context (or a scratch session merged by the host).
|
||||
No second registry of geometry ops.
|
||||
|
||||
## Rebuild flow
|
||||
|
||||
```text
|
||||
Timeline rebuild reaches FeatureKind::Plugin
|
||||
-> load plugin (wasmtime desktop / broker web) if permitted + present
|
||||
-> evaluate(stored params)
|
||||
-> plugin calls run("sketch.line", …), run("solid.extrude", …), …
|
||||
-> host merges geometry like any native feature
|
||||
-> errors surface on the feature row like native eval errors
|
||||
```
|
||||
|
||||
Param changes (parameter panel, `parameters.change`, config switch) re-run **`evaluate`** for
|
||||
affected plugin features only when dependencies say so (same invalidation rules as native features).
|
||||
|
||||
## Missing plugin and API drift
|
||||
|
||||
Manifest declares **`fallback_policy`**:
|
||||
|
||||
| Policy | Behaviour |
|
||||
|--------|-----------|
|
||||
| `recorded_mesh` | Optional B-rep or mesh blob stored beside params; open works, edit disabled without plugin |
|
||||
| `recorded_script` | Stored list of native commands; rebuild without plugin if every command is built-in |
|
||||
| `suppress_with_warning` | Feature suppressed; user sees clear message |
|
||||
| `error_on_rebuild` | Hard fail on eval (strict teams) |
|
||||
|
||||
Shared design files should prefer a policy that **opens safely** (`recorded_mesh` or
|
||||
`recorded_script`). CI for plugins (#1221) should include: uninstall plugin, reopen file, assert
|
||||
document still opens and policy matches manifest.
|
||||
|
||||
## Icons and metadata
|
||||
|
||||
Plugins declare allowed **`icon_id`** values in `manifest()`; the host rejects unknown ids. Icons
|
||||
stay in the app registry so untrusted plugins cannot inject arbitrary raster assets into the tree
|
||||
without a reviewed icon pack.
|
||||
|
||||
**Rename** by the user updates the feature user label; **`display.label`** remains the default from
|
||||
creation unless the plugin updates it via a host callback (optional future).
|
||||
|
||||
Command naming: prefer **`plugin_id.function`** in `run()` or registered command aliases to avoid
|
||||
collisions ([#1221 discussion](https://github.com/bherbruck/solidcraft/issues/1221)).
|
||||
|
||||
## Group header variant (lighter-weight v2a)
|
||||
|
||||
Before full **`evaluate`**, a stepping stone: one-shot plugin creates a **feature folder** whose
|
||||
header carries `PluginDisplay` metadata while children are ordinary native features. Rebuild does
|
||||
not need the plugin until the user edits plugin-owned params on the header. Migrates cleanly to
|
||||
full `FeatureKind::Plugin` when `evaluate` lands.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
Plugin **document model and eval** stay in SolidCraft (`crates/doc`, `crates/engine`).
|
||||
|
||||
## Phasing
|
||||
|
||||
1. WIT + wasmtime host + one-shot `execute` + permissions tests (#1221 milestone 1).
|
||||
2. `FeatureKind::Plugin` + `PluginDisplay` + timeline generic row + `evaluate` + fallback policy.
|
||||
3. Web broker runs same WIT; parametric features offline only if plugin module cached and policy
|
||||
allows.
|
||||
4. Parameter schema UI, cloud-signed plugin index, inspector extensions.
|
||||
|
||||
## Tests (parametric)
|
||||
|
||||
- Create plugin feature, change param, rebuild, measure geometry (oracle or bbox).
|
||||
- Undo/redo across plugin eval.
|
||||
- Remove plugin binary, reopen design, assert fallback policy.
|
||||
- Icon id from manifest appears on timeline (scenario or screenshot harness).
|
||||
Moved to [RFC 0002: Plugins](rfcs/0002-plugins.md).
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# RFC 0001: Craft bridge, handing work between Craft apps
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Status | Draft, open for comment |
|
||||
| Started | 2026-11-07 |
|
||||
| Apps | SolidCraft, CamCraft, PCBCraft |
|
||||
| Discussion | the pull request that adds this file, then an issue per phase |
|
||||
|
||||
Nothing here is built yet.
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
- One button: "Send to CamCraft" opens the current design in CamCraft, ready to set up.
|
||||
- Round trips that stay linked: edit the model in SolidCraft, and CamCraft sees the new revision, re-finds its picked faces and regenerates or marks paths stale.
|
||||
- The same for PCBCraft: board outline, holes and parts go to SolidCraft; the enclosure's outline and mounting points go back.
|
||||
- Nothing is lost quietly. A reference that no longer resolves is reported by name.
|
||||
|
||||
**Non-goals**
|
||||
- No merged app and no shared process. Each app runs and ships on its own.
|
||||
- No shared file format. `.sc`, `.camproj` and PCBCraft's project stay as they are. The bridge adds one small exchange format between them.
|
||||
- No editing another app's data. Each item has one owner app.
|
||||
|
||||
## 2. What crosses the bridge
|
||||
|
||||
One **exchange file**, `*.craftx`: a ZIP like the `.sc` container, with parts addressed by SHA-256 and a manifest.
|
||||
|
||||
| Part | Content | Notes |
|
||||
|---|---|---|
|
||||
| `manifest.json` | `format: "craft-exchange/1"`, source app and version, `source_id` (permanent design id), `revision`, content hash, units, the parts it has | Required. Readers ignore parts they don't know. |
|
||||
| `bodies/<n>.record` | Exact B-rep as a solidcore-record body file | Preferred geometry. CamCraft can read it once its Solidcore milestone (M12.1) lands. |
|
||||
| `bodies/<n>.step` | STEP of the same body | Fallback any app reads today. |
|
||||
| `mesh/<n>.bin` | Triangles, plus a per-triangle face index | For apps that cut or display meshes. CamCraft slices meshes today. |
|
||||
| `faces.json` | Per body: each face's index → persistent name, `exact` flag, surface type, aliases (names it also answers to after a merge). Edges the same way (`<face A>\|<face B>`). | The key to surviving edits. |
|
||||
| `params.json` | Exposed parameters: name, value, unit, expression (read-only) | So CAM can show "wall = 3 mm", and PCB can drive SolidCraft parameters later. |
|
||||
| `meta.json` | Material, part number, stock hint (box or offset), colour per face | Taken from design and component metadata. |
|
||||
| `board.json` | PCB only: outline, cutouts, holes with names, part placements and heights, keep-outs | Defined with PCBCraft. |
|
||||
|
||||
**Stable ids.** SolidCraft already names every face and edge from kernel history (`F2:top`, `F4:side:l4`, `F2:top#[…]` for pieces, `F5:blend:<edge>` for rounds). `model.faces` reports them, with `exact: false` where a name was found by geometry. The exchange carries these strings as given. Receivers treat them as opaque strings. CamCraft picks today are facet-geometry hashes; a linked model would also store the face name, which survives an added hole where a facet hash may not.
|
||||
|
||||
## 3. Transport
|
||||
|
||||
| Option | Desktop | Browser (wasm) | Good for | Weak at |
|
||||
|---|---|---|---|---|
|
||||
| **A. Exchange file + file watching** | Yes. Write beside the design, watch for changes. | Same-origin apps can share OPFS. No watching, so poll or use the `BroadcastChannel` below. | Durable, works with either app closed, easy to debug | Update waits for a save |
|
||||
| **B. Control channel session** (SolidCraft's existing loopback socket, token file, `control.subscribe` → `design.changed`) | Yes, already shipped | No sockets. The same messages can go over `postMessage` or `BroadcastChannel` between same-origin tabs. | Instant change notice, handshake | Both apps must run |
|
||||
| **C. MCP** | Yes (stdio) | No | Agents driving both apps | Made for agent to app, not app to app. No events between apps. |
|
||||
| **D. Launcher / "open in"** (start the other app with a path; later a registered file type or `camcraft://open?path=`) | Yes | `window.open` the other app's URL with the file in shared OPFS, or hand it over with `postMessage` | The button | Carries no updates by itself |
|
||||
|
||||
**Recommendation: A + D now, B as a speed-up.** The exchange file is the contract and the only thing an app must read. The button writes it and launches the other app with its path; live linking watches that file. A running app may also take a control-channel notice for faster updates. MCP stays for agents.
|
||||
|
||||
## 4. Linking and change propagation
|
||||
|
||||
- **A link** in the receiving project is `{source_id, path hint, revision, content hash}`. `source_id` is the permanent id. The path is only a hint, as in PCBCraft's file rules and SolidCraft's Insert Design links.
|
||||
- **Change detection**: the exchange file's hash differs from the linked one → there's a new revision. Taking it can be automatic or one click; the receiver decides.
|
||||
- **Re-resolving references** (each receiver does this, with the same outcomes SolidCraft uses for linked designs):
|
||||
|
||||
| Outcome | Meaning | Receiver does |
|
||||
|---|---|---|
|
||||
| exact | the name is there | keeps the pick |
|
||||
| merged | another face answers to it as an alias | keeps it, notes the merge |
|
||||
| split | several pieces carry the name | uses all pieces, or asks the user if that changes the meaning |
|
||||
| by geometry | the name only matched on an `exact: false` face | keeps it, flags "check this" |
|
||||
| missing | gone | **marks the operation broken, naming the face and where it was. It keeps the old path for viewing, refuses to post it, and asks the user to re-pick. Never dropped quietly.** |
|
||||
|
||||
- **Who owns what** (one owner per item; the other side holds a read-only linked copy, and Break Link turns it into its own editable copy):
|
||||
|
||||
| Item | Owner | Others get |
|
||||
|---|---|---|
|
||||
| Part geometry, parameters, face names | SolidCraft | CamCraft, PCBCraft (read) |
|
||||
| Setups, stock, WCS, tools, toolpaths, posts | CamCraft | Nothing yet (maybe stock and as-machined bodies later) |
|
||||
| Board outline, holes, part placement and heights | PCBCraft | SolidCraft (a linked body and sketch, like an inserted design) |
|
||||
| Enclosure-driven outline and mount points | SolidCraft | PCBCraft (read, shown as a constraint it can accept) |
|
||||
|
||||
To change something you don't own, "Edit in SolidCraft" (or the owner app) opens the source.
|
||||
|
||||
## 5. The SolidCraft side
|
||||
|
||||
Every step is a command with input and output schemas. Programmatic calls never open dialogs.
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `bridge.export {path?, bodies?, parts?: [record, step, mesh], mesh_tolerance?}` | Writes a `.craftx` and returns `{path, revision, hash, faces, warnings}`. Refuses with `BadParams` on a design with feature errors or no bodies, saying why. |
|
||||
| `bridge.send {app, bodies?}` | Exports, then launches `app` with the path. If the app isn't found, it returns an error that names the setting to fix. No file picker. |
|
||||
| `bridge.links` | Lists the targets this design exports to and the exchanges it has inserted, each with revision, `changed` and missing references. |
|
||||
| `bridge.auto {target, on}` | Rewrites the exchange on every save. |
|
||||
| `file.insert_exchange {path}` | Brings in a PCBCraft board (or any `.craftx`) as a linked component. It reuses Insert Design's link, Update Links and Break Link machinery and face matching. |
|
||||
| `component.update_links` | Also covers inserted exchanges. Reports joint and reference errors as it does today. |
|
||||
|
||||
Face names, `model.faces`, linked-design face matching, design metadata and `control.subscribe` already exist.
|
||||
|
||||
**Format additions** (one file per change in `crates/doc/src/format/pending/`):
|
||||
- `design-source-id.rs`: a permanent random id for each design, made on first save. Older designs get one when next saved. A copy made with Save As gets a new one.
|
||||
- `bridge-targets.rs`: the export targets (`app`, path hint, auto on or off).
|
||||
Inserted exchanges ride the existing Import feature link fields. An older release warns, opens what it can and keeps the rest. Another app's private data, if ever stored in a design, goes under its own reverse-DNS metadata key, which SolidCraft already preserves.
|
||||
|
||||
**UI**: a "Send to CamCraft" button, and later "Send to PCBCraft". It changes the default ribbon, so the owner decides where it goes before it merges.
|
||||
|
||||
## 6. A contract any Craft app can implement
|
||||
|
||||
- **Read**: `craft-exchange/1`. A reader reads the parts it knows, warns about the rest, and never refuses a file for a newer minor version.
|
||||
- **Versioning**: `craft-exchange/<major>`. Minor additions are new optional parts or fields. Only a major bump may change meaning. The manifest lists `requires` (the parts a reader must understand to use the file safely) separately from `parts`.
|
||||
- **Capabilities**: strings with versions, e.g. `brep.solidcore-record/1`, `step/ap214`, `mesh/1`, `faces.names/1`, `params/1`, `meta/1`, `board/1`. A writer includes what it can. A reader takes the best it supports, preferring record, then STEP, then mesh.
|
||||
- **Live session (optional)**: `bridge.hello {app, version, protocol: [1], capabilities}` gets the same answer back. Both use the highest common protocol and the capabilities they share. After that come `exchange.changed {source_id, revision, hash, path}` notices and `exchange.request {source_id}` asking for a fresh export. On desktop these go over the control channel. In the browser they go over `BroadcastChannel`, with the same JSON.
|
||||
- **Launch**: each app accepts `--open <file.craftx>` (or a file path argument). A running instance should take the file over instead of starting a second window.
|
||||
- **Units and frames**: millimetres, in the source design's world frame; the manifest carries the display unit. The receiver places bodies (WCS, component placement).
|
||||
|
||||
## 7. Phases
|
||||
|
||||
| Phase | Delivers | Who |
|
||||
|---|---|---|
|
||||
| 0 | Agree on this contract and the manifest | Both authors |
|
||||
| 1 | **Send to CamCraft**: `bridge.export` (STEP, mesh, face table) plus launching CamCraft with the path. CamCraft imports the STEP as it does today. | SolidCraft. CamCraft only needs a path argument. |
|
||||
| 2 | CamCraft reads `faces.json`: picks store face names, and re-sending updates the same model (its existing UpdateModel and stale marking), with the outcome table above | CamCraft |
|
||||
| 3 | Live: `bridge.auto` + file watching. CamCraft either marks paths stale or regenerates; it never posts on its own. Plus a control-channel notice for running apps. | Both |
|
||||
| 4 | Exact B-rep through solidcore-record, once CamCraft moves to Solidcore | Both |
|
||||
| 5 | PCBCraft: board → SolidCraft via `file.insert_exchange`, and enclosure outline → PCBCraft, once PCBCraft has boards | PCBCraft, SolidCraft |
|
||||
| 6 | Browser: shared OPFS, `BroadcastChannel`, `window.open` hand-off between same-origin web builds | All |
|
||||
|
||||
## 8. Open questions for CamCraft's author
|
||||
|
||||
1. **Would CamCraft store our face and edge names on its picks, next to its facet-hash ids, and re-resolve by name first?** That one choice decides whether CAM setups survive model edits.
|
||||
2. Does `camcraft-ui` accept a file path at launch, and can a running window take a second file instead of opening a new one?
|
||||
3. Should a linked model update automatically and mark paths stale, or regenerate them too, and with what confirmation?
|
||||
4. Should stock and material come from SolidCraft (a stock hint in `meta.json`), or stay CamCraft-only?
|
||||
5. Where should the exchange file live: beside the `.sc`, beside the `.camproj`, or copied into the project, as CamCraft keeps facets today?
|
||||
6. Is there anything CamCraft wants to send back, such as the as-machined body for drawings, or fixture bodies for assemblies?
|
||||
7. What's the timing for Solidcore (M12.1), so we know when to put the B-rep record first?
|
||||
8. Would CamCraft add the optional live session over a socket, or is watching the file enough?
|
||||
@@ -0,0 +1,143 @@
|
||||
# RFC 0002: Plugins, sandboxed WebAssembly extensions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Status | Draft, open for comment |
|
||||
| Started | 2026-10-14 (when #1221 opened) |
|
||||
| Apps | SolidCraft |
|
||||
| Discussion | [#1221](https://github.com/tangency-labs/solidcraft/issues/1221) |
|
||||
| Related | [#4980](https://github.com/tangency-labs/solidcraft/pull/4980) (draft WIT proposal, `solidcraft:plugin@0.1.0`), [#4933](https://github.com/tangency-labs/solidcraft/pull/4933) (plugin commands in Utilities) |
|
||||
|
||||
This RFC replaces `docs/plugins.md` and folds in what the 37 comments on #1221 settled. Only the Preferences page exists today. No plugin runs.
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
- Drop in a file and get a new command, importer, exporter or sketch tool, without rebuilding the app.
|
||||
- Sandboxed by default. A plugin can do only what its manifest asks and the user allows. A crash or a hang cannot take down the app or the model.
|
||||
- Any language that compiles to a WebAssembly component. Rust first.
|
||||
- One command surface. Plugins change the model only through the engine commands that MCP and the control channel already use, so every edit is undoable and journaled. No private geometry API.
|
||||
- A plugin built for API 1 keeps loading after app updates.
|
||||
|
||||
**Non-goals**
|
||||
- Not a way to override core commands. Overrides would make a model behave differently depending on what is installed.
|
||||
- Not where core features go. CAM, for example, stays in-tree (#1153); plugins add vendor posts, niche strategies and importers on top.
|
||||
- No plugin-to-plugin calls. If plugins ever cooperate, it goes through host events, and a failing listener never fails the caller.
|
||||
|
||||
## 2. The WIT contract
|
||||
|
||||
World `solidcraft:plugin@1.0.0`. The interface stays thin: a few typed functions plus the JSON command set, so the WIT stays small while the commands keep growing.
|
||||
|
||||
| Host import | Notes |
|
||||
|---|---|
|
||||
| `run(command, args) -> result<string, string>` | The engine commands, JSON in and out, exactly as over MCP. Filtered by the plugin's permissions. |
|
||||
| `log`, `progress` | Always available. |
|
||||
| `read-file`, `write-file` | Only inside folders the user granted. |
|
||||
| `fetch` | Only with the `net` permission, only to origins named in the manifest, made by the host. |
|
||||
|
||||
| Export | Notes |
|
||||
|---|---|
|
||||
| `manifest() -> string` | JSON: id, name, version, kind, permissions, parameters. |
|
||||
| `execute(params) -> result<string, string>` | A command or one-shot feature. |
|
||||
| `import(...)` / `export(...)` | File formats. See below. |
|
||||
|
||||
**Settled in the comments**
|
||||
- Plugin commands are namespaced `plugin_id.function`. They appear in `list_commands` and the Assistant's tool list with their parameter docs.
|
||||
- Importers need the file name, not just bytes (the extension picks the format), and return a design plus a loss report. Assemblies need read access to the input's folder, granted as a permission. #4980 streams the input to keep each host call small.
|
||||
- A trap fails that one call ("the importer failed on this file") and the next call gets a fresh instance.
|
||||
- Entries declare `effect: read | edit`. A `read` entry is limited to query commands, so the agent chat can run it without asking.
|
||||
- Entry inputs should be schemas built from the same field types as the command contracts (#2829), so one declaration gives a native dialog, strict MCP checks and generated tests. A one-line `params` doc is accepted until 1.0.
|
||||
|
||||
**Permissions** sit in the manifest, are shown on an approval screen, and are approved before any plugin code runs. A change to what was approved stops the plugin until the user approves again. A code-only update never adds an entry or surface silently.
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| (none) | `run`, `log`, `progress` |
|
||||
| `files` | Named folders, read or write |
|
||||
| `net` | Named `https` origins. On the web build, only through a host-approved fetch. |
|
||||
| `clock` | Wall clock. Without it, it reads a fixed epoch. |
|
||||
|
||||
## 3. Runtime and sandbox
|
||||
|
||||
| Item | Decision |
|
||||
|---|---|
|
||||
| Runtime | wasmtime on desktop, with the component model |
|
||||
| CPU | Fuel or epoch interruption; the loop is stopped and the call fails |
|
||||
| Memory | A per-plugin limit; a configurable stack (a real decoder needed about 16 MiB, wasmtime's default is 512 KiB) |
|
||||
| WASI | None unless a permission grants a narrow slice |
|
||||
| Failure | A fresh instance per call after a trap. The design and undo stack are untouched. |
|
||||
|
||||
**Binary-size trade-off.** wasmtime with Cranelift adds several MB to the app. wasmi is much smaller but runs core modules, not components. Options are wasmtime with Winch or Pulley, or accepting the size. Not decided.
|
||||
|
||||
**Component size.** #4980 caps components at 16 MiB. A real converter measured 28 MB (built for size) to 42 MB. Compiling happens in the app's process with no sandbox limit on the compiler, and the project forbids `unsafe`, so compiled code cannot be cached (`Component::deserialize` is `unsafe`). One plugin per format avoids most of this.
|
||||
|
||||
Rhai was proposed instead. The comments favour one runtime, WebAssembly, for isolation and language choice.
|
||||
|
||||
## 4. Plugin kinds
|
||||
|
||||
| Kind | Timeline | When |
|
||||
|---|---|---|
|
||||
| One-shot command | Optional. It may emit ordinary native features inside one undo step. | First |
|
||||
| Importer / exporter | None. Opens as a new unsaved design. | First |
|
||||
| Parametric feature | One `Plugin` feature row per instance, rebuilt when parameters change | Later |
|
||||
| Panels, overlays, workspaces | Host-drawn from a declared schema | Later, see questions |
|
||||
|
||||
**Parametric features** (from `docs/plugins.md`). A host-owned `FeatureKind::Plugin` row stores `plugin_id`, `api_version`, the plugin's JSON `params`, and a `PluginDisplay` (label, icon id from the app's registry, tooltip, type name), all recorded in the file. The timeline draws any such row through one code path. On rebuild the host calls `evaluate(params, ctx)`, which uses the same `run` import, and merges the result like a native feature. An optional `parameter_schema()` lets the host draw sliders and dimension rows.
|
||||
|
||||
**SolidCraft never refuses to open a file.** Every parametric plugin declares a recorded fallback, so a design opens without the plugin:
|
||||
|
||||
| Fallback | Behaviour without the plugin |
|
||||
|---|---|
|
||||
| `recorded_script` | Replays a stored list of native commands, when every command is built in |
|
||||
| `recorded_mesh` | Shows a stored B-rep or mesh; editing is disabled |
|
||||
| `suppress_with_warning` | Feature is suppressed with a clear message |
|
||||
| `error_on_rebuild` | Hard error; for strict teams only |
|
||||
|
||||
Shared designs should use one of the first two. A plugin's output should come from native features (sketches, extrudes, holes), so it works without the plugin. If a plugin cannot reach its goal that way, core is missing a building block. Opening a design never downloads or runs a missing package. Each instance keeps its plugin revision until the user updates it, and the update is undoable.
|
||||
|
||||
Design metadata under `plugin.<id>` survives a missing, disabled or newer plugin. Plugin store data is read additively across versions, and the first call after an update is told the previous version.
|
||||
|
||||
## 5. Distribution and signing
|
||||
|
||||
- **Now:** a folder per plugin, `{data}/plugins/<id>/plugin.json`.
|
||||
- **Next:** Install Plugin from a file or GitHub release. An Add-on Manager lists tools, versions and permissions, with enable, disable and remove.
|
||||
- **Later:** a curated index. Anyone can sideload. To be listed, a plugin must be open source and signed. The comments favour GitHub Releases as the artifact source and keeping packages independent of one host.
|
||||
- **Tools:** `solidcraft-cli plugin check <dir>` validates the manifest and schemas; `plugin test <dir>` runs every entry on the real host; `--plugin-dev <dir>` loads an unapproved folder, marked as dev, and reloads on change. The `plugin.json` schema is generated from the host's types.
|
||||
|
||||
## 6. Web build
|
||||
|
||||
wasmtime does not run in the browser tab. Options are jco-transpiled components, or desktop-only plugins at first. The same WIT world and manifest should serve both, with the same permission checks before `run` reaches the engine. Parametric features offline on the web need the module cached, so only plugins whose fallback allows it. Network access on the web goes through a host-approved fetch, never the page's own.
|
||||
|
||||
## 7. What exists today
|
||||
|
||||
**Built**
|
||||
- Preferences > Plugins (`crates/ui-egui/src/plugins.rs`, #2718): lists `{data}/plugins/<id>/plugin.json` folders, searches them, and turns each off. Turned-off ids are saved in `disabled_plugins`. Folders that are not plugins are listed with the reason.
|
||||
- The manifest it reads has only `id`, `name`, `version` and `description`. It is checked and capped at 16 KiB.
|
||||
|
||||
**Not built**
|
||||
- No wasmtime, no WIT file, no host, no example plugin, no permissions, no signing, no install flow (#4330 was closed), no `Plugin` feature kind. Nothing in the folder is loaded or run.
|
||||
- #4980 is an open draft of the WIT and a gear pilot, and is not merged.
|
||||
|
||||
## 8. Phases
|
||||
|
||||
| Phase | Delivers |
|
||||
|---|---|
|
||||
| 1 | WIT, a wasmtime host with fuel and memory limits, `execute`, permissions in the manifest, tests: no file access without a grant, a runaway loop is stopped, a crash leaves the model untouched, undo works |
|
||||
| 2 | Spur-gear example plugin, author guide, `plugin check` and `plugin test`, importers and exporters |
|
||||
| 3 | `FeatureKind::Plugin`, `evaluate`, recorded fallbacks, generic timeline row |
|
||||
| 4 | Install from file or GitHub, Add-on Manager, parameter schema dialogs |
|
||||
| 5 | Web host on the same WIT |
|
||||
| 6 | Curated, signed index; panels and overlays if agreed |
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. **Wasmtime size and component limit.** Accept the binary growth, and raise the 16 MiB limit after measuring compile memory?
|
||||
2. **Plugin UI.** Declarative panels, viewport overlays, then workspaces (the slicer-as-a-tab case). Or should such tools live in-tree?
|
||||
3. **Defaults.** Are newly installed plugins on or off until approved?
|
||||
4. **Local network.** May a plugin reach a companion service on the user's machine, entered by the user? Starting a print also needs a confirmation level above `edit`.
|
||||
5. **Extending core commands.** Data-driven extension points, such as thread standards in the Hole dialog, later. Never overrides.
|
||||
6. **Signing and the index.** What signs, who hosts the index, and who reviews listings?
|
||||
7. **Web.** jco, or desktop only?
|
||||
8. **Result annotations.** Text a plugin adds to command results can steer a model, so it needs its own threat model.
|
||||
9. **Recorded macros.** A plugin kind or a core feature?
|
||||
10. **Version numbers.** #4980 uses `0.1.0` until the interface settles; this RFC says `1.0.0`.
|
||||
@@ -0,0 +1,10 @@
|
||||
# RFCs
|
||||
|
||||
Proposals for changes that cross apps or repositories, or that need agreement before code. Each RFC is one file, `NNNN-short-name.md`, numbered in order, with a status table at the top: Draft, Accepted, Rejected or Replaced.
|
||||
|
||||
A Draft is discussed in the pull request that adds it. Once accepted, the work is tracked in issues and the RFC links to them. An RFC records the decision; it is not a promise of a date.
|
||||
|
||||
| RFC | Title | Status |
|
||||
|---|---|---|
|
||||
| [0001](0001-craft-bridge.md) | Craft bridge: handing work between Craft apps | Draft |
|
||||
| [0002](0002-plugins.md) | Plugins: sandboxed WebAssembly extensions | Draft |
|
||||
@@ -12,7 +12,6 @@ solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_wh
|
||||
solidcraft-doc::eval::cost_tests::a_tap_marking_searches_only_the_bodies_near_it main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-doc::eval::cost_tests::a_tap_marking_still_searches_the_bodies_within_its_reach main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-engine::cmd::edit::tests::inserting_variable_fillet_control_preserves_parameters_at_existing_positions solidcore refuses a variable fillet whose law has a corner (solidcore#430)
|
||||
solidcraft-engine::cmd::file::tests::each_format_has_an_export_command_of_its_own the box's planar faces are meshed into 124 triangles, not 12 (the STL is 6284 bytes, the test counts 84 + 50 x 12), as on SMLib
|
||||
solidcraft-engine::extent_tests::cut_to_the_far_side_of_a_curved_wall main's mesh-based version (#8634) not taken; the exact replacement is to come
|
||||
solidcraft-engine::face_name_tests::a_join_that_makes_the_body_bigger_leaves_round_walls_it_never_touched_alone main's edge lookup by a stored point (#11286) finds the edge on the display mesh (edge_near on the body's mesh), not taken; the exact replacement is to come
|
||||
solidcraft-engine::hole_face_tests::a_hole_wider_than_the_part_says_it_removes_all_of_it a 40 mm hole at (10, 10) touches the plate's edge y = 30 at one point; the kernel parts the plate there into 2 bodies, the test wants truck's 1, as on SMLib
|
||||
|
||||
@@ -107,6 +107,7 @@ material.inspect materials and their properties
|
||||
material.list materials and their properties
|
||||
material.properties.directional materials and their properties
|
||||
material.properties.validate materials and their properties
|
||||
model.body_features a listing of the model's timeline per body; crates/engine/src/schema/inspect_tests.rs
|
||||
model.edges a listing of the model's topology
|
||||
model.faces a listing of the model's topology
|
||||
model.threads a listing of the model's topology
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user