mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 13:01:52 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b5fdbb763 |
@@ -1,9 +1,6 @@
|
||||
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
|
||||
.gitattributes text eol=lf
|
||||
/assets/icons/ui/design/requested_icons.txt text eol=lf
|
||||
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
|
||||
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
|
||||
*.wgsl text eol=lf
|
||||
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
|
||||
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
|
||||
*.sc -linguist-detectable
|
||||
|
||||
@@ -842,8 +842,6 @@ 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 |
|
||||
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
|
||||
let mut unscoped = p.clone();
|
||||
unscoped.as_object_mut().unwrap().remove("body");
|
||||
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
|
||||
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
|
||||
assert!(err.contains("edges meet"), "{err}");
|
||||
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
|
||||
tried += 1;
|
||||
}
|
||||
|
||||
+6
-22
@@ -977,7 +977,7 @@
|
||||
"browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_title": "معايرة: {name}",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولًا.",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً.",
|
||||
"browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.",
|
||||
"browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.",
|
||||
"browser.capture_one": "تم تحريك مكوّن دون تثبيت موضعه.",
|
||||
@@ -1236,11 +1236,8 @@
|
||||
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
|
||||
"cmd.panel.float": "تعويم اللوحة",
|
||||
"cmd.panel.focus": "تركيز اللوحة",
|
||||
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
|
||||
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
|
||||
"cmd.panel.list": "سرد اللوحات",
|
||||
"cmd.panel.move": "نقل اللوحة",
|
||||
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
|
||||
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
|
||||
"cmd.panel.open": "فتح اللوحة",
|
||||
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
|
||||
@@ -1248,12 +1245,6 @@
|
||||
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
|
||||
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
|
||||
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
|
||||
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
|
||||
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
|
||||
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
|
||||
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
|
||||
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
|
||||
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
|
||||
"cmd.parameters_add": "إضافة معامل مستخدم",
|
||||
"cmd.parameters_change": "تغيير المعاملات",
|
||||
"cmd.parameters_comment": "التعليق على المعامل",
|
||||
@@ -1874,7 +1865,7 @@
|
||||
"help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.",
|
||||
"help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.",
|
||||
"help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم التخطيطي، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.",
|
||||
"help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.",
|
||||
"help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.",
|
||||
@@ -2529,7 +2520,7 @@
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
"prefs.browser": "المستعرض",
|
||||
"prefs.browser_body_features": "الميزات تحت الأجسام",
|
||||
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات الخط الزمني التي بنته، بترتيب الخط الزمني.",
|
||||
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات المخطط الزمني التي بنته، بترتيب المخطط الزمني.",
|
||||
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
|
||||
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
|
||||
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
|
||||
@@ -2570,8 +2561,6 @@
|
||||
"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": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
|
||||
@@ -2851,8 +2840,6 @@
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.selecting": "التحديد",
|
||||
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
|
||||
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
@@ -2875,8 +2862,8 @@
|
||||
"prefs.sketch.drawing": "الرسم",
|
||||
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
|
||||
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط على Esc مرتين ينهي الرسم",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي.",
|
||||
"prefs.sketch.exit_keep": "إبقاء",
|
||||
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
|
||||
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
|
||||
@@ -3397,7 +3384,7 @@
|
||||
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
|
||||
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
|
||||
"render.cpu_path_depth": "عمق المسار",
|
||||
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_point": "ضوء نقطي",
|
||||
@@ -5543,7 +5530,6 @@
|
||||
"ui.heading_plastic": "البلاستيك",
|
||||
"ui.heading_position": "الموضع",
|
||||
"ui.heading_prepare": "تحضير",
|
||||
"ui.heading_press_pull": "ضغط وسحب",
|
||||
"ui.heading_rectangular_pattern": "نمط مستطيلي",
|
||||
"ui.heading_revolve": "دوران",
|
||||
"ui.heading_right": "يمين",
|
||||
@@ -5887,8 +5873,6 @@
|
||||
"ui.profiles": "المقاطع",
|
||||
"ui.project": "إسقاط",
|
||||
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
|
||||
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
|
||||
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.properties": "الخصائص",
|
||||
"ui.properties.copy_matrix": "نسخ المصفوفة",
|
||||
"ui.properties.first_moments": "العزوم الأولى",
|
||||
|
||||
@@ -1047,11 +1047,8 @@
|
||||
"cmd.panel.dismiss": "Panel-Vorschau schließen",
|
||||
"cmd.panel.float": "Panel frei platzieren",
|
||||
"cmd.panel.focus": "Panel fokussieren",
|
||||
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
|
||||
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
|
||||
"cmd.panel.list": "Panels auflisten",
|
||||
"cmd.panel.move": "Panel verschieben",
|
||||
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
|
||||
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
|
||||
"cmd.panel.open": "Panel öffnen",
|
||||
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
|
||||
@@ -1059,12 +1056,6 @@
|
||||
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
|
||||
"cmd.panel.resize": "Panel-Bereich skalieren",
|
||||
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
|
||||
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
|
||||
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
|
||||
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
|
||||
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
|
||||
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
|
||||
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen",
|
||||
"cmd.parameters_add": "Benutzerparameter hinzufügen",
|
||||
"cmd.parameters_change": "Parameter ändern",
|
||||
"cmd.parameters_comment": "Parameter kommentieren",
|
||||
@@ -2357,8 +2348,6 @@
|
||||
"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.",
|
||||
@@ -2611,8 +2600,6 @@
|
||||
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
|
||||
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
|
||||
"prefs.selecting": "Auswahl",
|
||||
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
|
||||
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam.",
|
||||
"prefs.shift": "Umschalt",
|
||||
"prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um",
|
||||
"prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.",
|
||||
@@ -5263,7 +5250,6 @@
|
||||
"ui.heading_plastic": "PLASTIK",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "VORBEREITEN",
|
||||
"ui.heading_press_pull": "DRÜCKEN/ZIEHEN",
|
||||
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
|
||||
"ui.heading_revolve": "DREHEN",
|
||||
"ui.heading_right": "RECHTS",
|
||||
@@ -5603,8 +5589,6 @@
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
|
||||
"ui.project_references_construction": "Referenzen als Konstruktionsgeometrie projizieren",
|
||||
"ui.project_references_construction_note": "Projizieren, Schneiden, 3D-Geometrie einbeziehen und die Flächenkanten einer neuen Skizze auf einer Fläche erzeugen Konstruktionsgeometrie: Bezüge zum Zeichnen, die nicht zu Profilen gehören. Sie bleiben mit dem Modell verknüpft.",
|
||||
"ui.properties": "Eigenschaften",
|
||||
"ui.properties.copy_matrix": "Matrix kopieren",
|
||||
"ui.properties.first_moments": "Statische Momente",
|
||||
|
||||
@@ -1236,11 +1236,8 @@
|
||||
"cmd.panel.dismiss": "Dismiss Panel Peek",
|
||||
"cmd.panel.float": "Float Panel",
|
||||
"cmd.panel.focus": "Focus Panel",
|
||||
"cmd.panel.focus.next": "Focus Next Panel",
|
||||
"cmd.panel.focus.previous": "Focus Previous Panel",
|
||||
"cmd.panel.list": "List Panels",
|
||||
"cmd.panel.move": "Move Panel",
|
||||
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
|
||||
"cmd.panel.native_close": "Redock Native Panel Section",
|
||||
"cmd.panel.open": "Open Panel",
|
||||
"cmd.panel.peek": "Peek Collapsed Panel",
|
||||
@@ -1248,12 +1245,6 @@
|
||||
"cmd.panel.reset_pair": "Reset Panel Sash",
|
||||
"cmd.panel.resize": "Resize Panel Section",
|
||||
"cmd.panel.resize_pair": "Resize Panel Sash",
|
||||
"cmd.panel.sash.down": "Resize Panel Sash Down",
|
||||
"cmd.panel.sash.left": "Resize Panel Sash Left",
|
||||
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
|
||||
"cmd.panel.sash.right": "Resize Panel Sash Right",
|
||||
"cmd.panel.sash.up": "Resize Panel Sash Up",
|
||||
"cmd.panel.toggle": "Toggle Focused Panel",
|
||||
"cmd.parameters_add": "Add User Parameter",
|
||||
"cmd.parameters_change": "Change Parameters",
|
||||
"cmd.parameters_comment": "Comment Parameter",
|
||||
@@ -2570,8 +2561,6 @@
|
||||
"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.",
|
||||
@@ -2851,8 +2840,6 @@
|
||||
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
|
||||
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
|
||||
"prefs.selecting": "Selecting",
|
||||
"prefs.shared_panel_layout": "Use shared panel layout",
|
||||
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
|
||||
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
|
||||
@@ -5543,7 +5530,6 @@
|
||||
"ui.heading_plastic": "PLASTIC",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "PREPARE",
|
||||
"ui.heading_press_pull": "PRESS PULL",
|
||||
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
|
||||
"ui.heading_revolve": "REVOLVE",
|
||||
"ui.heading_right": "RIGHT",
|
||||
@@ -5887,8 +5873,6 @@
|
||||
"ui.profiles": "Profiles",
|
||||
"ui.project": "Project",
|
||||
"ui.project_face_edges_sketch": "Project face edges into new sketches",
|
||||
"ui.project_references_construction": "Project references as construction geometry",
|
||||
"ui.project_references_construction_note": "Project, Intersect, Include 3D Geometry and the face edges of a new sketch on a face make construction geometry: references to draw against, left out of profiles. They stay linked to the model.",
|
||||
"ui.properties": "Properties",
|
||||
"ui.properties.copy_matrix": "Copy matrix",
|
||||
"ui.properties.first_moments": "First moments",
|
||||
|
||||
@@ -1236,11 +1236,8 @@
|
||||
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
|
||||
"cmd.panel.float": "הצפת חלונית",
|
||||
"cmd.panel.focus": "מיקוד חלונית",
|
||||
"cmd.panel.focus.next": "מיקוד בלוח הבא",
|
||||
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
|
||||
"cmd.panel.list": "הצגת רשימת חלוניות",
|
||||
"cmd.panel.move": "העברת חלונית",
|
||||
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
|
||||
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
|
||||
"cmd.panel.open": "פתיחת חלונית",
|
||||
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
|
||||
@@ -1248,12 +1245,6 @@
|
||||
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
|
||||
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
|
||||
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
|
||||
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
|
||||
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
|
||||
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
|
||||
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
|
||||
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
|
||||
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד",
|
||||
"cmd.parameters_add": "הוספת פרמטר משתמש",
|
||||
"cmd.parameters_change": "שינוי פרמטרים",
|
||||
"cmd.parameters_comment": "הערה לפרמטר",
|
||||
@@ -2570,8 +2561,6 @@
|
||||
"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": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
|
||||
@@ -2851,8 +2840,6 @@
|
||||
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
|
||||
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
|
||||
"prefs.selecting": "בחירה",
|
||||
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
|
||||
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
|
||||
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
|
||||
@@ -5543,7 +5530,6 @@
|
||||
"ui.heading_plastic": "פלסטיק",
|
||||
"ui.heading_position": "מיקום",
|
||||
"ui.heading_prepare": "הכנה",
|
||||
"ui.heading_press_pull": "דחיפה/משיכה",
|
||||
"ui.heading_rectangular_pattern": "מערך מלבני",
|
||||
"ui.heading_revolve": "סיבוב",
|
||||
"ui.heading_right": "ימין",
|
||||
@@ -5887,8 +5873,6 @@
|
||||
"ui.profiles": "פרופילים",
|
||||
"ui.project": "הטלה",
|
||||
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
|
||||
"ui.project_references_construction": "הטלת הפניות כגאומטריית עזר",
|
||||
"ui.project_references_construction_note": "הטלה, חיתוך, הכללת גאומטריה תלת־ממדית ומקצועות הפאה של סקיצה חדשה על פאה יוצרים גאומטריית עזר: הפניות לשרטוט שאינן נכללות בפרופילים. הן נשארות מקושרות למודל.",
|
||||
"ui.properties": "מאפיינים",
|
||||
"ui.properties.copy_matrix": "העתקת המטריצה",
|
||||
"ui.properties.first_moments": "מומנטים ראשונים",
|
||||
|
||||
@@ -1236,11 +1236,8 @@
|
||||
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
|
||||
"cmd.panel.float": "パネルをフロート表示",
|
||||
"cmd.panel.focus": "パネルにフォーカス",
|
||||
"cmd.panel.focus.next": "次のパネルにフォーカス",
|
||||
"cmd.panel.focus.previous": "前のパネルにフォーカス",
|
||||
"cmd.panel.list": "パネルを一覧表示",
|
||||
"cmd.panel.move": "パネルを移動",
|
||||
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
|
||||
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
|
||||
"cmd.panel.open": "パネルを開く",
|
||||
"cmd.panel.peek": "折りたたんだパネルを一時表示",
|
||||
@@ -1248,12 +1245,6 @@
|
||||
"cmd.panel.reset_pair": "パネルの区切りをリセット",
|
||||
"cmd.panel.resize": "パネルのセクションのサイズを変更",
|
||||
"cmd.panel.resize_pair": "パネルの区切りを移動",
|
||||
"cmd.panel.sash.down": "パネルの区切りを下に移動",
|
||||
"cmd.panel.sash.left": "パネルの区切りを左に移動",
|
||||
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
|
||||
"cmd.panel.sash.right": "パネルの区切りを右に移動",
|
||||
"cmd.panel.sash.up": "パネルの区切りを上に移動",
|
||||
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え",
|
||||
"cmd.parameters_add": "ユーザー パラメータを追加",
|
||||
"cmd.parameters_change": "パラメータを変更",
|
||||
"cmd.parameters_comment": "パラメータにコメントを付与",
|
||||
@@ -2570,8 +2561,6 @@
|
||||
"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": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
|
||||
@@ -2851,8 +2840,6 @@
|
||||
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
|
||||
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
|
||||
"prefs.selecting": "選択",
|
||||
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
|
||||
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
|
||||
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
|
||||
@@ -5543,7 +5530,6 @@
|
||||
"ui.heading_plastic": "プラスチック",
|
||||
"ui.heading_position": "配置",
|
||||
"ui.heading_prepare": "準備",
|
||||
"ui.heading_press_pull": "プレス プル",
|
||||
"ui.heading_rectangular_pattern": "矩形パターン",
|
||||
"ui.heading_revolve": "回転",
|
||||
"ui.heading_right": "右側面",
|
||||
@@ -5887,8 +5873,6 @@
|
||||
"ui.profiles": "プロファイル",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
|
||||
"ui.project_references_construction": "参照をコンストラクションジオメトリとして投影",
|
||||
"ui.project_references_construction_note": "投影、交差、3D ジオメトリを含める、および面上の新規スケッチの面のエッジは、コンストラクションジオメトリになります。描画の参照用で、プロファイルには含まれません。モデルとのリンクは保たれます。",
|
||||
"ui.properties": "プロパティ",
|
||||
"ui.properties.copy_matrix": "行列をコピー",
|
||||
"ui.properties.first_moments": "一次モーメント",
|
||||
|
||||
@@ -992,11 +992,8 @@
|
||||
"cmd.panel.dismiss": "패널 임시 보기 닫기",
|
||||
"cmd.panel.float": "패널 띄우기",
|
||||
"cmd.panel.focus": "패널에 초점 맞추기",
|
||||
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
|
||||
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
|
||||
"cmd.panel.list": "패널 목록",
|
||||
"cmd.panel.move": "패널 이동",
|
||||
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
|
||||
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
|
||||
"cmd.panel.open": "패널 열기",
|
||||
"cmd.panel.peek": "접힌 패널 임시 보기",
|
||||
@@ -1004,12 +1001,6 @@
|
||||
"cmd.panel.reset_pair": "패널 구분선 초기화",
|
||||
"cmd.panel.resize": "패널 구역 크기 조정",
|
||||
"cmd.panel.resize_pair": "패널 구분선 이동",
|
||||
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
|
||||
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
|
||||
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
|
||||
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
|
||||
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
|
||||
"cmd.panel.toggle": "초점이 있는 패널 접기 전환",
|
||||
"cmd.parameters_add": "사용자 매개 변수 추가",
|
||||
"cmd.parameters_change": "매개 변수 변경",
|
||||
"cmd.parameters_comment": "매개 변수에 설명 추가",
|
||||
@@ -1938,8 +1929,6 @@
|
||||
"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": "가져온 파일",
|
||||
@@ -2146,8 +2135,6 @@
|
||||
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
|
||||
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
|
||||
"prefs.selecting": "선택",
|
||||
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
|
||||
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
|
||||
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
|
||||
@@ -4732,7 +4719,6 @@
|
||||
"ui.heading_plastic": "플라스틱",
|
||||
"ui.heading_position": "배치",
|
||||
"ui.heading_prepare": "준비",
|
||||
"ui.heading_press_pull": "밀기/당기기",
|
||||
"ui.heading_rectangular_pattern": "직사각형 패턴",
|
||||
"ui.heading_revolve": "회전",
|
||||
"ui.heading_right": "우측면",
|
||||
@@ -5054,8 +5040,6 @@
|
||||
"ui.profiles": "프로파일",
|
||||
"ui.project": "투영",
|
||||
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
|
||||
"ui.project_references_construction": "참조를 구성선으로 투영",
|
||||
"ui.project_references_construction_note": "투영, 교차, 3D 형상 포함과 면 위 새 스케치의 면 모서리가 구성선으로 만들어집니다. 프로파일에 포함되지 않는 그리기용 참조이며, 모델과의 연결은 유지됩니다.",
|
||||
"ui.properties": "속성",
|
||||
"ui.properties.copy_matrix": "행렬 복사",
|
||||
"ui.properties.first_moments": "1차 모멘트",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"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."
|
||||
},
|
||||
"ar": {
|
||||
"prefs.design_tools.hole_group": "تجميع الثقوب المصنوعة معًا",
|
||||
"prefs.design_tools.hole_group_help": "الثقوب المصنوعة دفعة واحدة تشترك في مجلد في السجل، مغلق في البداية؛ افتحه لتحرير كل ثقب على حدة."
|
||||
},
|
||||
"de": {
|
||||
"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."
|
||||
},
|
||||
"he": {
|
||||
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
|
||||
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
|
||||
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
|
||||
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요."
|
||||
},
|
||||
"vi": {
|
||||
"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ỗ."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.design_tools.hole_group": "将一起创建的孔分组",
|
||||
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。"
|
||||
}
|
||||
}
|
||||
@@ -1236,11 +1236,8 @@
|
||||
"cmd.panel.dismiss": "Đóng chế độ xem tạm của bảng",
|
||||
"cmd.panel.float": "Thả nổi bảng",
|
||||
"cmd.panel.focus": "Đặt tiêu điểm vào bảng",
|
||||
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
|
||||
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
|
||||
"cmd.panel.list": "Liệt kê các bảng",
|
||||
"cmd.panel.move": "Di chuyển bảng",
|
||||
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
|
||||
"cmd.panel.native_close": "Gắn lại vùng bảng từ cửa sổ riêng",
|
||||
"cmd.panel.open": "Mở bảng",
|
||||
"cmd.panel.peek": "Xem tạm bảng đã thu gọn",
|
||||
@@ -1248,12 +1245,6 @@
|
||||
"cmd.panel.reset_pair": "Đặt lại thanh chia bảng",
|
||||
"cmd.panel.resize": "Đổi kích thước vùng bảng",
|
||||
"cmd.panel.resize_pair": "Di chuyển thanh chia bảng",
|
||||
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
|
||||
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
|
||||
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
|
||||
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
|
||||
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
|
||||
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm",
|
||||
"cmd.parameters_add": "Thêm tham số người dùng",
|
||||
"cmd.parameters_change": "Thay đổi tham số",
|
||||
"cmd.parameters_comment": "Thêm chú thích tham số",
|
||||
@@ -2570,8 +2561,6 @@
|
||||
"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ỗ đó.",
|
||||
@@ -2851,8 +2840,6 @@
|
||||
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
|
||||
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
|
||||
"prefs.selecting": "Đang chọn",
|
||||
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
|
||||
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
|
||||
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
|
||||
@@ -5543,7 +5530,6 @@
|
||||
"ui.heading_plastic": "CHI TIẾT NHỰA",
|
||||
"ui.heading_position": "VỊ TRÍ",
|
||||
"ui.heading_prepare": "CHUẨN BỊ",
|
||||
"ui.heading_press_pull": "KÉO/ĐẨY",
|
||||
"ui.heading_rectangular_pattern": "SAO CHÉP HÌNH CHỮ NHẬT",
|
||||
"ui.heading_revolve": "XOAY TRÒN TẠO KHỐI",
|
||||
"ui.heading_right": "HÌNH CHIẾU CẠNH",
|
||||
@@ -5887,8 +5873,6 @@
|
||||
"ui.profiles": "Các biên dạng",
|
||||
"ui.project": "Chiếu hình học",
|
||||
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
|
||||
"ui.project_references_construction": "Chiếu tham chiếu thành đường dựng hình",
|
||||
"ui.project_references_construction_note": "Chiếu, Giao, Đưa vào hình học 3D và các cạnh bề mặt của phác thảo mới trên một bề mặt tạo ra đường dựng hình: tham chiếu để vẽ theo, không thuộc biên dạng. Chúng vẫn liên kết với mô hình.",
|
||||
"ui.properties": "Thuộc tính cơ lý",
|
||||
"ui.properties.copy_matrix": "Sao chép ma trận",
|
||||
"ui.properties.first_moments": "Mômen bậc nhất",
|
||||
|
||||
@@ -1236,11 +1236,8 @@
|
||||
"cmd.panel.dismiss": "关闭面板临时预览",
|
||||
"cmd.panel.float": "浮动面板",
|
||||
"cmd.panel.focus": "聚焦面板",
|
||||
"cmd.panel.focus.next": "聚焦下一个面板",
|
||||
"cmd.panel.focus.previous": "聚焦上一个面板",
|
||||
"cmd.panel.list": "列出面板",
|
||||
"cmd.panel.move": "移动面板",
|
||||
"cmd.panel.move.next_zone": "将面板移至下一个区域",
|
||||
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
|
||||
"cmd.panel.open": "打开面板",
|
||||
"cmd.panel.peek": "临时预览折叠面板",
|
||||
@@ -1248,12 +1245,6 @@
|
||||
"cmd.panel.reset_pair": "重置面板分隔条",
|
||||
"cmd.panel.resize": "调整面板分区大小",
|
||||
"cmd.panel.resize_pair": "移动面板分隔条",
|
||||
"cmd.panel.sash.down": "向下移动面板分隔条",
|
||||
"cmd.panel.sash.left": "向左移动面板分隔条",
|
||||
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
|
||||
"cmd.panel.sash.right": "向右移动面板分隔条",
|
||||
"cmd.panel.sash.up": "向上移动面板分隔条",
|
||||
"cmd.panel.toggle": "切换当前焦点面板的折叠状态",
|
||||
"cmd.parameters_add": "添加用户参数",
|
||||
"cmd.parameters_change": "更改参数",
|
||||
"cmd.parameters_comment": "注释参数",
|
||||
@@ -2570,8 +2561,6 @@
|
||||
"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": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
|
||||
@@ -2851,8 +2840,6 @@
|
||||
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
|
||||
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
|
||||
"prefs.selecting": "选择",
|
||||
"prefs.shared_panel_layout": "使用共享面板布局",
|
||||
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
|
||||
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
|
||||
@@ -5543,7 +5530,6 @@
|
||||
"ui.heading_plastic": "塑料",
|
||||
"ui.heading_position": "位置",
|
||||
"ui.heading_prepare": "准备",
|
||||
"ui.heading_press_pull": "按压拖动",
|
||||
"ui.heading_rectangular_pattern": "矩形阵列",
|
||||
"ui.heading_revolve": "旋转",
|
||||
"ui.heading_right": "右视",
|
||||
@@ -5887,8 +5873,6 @@
|
||||
"ui.profiles": "轮廓",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "将面的边投影到新草图",
|
||||
"ui.project_references_construction": "将参考投影为构造几何",
|
||||
"ui.project_references_construction_note": "投影、相交、包含三维几何以及在面上新建草图时投影的面边,都会生成构造几何:用于绘图参考,不计入轮廓。它们仍与模型保持关联。",
|
||||
"ui.properties": "属性",
|
||||
"ui.properties.copy_matrix": "复制矩阵",
|
||||
"ui.properties.first_moments": "一阶矩",
|
||||
|
||||
@@ -716,20 +716,14 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
|
||||
if (dot(n, v) < 0.0) { n = -n; }
|
||||
if u.product_lights_meta.x>0.0 {
|
||||
var radiance=vec3<f32>(0.0);
|
||||
let albedo=srgb_decode(base.rgb);
|
||||
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=albedo*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
let sample=product_light_sample(k,i.wp);
|
||||
radiance+=albedo*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
}
|
||||
let mapped=radiance/(vec3<f32>(1.0)+radiance);
|
||||
// A linear attachment takes linear light as it is; any other is encoded once, as the CPU
|
||||
// image is (decoding the encoded value again through `out_color` would flatten the dark end).
|
||||
if (u.back.w > 0.5) {
|
||||
return vec4<f32>(mapped,base.a);
|
||||
}
|
||||
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
|
||||
return vec4<f32>(encoded,base.a);
|
||||
return out_color(vec4<f32>(encoded,base.a));
|
||||
}
|
||||
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
|
||||
let diff = max(dot(n, key), 0.0);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
| File | Author | Source | Licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
|
||||
@@ -7021,22 +7021,6 @@ fn replay(
|
||||
instances.push(start..copies.len());
|
||||
}
|
||||
}
|
||||
// A cut or intersect copy with no named targets, made where the pattern's own component
|
||||
// has no body for it, works on the bodies of its source's component, as the source did
|
||||
// (#12996). A pattern whose component has bodies cuts them, as before. The same scope
|
||||
// counts misses, takes sheet cuts and applies the copies.
|
||||
let scoped;
|
||||
let f = if matches!(op, Operation::Cut | Operation::Intersect)
|
||||
&& targets.is_empty()
|
||||
&& f.participants.is_empty()
|
||||
&& src.component != f.component
|
||||
&& !st.bodies.iter().any(|b| b.component == f.component || b.feature == f.id)
|
||||
{
|
||||
scoped = Feature { component: src.component, ..f.clone() };
|
||||
&scoped
|
||||
} else {
|
||||
f
|
||||
};
|
||||
if op == Operation::Cut {
|
||||
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
|
||||
missed += m;
|
||||
|
||||
@@ -571,12 +571,9 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
|
||||
if descends(current, name) {
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
// The named piece, told apart by the faces around it: they still bound this piece. Or the
|
||||
// face it was a piece of, whole again since, which they still bound: the piece is all of it
|
||||
// (#11731). Only a piece told apart by faces: a numbered one (`#2`, nothing told it apart)
|
||||
// says nothing about which part of the whole face it was.
|
||||
// The named piece, told apart by the faces around it: they still bound this piece.
|
||||
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
|
||||
&& (crate::naming::parent_piece(current) == Some(stem) || (current == stem && !around.is_empty() && name.ends_with(']')))
|
||||
&& crate::naming::parent_piece(current) == Some(stem)
|
||||
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
|
||||
{
|
||||
return Some(Match::Piece);
|
||||
|
||||
@@ -171,21 +171,3 @@ fn a_linked_designs_face_names_keep_their_pieces_and_nothing_else() {
|
||||
let _ = role(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// A face that was split when a name was stored and is whole again (#11731): the stored piece
|
||||
/// is all of it, when the face still touches the faces that told the piece apart.
|
||||
#[test]
|
||||
fn a_piece_name_finds_its_face_once_it_is_whole_again() {
|
||||
let faces = named(&["top", "left", "right", "start"]);
|
||||
let names = qualify(faces, &[(0, 1), (0, 3)]);
|
||||
assert_eq!(names.faces[0], "top");
|
||||
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
|
||||
assert_eq!(matches(&names, 0, "top#[left,start]"), Some(Match::Piece));
|
||||
// It no longer touches a face the qualifier names: some other piece, gone since.
|
||||
assert_eq!(matches(&names, 0, "top#[right]"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]@F9.1"), None, "a copy's piece");
|
||||
// A numbered piece (nothing told it apart, or a tie) says nothing about which part it was.
|
||||
assert_eq!(matches(&names, 0, "top#1"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]#2"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[]"), Some(Match::Stem));
|
||||
}
|
||||
|
||||
@@ -243,57 +243,3 @@ fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
|
||||
assert_eq!(pick(5.).map(|(index, _)| index), Some(top));
|
||||
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
|
||||
}
|
||||
|
||||
/// An edge picked when its side face was split in two keeps the piece in its name
|
||||
/// (`F2:side:l1#[F2:start]`); once the side is one face again the edge is still found (#11731).
|
||||
#[test]
|
||||
fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
|
||||
let doc = Document {
|
||||
features: serde_json::from_value(serde_json::json!([
|
||||
{
|
||||
"id": 1, "name": "Sketch1", "type": "sketch", "plane": {"name": "XY", "type": "origin"},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{"fixed": true, "id": "origin", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p1", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p2", "pos": [10., 0.]},
|
||||
{"fixed": false, "id": "p3", "pos": [10., 16.1]},
|
||||
{"fixed": false, "id": "p4", "pos": [0., 16.1]}
|
||||
],
|
||||
"curves": [
|
||||
{"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"},
|
||||
{"a": 2, "b": 3, "construction": false, "id": "l2", "type": "line"},
|
||||
{"a": 3, "b": 4, "construction": false, "id": "l3", "type": "line"},
|
||||
{"a": 4, "b": 1, "construction": false, "id": "l4", "type": "line"}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {"k": 0, "l": 4, "p": 4}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2, "name": "Extrude1", "type": "extrude", "body_names": ["Body1"],
|
||||
"extent": {"convention": "total_distance", "direction": "positive", "distance": "5"},
|
||||
"operation": "new_body", "profiles": {"loops": [["l1", "l2", "l3", "l4"]], "type": "curves"}, "sketch": 1
|
||||
}
|
||||
]))
|
||||
.unwrap(),
|
||||
..Document::default()
|
||||
};
|
||||
let doc = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Body1").unwrap();
|
||||
// The side along x = 0 meets the start cap in the edge under the pick.
|
||||
let near = Vec3::new(0., 8.05, 0.);
|
||||
let names = crate::naming::edge_names(body);
|
||||
let whole = names.iter().find(|n| n.contains("F2:start") && n.contains("F2:side:l4")).expect("the side's edge on the start cap");
|
||||
let piece = whole.replace("F2:side:l4", "F2:side:l4#[F2:start]");
|
||||
let hit = crate::name_lookup::locate_edge(body, &piece, near).unwrap_or_else(|e| panic!("{piece}: {e}"));
|
||||
assert!(hit.on_point, "the edge under the saved pick");
|
||||
assert_eq!(hit.point, near);
|
||||
assert_eq!(names.get(hit.edge), Some(whole));
|
||||
// A piece told apart by a face the side never touches (the opposite side) is still refused.
|
||||
let gone = whole.replace("F2:side:l4", "F2:side:l4#[F2:side:l2]");
|
||||
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
|
||||
}
|
||||
|
||||
@@ -1552,57 +1552,3 @@ fn join_that_meets_no_body_keeps_the_piece_and_says_so() {
|
||||
let combine = s.execute("solid.combine", &json!({"target": "A", "tools": ["B"], "operation": "join"})).unwrap();
|
||||
assert!(combine["warning"].is_null(), "{combine}");
|
||||
}
|
||||
|
||||
/// #12996: a circular pattern of an untargeted cut made in a component, run with another
|
||||
/// component active, cuts the bodies of the cut's own component, as the cut did.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_in_a_component_works_with_root_active() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((one_hole - got - 9.0 * hole).abs() < 0.02 * hole, "{one_hole} -> {got}");
|
||||
}
|
||||
|
||||
/// #12996: when the component active for the pattern has a body of its own, the copies of an
|
||||
/// untargeted cut keep cutting it, as they did before: saved designs rebuild the same.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_still_cuts_its_own_components_body() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Plate2"}));
|
||||
let plate = volume(&s, "Plate2");
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let (body1, plate2) = (volume(&s, "Body1"), volume(&s, "Plate2"));
|
||||
assert!((one_hole - body1).abs() < 0.02 * hole, "Body1 untouched: {one_hole} -> {body1}");
|
||||
assert!((plate - plate2 - 9.0 * hole).abs() < 0.02 * hole, "Plate2 cut: {plate} -> {plate2}");
|
||||
}
|
||||
|
||||
@@ -13,13 +13,11 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::schema::component::{
|
||||
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, ChildEntry, CompactPlacement, ComponentNode, CopyParams,
|
||||
CopyResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DragParams, DragResult, EditInPlaceParams, EditInPlaceResult,
|
||||
FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult, ListParams, ListResult, MoveBodiesParams,
|
||||
MoveBodiesResult, MoveFeaturesParams, MoveFeaturesResult, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
|
||||
MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams,
|
||||
OccurrencePropertiesResult, PasteNewParams, PasteNewResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams,
|
||||
RenameResult, ReparentItemsParams, ReparentItemsResult, RevertPositionParams, RevertPositionResult, UnlockParams, UnlockResult,
|
||||
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};
|
||||
@@ -37,16 +35,6 @@ const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new
|
||||
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
|
||||
const MOVE_SKETCHES_TO_PARTS_CONTRACT: TypedContract<MoveSketchesToPartsParams> = TypedContract::new();
|
||||
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
|
||||
const CAPTURE_POSITION_CONTRACT: TypedContract<CapturePositionParams> = TypedContract::new();
|
||||
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
|
||||
const DRAG_CONTRACT: TypedContract<DragParams> = TypedContract::new();
|
||||
const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
|
||||
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
|
||||
const MOVE_BODIES_CONTRACT: TypedContract<MoveBodiesParams> = TypedContract::new();
|
||||
const MOVE_SKETCHES_CONTRACT: TypedContract<MoveSketchesParams> = TypedContract::new();
|
||||
const OCCURRENCE_PROPERTIES_CONTRACT: TypedContract<OccurrencePropertiesParams> = TypedContract::new();
|
||||
const PASTE_NEW_CONTRACT: TypedContract<PasteNewParams> = TypedContract::new();
|
||||
const REVERT_POSITION_CONTRACT: TypedContract<RevertPositionParams> = TypedContract::new();
|
||||
|
||||
#[path = "component_align.rs"]
|
||||
mod align;
|
||||
@@ -145,10 +133,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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", crate::checked!("occurrence.properties", properties::occurrence))
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.contract(&OCCURRENCE_PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<OccurrencePropertiesResult>)
|
||||
.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", crate::checked!("component.rename", rename))
|
||||
.icon("rename")
|
||||
@@ -168,11 +154,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"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", crate::checked!("component.move_bodies", move_bodies))
|
||||
.icon("move")
|
||||
.contract(&MOVE_BODIES_CONTRACT)
|
||||
.output(output_schema_for::<MoveBodiesResult>)
|
||||
.params(
|
||||
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 \
|
||||
move from (default: its only one, else its first, where its bodies have their own names)",
|
||||
@@ -191,10 +173,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
|
||||
`bodies` and `feature`, or the pasted components' `paths`",
|
||||
),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", crate::checked!("component.move_sketches", move_sketches))
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
|
||||
.icon("move")
|
||||
.contract(&MOVE_SKETCHES_CONTRACT)
|
||||
.output(output_schema_for::<MoveSketchesResult>)
|
||||
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world), except that a sketch on an exactly attached face stays where it is on the face"),
|
||||
CommandSpec::new(
|
||||
"component.move_sketches.to_parts",
|
||||
@@ -230,26 +210,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
|
||||
still elsewhere (a body transfer, which leaves its features behind)",
|
||||
),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", crate::checked!("occurrence.copy", copy_occurrence))
|
||||
.icon("copy")
|
||||
.contract(©_CONTRACT)
|
||||
.output(output_schema_for::<CopyResult>)
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", crate::checked!("component.paste_new", paste_new))
|
||||
.icon("paste")
|
||||
.contract(&PASTE_NEW_CONTRACT)
|
||||
.output(output_schema_for::<PasteNewResult>)
|
||||
.params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", crate::checked!("occurrence.move", move_occurrence))
|
||||
.icon("move")
|
||||
.contract(&MOVE_CONTRACT)
|
||||
.output(output_schema_for::<MoveResult>)
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared. Replies {occurrence, transform, pending}; when joints hold it to something fixed and put it back, transform is where it is and the reply adds reverted: true and a warning naming the joint"),
|
||||
CommandSpec::new("occurrence.drag", "Drag Occurrence", crate::checked!("occurrence.drag", drag::drag))
|
||||
.icon("move")
|
||||
.contract(&DRAG_CONTRACT)
|
||||
.output(output_schema_for::<DragResult>)
|
||||
.params(
|
||||
CommandSpec::new("occurrence.drag", "Drag Occurrence", drag::drag).icon("move").params(
|
||||
"occurrence or path: exact placement; at: [x,y,z] world point on it; point: [x,y,z] where it goes. Moves the placement by \
|
||||
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
|
||||
moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)",
|
||||
@@ -264,29 +230,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
world coordinates without changing shared component geometry; a placement inside a component placed more than once moves in \
|
||||
every placement of it, and the reply's `shared` lists those paths",
|
||||
),
|
||||
CommandSpec::new("occurrence.ground", "Ground", crate::checked!("occurrence.ground", ground))
|
||||
.icon("ground")
|
||||
.contract(&GROUND_CONTRACT)
|
||||
.output(output_schema_for::<GroundResult>)
|
||||
.params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("occurrence.reparent", "Move to Component", reparent::reparent).icon("move").params(
|
||||
"occurrence; path?: exact placement; parent: component id or name (0 or \"root\" for the design); parent_path?: the parent's \
|
||||
placement to place it by (default: its only one, else the active one; every instance of the parent gets it). The placement \
|
||||
keeps its world position; a move that would break a feature is refused",
|
||||
),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", crate::checked!("component.capture_position", capture))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("capture")
|
||||
.contract(&CAPTURE_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<CapturePositionResult>)
|
||||
.params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("revert")
|
||||
.noundo()
|
||||
.contract(&REVERT_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<RevertPositionResult>)
|
||||
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
|
||||
@@ -318,7 +269,7 @@ fn translation(t: Vec3) -> Mat {
|
||||
solidcraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
pub(crate) fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
|
||||
@@ -670,7 +621,7 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.component = to;
|
||||
}
|
||||
}
|
||||
reply(MoveSketchesResult { sketches: ids, component: to, param_warnings: Vec::new() })
|
||||
Ok(json!({"sketches": ids, "component": to}))
|
||||
}
|
||||
|
||||
pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -704,7 +655,7 @@ pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
reply(CopyResult { occurrence: id, component: comp, param_warnings: Vec::new() })
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
}
|
||||
|
||||
fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -834,7 +785,17 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delta
|
||||
};
|
||||
let cur = if let Some(value) = p.get("base_transform") {
|
||||
base_transform(value).map_err(|message| bad(cmd, message))?
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solidcraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
|
||||
}
|
||||
base
|
||||
} else {
|
||||
s.uncaptured_moves().get(&id).copied().unwrap_or(o.transform)
|
||||
};
|
||||
@@ -848,47 +809,24 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.transform = next;
|
||||
}
|
||||
if !s.doc.assembly.joints.is_empty() {
|
||||
return reply(after_joints(s, p, id, next, cmd));
|
||||
return Ok(after_joints(s, p, id, next, cmd));
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.insert(id, next);
|
||||
s.revision += 1;
|
||||
}
|
||||
reply(MoveResult {
|
||||
occurrence: id,
|
||||
transform: next,
|
||||
pending: !bool_(p, "capture").unwrap_or(true),
|
||||
reverted: false,
|
||||
warning: None,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A move's `base_transform`: a 4×4 matrix that is finite, affine and invertible, or what is
|
||||
/// wrong with it. The parameters contract reads it the same way.
|
||||
pub(crate) fn base_transform(value: &Value) -> std::result::Result<Mat, &'static str> {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| "base_transform must be a 4x4 affine matrix")?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solidcraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err("base_transform must be finite, affine and invertible");
|
||||
}
|
||||
Ok(base)
|
||||
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
|
||||
}
|
||||
|
||||
/// The reply to a captured move once the joints are solved (#11723): a part its joints hold to
|
||||
/// something fixed is put back by that solve, so the reply gives where it is now, says
|
||||
/// `reverted`, and names the joint and what to change instead.
|
||||
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> MoveResult {
|
||||
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> Value {
|
||||
s.refresh();
|
||||
let now = s.doc.occurrences.iter().find(|o| o.id == id).map_or(moved, |o| o.transform);
|
||||
let same = now.iter().flatten().zip(moved.iter().flatten()).all(|(a, b)| (a - b).abs() <= 1e-6);
|
||||
if same {
|
||||
return MoveResult { occurrence: id, transform: now, pending: false, reverted: false, warning: None, param_warnings: Vec::new() };
|
||||
return json!({"occurrence": id, "transform": now, "pending": false});
|
||||
}
|
||||
let name = s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
// The placement the caller named, else the first one: a part in a component placed more
|
||||
@@ -917,7 +855,7 @@ fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> M
|
||||
"{why}The joints put {name} back where they hold it. To move it, change the joint (joint.edit for its offset or angle, \
|
||||
joint.drive for a hinge or slider value), move what it is held to, or use joint.reach"
|
||||
);
|
||||
MoveResult { occurrence: id, transform: now, pending: false, reverted: true, warning: Some(warning), param_warnings: Vec::new() }
|
||||
json!({"occurrence": id, "transform": now, "pending": false, "reverted": true, "warning": warning})
|
||||
}
|
||||
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -930,7 +868,7 @@ fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
let g = o.grounded;
|
||||
reply(GroundResult { occurrence: id, grounded: g, param_warnings: Vec::new() })
|
||||
Ok(json!({"occurrence": id, "grounded": g}))
|
||||
}
|
||||
|
||||
fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -944,7 +882,7 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
|
||||
Ok(json!({ "captured": n }))
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -966,7 +904,7 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
s.revision += 1;
|
||||
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
|
||||
Ok(json!({ "reverted": n }))
|
||||
}
|
||||
|
||||
/// The active component's frame from world coordinates (for new features' geometry).
|
||||
|
||||
@@ -4,9 +4,8 @@ use solidcraft_geom::prelude::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::PasteNewResult;
|
||||
use crate::{Result, Sel, Session};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
|
||||
const CMD: &str = "component.paste_new";
|
||||
@@ -794,16 +793,7 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
let out = super::component::reply(PasteNewResult {
|
||||
component: root,
|
||||
occurrence: root_occurrence,
|
||||
features: new_ids,
|
||||
components: components.len(),
|
||||
bodies: expected_count,
|
||||
frozen_bodies: frozen,
|
||||
notices,
|
||||
param_warnings: Vec::new(),
|
||||
})?;
|
||||
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
|
||||
Ok((scratch, out))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
//! (`joint.reach` moves those as far as their joints allow). A chain of joints joined to nothing
|
||||
//! fixed moves as one, so its joints stay as they are.
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{mat_inverse, mat_mul};
|
||||
|
||||
use super::{move_vector, occurrence, occurrence_path, translation, valid_position};
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::DragResult;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -159,12 +158,5 @@ pub(super) fn drag(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let continuous = p.get("continuous").and_then(Value::as_bool).unwrap_or(false);
|
||||
super::reply(DragResult {
|
||||
occurrence: path.last().copied().unwrap_or_default(),
|
||||
path,
|
||||
moved,
|
||||
translate: [t.x, t.y, t.z],
|
||||
continuous,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
Ok(json!({"occurrence": path.last(), "path": path, "moved": moved, "translate": [t.x, t.y, t.z], "continuous": continuous}))
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::{Document, Mat};
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_geom::inertia::{self, Tensor3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::{
|
||||
BodyProperties, BoundingBox, InertiaAbout, OccurrencePropertiesResult, PhysicalProperties, PropertiesResult, TotalProperties,
|
||||
};
|
||||
use crate::schema::component::{BodyProperties, BoundingBox, InertiaAbout, PhysicalProperties, PropertiesResult, TotalProperties};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
@@ -106,9 +104,10 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
|
||||
}
|
||||
};
|
||||
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
let name = doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone());
|
||||
super::reply(OccurrencePropertiesResult { occurrence: id, name, properties, param_warnings: Vec::new() })
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
out["occurrence"] = json!(id);
|
||||
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The linear part of `m` is a rotation (no scale, shear or mirror), to rounding.
|
||||
@@ -200,6 +199,11 @@ 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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use crate::schema::component::{BodyComponent, FromBodiesResult, MoveBodiesResult};
|
||||
use crate::schema::component::{BodyComponent, FromBodiesResult};
|
||||
use solidcraft_doc::BodyName;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -188,7 +188,7 @@ fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<
|
||||
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
super::reply(MoveBodiesResult { bodies, component: to, features, param_warnings: Vec::new() })
|
||||
Ok(json!({"bodies":bodies,"component":to,"features":features}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+60
-102
@@ -8,17 +8,13 @@ use solidcraft_doc::{Feature, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, name_or_id_values, num, str_};
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::edit::{
|
||||
BrokenFeature, FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams,
|
||||
TimelineComputeAllResult, TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams,
|
||||
TimelineEditResult, TimelineGroupMade, TimelineGroupParams, TimelineGroupRenamed, TimelineInspectParams, TimelineInspectResult,
|
||||
TimelineMarkerResult, TimelineMoveManyParams, TimelineMoveToGroupParams, TimelineMoved, TimelineMovedToGroup, TimelineRedefineParams,
|
||||
TimelineRedefined, TimelineRenameGroupParams, TimelineRenameParams, TimelineRenameResult, TimelineReorderManyParams, TimelineReorderParams,
|
||||
TimelineReordered, TimelineReorderedMany, TimelineRollToParams, TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
|
||||
TimelineUngroupParams, TimelineUngrouped,
|
||||
FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams, TimelineComputeAllResult,
|
||||
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
|
||||
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
|
||||
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
|
||||
};
|
||||
use crate::schema::select::{PartialPick, SelectClearParams, SelectClearResult, SelectItem, SelectSetParams, SelectSetResult, read_item};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -91,35 +87,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&RENAME_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineRenameResult>)
|
||||
.params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", crate::checked!("timeline.group", group))
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineGroupMade>)
|
||||
.params("name?; features?: [id|name]; joints?: [id|name]; groups?: [folder ids]; parent?: folder id (features and joints leave any other timeline folder)"),
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", crate::checked!("timeline.ungroup", ungroup))
|
||||
.icon("folder")
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
|
||||
.doc_only()
|
||||
.contract(&UNGROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineUngrouped>)
|
||||
.params("group: id | groups: [ids] (features and joints remain in the timeline)"),
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", crate::checked!("timeline.rename_group", rename_group))
|
||||
.icon("rename")
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name (renames the folder holding features and joints)"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
|
||||
.doc_only()
|
||||
.contract(&RENAME_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineGroupRenamed>)
|
||||
.params("group: id, name (renames the folder holding features and joints)"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", crate::checked!("timeline.move_to_group", move_to_group))
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&MOVE_TO_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineMovedToGroup>)
|
||||
.params("features?: [id|name], joints?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", crate::checked!("timeline.move_many", move_many))
|
||||
.icon("reorder")
|
||||
.contract(&MOVE_MANY_CONTRACT)
|
||||
.output(output_schema_for::<TimelineMoved>)
|
||||
.params(
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", crate::checked!("timeline.rollback", rollback))
|
||||
@@ -132,22 +111,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&ROLL_TO_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", crate::checked!("timeline.reorder", reorder))
|
||||
.icon("reorder")
|
||||
.contract(&REORDER_CONTRACT)
|
||||
.output(output_schema_for::<TimelineReordered>)
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
|
||||
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke → feature, position: its index now, from: its index before, moved: bool, note?: why nothing moved, or that a position past the end meant the last slot"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", crate::checked!("timeline.reorder_many", reorder_many))
|
||||
.icon("reorder")
|
||||
.contract(&REORDER_MANY_CONTRACT)
|
||||
.output(output_schema_for::<TimelineReorderedMany>)
|
||||
.params(
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", crate::checked!("timeline.redefine", redefine))
|
||||
.icon("edit")
|
||||
.contract(&REDEFINE_CONTRACT)
|
||||
.output(output_schema_for::<TimelineRedefined>)
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", crate::checked!("timeline.dependents", dependents))
|
||||
.icon("graph")
|
||||
@@ -183,24 +152,16 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", crate::checked!("select.set", select_set)).icon("select")
|
||||
CommandSpec::new("select.set", "Select", select_set).icon("select")
|
||||
.noundo()
|
||||
.contract(&SELECT_SET_CONTRACT)
|
||||
.output(output_schema_for::<SelectSetResult>)
|
||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool; face/edge: body plus point [x,y,z] in world mm or index (both also accepted); a point uses Measure's pick tolerance, an index supplies a point on the item; invalid partial picks leave the selection unchanged"),
|
||||
CommandSpec::new("select.filter", "Selection filter", select_filter).icon("select").noundo().doc_only()
|
||||
.params("mode: body|face|component → validated UI pick granularity; headless echo only, no document edits or undo"),
|
||||
CommandSpec::new("select.clear", "Clear Selection", crate::checked!("select.clear", select_clear))
|
||||
.icon("select")
|
||||
.noundo()
|
||||
.contract(&SELECT_CLEAR_CONTRACT)
|
||||
.output(output_schema_for::<SelectClearResult>),
|
||||
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
|
||||
CommandSpec::new("select.same_look", "Select Same Look", select_same_look).icon("select").noundo().doc_only()
|
||||
.params("body?: world body name, face?: face index; defaults to the current body/face selection → selects objects using the resolved appearance"),
|
||||
];
|
||||
|
||||
const SELECT_SET_CONTRACT: TypedContract<SelectSetParams> = TypedContract::new();
|
||||
const SELECT_CLEAR_CONTRACT: TypedContract<SelectClearParams> = TypedContract::new();
|
||||
const COMPUTE_ALL_CONTRACT: TypedContract<TimelineComputeAllParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<TimelineDeleteParams> = TypedContract::new();
|
||||
const RENAME_CONTRACT: TypedContract<TimelineRenameParams> = TypedContract::new();
|
||||
@@ -210,14 +171,6 @@ const DEPENDENTS_CONTRACT: TypedContract<TimelineDependentsParams> = TypedContra
|
||||
const SUPPRESS_CONTRACT: TypedContract<TimelineSuppressParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<TimelineEditParams> = TypedContract::new();
|
||||
const INSPECT_CONTRACT: TypedContract<TimelineInspectParams> = TypedContract::new();
|
||||
const GROUP_CONTRACT: TypedContract<TimelineGroupParams> = TypedContract::new();
|
||||
const UNGROUP_CONTRACT: TypedContract<TimelineUngroupParams> = TypedContract::new();
|
||||
const RENAME_GROUP_CONTRACT: TypedContract<TimelineRenameGroupParams> = TypedContract::new();
|
||||
const MOVE_TO_GROUP_CONTRACT: TypedContract<TimelineMoveToGroupParams> = TypedContract::new();
|
||||
const MOVE_MANY_CONTRACT: TypedContract<TimelineMoveManyParams> = TypedContract::new();
|
||||
const REORDER_CONTRACT: TypedContract<TimelineReorderParams> = TypedContract::new();
|
||||
const REORDER_MANY_CONTRACT: TypedContract<TimelineReorderManyParams> = TypedContract::new();
|
||||
const REDEFINE_CONTRACT: TypedContract<TimelineRedefineParams> = TypedContract::new();
|
||||
const PLAY_CONTRACT: crate::schema::TypedContract<crate::schema::edit::TimelinePlayParams> = crate::schema::TypedContract::new();
|
||||
|
||||
fn play(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -655,7 +608,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(not_contiguous(&folders, broken, cmd));
|
||||
}
|
||||
s.doc_mut().timeline_groups = folders;
|
||||
reply(cmd, TimelineGroupMade { group: id, name, parent, groups, param_warnings: Vec::new() })
|
||||
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -712,7 +665,7 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
reply(cmd, TimelineUngrouped { groups: ids, param_warnings: Vec::new() })
|
||||
Ok(json!({"groups": ids}))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -725,7 +678,7 @@ fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
|
||||
group.name = name.to_string();
|
||||
}
|
||||
reply(cmd, TimelineGroupRenamed { group: id, name: name.to_string(), param_warnings: Vec::new() })
|
||||
Ok(json!({"group": id, "name": name}))
|
||||
}
|
||||
|
||||
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -762,7 +715,7 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
reply(cmd, TimelineMovedToGroup { features, joints, groups, group: target, param_warnings: Vec::new() })
|
||||
Ok(json!({"features": features, "joints": joints, "groups": groups, "group": target}))
|
||||
}
|
||||
|
||||
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -808,7 +761,7 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
reply(cmd, TimelineMoved { features: ordered, groups, group: target, position: slot, param_warnings: Vec::new() })
|
||||
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1455,10 +1408,20 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// an existing index-and-point pair. Unlike Measure, selection keeps the exact face/edge index:
|
||||
/// it must not merge a cylinder's patches or a circular rim's arcs into a different item.
|
||||
fn selection_item(s: &Session, value: Value) -> Result<Sel> {
|
||||
let (face, PartialPick { body, index, point }) = match read_item(value).map_err(|e| bad("select.set", e))? {
|
||||
SelectItem::Exact(sel) => return Ok(sel),
|
||||
SelectItem::Pick { face, pick } => (face, pick),
|
||||
};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
let kind = value.get("type").and_then(Value::as_str);
|
||||
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
|
||||
return serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()));
|
||||
}
|
||||
let face = kind == Some("face");
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Pick {
|
||||
body: String,
|
||||
index: Option<usize>,
|
||||
point: Option<Vec3>,
|
||||
}
|
||||
let Pick { body, index, point } = serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
let state = s.world_state()?;
|
||||
let b = state.body(&body).ok_or_else(|| bad("select.set", format!("no body `{body}`")))?;
|
||||
let missing = || bad("select.set", format!("no {} of `{body}` at the given index or point", if face { "face" } else { "edge" }));
|
||||
@@ -1533,7 +1496,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.refresh_display();
|
||||
reply("select.set", SelectSetResult { selected: s.selection.len(), param_warnings: Vec::new() })
|
||||
Ok(json!({"selected": s.selection.len()}))
|
||||
}
|
||||
|
||||
fn select_filter(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1590,7 +1553,7 @@ fn select_same_look(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.selection.clear();
|
||||
s.refresh_display();
|
||||
reply("select.clear", SelectClearResult { selected: 0, param_warnings: Vec::new() })
|
||||
Ok(json!({"selected": 0}))
|
||||
}
|
||||
|
||||
/// Features that worked in `before` and fail in `after`: (name, error).
|
||||
@@ -1697,8 +1660,7 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let active = active_param(p, cmd)?;
|
||||
if from == to && active.is_none() {
|
||||
let why = capped.map_or_else(|| format!("it is already at position {to}"), |c| format!("{c}, where it already is"));
|
||||
let note = Some(format!("nothing moved: {why}"));
|
||||
return reply(cmd, TimelineReordered { feature: id, position: to, from, moved: false, note, broken: None, param_warnings: Vec::new() });
|
||||
return Ok(json!({"feature": id, "position": to, "from": from, "moved": false, "note": format!("nothing moved: {why}")}));
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
let f = next.features.remove(from);
|
||||
@@ -1743,22 +1705,20 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let mut out = json!({"feature": id, "position": now, "from": from, "moved": now != from});
|
||||
// With `active`, a feature left in place may still cross the history marker: say which.
|
||||
let stayed = (now == from).then(|| {
|
||||
let what = if s.doc.marker == marker { "nothing moved" } else { "only the history marker moved" };
|
||||
format!("{what}: {name} is already at position {now}")
|
||||
});
|
||||
let note: Vec<String> = [capped, stayed].into_iter().flatten().collect();
|
||||
let out = TimelineReordered {
|
||||
feature: id,
|
||||
position: now,
|
||||
from,
|
||||
moved: now != from,
|
||||
note: (!note.is_empty()).then(|| note.join("; ")),
|
||||
broken: allow.then(|| broken_features(&broken)),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, out)
|
||||
if !note.is_empty() {
|
||||
out["note"] = json!(note.join("; "));
|
||||
}
|
||||
if allow {
|
||||
out["broken"] = broken_json(&broken);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The optional `allow_errors` of a move: make it even when it breaks features that worked.
|
||||
@@ -1771,8 +1731,8 @@ fn allow_errors(p: &Value, cmd: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
/// The features a move broke, as `[{feature, error}]`.
|
||||
fn broken_features(broken: &[(String, String)]) -> Vec<BrokenFeature> {
|
||||
broken.iter().map(|(feature, error)| BrokenFeature { feature: feature.clone(), error: error.clone() }).collect()
|
||||
fn broken_json(broken: &[(String, String)]) -> Value {
|
||||
broken.iter().map(|(feature, error)| json!({"feature": feature, "error": error})).collect()
|
||||
}
|
||||
|
||||
/// The optional `active` of a move: whether the moved features end up before the history marker.
|
||||
@@ -1818,8 +1778,11 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let position = slot.min(s.doc.features.len());
|
||||
reply(cmd, TimelineReorderedMany { features: ordered, position, broken: allow.then(|| broken_features(&broken)), param_warnings: Vec::new() })
|
||||
let mut out = json!({"features": ordered, "position": slot.min(s.doc.features.len())});
|
||||
if allow {
|
||||
out["broken"] = broken_json(&broken);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The reordered design, the moved features in order, and the model the move was checked on
|
||||
@@ -2130,20 +2093,15 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
|
||||
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
|
||||
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
|
||||
let note = rolled_back.then(|| {
|
||||
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
|
||||
if let Some(warning) = warning {
|
||||
reply["warning"] = json!(warning);
|
||||
}
|
||||
if rolled_back {
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
format!("{name} is after the history marker, so it is not built: move the marker past it to build it")
|
||||
});
|
||||
let out = TimelineRedefined {
|
||||
feature: id,
|
||||
recomputed: rebuilt + s.model.last_recomputed,
|
||||
restored: reused,
|
||||
rolled_back,
|
||||
warning,
|
||||
note,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, out)
|
||||
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -87,8 +87,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("F")
|
||||
.contract(&FILLET_CONTRACT)
|
||||
.output(output_schema_for::<FilletResult>)
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them); type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("combine")
|
||||
@@ -177,7 +177,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("presspull")
|
||||
.key("Q")
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet); body?: the body the face or edges are on (for edges, as for solid.fillet)"),
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place); occurrences?: [occurrence paths, root first] (only with copy: each component is copied as an independent component, as Paste New copies it, and the copy moves by the same translate, axis and angle in the world; the reply lists the copies in occurrences)"),
|
||||
];
|
||||
|
||||
@@ -1554,131 +1554,20 @@ fn prim_cone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::Cone { base, axis: Dir(axis), radius, top_radius, height, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
/// What [`edge_points`] found: a point on each edge, in its body's frame, and the body the
|
||||
/// feature keeps (`None`: each edge is looked up on its own).
|
||||
struct EdgePicks {
|
||||
points: Vec<Vec3>,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
/// A body as a pick is looked up on: the model body, the name it is shown by, and the frame it
|
||||
/// is placed by and its inverse.
|
||||
struct PickRow {
|
||||
body: usize,
|
||||
name: String,
|
||||
frame: solidcraft_doc::Mat,
|
||||
inverse: solidcraft_doc::Mat,
|
||||
}
|
||||
|
||||
/// What one body's edges near a pick are: the point in its frame, its distance from the pick on
|
||||
/// the exact curves and the edge there, whether any edge within reach is not a seam, and the
|
||||
/// edges within reach (polyline distance, seam, the direction leaving).
|
||||
struct RowHit {
|
||||
row: usize,
|
||||
q: Vec3,
|
||||
d: f64,
|
||||
edge: Option<usize>,
|
||||
edge_like: bool,
|
||||
near: Vec<(f64, bool, Option<Vec3>)>,
|
||||
}
|
||||
|
||||
/// How many bodies a refusal names before "and N more".
|
||||
const NAMED_BODIES: usize = 8;
|
||||
|
||||
/// A body's size, its edges' polylines and which of them are seams.
|
||||
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>);
|
||||
|
||||
/// How far a polyline is from `q`.
|
||||
fn polyline_dist(points: &[Vec3], q: Vec3) -> f64 {
|
||||
points.windows(2).filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// How far from an edge of a body this big a pick still means it.
|
||||
fn edge_reach(size: f64) -> f64 {
|
||||
size * 0.05 + 1e-3
|
||||
}
|
||||
|
||||
/// Edge references → points on the edges, each in the frame of the body it lies on.
|
||||
///
|
||||
/// A point is looked up where it was picked, on every placed body, shown or hidden: a part
|
||||
/// whose edge is in the same place only in its own frame is not in the way, and two bodies
|
||||
/// with an edge there make it ambiguous. The polylines short-list the edges; which body a pick
|
||||
/// means is decided on the exact curves.
|
||||
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
/// Edge references → points on the edges.
|
||||
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "select 1…1000 edges"));
|
||||
}
|
||||
let verb = if cmd == "solid.chamfer" { "chamfer" } else { "round" };
|
||||
let st = s.model.state();
|
||||
// A picked point must lie on an edge (within the distance the kernel re-finds an edge from)
|
||||
// of the body the feature names, or of any body: a pick far from every edge would round
|
||||
// whichever edge is nearest (#783).
|
||||
let given = str_(p, "body");
|
||||
let named = given.and_then(|n| st.bodies.iter().position(|b| b.name == n));
|
||||
// A pick's own body is the feature's, which keeps one.
|
||||
let agrees = |i: usize| -> Result<usize> {
|
||||
match given {
|
||||
Some(g) if named != Some(i) => {
|
||||
let n = st.bodies.get(i).map_or("", |b| b.name.as_str());
|
||||
Err(bad(cmd, format!("an edge names `{n}`, but `body` is `{g}`: give one")))
|
||||
}
|
||||
_ => Ok(i),
|
||||
}
|
||||
};
|
||||
let identity = |i: usize, b: &solidcraft_doc::ModelBody| PickRow {
|
||||
body: i,
|
||||
name: b.name.clone(),
|
||||
frame: solidcraft_doc::IDENTITY,
|
||||
inverse: solidcraft_doc::IDENTITY,
|
||||
};
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
let local: Vec<PickRow> = st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()).map(|(i, b)| identity(i, b)).collect();
|
||||
let placed = crate::frames::placed(s);
|
||||
// Worked out for the first pick looked up where it was picked.
|
||||
let world: std::cell::OnceCell<Vec<PickRow>> = std::cell::OnceCell::new();
|
||||
let world_rows = || {
|
||||
world.get_or_init(|| {
|
||||
let index: std::collections::BTreeMap<&str, usize> = st.bodies.iter().enumerate().map(|(i, b)| (b.definition_name(), i)).collect();
|
||||
s.body_frames()
|
||||
.iter()
|
||||
.filter_map(|(name, own, frame)| {
|
||||
let body = *index.get(own.as_str())?;
|
||||
let inverse = solidcraft_doc::mat_inverse(frame)?;
|
||||
(!st.bodies.get(body)?.body.is_mesh()).then(|| PickRow { body, name: name.clone(), frame: *frame, inverse })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
// Per body, worked out once.
|
||||
let mut shapes: std::collections::BTreeMap<usize, EdgeShape> = std::collections::BTreeMap::new();
|
||||
let shape_of = |i: usize, shapes: &mut std::collections::BTreeMap<usize, EdgeShape>| {
|
||||
if shapes.contains_key(&i) {
|
||||
return;
|
||||
}
|
||||
let Some(b) = st.bodies.get(i) else { return };
|
||||
let size = b.body.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
// The display mesh flags seams (a smooth surface meeting itself, as down the side
|
||||
// of a cylinder), which nobody means by a pick where they end.
|
||||
let shape = match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
|
||||
// An edge where the faces meet tangentially (the spans of a spline wall
|
||||
// meeting) can't be blended either: nobody means it by a pick.
|
||||
Ok(m) => Some((
|
||||
size,
|
||||
m.edges,
|
||||
m.seams.iter().zip(&m.smooth_edges).map(|(s, t)| *s || *t).chain(m.seams.iter().skip(m.smooth_edges.len()).copied()).collect(),
|
||||
)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
};
|
||||
if let Some(shape) = shape {
|
||||
shapes.insert(i, shape);
|
||||
}
|
||||
};
|
||||
// whichever edge is nearest (#783). Each body's edges are worked out once.
|
||||
let named = str_(p, "body").filter(|n| st.body(n).is_some());
|
||||
// Per body: its size, its edges' polylines and which of them are seams.
|
||||
let mut candidates: Option<Vec<(f64, Vec<Vec<Vec3>>, Vec<bool>)>> = None;
|
||||
let mut out = Vec::new();
|
||||
// Per pick: the body it was found on, where it was picked, and how far from that body's edge.
|
||||
let mut found: Vec<(usize, String, Vec3, Vec3, f64)> = Vec::new();
|
||||
let mut seen: Vec<Vec3> = Vec::new();
|
||||
for e in a {
|
||||
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
|
||||
EdgeRefForm::Point(pt) => pt,
|
||||
@@ -1687,139 +1576,57 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
let mid = edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid;
|
||||
if let Some(i) = st.bodies.iter().position(|m| m.name == b.name) {
|
||||
found.push((agrees(i)?, b.name.clone(), mid, mid, 0.0));
|
||||
}
|
||||
out.push(mid);
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The nth pick seen at this point is the nth remembered there.
|
||||
let nth = seen.iter().filter(|q| **q == pt).count();
|
||||
seen.push(pt);
|
||||
let picked = crate::frames::picked_world(pt, nth).filter(|_| placed);
|
||||
let shown = picked.unwrap_or(pt);
|
||||
let own = match e.get("body").and_then(Value::as_str) {
|
||||
Some(n) => Some(agrees(st.bodies.iter().position(|b| b.name == n).ok_or_else(|| bad(cmd, format!("no body `{n}`")))?)?),
|
||||
None => named,
|
||||
};
|
||||
// Named: on that body only, in its frame. Else where it was picked, on every placed body;
|
||||
// a command run by another without the world point, or a design with no placement to look
|
||||
// through, sees the bodies in their own frames.
|
||||
let (w, rows): (Vec3, Vec<&PickRow>) = match (own, picked) {
|
||||
(Some(i), _) => (pt, local.iter().filter(|r| r.body == i).collect()),
|
||||
(None, Some(w)) if !world_rows().is_empty() => (w, world_rows().iter().collect()),
|
||||
_ => (pt, local.iter().collect()),
|
||||
};
|
||||
let q_of = |r: &PickRow| {
|
||||
let q = solidcraft_doc::apply_point(&r.inverse, w);
|
||||
(q.x.is_finite() && q.y.is_finite() && q.z.is_finite()).then_some(q)
|
||||
};
|
||||
// Only bodies whose box (grown by the reach) holds the pick: an edge lies in its body's box.
|
||||
let culled: Vec<(usize, Vec3)> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, r)| {
|
||||
let b = st.bodies.get(r.body)?;
|
||||
let q = q_of(r)?;
|
||||
b.outline_bounds().inflated(edge_reach(b.body.size())).contains(q).then_some((k, q))
|
||||
})
|
||||
.collect();
|
||||
for (k, _) in &culled {
|
||||
if let Some(r) = rows.get(*k) {
|
||||
shape_of(r.body, &mut shapes);
|
||||
}
|
||||
}
|
||||
let mut hits: Vec<RowHit> = Vec::new();
|
||||
for (k, q) in culled {
|
||||
let Some(r) = rows.get(k) else { continue };
|
||||
let (Some((size, edges, seams)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let mut near = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = polyline_dist(points, q);
|
||||
if d <= edge_reach(*size) {
|
||||
let seam = seams.get(i).copied().unwrap_or(false);
|
||||
near.push((d, seam, leaving(points, q)));
|
||||
candidates.push((i, d, seam));
|
||||
}
|
||||
}
|
||||
if near.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Seams count only where nothing else is near.
|
||||
let edge_like = candidates.iter().any(|(_, _, seam)| !seam);
|
||||
let candidates: Vec<(usize, f64)> = candidates.into_iter().filter(|(_, _, seam)| !edge_like || !seam).map(|(i, d, _)| (i, d)).collect();
|
||||
let polyline = candidates.iter().map(|(_, d)| *d).fold(f64::INFINITY, f64::min);
|
||||
let (d, edge) = candidates
|
||||
let bodies = candidates.get_or_insert_with(|| {
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|(_, d)| *d <= polyline + 2.0 * chord)
|
||||
.map(|(i, d)| (edge_foot(&b.body, *i, q).map_or(*d, |(_, foot)| solidcraft_doc::apply_point(&r.frame, foot).dist(w)), *i))
|
||||
.fold((f64::INFINITY, None), |best, (d, i)| if d < best.0 { (d, Some(i)) } else { best });
|
||||
hits.push(RowHit { row: k, q, d, edge, edge_like, near });
|
||||
}
|
||||
if hits.is_empty() {
|
||||
if rows.is_empty() {
|
||||
out.push(pt);
|
||||
continue;
|
||||
}
|
||||
// How far the nearest edge is, on every body the pick was looked up on.
|
||||
let mut nearest = f64::INFINITY;
|
||||
for r in &rows {
|
||||
let Some(q) = q_of(r) else { continue };
|
||||
shape_of(r.body, &mut shapes);
|
||||
let Some((_, edges, _)) = shapes.get(&r.body) else { continue };
|
||||
nearest = edges.iter().map(|points| polyline_dist(points, q)).fold(nearest, f64::min);
|
||||
}
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", shown.x, shown.y, shown.z)));
|
||||
}
|
||||
// A body with only a seam there has no edge to blend, where another body has one.
|
||||
if hits.iter().any(|h| h.edge_like) {
|
||||
hits.retain(|h| h.edge_like);
|
||||
}
|
||||
// Bodies with an edge as near as the nearest one's: the pick is on both. A pick off its
|
||||
// curve by e widens the window to about 2e. The same body placed twice, at the same
|
||||
// edge or point of its frame, is one edge.
|
||||
let d_min = hits.iter().map(|h| h.d).fold(f64::INFINITY, f64::min);
|
||||
let mut groups: Vec<&RowHit> = Vec::new();
|
||||
for h in hits.iter().filter(|h| h.d <= 2.0 * d_min + 1e-3) {
|
||||
let Some(r) = rows.get(h.row) else { continue };
|
||||
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _)| *size);
|
||||
let same = |g: &&RowHit| {
|
||||
rows.get(g.row).is_some_and(|o| o.body == r.body) && ((g.edge.is_some() && g.edge == h.edge) || g.q.dist(h.q) <= 1e-6 * size.max(1.0))
|
||||
};
|
||||
if !groups.iter().any(same) {
|
||||
groups.push(h);
|
||||
}
|
||||
}
|
||||
if groups.len() > 1 {
|
||||
let names: Vec<String> = groups.iter().filter_map(|g| rows.get(g.row).map(|r| r.name.clone())).collect();
|
||||
let hidden: String = names.iter().filter(|n| !s.body_visible(n)).map(|n| format!(" ({n} is hidden)")).collect();
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"[{}, {}, {}] lies on edges of {} bodies, {}: name the one to {verb} with `body`{hidden}",
|
||||
shown.x,
|
||||
shown.y,
|
||||
shown.z,
|
||||
names.len(),
|
||||
and_list(&names, NAMED_BODIES)
|
||||
),
|
||||
));
|
||||
}
|
||||
let Some(hit) = hits.iter().filter(|h| h.d <= d_min).chain(hits.iter()).next() else { continue };
|
||||
let Some(row) = rows.get(hit.row) else { continue };
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
// The display mesh flags seams (a smooth surface meeting itself, as down the side
|
||||
// of a cylinder), which nobody means by a pick where they end.
|
||||
match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
|
||||
// An edge where the faces meet tangentially (the spans of a spline wall
|
||||
// meeting) can't be blended either: nobody means it by a pick.
|
||||
Ok(m) => Some((
|
||||
size,
|
||||
m.edges,
|
||||
m.seams
|
||||
.iter()
|
||||
.zip(&m.smooth_edges)
|
||||
.map(|(s, t)| *s || *t)
|
||||
.chain(m.seams.iter().skip(m.smooth_edges.len()).copied())
|
||||
.collect(),
|
||||
)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
// A point where several edges meet (a corner) is ambiguous: the kernel would round one of
|
||||
// them, picked silently (#5173). Edges that carry straight on through the point (the two
|
||||
// halves of a circle) are one edge to whoever picked it, and seams and tangent joins don't
|
||||
// count. Directions come from the polylines' end chords, which lean off a curve's
|
||||
// tangent: within about 45° of opposite counts as carrying on (edges at a real corner are
|
||||
// far from opposite).
|
||||
let nearest = hit.near.iter().map(|(d, _, _)| *d).fold(f64::INFINITY, f64::min);
|
||||
let mut meeting: Vec<Option<Vec3>> = hit.near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
|
||||
// count. Directions
|
||||
// come from the polylines' end chords, which lean off a curve's tangent: within about 45°
|
||||
// of opposite counts as carrying on (edges at a real corner are far from opposite).
|
||||
let mut nearest = f64::INFINITY;
|
||||
let mut near = Vec::new();
|
||||
for (size, edges, seams) in bodies.iter() {
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = points.windows(2).filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
if d <= size * 0.05 + 1e-3 {
|
||||
near.push((d, seams.get(i).copied().unwrap_or(false), leaving(points, pt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let matches = near.len();
|
||||
let mut meeting: Vec<Option<Vec3>> = near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
|
||||
let mut chains = 0usize;
|
||||
while let Some(dir) = meeting.pop() {
|
||||
chains += 1;
|
||||
@@ -1832,107 +1639,16 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
if chains > 1 {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", shown.x, shown.y, shown.z),
|
||||
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", pt.x, pt.y, pt.z),
|
||||
));
|
||||
}
|
||||
found.push((row.body, row.name.clone(), hit.q, shown, hit.d));
|
||||
out.push(hit.q);
|
||||
}
|
||||
if given.is_some() {
|
||||
return Ok(EdgePicks { points: out, body: given.map(str::to_string) });
|
||||
}
|
||||
let mut bodies: Vec<(usize, String)> = Vec::new();
|
||||
for (i, name, ..) in &found {
|
||||
if !bodies.iter().any(|(b, _)| b == i) {
|
||||
bodies.push((*i, name.clone()));
|
||||
if matches == 0 && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
}
|
||||
out.push(pt);
|
||||
}
|
||||
let body = match bodies.as_slice() {
|
||||
// One body of several: the feature keeps it, so the model never looks the edges up on
|
||||
// another body that has one in the same place in its own frame.
|
||||
[(i, _)] => st.bodies.get(*i).filter(|_| local.len() > 1).map(|b| b.name.clone()),
|
||||
[] => None,
|
||||
_ => {
|
||||
// Several: each edge is looked up on its own when the model is built, on whichever
|
||||
// body has an edge there in its own frame, so another body's edge there is refused.
|
||||
for (i, _, q, shown, d) in &found {
|
||||
for other in local.iter().filter(|r| r.body != *i) {
|
||||
let Some(b) = st.bodies.get(other.body) else { continue };
|
||||
let reach = edge_reach(b.body.size());
|
||||
if !b.outline_bounds().inflated(reach).contains(*q) {
|
||||
continue;
|
||||
}
|
||||
shape_of(other.body, &mut shapes);
|
||||
let Some((size, edges, seams)) = shapes.get(&other.body) else { continue };
|
||||
// The exact curve is no nearer than the polyline less its chord tolerance.
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let ghost = edges.iter().enumerate().filter(|(k, _)| !seams.get(*k).copied().unwrap_or(false)).any(|(k, points)| {
|
||||
let poly = polyline_dist(points, *q);
|
||||
poly <= reach && poly - chord <= 2.0 * d + 1e-3 && edge_foot(&b.body, k, *q).map_or(poly, |(d, _)| d) <= 2.0 * d + 1e-3
|
||||
});
|
||||
if ghost {
|
||||
let names: Vec<String> = bodies.iter().map(|(_, n)| n.clone()).collect();
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"the picks lie on {} bodies, {}, and at [{}, {}, {}] {} has an edge in the same place in its own component: {verb} each body in a command of its own, naming it with `body`",
|
||||
names.len(),
|
||||
and_list(&names, NAMED_BODIES),
|
||||
shown.x,
|
||||
shown.y,
|
||||
shown.z,
|
||||
b.name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(EdgePicks { points: out, body })
|
||||
}
|
||||
|
||||
/// "A", "A and B", "A, B and C", and past `most` names "A, B and 3 more".
|
||||
fn and_list(names: &[String], most: usize) -> String {
|
||||
let mut shown: Vec<String> = names.iter().take(most).cloned().collect();
|
||||
if names.len() > most {
|
||||
shown.push(format!("{} more", names.len() - most));
|
||||
}
|
||||
match shown.split_last() {
|
||||
Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
|
||||
Some((last, _)) => last.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance from `q` to edge `index` of `body` on its exact curve, and the point there:
|
||||
/// samples, then a golden-section search between the best one's neighbours.
|
||||
fn edge_foot(body: &solidcraft_kernel::Body, index: usize, q: Vec3) -> Option<(f64, Vec3)> {
|
||||
const SAMPLES: usize = 32;
|
||||
const STEPS: usize = 40;
|
||||
let e = body.record().edges.get(index)?;
|
||||
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
|
||||
let [t0, t1] = e.interval;
|
||||
if !(t0.is_finite() && t1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let at = |t: f64| c.point(t).map(|p| p.dist(q)).filter(|d| d.is_finite());
|
||||
let step = (t1 - t0) / SAMPLES as f64;
|
||||
let (k, _) = (0..=SAMPLES).filter_map(|k| Some((k, at(t0 + step * k as f64)?))).min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let (mut lo, mut hi) = (t0 + step * k.saturating_sub(1) as f64, t0 + step * (k + 1).min(SAMPLES) as f64);
|
||||
let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
|
||||
for _ in 0..STEPS {
|
||||
let (a, b) = (hi - ratio * (hi - lo), lo + ratio * (hi - lo));
|
||||
if at(a).unwrap_or(f64::INFINITY) <= at(b).unwrap_or(f64::INFINITY) {
|
||||
hi = b;
|
||||
} else {
|
||||
lo = a;
|
||||
}
|
||||
}
|
||||
let foot = c.point((lo + hi) / 2.0)?;
|
||||
let d = foot.dist(q);
|
||||
d.is_finite().then_some((d, foot))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
|
||||
@@ -1971,11 +1687,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.press_pull";
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
if p.get("edges").is_some() {
|
||||
let mut q = json!({"edges": p.get("edges"), "radius": distance});
|
||||
if let Some(body) = p.get("body").filter(|b| b.is_string()) {
|
||||
q["body"] = body.clone();
|
||||
}
|
||||
return fillet(s, &q);
|
||||
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
|
||||
}
|
||||
let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?;
|
||||
let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?;
|
||||
@@ -2025,7 +1737,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.fillet";
|
||||
let picks = edge_points(s, p, cmd)?;
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let radius = req_expr(cmd, p, "radius")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
|
||||
@@ -2128,9 +1840,9 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Fillet {
|
||||
edges: picks.points.into_iter().map(Position).collect(),
|
||||
edges: edges.into_iter().map(Position).collect(),
|
||||
radius,
|
||||
body: picks.body.map(BodyName::from),
|
||||
body: str_(p, "body").map(BodyName::from),
|
||||
style,
|
||||
fit,
|
||||
corner,
|
||||
@@ -2154,7 +1866,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.chamfer";
|
||||
let picks = edge_points(s, p, cmd)?;
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
|
||||
@@ -2172,12 +1884,12 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Chamfer {
|
||||
edges: picks.points.into_iter().map(Position).collect(),
|
||||
edges: edges.into_iter().map(Position).collect(),
|
||||
distance,
|
||||
distance2,
|
||||
angle,
|
||||
flip,
|
||||
body: picks.body.map(BodyName::from),
|
||||
body: str_(p, "body").map(BodyName::from),
|
||||
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
|
||||
},
|
||||
)
|
||||
|
||||
+69
-109
@@ -16,12 +16,10 @@ use solidcraft_geom::Aabb3;
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, vec3};
|
||||
use crate::schema::joints::{
|
||||
AsBuiltJointParams, CamLift, CamParams, CamResult, ContactStop, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult,
|
||||
DriveJointParams, DriveJointResult, EditJointResult, FloatingChain, GearParams, HeldValue, InspectJointParams, InspectJointResult,
|
||||
JointAddedResult, JointDeleted, JointFrame, JointLimitsParams, JointProblem, LimitSide, LimitStop, LinkInfo, ListJointsParams, ListJointsResult,
|
||||
ListedJoint, ListedOrigin, LoopStop, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, NamedValue, OccurrenceDof, RackPinionParams,
|
||||
ReachLoopStop, ReachParams, ReachResult, ReachedJoint, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveReport,
|
||||
SolveResult, ValueUnit,
|
||||
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};
|
||||
@@ -35,25 +33,12 @@ 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();
|
||||
const ORIGIN_CONTRACT: TypedContract<MakeJointOriginParams> = TypedContract::new();
|
||||
const RIGID_GROUP_CONTRACT: TypedContract<RigidGroupParams> = TypedContract::new();
|
||||
const REACH_CONTRACT: TypedContract<ReachParams> = TypedContract::new();
|
||||
const MOTION_LINK_CONTRACT: TypedContract<MotionLinkParams> = TypedContract::new();
|
||||
const GEAR_CONTRACT: TypedContract<GearParams> = TypedContract::new();
|
||||
const RACK_PINION_CONTRACT: TypedContract<RackPinionParams> = TypedContract::new();
|
||||
const SCREW_CONTRACT: TypedContract<ScrewParams> = TypedContract::new();
|
||||
const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
|
||||
|
||||
#[path = "joints_inspect.rs"]
|
||||
mod inspect;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("joint_origin")
|
||||
.contract(&ORIGIN_CONTRACT)
|
||||
.output(output_schema_for::<MakeJointOriginResult>)
|
||||
.params(
|
||||
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", crate::checked!("joint.create", joint))
|
||||
@@ -83,12 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"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", crate::checked!("joint.rigid_group", rigid_group))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("rigid_group")
|
||||
.contract(&RIGID_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<RigidGroupResult>)
|
||||
.params("occurrences: [ids or names] (move together, as they are)"),
|
||||
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", crate::checked!("joint.drive", drive))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("drive")
|
||||
@@ -103,59 +83,33 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
reason: why the joint can move nothing (suppressed: nothing is stored; its origins lost; its chain joined to nothing grounded → unconnected: \
|
||||
the occurrence named; or, when nothing moved, the first held value's why); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)",
|
||||
),
|
||||
CommandSpec::new("joint.reach", "Reach Point", crate::checked!("joint.reach", reach))
|
||||
.icon("reach")
|
||||
.contract(&REACH_CONTRACT)
|
||||
.output(output_schema_for::<ReachResult>)
|
||||
.params(
|
||||
CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params(
|
||||
"occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move",
|
||||
),
|
||||
CommandSpec::new("joint.motion_link", "Motion Link", crate::checked!("joint.motion_link", motion_link))
|
||||
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("motion_link")
|
||||
.contract(&MOTION_LINK_CONTRACT)
|
||||
.output(output_schema_for::<SolveResult>)
|
||||
.params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"),
|
||||
CommandSpec::new("joint.gear", "Gear", crate::checked!("joint.gear", gear))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("gear")
|
||||
.contract(&GEAR_CONTRACT)
|
||||
.output(output_schema_for::<CouplingResult>)
|
||||
.params(
|
||||
CommandSpec::new("joint.gear", "Gear", gear).at("SOLID", "ASSEMBLE").icon("gear").params(
|
||||
"a, b: joints that turn (revolute), b driven by a; ra, rb: the gears' pitch radii (or tooth counts): mm, an expression, or \
|
||||
{circle: [x,y,z], occurrence?}: the round face there, its radius (read again whenever the design changes, so the ratio follows \
|
||||
edits); internal?: bool (one is a ring gear: both turn the same way; by default b turns the other way); reverse?: bool; \
|
||||
link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is, also when the link replaces \
|
||||
another (at the current pose)",
|
||||
),
|
||||
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", crate::checked!("joint.rack_pinion", rack_pinion))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("rack_pinion")
|
||||
.contract(&RACK_PINION_CONTRACT)
|
||||
.output(output_schema_for::<CouplingResult>)
|
||||
.params(
|
||||
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", rack_pinion).at("SOLID", "ASSEMBLE").icon("rack_pinion").params(
|
||||
"a, b: a joint that turns (revolute: the pinion) and one that slides (slider: the rack), in either order, b driven by a; \
|
||||
radius: the pinion's pitch radius (mm, an expression, or {circle: [x,y,z], occurrence?}): the rack travels radius × the turn in \
|
||||
radians; reverse?: bool; link?: index (replace that motion link) → link, ratio (mm per degree, or degrees per mm); b keeps \
|
||||
where it is, also when the link replaces another (at the current pose)",
|
||||
),
|
||||
CommandSpec::new("joint.screw", "Screw", crate::checked!("joint.screw", screw))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("screw")
|
||||
.contract(&SCREW_CONTRACT)
|
||||
.output(output_schema_for::<CouplingResult>)
|
||||
.params(
|
||||
CommandSpec::new("joint.screw", "Screw", screw).at("SOLID", "ASSEMBLE").icon("screw").params(
|
||||
"joint: a cylindrical joint (its turn drives its travel) | a, b: a joint that turns and one that slides, b driven by a; pitch: \
|
||||
travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \
|
||||
ratio (mm per degree, or degrees per mm); b keeps where it is, also when the link replaces another (at the current \
|
||||
pose)",
|
||||
),
|
||||
CommandSpec::new("joint.cam", "Cam", crate::checked!("joint.cam", cam_cmd))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("cam")
|
||||
.contract(&CAM_CONTRACT)
|
||||
.output(output_schema_for::<CamResult>)
|
||||
.params(
|
||||
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", crate::checked!("joint.limits", limits))
|
||||
@@ -352,6 +306,11 @@ fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
/// 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()
|
||||
@@ -627,7 +586,7 @@ fn same_frame(s: &Session, o: &JointOrigin, at: Vec3, z: Vec3) -> bool {
|
||||
/// The longest face name a joint origin takes. A face that cuts split and merged keeps the names
|
||||
/// of the faces around it (`<name>#[<name>,…]`): several hundred characters on a part a text or
|
||||
/// a pattern of holes was cut through.
|
||||
pub(crate) const MAX_FACE_NAME: usize = 16_384;
|
||||
const MAX_FACE_NAME: usize = 16_384;
|
||||
|
||||
/// The component an occurrence places (0 for the root, or an unknown occurrence).
|
||||
pub(crate) fn component_of(s: &Session, occurrence: u64) -> u64 {
|
||||
@@ -1018,7 +977,7 @@ fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("there is already a joint origin `{name}`")));
|
||||
}
|
||||
s.doc_mut().assembly.origins.push(NamedOrigin { name: name.clone(), origin });
|
||||
reply(cmd, MakeJointOriginResult { origin: name, param_warnings: Vec::new() })
|
||||
Ok(json!({"origin": name}))
|
||||
}
|
||||
|
||||
fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
|
||||
@@ -1139,9 +1098,9 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut made = Vec::new();
|
||||
for other in ids.iter().skip(1) {
|
||||
let r = as_built(s, &json!({"type": "rigid", "a": first, "b": other, "name": format!("Rigid Group {first}:{other}")}))?;
|
||||
made.push(r["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint made has no id"))?);
|
||||
made.push(r["joint"].clone());
|
||||
}
|
||||
reply(cmd, RigidGroupResult { joints: made, param_warnings: Vec::new() })
|
||||
Ok(json!({"joints": made}))
|
||||
}
|
||||
|
||||
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1304,7 +1263,7 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &
|
||||
}
|
||||
|
||||
/// Joints `joint.reach` takes at most in `joints`.
|
||||
pub(crate) const MAX_REACH_JOINTS: usize = 1000;
|
||||
const MAX_REACH_JOINTS: usize = 1000;
|
||||
|
||||
fn reach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.reach";
|
||||
@@ -1355,54 +1314,49 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sol = if response_analysis(p) == joints::Analysis::Placement { place(s) } else { resolve(s) };
|
||||
let t = joints::occurrence_world(&s.doc, occ);
|
||||
let got = solidcraft_doc::apply_point(&t, at);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["occurrence"] = json!(occurrence_name(s, occ));
|
||||
out["point"] = json!([got.x, got.y, got.z]);
|
||||
out["error_mm"] = json!(got.dist(to));
|
||||
let mut there = got.dist(to) <= joints::REACH_TOL;
|
||||
let mut error_deg = None;
|
||||
if let Some((a, z)) = axis {
|
||||
// From the sine too: the cosine alone loses small angles to rounding.
|
||||
let v = solidcraft_doc::apply_vector(&t, a);
|
||||
let deg = v.cross(z).len().atan2(v.dot(z)).to_degrees();
|
||||
error_deg = Some(deg);
|
||||
out["error_deg"] = json!(deg);
|
||||
there &= deg <= joints::REACH_TOL;
|
||||
}
|
||||
out["reached"] = json!(there);
|
||||
let name = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id);
|
||||
// As joint.list gives them: with what motion links set.
|
||||
let effective = joints::joint_values(&s.doc.assembly);
|
||||
let values = reached
|
||||
out["values"] = reached
|
||||
.values
|
||||
.keys()
|
||||
.filter_map(|id| name(*id))
|
||||
.map(|j| ReachedJoint { joint: j.id, name: j.name.clone(), values: named_value_list(j, effective.get(&j.id).unwrap_or(&j.values)) })
|
||||
.map(|j| json!({"joint": j.id, "name": j.name, "values": named_values(j, effective.get(&j.id).unwrap_or(&j.values))}))
|
||||
.collect();
|
||||
let stopped_by_limit = (!reached.limits.is_empty()).then(|| {
|
||||
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
|
||||
out["stopped_by_limit"] = if reached.limits.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
reached
|
||||
.limits
|
||||
.iter()
|
||||
.filter_map(|(id, i, upper)| {
|
||||
let j = name(*id)?;
|
||||
Some(LimitStop {
|
||||
joint: j.name.clone(),
|
||||
value: j.kind.dofs().get(*i).map(|d| (*d).to_string()),
|
||||
limit: if *upper { LimitSide::Max } else { LimitSide::Min },
|
||||
})
|
||||
Some(json!({"joint": j.name, "value": j.kind.dofs().get(*i), "limit": if *upper { "max" } else { "min" }}))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let result = ReachResult {
|
||||
solved: solve_report(&sol, p),
|
||||
occurrence: occurrence_name(s, occ),
|
||||
point: [got.x, got.y, got.z],
|
||||
error_mm: got.dist(to),
|
||||
error_deg,
|
||||
reached: there,
|
||||
values,
|
||||
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
|
||||
stopped_by_limit,
|
||||
stopped_by_loop: reached.stopped_by_loop.and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
|
||||
reason: r.err(),
|
||||
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, result)
|
||||
out["stopped_by_loop"] = json!(reached.stopped_by_loop.and_then(name).map(|j| json!({"joint": j.name})));
|
||||
if let Err(why) = r {
|
||||
out["reason"] = json!(why);
|
||||
}
|
||||
if bool_(p, "continuous") == Some(true) {
|
||||
out["continuous"] = json!(true);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The centre of the box around an occurrence's bodies, in its own coordinates (its origin when
|
||||
@@ -1433,7 +1387,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ratio = ratio * ratio_scale(s, a, ia, b, ib);
|
||||
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() });
|
||||
let sol = resolve(s);
|
||||
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
/// A coupling length: a number (mm), an expression (kept as typed, so it follows the
|
||||
@@ -1488,7 +1442,7 @@ fn gear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
internal: bool_(p, "internal").unwrap_or(false),
|
||||
reverse: reverse(p),
|
||||
};
|
||||
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
}
|
||||
|
||||
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1496,7 +1450,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, b) = joint_pair(s, p, cmd)?;
|
||||
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
|
||||
let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) };
|
||||
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
}
|
||||
|
||||
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1516,7 +1470,7 @@ fn screw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) };
|
||||
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
}
|
||||
|
||||
fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1550,23 +1504,26 @@ fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false);
|
||||
let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let reverse = c.reverse;
|
||||
let coupled = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
|
||||
let lift = s.doc.assembly.links.get(coupled.link).and_then(|l| lift_info(l, oscillating));
|
||||
reply(
|
||||
cmd,
|
||||
CamResult {
|
||||
solved: coupled.solved,
|
||||
link: coupled.link,
|
||||
lift,
|
||||
reverse,
|
||||
follower_offset: (!oscillating).then_some(offset),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
|
||||
let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX);
|
||||
if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) {
|
||||
o.remove("ratio");
|
||||
o.insert("lift".into(), lift_json(l, oscillating));
|
||||
o.insert("reverse".into(), json!(reverse));
|
||||
if !oscillating {
|
||||
o.insert("follower_offset".into(), json!(offset));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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; `None` for a table that is not whole.
|
||||
/// 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 {
|
||||
@@ -1637,7 +1594,7 @@ fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result<CamProfile> {
|
||||
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
|
||||
/// sizes, its offset leaving b where it is (the teeth mesh as they are now), for a new link and
|
||||
/// one put in place of another alike.
|
||||
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<CouplingResult> {
|
||||
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Value> {
|
||||
let links = &s.doc.assembly.links;
|
||||
let replace = match p.get("link") {
|
||||
None => None,
|
||||
@@ -1690,7 +1647,10 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Co
|
||||
}
|
||||
};
|
||||
let sol = resolve(s);
|
||||
Ok(CouplingResult { solved: solve_report(&sol, p), link: index, ratio: shown, param_warnings: Vec::new() })
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["link"] = json!(index);
|
||||
out["ratio"] = json!(shown);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn is_angle(s: &Session, joint: u64, i: usize) -> bool {
|
||||
|
||||
@@ -86,8 +86,6 @@ mod script;
|
||||
mod section;
|
||||
pub mod settings;
|
||||
pub(crate) use clipboard::{component_path_form, copy_keys};
|
||||
pub(crate) use component::matrix::{rigid as rigid_matrix, tolerance as matrix_tolerance};
|
||||
pub(crate) use component::{base_transform, move_vector};
|
||||
pub(crate) use measure_sel::{measure_items, shape_of};
|
||||
pub(crate) use measure_shape::Shape;
|
||||
pub(crate) use script::step_params;
|
||||
@@ -106,8 +104,8 @@ mod render_ui;
|
||||
mod script_tests;
|
||||
mod sheet;
|
||||
pub(crate) use sheet::{
|
||||
FlangeType, ThenStep, bend_table_format, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position,
|
||||
perforation_pattern, sheet_points, sheet_rule_name, then_list,
|
||||
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
|
||||
sheet_rule_name, then_list,
|
||||
};
|
||||
mod silhouette;
|
||||
mod sketch;
|
||||
@@ -131,7 +129,6 @@ 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;
|
||||
|
||||
@@ -108,22 +108,6 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, expected_revision?: reopen retained state, activate and request focus"
|
||||
),
|
||||
command!(
|
||||
"panel.focus.next",
|
||||
"fit",
|
||||
"Focus Next Panel",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.focus.previous",
|
||||
"fit",
|
||||
"Focus Previous Panel",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.list",
|
||||
"list",
|
||||
@@ -140,14 +124,6 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, destination: new_section, join_tab or relative, activate?: false, expected_revision?"
|
||||
),
|
||||
command!(
|
||||
"panel.move.next_zone",
|
||||
"move",
|
||||
"Move Panel to Next Zone",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.native_close",
|
||||
"restore",
|
||||
@@ -204,56 +180,4 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
crate::panels::LayoutReply,
|
||||
"first, second, first_size, second_size, expected_revision?: conserve adjacent dock pair extent"
|
||||
),
|
||||
command!(
|
||||
"panel.sash.down",
|
||||
"scale",
|
||||
"Resize Panel Sash Down",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Down"),
|
||||
command!(
|
||||
"panel.sash.left",
|
||||
"scale",
|
||||
"Resize Panel Sash Left",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Left"),
|
||||
command!(
|
||||
"panel.sash.reset",
|
||||
"restore",
|
||||
"Reset Focused Panel Sash",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.sash.right",
|
||||
"scale",
|
||||
"Resize Panel Sash Right",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Right"),
|
||||
command!(
|
||||
"panel.sash.up",
|
||||
"scale",
|
||||
"Resize Panel Sash Up",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Up"),
|
||||
command!(
|
||||
"panel.toggle",
|
||||
"fold",
|
||||
"Toggle Focused Panel",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
];
|
||||
|
||||
+51
-110
@@ -16,10 +16,8 @@ use super::features::{add_feature, check_expr, feature_sketch, profiles, sketch_
|
||||
use crate::params::{bad, bool_, expr, expr_problem, req_expr, str_, string_list, vec3};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::sheet::{
|
||||
BendDirection, BendTableFormat, BendTableParams, BendTableResult, ConvertParams, FlangeParams, FlatBend, FlatBore, FlatCircle, FlatPatternParams,
|
||||
FlatPatternResult, FlatPerforation, FlatPerforationPattern, FlatPocket, FlatSegment, FlatShape, FoldParams, HemParams, ListedRule,
|
||||
ManageRulesParams, ManageRulesResult, PathFlangeParams, PerforateParams, PocketSide, RuleFeatureError, RuleReply, RulesParams, RulesResult,
|
||||
UnfoldParams,
|
||||
ConvertParams, FlangeParams, FoldParams, HemParams, ListedRule, ManageRulesParams, ManageRulesResult, PerforateParams, RuleFeatureError,
|
||||
RuleReply, RulesParams, RulesResult, UnfoldParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -34,10 +32,6 @@ const CONVERT_CONTRACT: TypedContract<ConvertParams> = TypedContract::new();
|
||||
const RULES_CONTRACT: TypedContract<RulesParams> = TypedContract::new();
|
||||
const MANAGE_RULES_CONTRACT: TypedContract<ManageRulesParams> = TypedContract::new();
|
||||
|
||||
const PATH_FLANGE_CONTRACT: TypedContract<PathFlangeParams> = TypedContract::new();
|
||||
const FLAT_PATTERN_CONTRACT: TypedContract<FlatPatternParams> = TypedContract::new();
|
||||
const BEND_TABLE_CONTRACT: TypedContract<BendTableParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.flange", "Flange", crate::checked!("sheet.flange", flange))
|
||||
.at("SHEET METAL", "CREATE")
|
||||
@@ -59,12 +53,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \
|
||||
and so is the corner relief on a base or contour flange",
|
||||
),
|
||||
CommandSpec::new("sheet.path_flange", "Path Flange", crate::checked!("sheet.path_flange", path_flange))
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("path_flange")
|
||||
.contract(&PATH_FLANGE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
CommandSpec::new("sheet.path_flange", "Path Flange", path_flange).at("SHEET METAL", "CREATE").icon("path_flange").params(
|
||||
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines drawn on a plane across \
|
||||
that edge, from the edge out (each line one flange as long as the line, to the outer face from the virtual sharp; each \
|
||||
corner one bend, turning the way the lines turn); radius? (default: rule); body?. One feature, one body",
|
||||
@@ -132,12 +121,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", crate::checked!("sheet.rules", list_rules))
|
||||
.contract(&RULES_CONTRACT).output(output_schema_for::<RulesResult>).icon("sheet_rules").noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", crate::checked!("sheet.flat_pattern", flat_pattern))
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
.noundo()
|
||||
.contract(&FLAT_PATTERN_CONTRACT)
|
||||
.output(output_schema_for::<FlatPatternResult>)
|
||||
.params(
|
||||
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
|
||||
),
|
||||
@@ -154,10 +141,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
→ DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \
|
||||
POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM (one per side and depth) and PERFORATION; inner contours come before the outline",
|
||||
),
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", crate::checked!("sheet.export_bend_table", export_bend_table)).icon("bendtable")
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table).icon("bendtable")
|
||||
.noundo()
|
||||
.contract(&BEND_TABLE_CONTRACT)
|
||||
.output(output_schema_for::<BendTableResult>)
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; angle_deg is the bend angle and included_angle_deg the angle left between the bend's sides (180° less it); a bend that cut-outs split has a row for each piece"),
|
||||
];
|
||||
|
||||
@@ -749,24 +734,24 @@ fn loop_circle(lp: &solidcore_record::Loop2) -> Option<(Vec2, f64)> {
|
||||
|
||||
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
|
||||
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
|
||||
fn loop_shape(lp: &solidcore_record::Loop2) -> FlatShape {
|
||||
fn loop_shape(lp: &solidcore_record::Loop2) -> Value {
|
||||
use solidcore_record::Seg2;
|
||||
let pt = |v: Vec2| [v.x, v.y];
|
||||
let pt = |v: Vec2| json!([v.x, v.y]);
|
||||
if let Some((center, radius)) = loop_circle(lp) {
|
||||
return FlatShape::Circle { circle: FlatCircle { center: pt(center), radius } };
|
||||
return json!({"circle": {"center": pt(center), "radius": radius}});
|
||||
}
|
||||
let segments: Vec<FlatSegment> = lp
|
||||
let segments: Vec<Value> = lp
|
||||
.segs
|
||||
.iter()
|
||||
.map(|seg| match *seg {
|
||||
Seg2::Line { a, b } => FlatSegment::Line([pt(a), pt(b)]),
|
||||
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
FlatSegment::Arc { center: pt(center), radius, start_deg: start.to_degrees(), sweep_deg: sweep.to_degrees() }
|
||||
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
|
||||
}
|
||||
ref other => FlatSegment::Polyline(other.polyline(1e-3).into_iter().map(pt).collect()),
|
||||
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
|
||||
})
|
||||
.collect();
|
||||
FlatShape::Segments { segments }
|
||||
json!({"segments": segments})
|
||||
}
|
||||
|
||||
fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
@@ -774,98 +759,63 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let loops: Vec<Ring<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
|
||||
let size = loops.iter().fold(Aabb2::EMPTY, |b, l| b.union(&l.bounds())).size();
|
||||
let (w, h) = (size.x, size.y);
|
||||
let pt = |v: &Vec2| [v.x, v.y];
|
||||
let outline: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| l.iter().map(pt).collect()).collect();
|
||||
let holes: Vec<Vec<[f64; 2]>> = regions.iter().flat_map(|r| &r.holes).map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect();
|
||||
let hole_shapes: Vec<FlatShape> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
|
||||
let bends: Vec<FlatBend> = sh
|
||||
let pt = |v: &Vec2| json!([v.x, v.y]);
|
||||
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
|
||||
let bends: Vec<Value> = sh
|
||||
.bends()
|
||||
.iter()
|
||||
.map(|b| FlatBend {
|
||||
bend: b.index,
|
||||
angle_deg: b.angle_deg,
|
||||
included_angle_deg: b.included_angle_deg(),
|
||||
radius: b.radius,
|
||||
direction: if b.up { BendDirection::Up } else { BendDirection::Down },
|
||||
allowance: b.allowance,
|
||||
length: b.length,
|
||||
start: pt(&b.start),
|
||||
end: pt(&b.end),
|
||||
.map(|b| {
|
||||
json!({"bend": b.index, "angle_deg": b.angle_deg, "included_angle_deg": b.included_angle_deg(), "radius": b.radius, "direction": if b.up { "up" } else { "down" },
|
||||
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
|
||||
})
|
||||
.collect();
|
||||
let pockets: Vec<FlatPocket> = sh
|
||||
let pockets: Vec<Value> = sh
|
||||
.machining()?
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
PocketSide::Bottom
|
||||
"bottom"
|
||||
} else if p.z1 == sh.t {
|
||||
PocketSide::Top
|
||||
"top"
|
||||
} else {
|
||||
PocketSide::Internal
|
||||
"internal"
|
||||
};
|
||||
FlatPocket {
|
||||
outline: p.region.outer.polyline(1e-3).iter().map(pt).collect(),
|
||||
cutouts: p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect(),
|
||||
cutout_shapes: p.region.holes.iter().map(loop_shape).collect(),
|
||||
side,
|
||||
depth_mm: p.z1 - p.z0,
|
||||
z0_mm: p.z0,
|
||||
z1_mm: p.z1,
|
||||
}
|
||||
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
|
||||
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
|
||||
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
|
||||
})
|
||||
.collect();
|
||||
// Holes drilled along a panel into its rim: drilled after cutting, not in the outline.
|
||||
let bores: Vec<FlatBore> = sh
|
||||
let bores: Vec<Value> = sh
|
||||
.bores
|
||||
.iter()
|
||||
.map(|b| FlatBore {
|
||||
start: [b.base.x, b.base.y, b.base.z],
|
||||
axis: [b.axis.x, b.axis.y, b.axis.z],
|
||||
diameter: b.radius * 2.0,
|
||||
length: b.height.abs(),
|
||||
})
|
||||
.map(|b| json!({"start": [b.base.x, b.base.y, b.base.z], "axis": [b.axis.x, b.axis.y, b.axis.z], "diameter": b.radius * 2.0, "length": b.height.abs()}))
|
||||
.collect();
|
||||
let mut perforations = Vec::new();
|
||||
for p in &sh.perforations {
|
||||
let n = sh.perforation_holes(p)?.len();
|
||||
perforations.push(FlatPerforation {
|
||||
feature: p.feature,
|
||||
diameter: p.diameter,
|
||||
pitch: p.pitch,
|
||||
margin: p.margin,
|
||||
pattern: if p.square { FlatPerforationPattern::Square } else { FlatPerforationPattern::Staggered },
|
||||
count: n,
|
||||
open_area_mm2: n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2),
|
||||
});
|
||||
perforations.push(json!({"feature": p.feature, "diameter": p.diameter, "pitch": p.pitch, "margin": p.margin,
|
||||
"pattern": if p.square { "square" } else { "staggered" }, "count": n,
|
||||
"open_area_mm2": n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2)}));
|
||||
}
|
||||
let bend_zones: Vec<[f64; 4]> = sh
|
||||
let bend_zones: Vec<Value> = sh
|
||||
.bend_zones()
|
||||
.iter()
|
||||
.map(|z| {
|
||||
let pts = z.outer.polyline(1e-3);
|
||||
let f = |g: fn(&Vec2) -> f64, m: fn(f64, f64) -> f64, s: f64| pts.iter().map(g).fold(s, m);
|
||||
[f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)]
|
||||
json!([f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)])
|
||||
})
|
||||
.collect();
|
||||
let out = FlatPatternResult {
|
||||
body: sh.body.clone(),
|
||||
rule: sh.rule.clone(),
|
||||
thickness: sh.t,
|
||||
k_factor: sh.k,
|
||||
flat_size_mm: [w, h, sh.t],
|
||||
outline,
|
||||
cutouts: holes,
|
||||
cutout_shapes: hole_shapes,
|
||||
pockets,
|
||||
bends,
|
||||
unfolded: sh.flat,
|
||||
perforations,
|
||||
bend_zones,
|
||||
bores,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(out).map_err(|e| EngineError::Other(e.to_string()))
|
||||
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat,
|
||||
"perforations": perforations, "bend_zones": bend_zones});
|
||||
if !bores.is_empty() {
|
||||
out["bores"] = Value::Array(bores);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1187,22 +1137,16 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
/// A bend table's `format`: csv or json, in any case.
|
||||
pub(crate) fn bend_table_format(t: &str) -> Option<BendTableFormat> {
|
||||
match t.to_ascii_lowercase().as_str() {
|
||||
"csv" => Some(BendTableFormat::Csv),
|
||||
"json" => Some(BendTableFormat::Json),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_bend_table";
|
||||
let format = str_(p, "format").map_or(Some(BendTableFormat::Csv), bend_table_format).ok_or_else(|| bad(cmd, "`format` must be csv or json"))?;
|
||||
let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
|
||||
if !matches!(format.as_str(), "csv" | "json") {
|
||||
return Err(bad(cmd, "`format` must be csv or json"));
|
||||
}
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
sh.flat_matches_body()?;
|
||||
let table = flat_json(&sh)?;
|
||||
let text = if format == BendTableFormat::Json {
|
||||
let text = if format == "json" {
|
||||
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
// Always quote text fields: body/rule names may include commas, quotes or newlines.
|
||||
@@ -1232,14 +1176,11 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
csv
|
||||
};
|
||||
let bends = sh.bends().len();
|
||||
let reply = if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
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}")))?;
|
||||
BendTableResult::Written { path: path.to_string(), format, bends, param_warnings: Vec::new() }
|
||||
} else {
|
||||
BendTableResult::Text { format, text, bends, param_warnings: Vec::new() }
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
|
||||
}
|
||||
Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+101
-204
@@ -8,21 +8,14 @@ use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Point
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec3};
|
||||
use crate::schema::frame::SketchSolve;
|
||||
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, TangentMade, TangentParams, TangentResult,
|
||||
};
|
||||
use crate::schema::sketch_session::{
|
||||
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
|
||||
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
|
||||
OptionsParams, OptionsReply, OptionsResult, SketchOptions, SolveParams, SolveResult,
|
||||
};
|
||||
use crate::schema::sketch_shapes::{
|
||||
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, Created, DrawResult, LineParams,
|
||||
PointParams, PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectCorners,
|
||||
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
|
||||
PointParams, PointResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
|
||||
@@ -51,16 +44,6 @@ macro_rules! model_operand_doc {
|
||||
};
|
||||
}
|
||||
|
||||
const CREATE_CONTRACT: TypedContract<CreateParams> = TypedContract::new();
|
||||
const CONSTRUCTION_CONTRACT: TypedContract<ConstructionParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<DeleteParams> = TypedContract::new();
|
||||
const MOVE_POINT_CONTRACT: TypedContract<MovePointParams> = TypedContract::new();
|
||||
const DRAG_CONTRACT: TypedContract<DragParams> = TypedContract::new();
|
||||
const DRAG_END_CONTRACT: TypedContract<DragEndParams> = TypedContract::new();
|
||||
const DIMENSION_TEXT_CONTRACT: TypedContract<DimensionTextParams> = TypedContract::new();
|
||||
const OPTIONS_CONTRACT: TypedContract<OptionsParams> = TypedContract::new();
|
||||
const CHAIN_CONTRACT: TypedContract<ChainParams> = TypedContract::new();
|
||||
const SOLVE_CONTRACT: TypedContract<SolveParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
|
||||
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
|
||||
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
|
||||
@@ -81,20 +64,11 @@ const CIRCLE_TWO_POINT_CONTRACT: TypedContract<CircleTwoPointParams> = TypedCont
|
||||
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();
|
||||
const RECT_TWO_POINT_CONTRACT: TypedContract<RectTwoPointParams> = TypedContract::new();
|
||||
const RECT_THREE_POINT_CONTRACT: TypedContract<RectThreePointParams> = TypedContract::new();
|
||||
const RECT_ROUNDED_CONTRACT: TypedContract<RectRoundedParams> = TypedContract::new();
|
||||
const RECT_CENTER_CONTRACT: TypedContract<RectCenterParams> = TypedContract::new();
|
||||
const POLYGON_INSCRIBED_CONTRACT: TypedContract<PolygonInscribedParams> = TypedContract::new();
|
||||
const POLYGON_CIRCUMSCRIBED_CONTRACT: TypedContract<PolygonCircumscribedParams> = TypedContract::new();
|
||||
const POLYGON_EDGE_CONTRACT: TypedContract<PolygonEdgeParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", crate::checked!("sketch.create", create_sketch))
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<CreateResult>)
|
||||
.params("plane: XY|XZ|YZ (sketch x, y along world +X, +Y on XY; +X, -Z on XZ; +Y, +Z on YZ) | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} (in world coordinates unless frame says otherwise; a named plane is the active component's own) | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), frame?: world (default) | component (which coordinates an explicit plane is in; the reply's `frame` says which, with a `note` when the active component is moved or turned), project_edges?: bool, activate_part?: bool (default false; on a face of a body of another component than the active one: activate that component at the picked placement first, as component.activate would, and make the sketch there; it stays active afterwards and the reply's `activated` names it), name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", crate::checked!("sketch.edit", edit_sketch))
|
||||
.noundo().icon("sketch")
|
||||
@@ -121,34 +95,26 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value, or `name = expression` (also `#name = expression`), which defines the user parameter `name` as sketch.dimension does (the reply's `defined` lists the names) (optional with as_drawn or locate), as_drawn?: bool (also dimension every other role of the shape at its size as drawn; a radius or diameter given takes the place of the other, as an edge polygon's side length does its radius, a circle otherwise takes its diameter, and a size the sketch already determines, such as the angle of a line inferred horizontal, is left out), locate?: bool (then place the shape from the sketch origin until nothing of it is left free: horizontal and vertical distances from the origin to its points, centres first, each kept only where it removes freedom, so a shape anchored to the origin or to placed geometry gets none; a point on an axis goes onto that axis instead of a 0 distance; the reply's located lists them); creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", crate::checked!("sketch.rectangle.two_point", rect_two_point))
|
||||
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect")
|
||||
.key("R")
|
||||
.enabled(in_sketch)
|
||||
.contract(&RECT_TWO_POINT_CONTRACT)
|
||||
.output(output_schema_for::<RectResult>)
|
||||
.params(concat!("p0, p1: opposite corners [x,y] (a coordinate may be an expression over the design's parameters: the corner follows them); construction?. 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.rectangle.three_point", "3-Point Rectangle", crate::checked!("sketch.rectangle.three_point", rect_three_point))
|
||||
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect3")
|
||||
.enabled(in_sketch)
|
||||
.contract(&RECT_THREE_POINT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("p0, p1: first edge; p2: point on the opposite edge; construction?: bool", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.rounded", "Rounded Rectangle", crate::checked!("sketch.rectangle.rounded", rect_rounded))
|
||||
CommandSpec::new("sketch.rectangle.rounded", "Rounded Rectangle", rect_rounded)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect_rounded")
|
||||
.enabled(in_sketch)
|
||||
.contract(&RECT_ROUNDED_CONTRACT)
|
||||
.output(output_schema_for::<RoundedRectResult>)
|
||||
.params(concat!("p0, p1: opposite corners [x,y] (a coordinate may be an expression over the design's parameters: the corner follows them, as in `sketch.rectangle.two_point`); radius: expr (the corner radius, less than half the shorter side); construction?. A 2-point rectangle whose four corners are filleted alike: one radius dimension (`param`), the other arcs equal to it. Returns sides {bottom, right, top, left} (line ids) and arcs (the corner arcs: bottom-left, bottom-right, top-right, top-left)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", crate::checked!("sketch.rectangle.center", rect_center))
|
||||
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect_center")
|
||||
.enabled(in_sketch)
|
||||
.contract(&RECT_CENTER_CONTRACT)
|
||||
.output(output_schema_for::<RectResult>)
|
||||
.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", crate::checked!("sketch.circle.center", circle_center))
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -184,26 +150,20 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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", crate::checked!("sketch.polygon.inscribed", polygon_inscribed))
|
||||
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.contract(&POLYGON_INSCRIBED_CONTRACT)
|
||||
.output(output_schema_for::<PolygonResult>)
|
||||
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", crate::checked!("sketch.polygon.circumscribed", polygon_circumscribed))
|
||||
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon_circumscribed")
|
||||
.enabled(in_sketch)
|
||||
.contract(&POLYGON_CIRCUMSCRIBED_CONTRACT)
|
||||
.output(output_schema_for::<PolygonResult>)
|
||||
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", crate::checked!("sketch.polygon.edge", polygon_edge))
|
||||
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon_edge")
|
||||
.enabled(in_sketch)
|
||||
.contract(&POLYGON_EDGE_CONTRACT)
|
||||
.output(output_schema_for::<PolygonResult>)
|
||||
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -296,71 +256,40 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&SYMMETRY_CONTRACT)
|
||||
.output(output_schema_for::<SymmetryResult>)
|
||||
.params(concat!("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created); or point (or a point id as line, \"origin\" too): symmetric about that point, each pair joined by a construction line it is the midpoint of", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.construction", "Normal/Construction", crate::checked!("sketch.construction", construction))
|
||||
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("construction")
|
||||
.key("X")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CONSTRUCTION_CONTRACT)
|
||||
.output(output_schema_for::<ConstructionResult>)
|
||||
.params("curves: [ids], value?: bool (default: construction unless all of them already are)"),
|
||||
CommandSpec::new("sketch.delete", "Delete Sketch Entities", crate::checked!("sketch.delete", sketch_delete)).icon("delete")
|
||||
CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete).icon("delete")
|
||||
.enabled(in_sketch)
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(output_schema_for::<DeleteResult>)
|
||||
.params("entities: [curve, point or constraint ids]"),
|
||||
CommandSpec::new("sketch.move_point", "Drag Sketch Point", crate::checked!("sketch.move_point", move_point))
|
||||
.icon("sketch_move")
|
||||
.enabled(in_sketch)
|
||||
.contract(&MOVE_POINT_CONTRACT)
|
||||
.output(output_schema_for::<MovePointResult>)
|
||||
.params("point: ref, to: [x,y]"),
|
||||
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"),
|
||||
CommandSpec::new("sketch.move_points", "Move Sketch Points", move_points).icon("sketch_move").enabled(in_sketch).params(
|
||||
"moves: [{point: ref | near: [x,y], to: [x,y]}] (each point to its own target; `near`: the sketch point closest to it, \
|
||||
refused when none lies within `tolerance` or two are as close), tolerance?: mm (default 0.1); sketch? → moved: \
|
||||
[{point, to, at, reached}] (`at`: where it ended up after the one solve; `reached`: whether that is `to`)",
|
||||
),
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", crate::checked!("sketch.drag", drag))
|
||||
.icon("sketch_move")
|
||||
.enabled(in_sketch)
|
||||
.live(drag_live)
|
||||
.contract(&DRAG_CONTRACT)
|
||||
.output(output_schema_for::<DragResult>)
|
||||
.params(
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: points and the material point grabbed on a line move toward `to` within the design constraints. When exact placement is impossible, bounded feasible steps minimize cursor distance. Splines slide by to - from; a circle or arc grabbed on its edge takes the radius to `to`. `with?`: [point or curve ids] moves the rest of a selection with the entity as one translation (circles keep their radii). Long moves retain the solution branch; arcs cannot sweep through their starts. Returns `reached`: progress projected onto the requested move (0..1), `at`: the grabbed position for the next frame, and `retry`: budget-limited progress can continue in another frame. `live: true` shares a work budget across held-solve decomposition and cursor correction; ordinary recovery and the final model evaluation retain their existing limits, not a wall-clock deadline. Later features stay stale until `sketch.drag_end`; `dof` stays from before the drag",
|
||||
),
|
||||
CommandSpec::new("sketch.drag_end", "End Sketch Drag", crate::checked!("sketch.drag_end", drag_end))
|
||||
.icon("sketch_move")
|
||||
.enabled(in_sketch)
|
||||
.doc_only()
|
||||
.contract(&DRAG_END_CONTRACT)
|
||||
.output(output_schema_for::<DragEndResult>)
|
||||
.params(
|
||||
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params(
|
||||
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
|
||||
),
|
||||
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", crate::checked!("sketch.dimension_text", dimension_text)).icon("edit")
|
||||
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text).icon("edit")
|
||||
.enabled(in_sketch)
|
||||
.contract(&DIMENSION_TEXT_CONTRACT)
|
||||
.output(output_schema_for::<DimensionTextResult>)
|
||||
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
|
||||
CommandSpec::new("sketch.options", "Sketch Palette Options", crate::checked!("sketch.options", sketch_options))
|
||||
.icon("sketch")
|
||||
.contract(&OPTIONS_CONTRACT)
|
||||
.output(output_schema_for::<OptionsResult>)
|
||||
.params(
|
||||
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params(
|
||||
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all",
|
||||
),
|
||||
CommandSpec::new("sketch.chain", "Chain", crate::checked!("sketch.chain", chain_cmd)).icon("chain")
|
||||
CommandSpec::new("sketch.chain", "Chain", chain_cmd).icon("chain")
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.contract(&CHAIN_CONTRACT)
|
||||
.output(output_schema_for::<ChainResult>)
|
||||
.params("curve: id: the curves joined to it end to end (what a double-click selects)"),
|
||||
CommandSpec::new("sketch.solve", "Solve Sketch", crate::checked!("sketch.solve", solve_cmd)).icon("compute")
|
||||
CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd).icon("compute")
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.contract(&SOLVE_CONTRACT)
|
||||
.output(output_schema_for::<SolveResult>)
|
||||
.params("reports the solver status and degrees of freedom"),
|
||||
];
|
||||
|
||||
@@ -376,31 +305,14 @@ pub(super) fn edit<T>(
|
||||
strict: bool,
|
||||
f: impl FnOnce(&mut Sketch, &mut solidcraft_doc::Document) -> Result<T>,
|
||||
) -> Result<(T, Value)> {
|
||||
let (out, info) = edit_solve(s, p, cmd, strict, f)?;
|
||||
Ok((out, info.and_then(|info| serde_json::to_value(info).ok()).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
/// [`edit`] with the solve as its typed value (`None`: the edit did not solve the sketch again).
|
||||
pub(super) fn edit_solve<T>(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
strict: bool,
|
||||
f: impl FnOnce(&mut Sketch, &mut solidcraft_doc::Document) -> Result<T>,
|
||||
) -> Result<(T, Option<SketchSolve>)> {
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let (out, rep) = edit_solved(s, id, strict, Analysis::Freedom, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
|
||||
Ok((out, rep.as_ref().map(solve_info)))
|
||||
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
/// A solve's outcome as commands return it.
|
||||
pub(super) fn solve_info(rep: &SolveReport) -> SketchSolve {
|
||||
SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof }
|
||||
}
|
||||
|
||||
/// A solve's outcome as commands return it, as JSON.
|
||||
pub(super) fn report_json(rep: &SolveReport) -> Value {
|
||||
serde_json::to_value(solve_info(rep)).unwrap_or(Value::Null)
|
||||
json!({"solved": rep.keeps_orientation(), "dof": rep.dof})
|
||||
}
|
||||
|
||||
/// What an [`edit_solved`] step leaves for the edit's own solve.
|
||||
@@ -981,10 +893,13 @@ pub(super) fn frame_note(frame: Option<&str>, placement: &solidcraft_doc::Mat) -
|
||||
|
||||
/// 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.
|
||||
fn frame_reply(out: &mut CreateResult, frame: Option<&str>, placement: &solidcraft_doc::Mat) {
|
||||
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 };
|
||||
out.frame = Frame::named(frame);
|
||||
out.note = frame_note(Some(frame), placement).map(str::to_string);
|
||||
o.insert("frame".into(), json!(frame));
|
||||
if let Some(note) = frame_note(Some(frame), placement) {
|
||||
o.insert("note".into(), json!(note));
|
||||
}
|
||||
}
|
||||
|
||||
/// The indices of `m`'s triangles by face, for `faces` faces (triangles of a face past that
|
||||
@@ -1020,11 +935,6 @@ pub(super) fn face_contains(m: &solidcraft_geom::Mesh, tris: &[usize], p: Vec3,
|
||||
}
|
||||
|
||||
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply("sketch.create", new_sketch(s, p)?)
|
||||
}
|
||||
|
||||
/// What `sketch.create` makes, from its parameters.
|
||||
fn new_sketch(s: &mut Session, p: &Value) -> Result<CreateResult> {
|
||||
let cmd = "sketch.create";
|
||||
// These name the plane, so they belong inside `plane`. Next to it they were ignored and the
|
||||
// sketch went to XY (or the plane given) without a word.
|
||||
@@ -1063,7 +973,7 @@ fn new_sketch(s: &mut Session, p: &Value) -> Result<CreateResult> {
|
||||
let made = plane_ref(s, p, cmd).and_then(|plane| create_sketch_on(s, p, plane, frame, cmd));
|
||||
match made {
|
||||
Ok(mut out) => {
|
||||
out.activated = Some(Activated { component, path });
|
||||
out["activated"] = json!({"component": component, "path": path});
|
||||
Ok(out)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -1074,11 +984,11 @@ fn new_sketch(s: &mut Session, p: &Value) -> Result<CreateResult> {
|
||||
}
|
||||
|
||||
/// The rest of `sketch.create` once the active component is settled: `flip`, the sketch, the reply's `frame`.
|
||||
fn create_sketch_on(s: &mut Session, p: &Value, mut plane: PlaneRef, frame: Option<&str>, cmd: &str) -> Result<CreateResult> {
|
||||
fn create_sketch_on(s: &mut Session, p: &Value, mut plane: PlaneRef, frame: Option<&str>, cmd: &str) -> Result<Value> {
|
||||
if let Some(about_y) = flip_param(s, p, &plane, cmd)? {
|
||||
plane = PlaneRef::Flipped { base: Box::new(plane), about_y };
|
||||
}
|
||||
let mut out = sketch_on_plane(s, p, plane, None)?;
|
||||
let mut out = create_sketch_with_plane(s, p, plane, None)?;
|
||||
frame_reply(&mut out, frame, &s.active_component_frame());
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1111,11 +1021,6 @@ pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index:
|
||||
}
|
||||
|
||||
pub(super) fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result<Value> {
|
||||
reply("sketch.create", sketch_on_plane(s, p, plane, internal_body)?)
|
||||
}
|
||||
|
||||
/// A new sketch on `plane`, as `sketch.create` replies.
|
||||
fn sketch_on_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result<CreateResult> {
|
||||
let before = entry_point(s);
|
||||
if face::is_face(p) {
|
||||
face::capture_positions(s, &plane)?;
|
||||
@@ -1148,25 +1053,20 @@ fn sketch_on_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: O
|
||||
{
|
||||
link = Some(super::sketch_project::project_internal_face(s, id, body, index)?);
|
||||
}
|
||||
if s.project_construction
|
||||
&& let Some(link) = &link
|
||||
{
|
||||
super::sketch_project::construct_link(s, id, link)?;
|
||||
}
|
||||
}
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
|
||||
Attachment::ExactFace
|
||||
"exact_face"
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
Attachment::SheetFace
|
||||
"sheet_face"
|
||||
} else if internal_body.is_some() {
|
||||
Attachment::InternalLegacyFace
|
||||
"internal_legacy_face"
|
||||
} else {
|
||||
Attachment::Fixed
|
||||
"fixed"
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
Ok(CreateResult { sketch: id, name, projected: link, attachment, frame: None, note: None, activated: None, param_warnings: Vec::new() })
|
||||
Ok(json!({"sketch": id, "name": name, "projected": link,"attachment":attachment}))
|
||||
}
|
||||
|
||||
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1558,19 +1458,22 @@ fn drop_implied_axes(sk: &mut Sketch, cons: &mut Vec<String>) {
|
||||
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
|
||||
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
|
||||
/// caller can dimension it without inspecting the sketch first.
|
||||
fn rect_names(sk: &Sketch, l: &[usize], pts: &[usize]) -> (RectSides, RectCorners) {
|
||||
fn rect_names(sk: &Sketch, l: &[usize], pts: &[usize]) -> Value {
|
||||
let curve = |i: usize| l.get(i).and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone());
|
||||
let point = |i: usize| pts.get(i).and_then(|q| sk.points.get(*q)).map(|q| q.id.clone());
|
||||
(
|
||||
RectSides { bottom: curve(0), right: curve(1), top: curve(2), left: curve(3) },
|
||||
RectCorners { bottom_left: point(0), bottom_right: point(1), top_right: point(2), top_left: point(3) },
|
||||
)
|
||||
json!({
|
||||
"sides": {"bottom": curve(0), "right": curve(1), "top": curve(2), "left": curve(3)},
|
||||
"corners": {"bottom_left": point(0), "bottom_right": point(1), "top_right": point(2), "top_left": point(3)},
|
||||
})
|
||||
}
|
||||
|
||||
/// The reply of a rectangle with named sides and corners, built from [`RectResult`].
|
||||
fn rect_result(sk_out: (Vec<String>, Vec<String>), (sides, corners): (RectSides, RectCorners), info: Value) -> Result<Value> {
|
||||
let reply = RectResult { curves: sk_out.0, constraints: sk_out.1, sketch: info, sides, corners, created: None, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
/// `result` plus the rectangle's named sides and corners.
|
||||
fn rect_result(sk_out: (Vec<String>, Vec<String>), names: Value, info: Value) -> Value {
|
||||
let mut out = result(sk_out, info);
|
||||
if let (Some(o), Value::Object(n)) = (out.as_object_mut(), names) {
|
||||
o.extend(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A rectangle corner: `[x, y]` of numbers, or with a coordinate typed as an expression (as a
|
||||
@@ -1615,7 +1518,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
rect_result(out, names, info)
|
||||
Ok(rect_result(out, names, info))
|
||||
}
|
||||
|
||||
/// A 2-point rectangle with its corners filleted, as one undo step: the rectangle and the
|
||||
@@ -1639,7 +1542,7 @@ fn rect_rounded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.remove("radius");
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let out = scratch.execute("sketch.rectangle.two_point", &rect)?;
|
||||
let mut out = scratch.execute("sketch.rectangle.two_point", &rect)?;
|
||||
// The fillet rounds each corner by the two sides meeting there, not by the corner point: a
|
||||
// corner that lands on another curve's end is shared with it, and rounding the point would
|
||||
// weld the two together, leaving the corner on three curves (#7878).
|
||||
@@ -1665,23 +1568,23 @@ fn rect_rounded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
|
||||
scratch.undo.clear();
|
||||
s.accept_computed(scratch);
|
||||
let ids = |v: &Value, k: &str| -> Vec<String> {
|
||||
v.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
};
|
||||
let side = |k: &str| out["sides"].get(k).and_then(Value::as_str).map(str::to_string);
|
||||
let arcs = ids(&rounded, "curves");
|
||||
// The sharp corners are gone (the lines end at the arcs now): the reply names no corners.
|
||||
let reply = RoundedRectResult {
|
||||
curves: ids(&out, "curves").into_iter().chain(arcs.iter().cloned()).collect(),
|
||||
constraints: ids(&out, "constraints").into_iter().chain(ids(&rounded, "constraints")).collect(),
|
||||
sketch: rounded.get("sketch").or_else(|| out.get("sketch")).cloned().unwrap_or(Value::Null),
|
||||
sides: RectSides { bottom: side("bottom"), right: side("right"), top: side("top"), left: side("left") },
|
||||
arcs,
|
||||
param: rounded.get("param").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
created: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
let arcs = rounded.get("curves").cloned().unwrap_or_else(|| json!([]));
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
// The sharp corners are gone (the lines end at the arcs now).
|
||||
o.remove("corners");
|
||||
if let (Some(Value::Array(curves)), Value::Array(extra)) = (o.get_mut("curves"), &arcs) {
|
||||
curves.extend(extra.iter().cloned());
|
||||
}
|
||||
if let (Some(Value::Array(cons)), Some(Value::Array(extra))) = (o.get_mut("constraints"), rounded.get("constraints")) {
|
||||
cons.extend(extra.iter().cloned());
|
||||
}
|
||||
o.insert("arcs".into(), arcs);
|
||||
o.insert("param".into(), rounded.get("param").cloned().unwrap_or(Value::Null));
|
||||
if let Some(info) = rounded.get("sketch") {
|
||||
o.insert("sketch".into(), info.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1723,7 +1626,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
rect_result(out, names, info)
|
||||
Ok(rect_result(out, names, info))
|
||||
}
|
||||
|
||||
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1746,7 +1649,7 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &l, p);
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
})?;
|
||||
draw_reply(out, info)
|
||||
Ok(result(out, info))
|
||||
}
|
||||
|
||||
pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
|
||||
@@ -2004,8 +1907,9 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
|
||||
}
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
})?;
|
||||
let reply = PolygonResult { curves: out.0, constraints: out.1, sketch: info, support_circle, created: None, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
let mut value = result(out, info);
|
||||
value["support_circle"] = json!(support_circle);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn sides(p: &Value, cmd: &str) -> Result<usize> {
|
||||
@@ -3792,7 +3696,7 @@ fn dimension_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.dimension_text";
|
||||
let key = str_(p, "dimension").ok_or_else(|| bad(cmd, "`dimension` must be a dimension id or parameter name"))?.to_string();
|
||||
let at = if bool_(p, "reset").unwrap_or(false) { None } else { Some(req_vec2(cmd, p, "at")?) };
|
||||
let (id, info) = edit_solve(s, p, cmd, false, |sk, _| {
|
||||
let (id, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let c = sk
|
||||
.constraints
|
||||
.iter()
|
||||
@@ -3802,7 +3706,7 @@ fn dimension_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
place_text(sk, &id, at);
|
||||
Ok(id)
|
||||
})?;
|
||||
reply(cmd, DimensionTextResult { dimension: id, sketch: info, param_warnings: Vec::new() })
|
||||
Ok(json!({"dimension": id, "sketch": info}))
|
||||
}
|
||||
|
||||
/// Dimension a point (or a parallel line) to line `l` as a diameter: when `l` is a centerline
|
||||
@@ -3948,7 +3852,7 @@ fn construction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`curves` must list curve ids"));
|
||||
}
|
||||
let want = bool_(p, "value");
|
||||
let (n, info) = edit_solve(s, p, cmd, false, |sk, _| {
|
||||
let (n, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let cs: Vec<usize> =
|
||||
ids.iter().map(|id| sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))).collect::<Result<_>>()?;
|
||||
// The selection switches together (a slot or polygon as a whole): to construction
|
||||
@@ -3972,7 +3876,7 @@ fn construction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((n, to))
|
||||
})?;
|
||||
reply(cmd, ConstructionResult { changed: n.0, construction: n.1, sketch: info, param_warnings: Vec::new() })
|
||||
Ok(json!({"changed": n.0, "construction": n.1, "sketch": info}))
|
||||
}
|
||||
|
||||
fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -3981,7 +3885,7 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "`entities` must list sketch entity ids"));
|
||||
}
|
||||
let (n, info) = edit_solve(s, p, cmd, false, |sk, doc| {
|
||||
let (n, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let mut curves = Vec::new();
|
||||
let mut points = Vec::new();
|
||||
let mut cons = Vec::new();
|
||||
@@ -4056,7 +3960,7 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
// Dimension parameters may have been dropped; prune again with the stored sketch.
|
||||
s.doc_mut().prune_model_params();
|
||||
reply(cmd, DeleteResult { deleted: n, sketch: info, param_warnings: Vec::new() })
|
||||
Ok(json!({"deleted": n, "sketch": info}))
|
||||
}
|
||||
|
||||
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -4069,7 +3973,7 @@ fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let q = point_ref(sk, p.get("point"), cmd, "point")?;
|
||||
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false, last).1.solve_again()))
|
||||
})?;
|
||||
reply(cmd, MovePointResult { sketch: rep.as_ref().map(solve_info), param_warnings: Vec::new() })
|
||||
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
|
||||
}
|
||||
|
||||
/// `sketch.move_points`: several points, each dragged to its own target, in one edit (one undo
|
||||
@@ -4189,10 +4093,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
|
||||
let sk = sketch_store::read(&s.doc, id)?;
|
||||
let at = d.at(sk, from, to);
|
||||
let rep = rep.or_else(|| last.map(|ss| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..ss.report.clone() }));
|
||||
let out = reply(
|
||||
cmd,
|
||||
DragResult { sketch: rep.as_ref().map(solve_info), reached: d.reached, at: [at.x, at.y], retry: d.retry, param_warnings: Vec::new() },
|
||||
)?;
|
||||
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y], "retry": d.retry});
|
||||
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
}
|
||||
|
||||
@@ -4218,30 +4119,29 @@ fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// A new constraint changed the document: `execute` evaluates the model after this.
|
||||
if !std::sync::Arc::ptr_eq(&start, &s.doc) {
|
||||
let sketch = constraint.get("sketch").and_then(|solve| serde_json::from_value(solve.clone()).ok());
|
||||
return reply(cmd, DragEndResult { constraint, sketch, param_warnings: Vec::new() });
|
||||
return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")}));
|
||||
}
|
||||
if s.model.is_stale() {
|
||||
s.refresh();
|
||||
}
|
||||
let sketch = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| solve_info(&ss.report));
|
||||
reply(cmd, DragEndResult { constraint, sketch, param_warnings: Vec::new() })
|
||||
let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report));
|
||||
Ok(json!({"constraint": constraint, "sketch": report}))
|
||||
}
|
||||
|
||||
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
|
||||
fn options_of(v: &solidcraft_sketch::SketchView) -> SketchOptions {
|
||||
SketchOptions {
|
||||
show_profile: !v.hide_profile,
|
||||
show_points: !v.hide_points,
|
||||
show_dimensions: !v.hide_dimensions,
|
||||
show_constraints: !v.hide_constraints,
|
||||
show_projected: !v.hide_projected,
|
||||
show_construction: !v.hide_construction,
|
||||
grid: !v.hide_grid,
|
||||
snap: !v.no_snap,
|
||||
slice: v.slice,
|
||||
sketch_3d: v.three_d,
|
||||
}
|
||||
fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
|
||||
json!({
|
||||
"show_profile": !v.hide_profile,
|
||||
"show_points": !v.hide_points,
|
||||
"show_dimensions": !v.hide_dimensions,
|
||||
"show_constraints": !v.hide_constraints,
|
||||
"show_projected": !v.hide_projected,
|
||||
"show_construction": !v.hide_construction,
|
||||
"grid": !v.hide_grid,
|
||||
"snap": !v.no_snap,
|
||||
"slice": v.slice,
|
||||
"sketch_3d": v.three_d,
|
||||
})
|
||||
}
|
||||
|
||||
fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -4266,10 +4166,9 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let set = |k: &str| bool_(p, k);
|
||||
if keys.iter().all(|k| set(k).is_none()) {
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let options = OptionsReply::Read { options: options_of(&sketch_store::read(&s.doc, id)?.view) };
|
||||
return reply(cmd, OptionsResult { options, param_warnings: Vec::new() });
|
||||
return Ok(json!({"options": options_json(&sketch_store::read(&s.doc, id)?.view)}));
|
||||
}
|
||||
let (v, info) = edit_solve(s, p, cmd, false, |sk, _| {
|
||||
let (v, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let v = &mut sk.view;
|
||||
let put = |k: &str, flag: &mut bool, shown_means_off: bool| {
|
||||
if let Some(b) = set(k) {
|
||||
@@ -4286,9 +4185,9 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
put("snap", &mut v.no_snap, true);
|
||||
put("slice", &mut v.slice, false);
|
||||
put("sketch_3d", &mut v.three_d, false);
|
||||
Ok(options_of(v))
|
||||
Ok(options_json(v))
|
||||
})?;
|
||||
reply(cmd, OptionsResult { options: OptionsReply::Changed { options: v, sketch: info }, param_warnings: Vec::new() })
|
||||
Ok(json!({"options": v, "sketch": info}))
|
||||
}
|
||||
|
||||
fn chain_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -4296,16 +4195,14 @@ fn chain_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let sk = sketch_store::read(&s.doc, id)?;
|
||||
let c = curve_ref(sk, p.get("curve"), cmd, "curve")?;
|
||||
reply(cmd, ChainResult { curves: ids_of(sk, &sk.chain(c)), param_warnings: Vec::new() })
|
||||
Ok(json!({"curves": ids_of(sk, &sk.chain(c))}))
|
||||
}
|
||||
|
||||
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.solve";
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let id = sketch_store::target(s, p, "sketch.solve")?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?;
|
||||
let (status, dof, failing, profiles) = (ss.report.status.into(), ss.report.dof, ss.report.failing.clone(), ss.profiles.len());
|
||||
reply(cmd, SolveResult { status, dof, failing, profiles, param_warnings: Vec::new() })
|
||||
Ok(json!({"status": ss.report.status, "dof": ss.report.dof, "failing": ss.report.failing, "profiles": ss.profiles.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -6,48 +6,29 @@ use std::collections::HashMap;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
|
||||
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::sketch::{Left, edit_solved, ids_of};
|
||||
use super::sketch::{edit, ids_of};
|
||||
use super::sketch_store;
|
||||
use crate::params::{bad, num, str_, vec2};
|
||||
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const INSERT_DXF_CONTRACT: TypedContract<InsertDxfParams> = TypedContract::new();
|
||||
const INSERT_SVG_CONTRACT: TypedContract<InsertSvgParams> = TypedContract::new();
|
||||
const EXPORT_DXF_CONTRACT: TypedContract<ExportDxfParams> = TypedContract::new();
|
||||
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.insert_dxf", insert_dxf))
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("sketch_dxf")
|
||||
.contract(&INSERT_DXF_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("svg")
|
||||
.contract(&INSERT_SVG_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
|
||||
.icon("svg")
|
||||
.noundo()
|
||||
.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", crate::checked!("sketch.export_dxf", export_dxf))
|
||||
.icon("sketch_dxf")
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
|
||||
.noundo()
|
||||
.contract(&EXPORT_DXF_CONTRACT)
|
||||
.output(output_schema_for::<ExportDxfResult>)
|
||||
.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)"),
|
||||
];
|
||||
|
||||
@@ -270,13 +251,10 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
if geom.len() > solidcraft_sketch::MAX_POINTS {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
// `sketch::edit`, but keeping the solve as a typed value for the reply.
|
||||
let target = sketch_store::target(s, p, cmd)?;
|
||||
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
|
||||
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
|
||||
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
|
||||
Ok((ids_of(sk, &cs), texts))
|
||||
})?;
|
||||
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
|
||||
// Text becomes sketch text.
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
|
||||
let mut n_text = 0;
|
||||
@@ -301,7 +279,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
|
||||
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
|
||||
}
|
||||
|
||||
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -544,10 +522,9 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
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}")))?;
|
||||
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
|
||||
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
|
||||
return Ok(json!({"path": path, "entities": entity_count}));
|
||||
}
|
||||
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
|
||||
Ok(json!({"dxf": text, "entities": entity_count}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,12 +13,6 @@ 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 {
|
||||
@@ -27,160 +21,105 @@ 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", 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); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
|
||||
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
|
||||
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
|
||||
),
|
||||
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); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
|
||||
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).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", crate::checked!("sketch.include_3d", include_cmd))
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("include")
|
||||
.enabled(in_sketch)
|
||||
.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; construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", crate::checked!("sketch.project_to_surface", project_to_surface))
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("project_surface")
|
||||
.enabled(in_sketch)
|
||||
.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))
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("intersection_curve")
|
||||
.enabled(in_sketch)
|
||||
.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))
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spun")
|
||||
.enabled(in_sketch)
|
||||
.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))
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("iso_curve")
|
||||
.enabled(in_sketch)
|
||||
.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))
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("fit_section")
|
||||
.enabled(in_sketch)
|
||||
.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")
|
||||
.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")
|
||||
.enabled(in_sketch)
|
||||
.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")
|
||||
.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")
|
||||
.noundo()
|
||||
.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"),
|
||||
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
];
|
||||
|
||||
/// 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> {
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
if let Some(name) = v.as_str() {
|
||||
return Ok(RefForm::Name(name));
|
||||
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}`"))),
|
||||
};
|
||||
}
|
||||
let body = || str_(v, "body").unwrap_or("").to_string();
|
||||
if let Some(at) = v.get("edge").and_then(vec3) {
|
||||
return Ok(RefForm::Edge { body: body(), at });
|
||||
return Ok(LinkSource::Edge { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("face").and_then(vec3) {
|
||||
return Ok(RefForm::Face { body: body(), at });
|
||||
return Ok(LinkSource::Face { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("vertex").and_then(vec3) {
|
||||
return Ok(RefForm::Vertex { body: body(), at });
|
||||
return Ok(LinkSource::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("`sketch` must be an id or a name".into()),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
|
||||
};
|
||||
return Ok(RefForm::Sketch { key, curve: str_(v, "curve"), point: str_(v, "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) = 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`"));
|
||||
}
|
||||
if let Some(b) = str_(v, "body") {
|
||||
return Ok(RefForm::Body(b));
|
||||
return Ok(LinkSource::Body { body: b.to_string() });
|
||||
}
|
||||
if let Some(a) = str_(v, "axis") {
|
||||
return Ok(RefForm::Axis(a));
|
||||
return axis_source(s, a, cmd);
|
||||
}
|
||||
if let Some(p) = str_(v, "plane") {
|
||||
return Ok(RefForm::Plane(p));
|
||||
return Ok(LinkSource::Plane { name: p.to_string() });
|
||||
}
|
||||
if v.get("origin").is_some() {
|
||||
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),
|
||||
return 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).
|
||||
@@ -452,37 +391,10 @@ fn add_link_unless_present(
|
||||
Ok(Some(link))
|
||||
}
|
||||
|
||||
/// Make the curves of `link` construction geometry: references to draw against, left out of
|
||||
/// profiles (#13142). They stay linked; a link rebuilt because its shape changed comes back as
|
||||
/// normal geometry, as curves switched to construction by hand do.
|
||||
fn mark_construction(sk: &mut Sketch, link: &str) {
|
||||
for c in sk.link_curves(link) {
|
||||
if let Some(curve) = sk.curves.get_mut(c) {
|
||||
curve.construction = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`mark_construction`] for `link` in sketch `id`, as the session's preference asks of the face
|
||||
/// edges a new sketch projects (#13142).
|
||||
pub(super) fn construct_link(s: &mut Session, id: u64, link: &str) -> Result<()> {
|
||||
sketch_store::update(s, id, |_, _, sk| {
|
||||
mark_construction(sk, link);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||
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();
|
||||
(curves, points)
|
||||
json!({"link": link, "curves": curves, "points": points})
|
||||
}
|
||||
|
||||
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
|
||||
@@ -494,11 +406,6 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
if refs.is_empty() {
|
||||
return Err(bad(cmd, "`refs` is empty"));
|
||||
}
|
||||
let construction = match p.get("construction") {
|
||||
None => s.project_construction,
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(_) => return Err(bad(cmd, "`construction` must be a boolean")),
|
||||
};
|
||||
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
let sources: Vec<LinkSource> = refs.iter().map(|v| source(s, v, cmd)).collect::<Result<_>>()?;
|
||||
let (out, present) = sketch_store::update(s, id, |s, doc, sk| {
|
||||
@@ -511,13 +418,9 @@ 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 (curves, points) = link_parts(sk, &l);
|
||||
if construction {
|
||||
mark_construction(sk, &l);
|
||||
}
|
||||
let mut result = ProjectedLink { link: Some(l.clone()), curves, points };
|
||||
let mut result = link_json(sk, &l);
|
||||
if !projection_link {
|
||||
result.link = None;
|
||||
result["link"] = Value::Null;
|
||||
sk.break_link(&l)?;
|
||||
}
|
||||
out.push(result);
|
||||
@@ -532,7 +435,11 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
}
|
||||
Ok((out, present))
|
||||
})?;
|
||||
reply(cmd, ProjectResult { links: out, already_projected: present, created: None, param_warnings: Vec::new() })
|
||||
let mut r = json!({"links": out});
|
||||
if !present.is_empty() {
|
||||
r["already_projected"] = json!(present);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -636,15 +543,16 @@ 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<LinkMade> {
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
|
||||
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 (curves, points) = link_parts(sk, &l);
|
||||
let mut out = link_json(sk, &l);
|
||||
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
|
||||
Ok(LinkMade { link: l, curves, points, wires })
|
||||
out["wires"] = json!(wires);
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -663,7 +571,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 })?);
|
||||
}
|
||||
reply(cmd, ProjectToSurfaceResult { links: out, created: None, param_warnings: Vec::new() })
|
||||
Ok(json!({"links": out}))
|
||||
}
|
||||
|
||||
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -676,8 +584,7 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (a, b) = (side("a")?, side("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() })
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })
|
||||
}
|
||||
|
||||
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -698,8 +605,7 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(wa, wb - wa)
|
||||
}
|
||||
};
|
||||
let made = one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })
|
||||
}
|
||||
|
||||
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -714,8 +620,7 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format!("{},{},{}", d.x, d.y, d.z)
|
||||
}
|
||||
};
|
||||
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })
|
||||
}
|
||||
|
||||
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -731,7 +636,7 @@ fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.break_link(&l)?;
|
||||
Ok(curves)
|
||||
})?;
|
||||
reply(cmd, FitSectionResult { curves, created: None, param_warnings: Vec::new() })
|
||||
Ok(json!({"curves": curves}))
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -765,12 +670,12 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(links)
|
||||
})?;
|
||||
reply(cmd, SketchBreakLinkResult { broken: links, created: None, param_warnings: Vec::new() })
|
||||
Ok(json!({"broken": links}))
|
||||
}
|
||||
|
||||
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
|
||||
reply("sketch.auto_project", AutoProjectResult { auto_project: s.auto_project, created: None, param_warnings: Vec::new() })
|
||||
Ok(json!({"auto_project": s.auto_project}))
|
||||
}
|
||||
|
||||
/// `"x:<a>+<b>:x,y"` from interactive snapping: two distinct curve ids and a hint point.
|
||||
|
||||
@@ -1763,43 +1763,3 @@ fn a_second_placements_face_and_edge_project_where_it_is_placed() {
|
||||
check(&s, 15.0);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()), 15.0);
|
||||
}
|
||||
|
||||
/// #13142: projected references can start as construction geometry, asked for per call or for
|
||||
/// the whole session (a preference, kept for designs started and opened like `auto_project`).
|
||||
#[test]
|
||||
fn projected_references_can_start_as_construction() {
|
||||
let flags = |s: &mut Session| linked_curves(&inspect(s)).iter().map(|c| c["construction"] == true).collect::<Vec<_>>();
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
// Off (the default): the face loop is normal geometry and closes a profile.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
let on_face = flags(&mut s);
|
||||
assert!(on_face.len() == 4 && on_face.iter().all(|c| !c), "{on_face:?}");
|
||||
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(1));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Asked for in the call, and not.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}], "construction": true}));
|
||||
assert_eq!(flags(&mut s), vec![true]);
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}]}));
|
||||
assert_eq!(flags(&mut s), vec![true, false]);
|
||||
run(&mut s, "sketch.intersect", json!({"refs": [{"edge": [0, 0, 10]}], "construction": true}));
|
||||
let e = s.execute("sketch.project", &json!({"refs": [{"edge": [20, 30, 20]}], "construction": 1}));
|
||||
assert!(e.is_err(), "construction must be a boolean");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// The session's preference: a sketch on a face starts with a construction loop and no
|
||||
// profile, and it holds for the next design; `construction: false` still asks for normal.
|
||||
s.project_construction = true;
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert!(s.project_construction, "file.new");
|
||||
box_part(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
let on_face = flags(&mut s);
|
||||
assert!(on_face.len() == 4 && on_face.iter().all(|c| *c), "{on_face:?}");
|
||||
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(0));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}]}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
|
||||
assert_eq!(flags(&mut s), vec![true, false]);
|
||||
}
|
||||
|
||||
+16
-33
@@ -72,7 +72,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"solid.thread" => &[("face", Pick), ("from", Pick), ("up_to", Plane)],
|
||||
"solid.emboss" => &[("face", Make)],
|
||||
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
|
||||
"solid.press_pull" => &[("face", Pick), ("edges", Picks), ("body", Body)],
|
||||
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
|
||||
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
|
||||
// Align finds its picks where they are shown and takes both ends into the moved bodies'
|
||||
// frame itself: a pick mapped into the frame of the body under it would put the two ends
|
||||
@@ -227,11 +227,11 @@ impl Mapper<'_> {
|
||||
let m = if self.here { self.f.sketch } else { under.as_ref().map(|(m, _)| *m).unwrap_or(self.f.active) };
|
||||
self.first.get_or_insert(m);
|
||||
let out = self.local_point(&m, p);
|
||||
// The command sees the pick in the frame of the body under it. Remember where it was
|
||||
// picked and the bodies whose faces meet there, so a face of that body is found and a
|
||||
// body that only shares its place in the model does not make the pick ambiguous (#419).
|
||||
if !self.here {
|
||||
remember(out.as_ref().and_then(vec3).unwrap_or(p), p, under.map(|(_, names)| names).unwrap_or_default());
|
||||
// The command sees the pick in the frame of the body under it. Remember the bodies
|
||||
// whose faces meet there, so a face of that body is found and a body that only
|
||||
// shares its place in the model does not make the pick ambiguous (#419).
|
||||
if let Some((_, names)) = &under {
|
||||
remember(out.as_ref().and_then(vec3).unwrap_or(p), names.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -361,19 +361,9 @@ thread_local! {
|
||||
/// The outermost command is `solid.move`, whose parameters stay in the world when its bodies
|
||||
/// lie in differently turned frames (`split_move`).
|
||||
static OUTER_MOVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// The picks of the running command.
|
||||
static PICKS: std::cell::RefCell<Vec<Pick>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// A pick of the running command.
|
||||
struct Pick {
|
||||
/// The point the command sees.
|
||||
local: Vec3,
|
||||
/// Where it was picked.
|
||||
world: Vec3,
|
||||
/// The bodies whose faces meet there: the first is the body under it, more than one means
|
||||
/// the pick is ambiguous, and none that no face is under it.
|
||||
bodies: Vec<String>,
|
||||
/// The picks of the running command: the point it sees and the bodies whose faces meet
|
||||
/// there (the first is the body under it; more than one means the pick is ambiguous).
|
||||
static PICKS: std::cell::RefCell<Vec<(Vec3, Vec<String>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// How many picks of one command are remembered (a command takes few).
|
||||
@@ -381,28 +371,21 @@ const MAX_PICKS: usize = 4096;
|
||||
/// How many bodies may meet at one pick (an ambiguous pick names them).
|
||||
const MAX_PICK_BODIES: usize = 64;
|
||||
|
||||
/// Remember where a pick was made and the bodies whose faces meet there, against the point the
|
||||
/// command sees.
|
||||
fn remember(local: Vec3, world: Vec3, names: Vec<String>) {
|
||||
let bodies: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
|
||||
/// Remember the bodies whose faces meet at a pick, against the point the command sees.
|
||||
fn remember(point: Vec3, names: Vec<String>) {
|
||||
let names: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
|
||||
PICKS.with(|picks| {
|
||||
let mut picks = picks.borrow_mut();
|
||||
if picks.len() < MAX_PICKS {
|
||||
picks.push(Pick { local, world, bodies });
|
||||
picks.push((point, names));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The bodies whose faces meet at `point`, when the running command picked it on a face: the
|
||||
/// first is the body under the pick, and more than one means the pick is ambiguous.
|
||||
/// The bodies whose faces meet at `point`, when the running command picked it: the first is
|
||||
/// the body under the pick, and more than one means the pick is ambiguous.
|
||||
pub(crate) fn picked(point: Vec3) -> Option<Vec<String>> {
|
||||
PICKS.with(|picks| picks.borrow().iter().find(|p| p.local == point && !p.bodies.is_empty()).map(|p| p.bodies.clone()))
|
||||
}
|
||||
|
||||
/// Where the running command's `nth` pick seen at `point` was picked (counting from 0: two picks
|
||||
/// on parts placed apart can be the same point in their own frames).
|
||||
pub(crate) fn picked_world(point: Vec3, nth: usize) -> Option<Vec3> {
|
||||
PICKS.with(|picks| picks.borrow().iter().filter(|p| p.local == point).nth(nth).map(|p| p.world))
|
||||
PICKS.with(|picks| picks.borrow().iter().find(|(p, _)| *p == point).map(|(_, names)| names.clone()))
|
||||
}
|
||||
|
||||
/// Forget the picks of the last command: a new one starts with none.
|
||||
|
||||
@@ -611,399 +611,3 @@ fn a_combine_tool_stays_where_it_is_placed() {
|
||||
assert_eq!(lumps(&s), Some(2), "the boss stays where it is in the world");
|
||||
assert!((volume(&mut s) - 17000.0).abs() < 1e-6, "{}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// The volume of one body in its own frame (a component's body once, however often it is placed).
|
||||
fn body_volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).unwrap_or_else(|| panic!("no body {body}")).measure().unwrap().volume
|
||||
}
|
||||
|
||||
/// The body the last feature, a fillet or chamfer, keeps.
|
||||
fn blend_body(s: &Session) -> Option<String> {
|
||||
match &s.doc.features.last().unwrap().kind {
|
||||
solidcraft_doc::FeatureKind::Fillet { body, .. } | solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The points the last feature, a fillet or chamfer, keeps.
|
||||
fn blend_edges(s: &Session) -> Vec<Vec3> {
|
||||
match &s.doc.features.last().unwrap().kind {
|
||||
solidcraft_doc::FeatureKind::Fillet { edges, .. } | solidcraft_doc::FeatureKind::Chamfer { edges, .. } => edges.iter().map(|p| p.0).collect(),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, id: &str, p: Value) -> String {
|
||||
let features = s.doc.features.len();
|
||||
let err = s.execute(id, &p).expect_err(&format!("{id} {p}")).to_string();
|
||||
assert_eq!(s.doc.features.len(), features, "{id} {p}: {err}");
|
||||
err
|
||||
}
|
||||
|
||||
/// A 1 mm chamfer of a 40 mm edge between square faces.
|
||||
const CUT: f64 = 20.0;
|
||||
/// A box 40 x 30 x 10.
|
||||
const BLOCK: f64 = 12000.0;
|
||||
|
||||
/// Component Base holds "Base block" and component Cap "Cap block", each a 40 x 30 x 10 box,
|
||||
/// Cap's at `cap_z` in its own frame and its placement moved by `moved`; Base is active.
|
||||
fn two_parts(cap_z: f64, moved: [f64; 3]) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Base block"}));
|
||||
run(&mut s, "component.create", json!({"name": "Cap", "parent": "root"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, cap_z], "body_name": "Cap block"}));
|
||||
if moved != [0.0; 3] {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Cap:1", "translate": moved}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component": "Base"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Component Lower holds "Lower block" (40 x 30 x 10) and component Upper "Upper block" (40 x 30
|
||||
/// x 2), both from their frames' origins, so Upper's bottom edges lie on Lower's in their own
|
||||
/// frames. Upper:1 sits on Lower, and a second placement, Upper:2, under it. The root is active.
|
||||
fn lower_and_uppers() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Lower"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
|
||||
run(&mut s, "component.create", json!({"name": "Upper", "parent": "root"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 2, "body_name": "Upper block"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Upper:1", "translate": [0, 0, 10]}));
|
||||
let under = run(&mut s, "occurrence.copy", json!({"component": "Upper"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -12]}));
|
||||
let (lo, hi) = world_bbox(&s, "Upper block (Upper:2)");
|
||||
assert!(near(lo, Vec3::new(0.0, 0.0, -2.0)) && near(hi, Vec3::new(40.0, 30.0, 0.0)), "{lo:?} {hi:?}");
|
||||
(s, under)
|
||||
}
|
||||
|
||||
/// A world pick on an edge of a moved part finds that part, though another part has an edge at
|
||||
/// the same point in its own frame, and the blend keeps the body it found.
|
||||
#[test]
|
||||
fn edge_picks_on_a_moved_part_find_it_past_other_parts_frames() {
|
||||
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
|
||||
for (case, mut s, id, p, body, base, cap) in [
|
||||
("v0", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
|
||||
("v4", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}), "Cap block", BLOCK, BLOCK - CUT),
|
||||
("v5", two_parts(0.0, [500.0, 0.0, 0.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
|
||||
(
|
||||
"v6",
|
||||
two_parts(0.0, [0.0, 0.0, 50.0]),
|
||||
"solid.chamfer",
|
||||
json!({"edges": [[20, 0, 0]], "distance": 1, "tangent_chain": false}),
|
||||
"Base block",
|
||||
BLOCK - CUT,
|
||||
BLOCK,
|
||||
),
|
||||
("fillet", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 1}), "Base block", BLOCK - round, BLOCK),
|
||||
(
|
||||
"press_pull",
|
||||
two_parts(0.0, [0.0, 0.0, 50.0]),
|
||||
"solid.press_pull",
|
||||
json!({"edges": [[20, 0, 0]], "distance": 1}),
|
||||
"Base block",
|
||||
BLOCK - round,
|
||||
BLOCK,
|
||||
),
|
||||
("v1 unplaced", two_parts(50.0, [0.0; 3]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
|
||||
] {
|
||||
if case == "v5" {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
}
|
||||
let features = s.doc.features.len();
|
||||
run(&mut s, id, p.clone());
|
||||
assert_eq!(s.doc.features.len(), features + 1, "{case}");
|
||||
assert_eq!(blend_body(&s).as_deref(), Some(body), "{case}");
|
||||
let (b, c) = (body_volume(&s, "Base block"), body_volume(&s, "Cap block"));
|
||||
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{case}: Base {b}, Cap {c}");
|
||||
}
|
||||
// The pick on Cap is kept in Cap's frame.
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}));
|
||||
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
/// Where the edges of two parts lie in one place in the world, a pick without `body` is refused,
|
||||
/// naming both; with `body`, or a pick's own `body`, the named part is cut.
|
||||
#[test]
|
||||
fn edge_picks_where_two_parts_meet_name_both() {
|
||||
for (case, cap_z, moved) in [("v2 unplaced", 0.0, [0.0; 3]), ("v3 moved", 50.0, [0.0, 0.0, -50.0])] {
|
||||
for (id, size) in [("solid.chamfer", "distance"), ("solid.fillet", "radius")] {
|
||||
let mut s = two_parts(cap_z, moved);
|
||||
let err = refused(&mut s, id, json!({"edges": [[20, 0, 0]], size: 1}));
|
||||
assert!(
|
||||
err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block") && err.contains("with `body`"),
|
||||
"{case} {id}: {err}"
|
||||
);
|
||||
assert!(!err.contains("hidden"), "{err}");
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
}
|
||||
let mut s = two_parts(cap_z, moved);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
|
||||
let mut s = two_parts(cap_z, moved);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Cap block"}], "distance": 1}));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lower block's outer bottom edge, with a hidden upper block placed under it: that block's
|
||||
/// top edge lies there too, so the pick is refused and the message says it is hidden. A lower
|
||||
/// block's edge whose only twin is in the upper block's own frame is cut.
|
||||
#[test]
|
||||
fn edge_picks_beside_a_hidden_placement_name_it() {
|
||||
let (mut s, under) = lower_and_uppers();
|
||||
run(&mut s, "browser.visibility", json!({"visible": false, "paths": [[under]]}));
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!(
|
||||
err.contains("lies on edges of 2 bodies, Lower block and Upper block (Upper:2)") && err.contains("(Upper block (Upper:2) is hidden)"),
|
||||
"{err}"
|
||||
);
|
||||
run(&mut s, "browser.visibility", json!({"visible": true, "paths": [[under]]}));
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!(err.contains("Lower block and Upper block (Upper:2)") && !err.contains("hidden"), "{err}");
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Lower block"}));
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
// Upper:2 out of the way: the lower block's bottom left edge has a twin only in Upper's frame.
|
||||
let (mut s, under) = lower_and_uppers();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -100]}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[0, 15, 0]], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - 15.0)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
}
|
||||
|
||||
/// The lower block's top edge, where the upper block on it has its bottom edges, is refused
|
||||
/// naming both; `body` cuts either. A second lower block placed on the upper one meets it at the
|
||||
/// upper block's top edges likewise.
|
||||
#[test]
|
||||
fn edge_picks_on_a_top_edge_under_another_part_name_both() {
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
|
||||
assert!(err.contains("[20, 0, 10] lies on edges of 2 bodies, Lower block and Upper block:"), "{err}");
|
||||
for (body, lower, upper) in [("Lower block", BLOCK - CUT, 2400.0), ("Upper block", BLOCK, 2400.0 - CUT)] {
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1, "body": body}));
|
||||
let (b, c) = (body_volume(&s, "Lower block"), body_volume(&s, "Upper block"));
|
||||
assert!((b - lower).abs() < 1e-3 * CUT && (c - upper).abs() < 1e-3 * CUT, "{body}: Lower {b}, Upper {c}");
|
||||
}
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1}));
|
||||
assert!(err.contains("[20, 0, 12] lies on edges of 2 bodies, Upper block and Lower block (Lower:2):"), "{err}");
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
}
|
||||
|
||||
/// A block's top edge inside the bottom face of a plate placed on it: the block alone has an edge
|
||||
/// there.
|
||||
#[test]
|
||||
fn an_edge_inside_another_parts_face_is_picked() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Lower"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
|
||||
run(&mut s, "component.create", json!({"name": "Plate", "parent": "root"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 2, "corner": [-30, -35, 0], "body_name": "Plate block"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Plate:1", "translate": [0, 0, 10]}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Plate block") == 20000.0);
|
||||
}
|
||||
|
||||
/// Round edges of two parts in one place: a pick a little inside them, as a click on the drawn
|
||||
/// edge lands, is refused naming both. A disc edge 0.2 mm out is another edge.
|
||||
#[test]
|
||||
fn coincident_round_edges_of_two_parts_are_refused() {
|
||||
let parts = |b_radius: f64| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "A"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 10, "body_name": "A rod"}));
|
||||
run(&mut s, "component.create", json!({"name": "B", "parent": "root"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": b_radius, "height": 2, "body_name": "B disc"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "B:1", "translate": [0, 0, 10]}));
|
||||
s
|
||||
};
|
||||
let pi = std::f64::consts::PI;
|
||||
let disc = |r: f64| pi * r * r * 2.0;
|
||||
// A round of radius 1 on the rod's top edge, radius 10: its section's area at its centroid's
|
||||
// radius.
|
||||
let (a, c) = (1.0 - pi / 4.0, (10.0 - 3.0 * pi) / (3.0 * (4.0 - pi)));
|
||||
let rounded = pi * 100.0 * 10.0 - a * 2.0 * pi * (10.0 - c);
|
||||
let mut s = parts(10.0);
|
||||
let err = refused(&mut s, "solid.fillet", json!({"edges": [[0, 9.98, 10]], "radius": 1}));
|
||||
assert!(err.contains("lies on edges of 2 bodies, A rod and B disc"), "{err}");
|
||||
for pick in [[0.0, 9.98, 10.0], [0.0, 10.0, 10.0]] {
|
||||
let mut s = parts(10.2);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [pick], "radius": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("A rod"), "{pick:?}");
|
||||
let (v, b) = (body_volume(&s, "A rod"), body_volume(&s, "B disc"));
|
||||
assert!((v - rounded).abs() < 2e-4 * rounded && (b - disc(10.2)).abs() < 1e-6 * b, "{pick:?}: {v} vs {rounded}, {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two picks at one point of their parts' frames, on two parts, in one command: each is looked
|
||||
/// up where it was picked, and as the feature keeps one body or none, it is refused.
|
||||
#[test]
|
||||
fn two_picks_at_one_point_of_two_parts_frames_are_refused() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0], [20, 0, 50]], "distance": 1}));
|
||||
assert!(err.contains("the picks lie on 2 bodies, Base block and Cap block") && err.contains("in a command of its own"), "{err}");
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
}
|
||||
|
||||
/// One part placed twice: its edge picked through both placements, or through two placements in
|
||||
/// one place, is one edge of one body.
|
||||
#[test]
|
||||
fn one_edge_of_two_placements_is_one_edge() {
|
||||
for (offset, edges) in [
|
||||
([0.0, 40.0, 0.0], json!([[20, 0, 0], [20, 40, 0]])),
|
||||
([0.0; 3], json!([[20, 0, 0]])),
|
||||
// Overlapping along the edge: the pick is at x 25 of one placement and x 15 of the other.
|
||||
([10.0, 0.0, 0.0], json!([[25, 0, 0]])),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Pin"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Pin block"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": "Pin"}))["occurrence"].as_u64().unwrap();
|
||||
if offset != [0.0; 3] {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": copy, "space": "world", "translate": offset}));
|
||||
}
|
||||
run(&mut s, "solid.chamfer", json!({"edges": edges, "distance": 1}));
|
||||
assert!((body_volume(&s, "Pin block") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{offset:?}: {}", body_volume(&s, "Pin block"));
|
||||
assert!((volume(&mut s) - 2.0 * (BLOCK - CUT)).abs() < 2e-3 * CUT, "{offset:?}");
|
||||
assert_eq!(blend_body(&s), None, "one body in the model");
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge given by body and index keeps its body on the feature.
|
||||
#[test]
|
||||
fn an_edge_by_index_keeps_its_body() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let tol = 1e-3_f64.max(s.model.state().body("Base block").unwrap().body.size() * 2e-3);
|
||||
let edges = s.model.state().body("Base block").unwrap().body.edges(tol).unwrap();
|
||||
let index = edges.iter().position(|e| e.mid.dist(Vec3::new(20.0, 0.0, 0.0)) < 1e-6).unwrap();
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [{"body": "Base block", "index": index}], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
|
||||
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
|
||||
}
|
||||
|
||||
/// A pick 0.42 mm off a moved part's edge, beyond the faces the placement is found by, is still
|
||||
/// looked up where it was picked: another part's edge there in its own frame is not in the way.
|
||||
#[test]
|
||||
fn a_pick_beside_an_edge_is_looked_up_where_it_was_picked() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, -0.3, -0.3]], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
|
||||
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
|
||||
}
|
||||
|
||||
/// A chamfer of a moved part, found by a world pick, stays on that part through an edit of
|
||||
/// the other part, a recompute, undo and redo, and Edit Feature.
|
||||
#[test]
|
||||
fn a_blend_found_by_a_world_pick_keeps_its_part() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1, "name": "Edge"}));
|
||||
let check = |s: &Session, base: f64, cap: f64, step: &str| {
|
||||
let (b, c) = (body_volume(s, "Base block"), body_volume(s, "Cap block"));
|
||||
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{step}: Base {b}, Cap {c}");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{step}: {:?}", s.model.results);
|
||||
};
|
||||
check(&s, BLOCK, BLOCK - CUT, "made");
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Box1", "set": {"length": "50"}}));
|
||||
check(&s, 15000.0, BLOCK - CUT, "Base edited");
|
||||
run(&mut s, "timeline.compute_all", json!({}));
|
||||
check(&s, 15000.0, BLOCK - CUT, "computed");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, BLOCK, BLOCK - CUT, "undone");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s, 15000.0, BLOCK - CUT, "redone");
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Edge", "command": "solid.chamfer", "params": {"edges": [[20, 0, 50]], "distance": 2}}));
|
||||
check(&s, 15000.0, BLOCK - 4.0 * CUT, "redefined");
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
|
||||
}
|
||||
|
||||
/// A corner of a moved part is refused at the point picked, in the world.
|
||||
#[test]
|
||||
fn a_corner_of_a_moved_part_is_refused_where_it_was_picked() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[0, 0, 50]], "distance": 1}));
|
||||
assert!(err.contains("[0, 0, 50] is where 3 edges meet"), "{err}");
|
||||
}
|
||||
|
||||
/// Picks far out, or overflowing a placement, in a design with moved parts are refused.
|
||||
#[test]
|
||||
fn hostile_edge_picks_in_a_placed_design_are_refused() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
for pick in [json!([1e300, 0, 0]), json!([1.7e308, -1.7e308, 1.7e308]), json!([[1e300, 0, 0]])] {
|
||||
let err = s.execute("solid.chamfer", &json!({"edges": [pick], "distance": 1})).expect_err("refused");
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }), "{pick}: {err}");
|
||||
}
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
}
|
||||
|
||||
/// The index of the edge of `body` whose midpoint is `mid`.
|
||||
fn edge_index(s: &Session, body: &str, mid: Vec3) -> usize {
|
||||
let b = s.model.state().body(body).unwrap().body.clone();
|
||||
let edges = b.edges(1e-3_f64.max(b.size() * 2e-3)).unwrap();
|
||||
edges.iter().position(|e| e.mid.dist(mid) < 1e-6).unwrap()
|
||||
}
|
||||
|
||||
/// A pick's own `body` must exist and agree with the feature's `body`: the feature keeps one
|
||||
/// body, and the pick is found on its own.
|
||||
#[test]
|
||||
fn a_picks_own_body_must_agree_with_body() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let index = edge_index(&s, "Cap block", Vec3::new(20.0, 0.0, 0.0));
|
||||
for edge in [json!({"point": [20, 0, 50], "body": "Cap block"}), json!({"body": "Cap block", "index": index})] {
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [edge], "body": "Base block", "distance": 1}));
|
||||
assert!(err.contains("an edge names `Cap block`, but `body` is `Base block`: give one"), "{edge}: {err}");
|
||||
}
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Nobody"}], "distance": 1}));
|
||||
assert!(err.contains("no body `Nobody`"), "{err}");
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 50], "body": "Cap block"}], "body": "Cap block", "distance": 1}));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
|
||||
}
|
||||
|
||||
/// Press Pull on an edge is a fillet: where two parts meet it is refused naming both, and
|
||||
/// `body`, by its own name or a placement's, rounds that part.
|
||||
#[test]
|
||||
fn press_pull_on_an_edge_where_two_parts_meet_takes_body() {
|
||||
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
|
||||
let mut s = two_parts(50.0, [0.0, 0.0, -50.0]);
|
||||
let err = refused(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!(err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block: name the one to round with `body`"), "{err}");
|
||||
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
|
||||
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
}
|
||||
|
||||
/// A body offset squashed flat, as only a hostile file has, leaves no placement to look a pick
|
||||
/// up through: the pick is looked up in the body's own frame, so one far from every edge is
|
||||
/// refused and one on an edge is cut.
|
||||
#[test]
|
||||
fn edge_picks_with_no_placement_to_look_through_are_checked() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Box"}));
|
||||
let mut flat = solidcraft_doc::IDENTITY;
|
||||
flat[2][2] = 0.0;
|
||||
s.doc_mut().body_offsets.insert("Box".into(), flat);
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 15, 10]], "distance": 1}));
|
||||
assert!(err.contains("no edge at [20, 15, 10]"), "{err}");
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!((body_volume(&s, "Box") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{}", body_volume(&s, "Box"));
|
||||
}
|
||||
|
||||
+10
-24
@@ -519,10 +519,6 @@ pub struct Session {
|
||||
pub(crate) followed: Option<cmd::inspect::Followed>,
|
||||
/// Project a face's edges into a sketch created on it (the default; `sketch.auto_project`).
|
||||
pub auto_project: bool,
|
||||
/// Project, Intersect, Include 3D Geometry and the face edges projected into a new sketch
|
||||
/// make construction geometry rather than normal geometry (off by default; #13142). A
|
||||
/// preference, kept like `auto_project` when another design is opened or started.
|
||||
pub project_construction: bool,
|
||||
/// How calls that break a command's parameter contract are treated (off by default). It
|
||||
/// belongs to the session, not the design, so it survives opening and starting designs.
|
||||
pub param_check: schema::ParamCheck,
|
||||
@@ -831,7 +827,6 @@ impl Session {
|
||||
document_id: cmd::inspect::new_document_id(),
|
||||
followed: None,
|
||||
auto_project: true,
|
||||
project_construction: false,
|
||||
param_check: schema::ParamCheck::Off,
|
||||
easyprint_upload: None,
|
||||
live_links: None,
|
||||
@@ -895,7 +890,6 @@ impl Session {
|
||||
self.stop_computing();
|
||||
}
|
||||
let auto_project = self.auto_project;
|
||||
let project_construction = self.project_construction;
|
||||
let validation = self.validation();
|
||||
let single_surface_analysis = self.single_surface_analysis;
|
||||
let param_check = self.param_check;
|
||||
@@ -913,7 +907,6 @@ impl Session {
|
||||
self.crash_journal = crash_journal;
|
||||
self.command_check = command_check;
|
||||
self.auto_project = auto_project;
|
||||
self.project_construction = project_construction;
|
||||
// The user's, like `auto_project`: the commands that open a design evaluate it under it
|
||||
// already (`Session::opened_validating`).
|
||||
self.set_validation(validation);
|
||||
@@ -1952,7 +1945,6 @@ impl Session {
|
||||
document_id: self.document_id.clone(),
|
||||
followed: None,
|
||||
auto_project: self.auto_project,
|
||||
project_construction: self.project_construction,
|
||||
param_check: self.param_check,
|
||||
easyprint_upload: self.easyprint_upload.clone(),
|
||||
live_links: None,
|
||||
@@ -2329,7 +2321,16 @@ impl Session {
|
||||
}
|
||||
|
||||
fn run_script_with(&mut self, script: &Value, profile: Option<bool>, each: &mut dyn FnMut(usize, &Value)) -> Result<(Vec<Value>, Option<Value>)> {
|
||||
let list = script_steps(script)?;
|
||||
let list = match script {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(o) => {
|
||||
o.get("commands").and_then(Value::as_array).cloned().ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
|
||||
}
|
||||
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
|
||||
};
|
||||
if list.len() > 100_000 {
|
||||
return Err(EngineError::Other("script too long".into()));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut steps = Vec::new();
|
||||
let clock = timing::Clock::start();
|
||||
@@ -2357,21 +2358,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// A script's steps: the JSON array itself, or the `commands` array of an object; at most 100 000.
|
||||
pub fn script_steps(script: &Value) -> Result<&Vec<Value>> {
|
||||
let list = match script {
|
||||
Value::Array(a) => a,
|
||||
Value::Object(o) => {
|
||||
o.get("commands").and_then(Value::as_array).ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
|
||||
}
|
||||
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
|
||||
};
|
||||
if list.len() > 100_000 {
|
||||
return Err(EngineError::Other("script too long".into()));
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boolean_regression_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -297,11 +297,8 @@ pub const IDS: &[&str] = &[
|
||||
"panel.dismiss",
|
||||
"panel.float",
|
||||
"panel.focus",
|
||||
"panel.focus.next",
|
||||
"panel.focus.previous",
|
||||
"panel.list",
|
||||
"panel.move",
|
||||
"panel.move.next_zone",
|
||||
"panel.native_close",
|
||||
"panel.open",
|
||||
"panel.peek",
|
||||
@@ -309,12 +306,6 @@ pub const IDS: &[&str] = &[
|
||||
"panel.reset_pair",
|
||||
"panel.resize",
|
||||
"panel.resize_pair",
|
||||
"panel.sash.down",
|
||||
"panel.sash.left",
|
||||
"panel.sash.reset",
|
||||
"panel.sash.right",
|
||||
"panel.sash.up",
|
||||
"panel.toggle",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -323,7 +314,6 @@ pub enum PanelCommand {
|
||||
Get,
|
||||
Open(wire::Open),
|
||||
HeaderAction(wire::HeaderAction),
|
||||
Contextual { action: ContextualPanelAction, params: wire::Revision },
|
||||
Select { operation: &'static str, params: wire::Select },
|
||||
Move { operation: &'static str, params: wire::Move },
|
||||
Resize(wire::Resize),
|
||||
@@ -363,11 +353,6 @@ pub fn parse_command(id: &str, params: &Value) -> PanelResult<PanelCommand> {
|
||||
}
|
||||
serde_json::to_writer(Counter(0), params).map_err(|_| error(Rule::CollectionLimit, "panel command byte cap"))?;
|
||||
let id = crate::find_command(id).map(|s| s.id).ok_or_else(|| error(Rule::MissingTarget, "unknown panel command"))?;
|
||||
if let Some(action) = ContextualPanelAction::from_command_id(id) {
|
||||
let command = PanelCommand::Contextual { action, params: read(params)? };
|
||||
validate_command(&command)?;
|
||||
return Ok(command);
|
||||
}
|
||||
let command = match id {
|
||||
"panel.list" => {
|
||||
let _: wire::Empty = read(params)?;
|
||||
@@ -825,11 +810,6 @@ pub fn execute_command(command: PanelCommand, context: &mut PanelCommandContext<
|
||||
validate_command(&command)?;
|
||||
let state = context.sink.inspect();
|
||||
match command {
|
||||
PanelCommand::Contextual { action, params } => {
|
||||
revision(params.expected_revision, &state)?;
|
||||
let action = context.sink.resolve_contextual(action)?;
|
||||
return value(execute_layout_action(action, context)?);
|
||||
}
|
||||
PanelCommand::List => return value(wire::ListReply { state, kinds: context.registry.specs().cloned().collect() }),
|
||||
PanelCommand::Get => return value(wire::GetReply { state, workspaces: context.catalogue.state().summaries() }),
|
||||
command @ PanelCommand::HeaderAction(_) => {
|
||||
@@ -1058,7 +1038,6 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
|
||||
let mut replacement = false;
|
||||
let mut mutation = false;
|
||||
let mut header_action = false;
|
||||
let mut contextual = false;
|
||||
for step in steps {
|
||||
let id =
|
||||
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
|
||||
@@ -1066,7 +1045,6 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
|
||||
match parse_command(id, step.get("params").unwrap_or(&empty))? {
|
||||
PanelCommand::List | PanelCommand::Get | PanelCommand::Save(_) => {}
|
||||
PanelCommand::HeaderAction(_) => header_action = true,
|
||||
PanelCommand::Contextual { .. } => contextual = true,
|
||||
PanelCommand::Apply(_) | PanelCommand::Reset(_) => replacement = true,
|
||||
PanelCommand::Workspace(params) => {
|
||||
replacement |= params.workspace != selected;
|
||||
@@ -1075,9 +1053,6 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
|
||||
_ => mutation = true,
|
||||
}
|
||||
}
|
||||
if contextual && steps.len() != 1 {
|
||||
return Err(error(Rule::Capability, "contextual panel actions require a standalone transaction"));
|
||||
}
|
||||
if header_action && steps.len() != 1 {
|
||||
return Err(error(Rule::Capability, "header actions require a standalone transaction"));
|
||||
}
|
||||
@@ -1100,7 +1075,7 @@ pub fn execute_batch(steps: &[Value], context: &mut PanelCommandContext<'_>) ->
|
||||
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
|
||||
let empty = json!({});
|
||||
let command = parse_command(id, step.get("params").unwrap_or(&empty))?;
|
||||
if matches!(&command, PanelCommand::HeaderAction(_) | PanelCommand::Contextual { .. }) {
|
||||
if matches!(&command, PanelCommand::HeaderAction(_)) {
|
||||
let result = execute_command(command, context)?;
|
||||
return Ok(json!({"completed":1,"results":[result],"undone":0}));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ pub fn synthetic_registry() -> PanelResult<PanelRegistry> {
|
||||
surface: PanelSurface::FollowZone,
|
||||
features: PanelFeatures { multi_instance: multi, ..PanelFeatures::default() },
|
||||
side_height_policy: SideHeightPolicy::Content,
|
||||
fixed_bottom_height: None,
|
||||
header_actions: Vec::new(),
|
||||
text_heavy: false,
|
||||
})?;
|
||||
|
||||
@@ -197,33 +197,12 @@ pub struct PanelSpec {
|
||||
pub features: PanelFeatures,
|
||||
#[serde(default)]
|
||||
pub side_height_policy: SideHeightPolicy,
|
||||
/// A rigid, expanded Bottom row. Other zones retain their ordinary bounds
|
||||
/// and collapse capability. The declared height must satisfy those bounds.
|
||||
#[serde(default)]
|
||||
pub fixed_bottom_height: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub header_actions: Vec<HeaderActionSpec>,
|
||||
#[serde(default)]
|
||||
pub text_heavy: bool,
|
||||
}
|
||||
|
||||
impl PanelSpec {
|
||||
pub fn size_bounds(&self, zone: Zone) -> (Size, Size) {
|
||||
let (mut min, mut max) = (self.min_size, self.max_size);
|
||||
if zone == Zone::Bottom
|
||||
&& let Some(height) = self.fixed_bottom_height
|
||||
{
|
||||
min.height = height;
|
||||
max.height = height;
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
|
||||
pub fn collapsible_in(&self, zone: Zone) -> bool {
|
||||
self.features.collapsible && !(zone == Zone::Bottom && self.fixed_bottom_height.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PanelRegistry {
|
||||
specs: BTreeMap<PanelKind, PanelSpec>,
|
||||
@@ -252,11 +231,6 @@ impl PanelRegistry {
|
||||
if !spec.default_size.within(spec.min_size, spec.max_size) {
|
||||
return Err(error(Rule::Bounds, "min <= default <= max required in both dimensions"));
|
||||
}
|
||||
if spec.fixed_bottom_height.is_some_and(|height| {
|
||||
!height.is_finite() || height < spec.min_size.height || height > spec.max_size.height || !spec.allowed_zones.contains(&Zone::Bottom)
|
||||
}) {
|
||||
return Err(error(Rule::Bounds, "fixed Bottom height must satisfy registered bounds in an allowed Bottom zone"));
|
||||
}
|
||||
if !spec.allowed_zones.contains(&Zone::Floating) || !spec.allowed_zones.contains(&spec.default_zone) {
|
||||
return Err(error(Rule::Placement, "Floating and default_zone must be allowed"));
|
||||
}
|
||||
@@ -901,11 +875,10 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
|
||||
let mut max = Size { width: MAX_LOGICAL, height: MAX_LOGICAL };
|
||||
for id in §ion.tabs {
|
||||
let spec = registry.get(&layout.instance(id)?.kind)?;
|
||||
let (lower, upper) = spec.size_bounds(section.location.zone());
|
||||
min.width = min.width.max(lower.width);
|
||||
min.height = min.height.max(lower.height);
|
||||
max.width = max.width.min(upper.width);
|
||||
max.height = max.height.min(upper.height);
|
||||
min.width = min.width.max(spec.min_size.width);
|
||||
min.height = min.height.max(spec.min_size.height);
|
||||
max.width = max.width.min(spec.max_size.width);
|
||||
max.height = max.height.min(spec.max_size.height);
|
||||
}
|
||||
if min.width > max.width || min.height > max.height {
|
||||
return Err(error(Rule::IncompatibleTabs, "tab size constraints have no intersection"));
|
||||
@@ -913,15 +886,6 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
|
||||
Ok((min, max))
|
||||
}
|
||||
|
||||
pub fn section_collapsible(layout: &Layout, registry: &PanelRegistry, section: &Section) -> PanelResult<bool> {
|
||||
for id in §ion.tabs {
|
||||
if !registry.get(&layout.instance(id)?.kind)?.collapsible_in(section.location.zone()) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn column_bounds(layout: &Layout, registry: &PanelRegistry, column: &ColumnId) -> PanelResult<(f32, f32)> {
|
||||
layout.column(column)?;
|
||||
let mut min: f32 = 0.0;
|
||||
@@ -1060,7 +1024,7 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
if s.tabs.len() > 1 && !spec.features.tabbable {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot join tabs"));
|
||||
}
|
||||
if s.collapsed && !spec.collapsible_in(zone) {
|
||||
if s.collapsed && !spec.features.collapsible {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot collapse"));
|
||||
}
|
||||
if zone == Zone::Window && !spec.features.popout {
|
||||
@@ -1091,9 +1055,9 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
for id in &s.tabs {
|
||||
let spec = registry.get(&layout.instance(id)?.kind)?;
|
||||
if !spec.allowed_zones.contains(&return_to.zone)
|
||||
|| !return_to.size.within(spec.size_bounds(return_to.zone).0, spec.size_bounds(return_to.zone).1)
|
||||
|| !return_to.size.within(spec.min_size, spec.max_size)
|
||||
|| return_to.column_width.is_some_and(|width| width < spec.min_size.width || width > spec.max_size.width)
|
||||
|| (return_to.collapsed && !spec.collapsible_in(return_to.zone))
|
||||
|| (return_to.collapsed && !spec.features.collapsible)
|
||||
{
|
||||
return Err(error(Rule::Placement, "native return violates member constraints"));
|
||||
}
|
||||
|
||||
@@ -180,55 +180,8 @@ pub struct LayoutReply {
|
||||
pub availability: AvailabilityMap,
|
||||
}
|
||||
|
||||
/// Keyboard actions whose target belongs to the live panel host, never the document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContextualPanelAction {
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
Toggle,
|
||||
MoveNextZone,
|
||||
ResizeLeft,
|
||||
ResizeRight,
|
||||
ResizeUp,
|
||||
ResizeDown,
|
||||
ResetSash,
|
||||
}
|
||||
impl ContextualPanelAction {
|
||||
pub const ALL: [Self; 9] = [
|
||||
Self::FocusNext,
|
||||
Self::FocusPrevious,
|
||||
Self::Toggle,
|
||||
Self::MoveNextZone,
|
||||
Self::ResizeLeft,
|
||||
Self::ResizeRight,
|
||||
Self::ResizeUp,
|
||||
Self::ResizeDown,
|
||||
Self::ResetSash,
|
||||
];
|
||||
pub const fn command_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::FocusNext => "panel.focus.next",
|
||||
Self::FocusPrevious => "panel.focus.previous",
|
||||
Self::Toggle => "panel.toggle",
|
||||
Self::MoveNextZone => "panel.move.next_zone",
|
||||
Self::ResizeLeft => "panel.sash.left",
|
||||
Self::ResizeRight => "panel.sash.right",
|
||||
Self::ResizeUp => "panel.sash.up",
|
||||
Self::ResizeDown => "panel.sash.down",
|
||||
Self::ResetSash => "panel.sash.reset",
|
||||
}
|
||||
}
|
||||
pub fn from_command_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|action| action.command_id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Same boundary for GUI and programmatic callers. No mutable Session reference.
|
||||
pub trait ActionSink {
|
||||
/// Optional live-host target resolution. Headless sinks cannot infer UI focus.
|
||||
fn resolve_contextual(&self, _action: ContextualPanelAction) -> PanelResult<LayoutAction> {
|
||||
Err(error(Rule::Availability, "live panel host keyboard context is unavailable"))
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply>;
|
||||
fn inspect(&self) -> LayoutReply;
|
||||
/// Pure projection on the authoritative service. Adapters stage bounded
|
||||
@@ -978,15 +931,9 @@ impl Reservations<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn place(
|
||||
layout: &mut Layout,
|
||||
id: &InstanceId,
|
||||
destination: Destination,
|
||||
reservations: &Reservations<'_>,
|
||||
new_column_width: Option<f32>,
|
||||
) -> PanelResult<()> {
|
||||
fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reservations: &Reservations<'_>) -> PanelResult<()> {
|
||||
match destination {
|
||||
Destination::NewSection { section, index, mut location, mut size } => {
|
||||
Destination::NewSection { section, index, mut location, size } => {
|
||||
fresh_section(layout, id, §ion)?;
|
||||
if let SectionLocation::Window { return_to, .. } = &mut location {
|
||||
if return_to.zone.side() && return_to.column.is_none() {
|
||||
@@ -1023,19 +970,11 @@ fn place(
|
||||
.filter(|p| p.memory.section == section && p.memory.location.zone() == location.zone())
|
||||
.map(|p| p.memory.clone());
|
||||
let zone = location.zone();
|
||||
let mut created_column = false;
|
||||
let column = if zone.side() {
|
||||
if let Some(memory) = &memory
|
||||
&& let Some(column) = &memory.column
|
||||
{
|
||||
created_column = layout.column(column).is_err();
|
||||
Some(restore_column(
|
||||
layout,
|
||||
column.clone(),
|
||||
zone,
|
||||
memory.column_index,
|
||||
new_column_width.unwrap_or_else(|| memory.column_width.unwrap_or(size.width)),
|
||||
)?)
|
||||
Some(restore_column(layout, column.clone(), zone, memory.column_index, memory.column_width.unwrap_or(size.width))?)
|
||||
} else {
|
||||
let existing = layout
|
||||
.sections
|
||||
@@ -1047,21 +986,14 @@ fn place(
|
||||
Some(match existing {
|
||||
Some(column) => column,
|
||||
None => {
|
||||
created_column = true;
|
||||
let column = next_column_id_with_reservations(layout, reservations.columns)?;
|
||||
restore_column(layout, column, zone, MAX_SECTIONS, new_column_width.unwrap_or(size.width))?
|
||||
restore_column(layout, column, zone, MAX_SECTIONS, size.width)?
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if created_column
|
||||
&& new_column_width.is_some()
|
||||
&& let Some(column) = &column
|
||||
{
|
||||
size.width = layout.column(column)?.width;
|
||||
}
|
||||
let index = if let Some(column) = &column { column_insert_index(layout, column, index)? } else { index };
|
||||
layout.sections.insert(
|
||||
index,
|
||||
@@ -1087,7 +1019,7 @@ fn place(
|
||||
s.tabs.insert(index, id.clone());
|
||||
// Joining never steals active tab or resizes the group.
|
||||
}
|
||||
Destination::Relative { section, placement, new_section, new_column, mut size } => {
|
||||
Destination::Relative { section, placement, new_section, new_column, size } => {
|
||||
fresh_section(layout, id, &new_section)?;
|
||||
size.validate()?;
|
||||
let target = layout.section(§ion)?.clone();
|
||||
@@ -1107,9 +1039,6 @@ fn place(
|
||||
(target_column.clone(), target_index + usize::from(placement == RelativePlacement::Below), weight)
|
||||
}
|
||||
RelativePlacement::BeforeColumn | RelativePlacement::AfterColumn => {
|
||||
if let Some(width) = new_column_width {
|
||||
size.width = width;
|
||||
}
|
||||
let column = new_column.ok_or_else(|| error(Rule::Placement, "column placement requires identity"))?;
|
||||
valid_id(&column.0)?;
|
||||
if layout.columns.iter().any(|c| c.id == column)
|
||||
@@ -1207,8 +1136,6 @@ fn apply(
|
||||
mutation: Mutation,
|
||||
) -> PanelResult<()> {
|
||||
let MutationContext { registry, availability, caps, reservations, defer_availability } = *context;
|
||||
let changes_placement =
|
||||
matches!(&mutation, Mutation::Open { .. } | Mutation::Move { .. } | Mutation::Focus { .. } | Mutation::NativeClose { .. });
|
||||
match mutation {
|
||||
Mutation::Open { kind, instance, destination } => {
|
||||
valid_id(&instance.0)?;
|
||||
@@ -1229,7 +1156,7 @@ fn apply(
|
||||
let mut destination = destination.unwrap_or_else(|| reopen_destination(layout, &p));
|
||||
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
|
||||
let created = !matches!(&destination, Destination::JoinTab { .. });
|
||||
place(layout, &instance, destination, reservations, None)?;
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
if created {
|
||||
let section = layout.containing(&instance)?.id.clone();
|
||||
let section = section_mut(layout, §ion)?;
|
||||
@@ -1248,7 +1175,7 @@ fn apply(
|
||||
}
|
||||
} else {
|
||||
let destination = destination.ok_or_else(|| error(Rule::Placement, "new instance requires explicit destination"))?;
|
||||
place(layout, &instance, destination, reservations, None)?;
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
let s = layout.containing(&instance)?.clone();
|
||||
layout.instances.push(PanelInstance {
|
||||
id: instance.clone(),
|
||||
@@ -1297,10 +1224,6 @@ fn apply(
|
||||
}
|
||||
let collapsed = layout.containing(&instance)?.collapsed;
|
||||
let source = layout.containing(&instance)?.clone();
|
||||
let column_width = match &source.column {
|
||||
Some(column) => layout.column(column)?.width,
|
||||
None => registry.get(&layout.instance(&instance)?.kind)?.default_size.width,
|
||||
};
|
||||
if let Destination::NewSection { location: SectionLocation::Window { return_to, .. }, .. } = &mut destination
|
||||
&& source.location.zone() == return_to.zone
|
||||
&& source.location.zone().docked()
|
||||
@@ -1334,7 +1257,7 @@ fn apply(
|
||||
Destination::Relative { new_section, .. } => Some(new_section.clone()),
|
||||
_ => None,
|
||||
};
|
||||
place(layout, &instance, destination, reservations, Some(column_width))?;
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
if let Some(id) = new_section {
|
||||
let section = section_mut(layout, &id)?;
|
||||
section.collapsed = collapsed;
|
||||
@@ -1353,7 +1276,7 @@ fn apply(
|
||||
if !p.requested_open {
|
||||
let mut destination = reopen_destination(layout, &p);
|
||||
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
|
||||
place(layout, &instance, destination, reservations, None)?;
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
if let Some(entry) = layout.instances.iter_mut().find(|p| p.id == instance) {
|
||||
entry.requested_open = true;
|
||||
}
|
||||
@@ -1612,7 +1535,6 @@ fn apply(
|
||||
let index = join.index.min(target.tabs.len());
|
||||
drop(target.tabs.splice(index..index, section.tabs));
|
||||
let members = target.tabs.clone();
|
||||
normalize_fixed_bottom_rows(layout, registry)?;
|
||||
for instance in members {
|
||||
remember(layout, &instance)?;
|
||||
}
|
||||
@@ -1644,9 +1566,6 @@ fn apply(
|
||||
layout.sections.insert(index, section);
|
||||
}
|
||||
}
|
||||
if changes_placement {
|
||||
normalize_fixed_bottom_rows(layout, registry)?;
|
||||
}
|
||||
// Every live member gets the same committed group geometry/proportions.
|
||||
// Closed memories are intentionally independent until their panel reopens.
|
||||
let members: Vec<_> = layout.sections.iter().flat_map(|s| s.tabs.iter().cloned()).collect();
|
||||
@@ -1662,26 +1581,6 @@ fn apply(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_fixed_bottom_rows(layout: &mut Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
let mut fixed = Vec::new();
|
||||
for section in layout.sections.iter().filter(|section| section.location.zone() == Zone::Bottom) {
|
||||
let mut declared = false;
|
||||
for instance in §ion.tabs {
|
||||
declared |= registry.get(&layout.instance(instance)?.kind)?.fixed_bottom_height.is_some();
|
||||
}
|
||||
if declared {
|
||||
let (min, _) = tab_bounds(layout, registry, section)?;
|
||||
fixed.push((section.id.clone(), min.height));
|
||||
}
|
||||
}
|
||||
for (id, height) in fixed {
|
||||
let section = section_mut(layout, &id)?;
|
||||
section.size.height = height;
|
||||
section.collapsed = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prune_columns(layout: &mut Layout) {
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
for column in layout.sections.iter().filter_map(|s| s.column.clone()).chain(layout.instances.iter().filter_map(|p| p.memory.column.clone())) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::commands::LayoutCatalogue;
|
||||
use super::*;
|
||||
|
||||
fn id(value: &str) -> InstanceId {
|
||||
@@ -23,172 +22,6 @@ fn reject(service: &mut LayoutService, mutations: Vec<Mutation>, rule: Rule) {
|
||||
assert_eq!(service.inspect(), before, "failed action must preserve layout/revision/interaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_bottom_height_is_validated_without_weakening_other_zone_bounds() {
|
||||
let base = synthetic_registry().unwrap().get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
|
||||
for height in [f32::NAN, f32::INFINITY, 0.0, 119.0, 1201.0] {
|
||||
let mut spec = base.clone();
|
||||
spec.fixed_bottom_height = Some(height);
|
||||
assert_eq!(PanelRegistry::default().register(spec).unwrap_err().rule, Rule::Bounds);
|
||||
}
|
||||
let mut spec = base;
|
||||
spec.fixed_bottom_height = Some(160.0);
|
||||
assert_eq!(spec.size_bounds(Zone::Bottom), (size(160.0, 160.0), size(1200.0, 160.0)));
|
||||
assert_eq!(spec.size_bounds(Zone::Left), (spec.min_size, spec.max_size));
|
||||
assert!(!spec.collapsible_in(Zone::Bottom));
|
||||
assert!(spec.collapsible_in(Zone::Left));
|
||||
PanelRegistry::default().register(spec).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_bottom_rows_normalize_placement_but_reject_collapse_and_height_commands_atomically() {
|
||||
use serde_json::json;
|
||||
let mut registry = synthetic_registry().unwrap();
|
||||
let mut spec = registry.get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
|
||||
spec.kind = PanelKind::new("fixture.rigid-row").unwrap();
|
||||
spec.fixed_bottom_height = Some(160.0);
|
||||
registry.register(spec).unwrap();
|
||||
let mut service =
|
||||
LayoutService::new(registry.clone(), Layout::empty(WorkspaceId::new("fixture.workspace").unwrap()), HostCapabilities::default()).unwrap();
|
||||
service.dispatch(transaction(vec![fixture_open("fixture.rigid-row", "row", "side", Zone::Left, 0).unwrap()])).unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::Collapse { section: section("side"), collapsed: true }])).unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![Mutation::Move {
|
||||
instance: id("row"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("bottom"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Bottom },
|
||||
size: size(500.0, 300.0),
|
||||
},
|
||||
}]))
|
||||
.unwrap();
|
||||
let bottom = service.layout().section(§ion("bottom")).unwrap();
|
||||
assert_eq!(bottom.size.height, 160.0);
|
||||
assert!(!bottom.collapsed);
|
||||
assert!(!section_collapsible(service.layout(), ®istry, bottom).unwrap());
|
||||
reject(&mut service, vec![Mutation::Collapse { section: section("bottom"), collapsed: true }], Rule::FeatureDisabled);
|
||||
reject(&mut service, vec![Mutation::SetSize { section: section("bottom"), size: size(500.0, 161.0) }], Rule::Bounds);
|
||||
reject(&mut service, vec![Mutation::SetBottomHeight { section: section("bottom"), height: 161.0 }], Rule::Bounds);
|
||||
|
||||
let mut backend = commands::InMemoryPanelBackend::new(service, fixture_command_catalogue(®istry).unwrap());
|
||||
for (command, params, rule) in [
|
||||
("panel.collapse", json!({"target":command_target("row"),"collapsed":true}), Rule::FeatureDisabled),
|
||||
("panel.resize", json!({"target":command_target("row"),"size":{"width":500,"height":161}}), Rule::Bounds),
|
||||
("panel.resize", json!({"target":command_target("row"),"size":{"width":1,"height":161},"thickness_only":true}), Rule::Bounds),
|
||||
] {
|
||||
let before = backend.service.inspect();
|
||||
let catalogue = backend.catalogue.state().clone();
|
||||
assert_eq!(backend.execute(command, ¶ms).unwrap_err().rule, rule);
|
||||
assert_eq!(backend.service.inspect(), before);
|
||||
assert_eq!(backend.catalogue.state(), &catalogue);
|
||||
}
|
||||
backend.service.dispatch(transaction(vec![Mutation::Close { instance: id("row") }])).unwrap();
|
||||
backend
|
||||
.service
|
||||
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.rigid-row").unwrap(), instance: id("row"), destination: None }]))
|
||||
.unwrap();
|
||||
assert_eq!(backend.service.layout().containing(&id("row")).unwrap().size.height, 160.0);
|
||||
backend
|
||||
.service
|
||||
.dispatch(transaction(vec![Mutation::Move {
|
||||
instance: id("row"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("right"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Right },
|
||||
size: size(500.0, 300.0),
|
||||
},
|
||||
}]))
|
||||
.unwrap();
|
||||
backend.service.dispatch(transaction(vec![Mutation::Collapse { section: section("right"), collapsed: true }])).unwrap();
|
||||
assert_eq!(backend.service.layout().section(§ion("right")).unwrap().size.height, 300.0);
|
||||
|
||||
backend.service.dispatch(transaction(vec![fixture_open("fixture.log", "ordinary", "ordinary", Zone::Bottom, 1).unwrap()])).unwrap();
|
||||
backend
|
||||
.service
|
||||
.dispatch(transaction(vec![Mutation::Open {
|
||||
kind: PanelKind::new("fixture.rigid-row").unwrap(),
|
||||
instance: id("inactive"),
|
||||
destination: Some(Destination::JoinTab { section: section("ordinary"), index: 1 }),
|
||||
}]))
|
||||
.unwrap();
|
||||
let ordinary = backend.service.layout().section(§ion("ordinary")).unwrap();
|
||||
assert_eq!(ordinary.active, id("ordinary"));
|
||||
assert_eq!(ordinary.size.height, 160.0, "inactive fixed-row metadata still constrains its group");
|
||||
reject(&mut backend.service, vec![Mutation::Collapse { section: section("ordinary"), collapsed: true }], Rule::FeatureDisabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_column_moves_preserve_source_column_width_or_use_the_registered_default() {
|
||||
for source_zone in [Zone::Left, Zone::Right, Zone::Bottom, Zone::Floating] {
|
||||
for relative in [true, false] {
|
||||
let mut service = fixture_service(false).unwrap();
|
||||
let target_zone = if source_zone == Zone::Right { Zone::Left } else { Zone::Right };
|
||||
service
|
||||
.dispatch(transaction(vec![
|
||||
fixture_open("fixture.log", "source", "source", source_zone, 0).unwrap(),
|
||||
fixture_open("fixture.log", "anchor", "anchor", target_zone, 1).unwrap(),
|
||||
]))
|
||||
.unwrap();
|
||||
let target_column = service.layout().containing(&id("anchor")).unwrap().column.clone().unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: target_column.clone(), width: 330.0 }])).unwrap();
|
||||
if source_zone.side() {
|
||||
let source_column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: source_column, width: 410.0 }])).unwrap();
|
||||
} else {
|
||||
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
|
||||
}
|
||||
let destination = if relative {
|
||||
Destination::Relative {
|
||||
section: section("anchor"),
|
||||
placement: RelativePlacement::BeforeColumn,
|
||||
new_section: section("moved"),
|
||||
new_column: Some(ColumnId::new("moved-column").unwrap()),
|
||||
size: size(999.0, 240.0),
|
||||
}
|
||||
} else {
|
||||
Destination::NewSection {
|
||||
section: section("moved"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: target_zone },
|
||||
size: size(999.0, 240.0),
|
||||
}
|
||||
};
|
||||
service.dispatch(transaction(vec![Mutation::Move { instance: id("source"), destination }])).unwrap();
|
||||
let moved = service.layout().containing(&id("source")).unwrap();
|
||||
let column = service.layout().column(moved.column.as_ref().unwrap()).unwrap();
|
||||
let expected = if relative { if source_zone.side() { 410.0 } else { 280.0 } } else { 330.0 };
|
||||
assert_eq!(column.width, expected);
|
||||
assert_eq!(moved.size.width, if relative { expected } else { 999.0 });
|
||||
assert_eq!(service.layout().column(&target_column).unwrap().width, 330.0, "joining an existing column never resizes it");
|
||||
}
|
||||
}
|
||||
|
||||
let mut service = fixture_service(false).unwrap();
|
||||
service.dispatch(transaction(vec![fixture_open("fixture.log", "source", "source", Zone::Floating, 0).unwrap()])).unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![Mutation::Move {
|
||||
instance: id("source"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("moved"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Left },
|
||||
size: size(999.0, 240.0),
|
||||
},
|
||||
}]))
|
||||
.unwrap();
|
||||
let column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
|
||||
assert_eq!(service.layout().column(&column).unwrap().width, 280.0);
|
||||
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: column.clone(), width: 730.0 }])).unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::Close { instance: id("source") }])).unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.log").unwrap(), instance: id("source"), destination: None }]))
|
||||
.unwrap();
|
||||
assert_eq!(service.layout().column(&column).unwrap().width, 730.0, "reopen restores the saved column width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_constraints_and_identifiers_are_bounded() {
|
||||
let base = synthetic_registry().unwrap().specs().next().unwrap().clone();
|
||||
@@ -1597,7 +1430,7 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
|
||||
floating["location"] = json!({"type":"floating","origin":{"x":20,"y":30}});
|
||||
let mut native = floating.clone();
|
||||
native["location"] = json!({"type":"window","host":"native","origin":{"x":20,"y":30},"return_to":{"zone":"left","index":0,"size":{"width":280,"height":240},"collapsed":false}});
|
||||
let mut inputs = vec![
|
||||
vec![
|
||||
("layout.apply", json!({"name":"Fixture default"})),
|
||||
("layout.get", json!({})),
|
||||
("layout.reset", json!({})),
|
||||
@@ -1625,9 +1458,7 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
|
||||
"panel.resize_pair",
|
||||
json!({"first":"s-one","second":"s-two","first_size":{"width":280,"height":260},"second_size":{"width":280,"height":220}}),
|
||||
),
|
||||
];
|
||||
inputs.extend(ContextualPanelAction::ALL.into_iter().map(|action| (action.command_id(), json!({}))));
|
||||
inputs
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1679,10 +1510,6 @@ fn panel_command_success_replies_describe_committed_state_for_every_executable_o
|
||||
backend.execute("panel.collapse", &serde_json::json!({"target":command_target("one"),"collapsed":true})).unwrap();
|
||||
}
|
||||
let result = backend.execute(id, &input);
|
||||
if ContextualPanelAction::from_command_id(id).is_some() {
|
||||
assert_eq!(result.unwrap_err().rule, Rule::Availability, "headless cannot infer shared-control focus");
|
||||
continue;
|
||||
}
|
||||
if id == "panel.action" {
|
||||
assert_eq!(result.unwrap_err().rule, Rule::Capability, "headless cannot invoke an unbound controller");
|
||||
continue;
|
||||
@@ -2237,73 +2064,6 @@ fn command_sidecar_collisions_and_aggregate_catalogue_growth_are_rejected_atomic
|
||||
assert!(rejected, "aggregate bytes reject before count cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_panel_commands_resolve_once_and_commit_through_the_registered_sink() {
|
||||
use commands::LayoutCatalogue;
|
||||
struct FocusSink {
|
||||
service: LayoutService,
|
||||
resolutions: std::cell::Cell<usize>,
|
||||
}
|
||||
impl ActionSink for FocusSink {
|
||||
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
|
||||
assert_eq!(action, ContextualPanelAction::FocusNext);
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
Ok(LayoutAction::Apply {
|
||||
expected_revision: Some(self.service.inspect().revision),
|
||||
mutations: vec![Mutation::Focus { instance: InstanceId::new("two").unwrap() }],
|
||||
})
|
||||
}
|
||||
fn inspect(&self) -> LayoutReply {
|
||||
self.service.inspect()
|
||||
}
|
||||
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
|
||||
self.service.project(action)
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
self.service.dispatch(action)
|
||||
}
|
||||
}
|
||||
let backend = command_ready_backend();
|
||||
let mut sink = FocusSink { service: backend.service, resolutions: std::cell::Cell::new(0) };
|
||||
let mut catalogue = backend.catalogue;
|
||||
let registry = sink.service.registry().clone();
|
||||
let before = sink.inspect();
|
||||
let original_catalogue = catalogue.state().clone();
|
||||
let mut context = commands::PanelCommandContext { sink: &mut sink, registry: ®istry, catalogue: &mut catalogue, web: false };
|
||||
assert_eq!(
|
||||
commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":u64::MAX}), &mut context).unwrap_err().rule,
|
||||
Rule::Revision
|
||||
);
|
||||
assert_eq!(context.sink.inspect(), before);
|
||||
let result = commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":before.revision}), &mut context).unwrap();
|
||||
let reply: LayoutReply = serde_json::from_value(result).unwrap();
|
||||
assert_eq!(reply.layout, context.sink.inspect().layout);
|
||||
assert_eq!(reply.revision, context.sink.inspect().revision);
|
||||
assert_eq!(reply.interaction, context.sink.inspect().interaction);
|
||||
assert_eq!(reply.interaction.focused, Some(InstanceId::new("two").unwrap()));
|
||||
assert_eq!(sink.resolutions.get(), 1, "revision rejection precedes live focus resolution");
|
||||
assert_eq!(catalogue.state(), &original_catalogue, "focus does not change the saved arrangement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_panel_commands_without_live_focus_refuse_without_effects_including_batches() {
|
||||
use commands::LayoutCatalogue;
|
||||
for action in ContextualPanelAction::ALL {
|
||||
let mut backend = command_ready_backend();
|
||||
let before = backend.service.inspect();
|
||||
let catalogue = backend.catalogue.state().clone();
|
||||
let reason = backend.execute(action.command_id(), &serde_json::json!({})).unwrap_err();
|
||||
assert_eq!(reason.rule, Rule::Availability);
|
||||
assert!(reason.detail.contains("live panel host keyboard context"));
|
||||
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()})]).unwrap_err();
|
||||
assert_eq!(reason.rule, Rule::Availability);
|
||||
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()}), serde_json::json!({"command":"layout.get"})]).unwrap_err();
|
||||
assert_eq!(reason.rule, Rule::Capability);
|
||||
assert_eq!(backend.service.inspect(), before);
|
||||
assert_eq!(backend.catalogue.state(), &catalogue);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_only_panel_commands_report_capability_without_touching_document_or_model() {
|
||||
let mut session = crate::Session::default();
|
||||
@@ -4086,11 +3846,7 @@ fn closed_panel_reopens_with_column_identity_width_rank_and_saved_weight() {
|
||||
}]))
|
||||
.unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![
|
||||
Mutation::SetColumnWidth { column: column.clone(), width: 310.0 },
|
||||
Mutation::Collapse { section: section("inner"), collapsed: true },
|
||||
Mutation::Close { instance: id("two") },
|
||||
]))
|
||||
.dispatch(transaction(vec![Mutation::Collapse { section: section("inner"), collapsed: true }, Mutation::Close { instance: id("two") }]))
|
||||
.unwrap();
|
||||
let memory = service.layout().instance(&id("two")).unwrap().memory.clone();
|
||||
assert_eq!(memory.column, Some(column.clone()));
|
||||
|
||||
@@ -83,7 +83,7 @@ fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
|
||||
fn a_lost_profile_is_an_error_not_another_pick() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10], "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 5], "radius": 3, "id": "ring"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let ring = s.model.state().sketches[0].profiles.iter().position(|p| p.outer_curves == ["ring"]).unwrap();
|
||||
|
||||
@@ -1130,834 +1130,6 @@ pub struct UnlockResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and
|
||||
// Paste New (#13186).
|
||||
//
|
||||
// As above, each key reads as its handler reads it and fails with the handler's message; a key
|
||||
// the handler refuses when it is left out is optional in the type and required in the schema.
|
||||
// Some handlers read a key only in some cases (`occurrence.copy` reads `component` only without
|
||||
// `occurrence`, `occurrence.move` reads `tolerance` only with `matrix`, and so on): those
|
||||
// parameters types are read stage by stage in the handler's order, from only the keys the handler
|
||||
// reads in that case, so a call that works today gets no warning and a call that fails gets the
|
||||
// handler's message.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The message of a handler's refusal, to give whole.
|
||||
fn refusal(e: crate::EngineError) -> String {
|
||||
match e {
|
||||
crate::EngineError::BadParams { msg, .. } => msg,
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of `value` that are in `keys`, as an object.
|
||||
fn only(value: &Value, keys: &[&str]) -> Value {
|
||||
Value::Object(value.as_object().into_iter().flatten().filter(|(k, _)| keys.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
}
|
||||
|
||||
/// `K` read from `value`: first from just the keys of each of `stages` in turn (the order the
|
||||
/// handler reads them in, so the first refusal is the handler's), then from all of it.
|
||||
fn staged<K: serde::de::DeserializeOwned, E: serde::de::Error>(value: &Value, stages: &[&[&str]]) -> Result<K, E> {
|
||||
read_stages::<K, E>(value, stages)?;
|
||||
serde_path_to_error::deserialize(value).map_err(|e| E::custom(located(&e)))
|
||||
}
|
||||
|
||||
/// What [`staged`] reads first: just the keys of each of `stages` in turn, for the first refusal.
|
||||
fn read_stages<K: serde::de::DeserializeOwned, E: serde::de::Error>(value: &Value, stages: &[&[&str]]) -> Result<(), E> {
|
||||
for stage in stages {
|
||||
serde_path_to_error::deserialize::<_, K>(&only(value, stage)).map_err(|e| E::custom(located(&e)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The keys of `value` without `keys`.
|
||||
fn without(mut value: Value, keys: &[&str]) -> Value {
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
for key in keys {
|
||||
object.remove(*key);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// `key`'s value as `cmd::move_vector` reads it: `[x, y, z]` or `{x, y, z}` of numbers below 1e9
|
||||
/// in size, with its message `key must contain three finite numbers` for anything else, `null`
|
||||
/// included.
|
||||
fn move_point_key<'de, D: Deserializer<'de>>(d: D, key: &str, coordinate: bool) -> Result<Option<Point3>, D::Error> {
|
||||
let mut one = serde_json::Map::new();
|
||||
one.insert(key.to_string(), Value::deserialize(d)?);
|
||||
crate::cmd::move_vector(&Value::Object(one), key, Vec3::ZERO, coordinate, "")
|
||||
.map(|v| Some(Point3(v)))
|
||||
.map_err(|e| D::Error::custom(whole(refusal(e))))
|
||||
}
|
||||
|
||||
macro_rules! move_point {
|
||||
($name:ident, $key:literal, $coordinate:literal) => {
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point3>, D::Error> {
|
||||
move_point_key(d, $key, $coordinate)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
move_point!(translate_key, "translate", true);
|
||||
move_point!(axis_key, "axis", false);
|
||||
move_point!(origin_key, "origin", true);
|
||||
move_point!(at_key, "at", true);
|
||||
move_point!(point_key, "point", true);
|
||||
|
||||
/// A placement path as `placing::parent_path` and `component.move_bodies` read `parent_path`,
|
||||
/// `component_path` and `from_path`: up to 64 occurrence ids (none is the design itself).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PlacementPath(pub Vec<u64>);
|
||||
|
||||
impl<'de> Deserialize<'de> for PlacementPath {
|
||||
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(PlacementPath)
|
||||
.ok_or_else(|| D::Error::custom("must list 0..64 occurrence ids"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PlacementPath {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PlacementPath".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An exact placement path: up to 64 occurrence ids from the root down; empty for the design itself.",
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
|
||||
"maxItems": 64
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `component.revert_position`'s `occurrences`: 1 to 10000 occurrence ids.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OccurrenceIds(pub Vec<u64>);
|
||||
|
||||
impl<'de> Deserialize<'de> for OccurrenceIds {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 10_000)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.map(OccurrenceIds)
|
||||
.ok_or_else(|| D::Error::custom(whole("occurrences must list 1..10000 ids")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for OccurrenceIds {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"OccurrenceIds".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "1 to 10000 occurrence ids.",
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
|
||||
"minItems": 1,
|
||||
"maxItems": 10000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `occurrence.move`'s `angle`: a number (radians) or an angle expression, which the handler
|
||||
/// evaluates against the design's parameters.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MoveAngle(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for MoveAngle {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
v @ (Value::Number(_) | Value::String(_)) => Ok(MoveAngle(v)),
|
||||
_ => Err(D::Error::custom(whole("angle must be a number or angle expression"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MoveAngle {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MoveAngle".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An angle in radians (a number) or an angle expression such as \"30 deg\".", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
/// Which frame `occurrence.move` reads its move in: `local` or `world`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MoveSpace {
|
||||
Local,
|
||||
World,
|
||||
}
|
||||
|
||||
fn move_space<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MoveSpace>, D::Error> {
|
||||
super::view_navigation::read_as(d, "space must be local or world")
|
||||
}
|
||||
|
||||
/// `occurrence.move`'s `matrix`: 12 numbers (a row-major 3×4) or 16 (a 4×4), flat or as rows. The
|
||||
/// parameters read it with `tolerance` (`matrix::rigid`), which refuses what isn't a rigid move.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MatrixParam(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for MatrixParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d).map(MatrixParam)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MatrixParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MoveMatrix".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let row = json!({"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4});
|
||||
json_schema!({
|
||||
"description": "A rigid move as a row-major 3x4 (12 numbers) or 4x4 (16 numbers) matrix, flat or as 3 or 4 rows of 4.",
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "number"}, "minItems": 12, "maxItems": 12},
|
||||
{"type": "array", "items": {"type": "number"}, "minItems": 16, "maxItems": 16},
|
||||
{"type": "array", "items": row, "minItems": 3, "maxItems": 4}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `occurrence.move`'s `tolerance`, read only with `matrix`: a number above 0 and at most 1e-3.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MatrixTolerance(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for MatrixTolerance {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d).map(MatrixTolerance)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MatrixTolerance {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MatrixTolerance".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A number above 0 and at most 0.001.", "type": "number", "exclusiveMinimum": 0, "maximum": 0.001})
|
||||
}
|
||||
}
|
||||
|
||||
/// `occurrence.move`'s `base_transform`: the 4×4 matrix a move starts from, as the Browser's
|
||||
/// drag passes it, in the layout of a placement's `transform` (4 columns of 4 numbers): finite,
|
||||
/// affine (the fourth number of the first three columns is 0, of the last 1) and invertible.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BaseTransform(pub solidcraft_doc::Mat);
|
||||
|
||||
impl<'de> Deserialize<'de> for BaseTransform {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::base_transform(&Value::deserialize(d)?).map(BaseTransform).map_err(|message| D::Error::custom(whole(message)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for BaseTransform {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"BaseTransform".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let row = json!({"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4});
|
||||
json_schema!({
|
||||
"description": "A 4x4 matrix as 4 columns of 4 numbers, as a placement's `transform` is given (the translation is the last column): finite, affine (the fourth number of the first three columns is 0, of the last column 1) and invertible.",
|
||||
"type": "array",
|
||||
"items": row,
|
||||
"minItems": 4,
|
||||
"maxItems": 4
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! wraps_keys {
|
||||
($params:ident, $keys:ident, $name:literal) => {
|
||||
impl JsonSchema for $params {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
$name.into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
$keys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn occurrence_required(schema: &mut Schema) {
|
||||
require(schema, "occurrence");
|
||||
}
|
||||
|
||||
fn component_and_bodies_required(schema: &mut Schema) {
|
||||
require(schema, "component");
|
||||
require(schema, "bodies");
|
||||
}
|
||||
|
||||
fn component_and_sketches_required(schema: &mut Schema) {
|
||||
require(schema, "component");
|
||||
require(schema, "sketches");
|
||||
}
|
||||
|
||||
fn occurrence_or_component_required(schema: &mut Schema) {
|
||||
schema.insert("anyOf".into(), json!([{"required": ["occurrence"]}, {"required": ["component"]}]));
|
||||
}
|
||||
|
||||
fn drag_schema(schema: &mut Schema) {
|
||||
schema.insert("anyOf".into(), json!([{"required": ["occurrence"]}, {"required": ["path"]}]));
|
||||
require(schema, "at");
|
||||
require(schema, "point");
|
||||
}
|
||||
|
||||
fn move_schema(schema: &mut Schema) {
|
||||
require(schema, "occurrence");
|
||||
schema.insert("allOf".into(), json!(["translate", "axis", "angle", "origin"].map(|key| json!({"not": {"required": ["matrix", key]}}))));
|
||||
}
|
||||
|
||||
/// `sketches` as `string_list` reads it (one name or a list, with its messages), where `null`,
|
||||
/// `[]` and a missing key all get the handler's `` `sketches` must list sketches ``.
|
||||
fn sketch_names<'de, D: Deserializer<'de>>(d: D) -> Result<Option<super::config::NameList>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let names = if v.is_null() { Default::default() } else { super::config::NameList::deserialize(v).map_err(D::Error::custom)? };
|
||||
if names.0.is_empty() { Err(D::Error::custom(whole("`sketches` must list sketches"))) } else { Ok(Some(names)) }
|
||||
}
|
||||
|
||||
fn sketch_names_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The sketches, by id or name as text: one, or a list of 1 to 10000.",
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": params::MAX_STRING_LIST}]
|
||||
})
|
||||
}
|
||||
|
||||
/// Make every pending occurrence move the occurrence's placement. The exploded view is shown,
|
||||
/// never captured: it stays shown, and the design keeps its placements.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CapturePositionParams {}
|
||||
|
||||
/// What `component.capture_position` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CapturePositionResult {
|
||||
/// How many pending moves became the occurrences' placements.
|
||||
pub captured: 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>,
|
||||
}
|
||||
|
||||
/// Drop pending occurrence moves, putting the occurrences back where they were captured. Not an
|
||||
/// undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RevertPositionParams {
|
||||
/// The occurrences to put back, by id. Default: every one with a pending move.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrenceIds")]
|
||||
pub occurrences: Option<OccurrenceIds>,
|
||||
}
|
||||
|
||||
/// What `component.revert_position` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RevertPositionResult {
|
||||
/// How many pending moves were dropped.
|
||||
pub reverted: 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>,
|
||||
}
|
||||
|
||||
/// Ground a placement, or let it go (a grounded part stays where it is when joints move
|
||||
/// things). Without `grounded` it toggles.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = occurrence_required)]
|
||||
pub struct GroundParams {
|
||||
// Optional in the type and required in the schema (`occurrence_required`).
|
||||
/// The occurrence, by id or name.
|
||||
#[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`. It is only checked.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrencePath")]
|
||||
pub path: Option<OccurrencePath>,
|
||||
/// `true` grounds it, `false` frees it. Default: toggle.
|
||||
#[serde(default)]
|
||||
pub grounded: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `occurrence.ground` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct GroundResult {
|
||||
/// The occurrence, by id.
|
||||
pub occurrence: u64,
|
||||
/// Whether it is grounded now.
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Place a component again (Paste as an instance): a new occurrence of the same component, which
|
||||
/// shares its contents, at the selected placement's transform (pending moves included) moved by
|
||||
/// `translate`. Give the `occurrence` to copy, or the `component`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = occurrence_or_component_required)]
|
||||
pub struct CopyKeys {
|
||||
// `occurrence` or `component` is needed: optional in the type and required in the schema
|
||||
// (`occurrence_or_component_required`), so that the handler's message is what a caller sees.
|
||||
/// The placement to copy, by id or name. When given, `component` is not read.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrenceRef")]
|
||||
pub occurrence: Option<OccurrenceRef>,
|
||||
/// The component to place again, by id or name: its first placement is copied. Read only
|
||||
/// without `occurrence`.
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
/// The placement's exact path, as occurrence ids from the top of the design (1 to 64),
|
||||
/// ending in the placement copied. Needed for a placement inside a component placed more
|
||||
/// than once, with `in_place`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrencePath")]
|
||||
pub path: Option<OccurrencePath>,
|
||||
/// The component the copy goes in, by id or name. Default: the copied placement's parent.
|
||||
#[serde(default, deserialize_with = "parent", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub parent: Option<ComponentRef>,
|
||||
/// Move the copy by this much, in mm (up to 1e9). Default: no move.
|
||||
#[serde(default, deserialize_with = "translate_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point3")]
|
||||
pub translate: Option<Point3>,
|
||||
/// `false`: the copy has the selected transform within its parent. `true`: it sits where the
|
||||
/// selected placement is in the world, and `translate` is along the world axes. Default:
|
||||
/// `false`.
|
||||
#[serde(default)]
|
||||
pub in_place: Option<bool>,
|
||||
/// With `in_place: true`: the placement of `parent` to place the copy by, as occurrence ids
|
||||
/// from the top (up to 64; none is the design). A parent placed more than once puts the copy
|
||||
/// in every instance. Default: the parent's only placement, else the active one. Read only
|
||||
/// with `in_place: true`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PlacementPath")]
|
||||
pub parent_path: Option<PlacementPath>,
|
||||
}
|
||||
|
||||
/// `occurrence.copy`'s parameters: [`CopyKeys`], with `component` read only without `occurrence`
|
||||
/// and `parent_path` only with `in_place: true`, as the handler reads them.
|
||||
#[derive(Debug)]
|
||||
pub struct CopyParams(pub CopyKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for CopyParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if value.get("occurrence").is_some() {
|
||||
value = without(value, &["component"]);
|
||||
}
|
||||
if value.get("in_place").and_then(Value::as_bool) != Some(true) {
|
||||
value = without(value, &["parent_path"]);
|
||||
}
|
||||
staged(&value, &[&["occurrence", "component"], &["path"], &["parent"], &["translate"]]).map(CopyParams)
|
||||
}
|
||||
}
|
||||
|
||||
wraps_keys!(CopyParams, CopyKeys, "CopyParams");
|
||||
|
||||
/// What `occurrence.copy` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CopyResult {
|
||||
/// The new placement, by id.
|
||||
pub occurrence: u64,
|
||||
/// The component it places, by id.
|
||||
pub component: 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>,
|
||||
}
|
||||
|
||||
/// Paste New: an independent copy of a component, with its nested components, features, bodies,
|
||||
/// materials, styles, joints and motion, placed at the source placement's transform (pending
|
||||
/// moves included) moved by `translate`. Unlike `occurrence.copy`, the copy shares nothing with
|
||||
/// the original. A body reassigned from outside becomes base geometry; links kept to outside
|
||||
/// things and joints omitted at the boundary are listed in `notices`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = component_required)]
|
||||
pub struct PasteNewKeys {
|
||||
// Optional in the type and required in the schema (`component_required`).
|
||||
/// The component to copy, by id or name (not the design).
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
/// The source placement, by id or name; it must place `component`. Default: the component's
|
||||
/// first placement.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrenceRef")]
|
||||
pub occurrence: Option<OccurrenceRef>,
|
||||
/// The source placement's exact path, as occurrence ids from the top of the design (1 to 64),
|
||||
/// ending in the placement copied.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrencePath")]
|
||||
pub path: Option<OccurrencePath>,
|
||||
/// The component the copy goes in, by id or name. Default: the source placement's parent. It
|
||||
/// may not be the component or one inside it.
|
||||
#[serde(default, deserialize_with = "parent", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub parent: Option<ComponentRef>,
|
||||
/// Move the copy by this much, in mm (up to 1e9). Default: no move.
|
||||
#[serde(default, deserialize_with = "translate_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point3")]
|
||||
pub translate: Option<Point3>,
|
||||
/// `false`: the copy has the source transform within its parent. `true`: it sits where the
|
||||
/// source placement is in the world, and `translate` is along the world axes. Default:
|
||||
/// `false`.
|
||||
#[serde(default)]
|
||||
pub in_place: Option<bool>,
|
||||
/// With `in_place: true`: the placement of `parent` to place the copy by, as occurrence ids
|
||||
/// from the top (up to 64; none is the design). Read only with `in_place: true`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PlacementPath")]
|
||||
pub parent_path: Option<PlacementPath>,
|
||||
}
|
||||
|
||||
/// `component.paste_new`'s parameters: [`PasteNewKeys`], with `parent_path` read only with
|
||||
/// `in_place: true`, as the handler reads it.
|
||||
#[derive(Debug)]
|
||||
pub struct PasteNewParams(pub PasteNewKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for PasteNewParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if value.get("in_place").and_then(Value::as_bool) != Some(true) {
|
||||
value = without(value, &["parent_path"]);
|
||||
}
|
||||
let first: &[&str] = if value.get("occurrence").is_some() { &["occurrence", "path"] } else { &["occurrence"] };
|
||||
staged(&value, &[&["component"], first, &["translate"], &["parent"], &["path"]]).map(PasteNewParams)
|
||||
}
|
||||
}
|
||||
|
||||
wraps_keys!(PasteNewParams, PasteNewKeys, "PasteNewParams");
|
||||
|
||||
/// What `component.paste_new` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PasteNewResult {
|
||||
/// The copy of the component, by id.
|
||||
pub component: u64,
|
||||
/// Its placement, by id.
|
||||
pub occurrence: u64,
|
||||
/// The new features, by id.
|
||||
pub features: Vec<u64>,
|
||||
/// How many components were made: the copy and what is inside it.
|
||||
pub components: usize,
|
||||
/// How many bodies the copy has.
|
||||
pub bodies: usize,
|
||||
/// The bodies copied as frozen geometry (their features stay with the original).
|
||||
pub frozen_bodies: Vec<String>,
|
||||
/// What was left behind or kept pointing outside the copy: links to bodies, sketches, planes,
|
||||
/// parameters and features outside it, and joints and motion across its boundary.
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Move an occurrence: by `translate`, or turned by `angle` about `axis` through `origin`, or by
|
||||
/// a rigid `matrix` instead of those. Nested occurrences' definitions stay shared. A grounded
|
||||
/// occurrence refuses unless `move_grounded` is true. When joints hold it to something fixed and
|
||||
/// put it back, the reply says so (`reverted`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = move_schema)]
|
||||
pub struct MoveKeys {
|
||||
// Optional in the type and required in the schema (`move_schema`).
|
||||
/// The occurrence, by id or name.
|
||||
#[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`. Needed with `space: world` for a placement inside a component
|
||||
/// placed more than once.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrencePath")]
|
||||
pub path: Option<OccurrencePath>,
|
||||
/// The frame of the move: `local` is the parent's (the placement's own), `world` is world
|
||||
/// coordinates. Default: `local`.
|
||||
#[serde(default, deserialize_with = "move_space", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "MoveSpace")]
|
||||
pub space: Option<MoveSpace>,
|
||||
/// Move by this much, in mm. Default: no move. Not with `matrix`.
|
||||
#[serde(default, deserialize_with = "translate_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point3")]
|
||||
pub translate: Option<Point3>,
|
||||
/// The axis to turn about, not zero when `angle` is. Default: Z. Not with `matrix`.
|
||||
#[serde(default, deserialize_with = "axis_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point3")]
|
||||
pub axis: Option<Point3>,
|
||||
/// The turn about `axis`: radians, or an angle expression. Default: 0. Not with `matrix`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "MoveAngle")]
|
||||
pub angle: Option<MoveAngle>,
|
||||
/// The point the turn is about, in mm. Default: the origin. Not with `matrix`.
|
||||
#[serde(default, deserialize_with = "origin_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point3")]
|
||||
pub origin: Option<Point3>,
|
||||
/// A rigid move instead of `translate`, `axis`, `angle` and `origin`: a rotation and a
|
||||
/// translation. One that is a rotation only up to `tolerance` is made exact; a scale, shear or
|
||||
/// mirror is refused.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "MatrixParam")]
|
||||
pub matrix: Option<MatrixParam>,
|
||||
/// With `matrix`: the largest accepted |RᵀR − I| per entry. Default: 1e-9. Read only with
|
||||
/// `matrix`.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<MatrixTolerance>,
|
||||
/// `true` makes the move the occurrence's placement now; `false` keeps it pending until
|
||||
/// `component.capture_position`. Default: `true`.
|
||||
#[serde(default)]
|
||||
pub capture: Option<bool>,
|
||||
/// `true` moves a grounded occurrence, which stays grounded. Default: `false`, which refuses.
|
||||
#[serde(default)]
|
||||
pub move_grounded: Option<bool>,
|
||||
/// The transform the move starts from, in place of the occurrence's current one (what a drag
|
||||
/// in the Browser passes).
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "BaseTransform")]
|
||||
pub base_transform: Option<BaseTransform>,
|
||||
}
|
||||
|
||||
/// `occurrence.move`'s parameters: [`MoveKeys`], read as the handler reads them: with `matrix`,
|
||||
/// `translate`, `axis`, `angle` and `origin` are refused and `tolerance` is read; without it,
|
||||
/// `tolerance` is not.
|
||||
#[derive(Debug)]
|
||||
pub struct MoveParams(pub MoveKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for MoveParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if value.get("matrix").is_some() {
|
||||
read_stages::<MoveKeys, D::Error>(&value, &[&["occurrence"]])?;
|
||||
if let Some(key) = ["translate", "axis", "angle", "origin"].into_iter().find(|k| value.get(*k).is_some()) {
|
||||
return Err(D::Error::custom(whole(format!("give either matrix or {key}, not both"))));
|
||||
}
|
||||
let tolerance = crate::cmd::matrix_tolerance(&value, "").map_err(|e| D::Error::custom(whole(refusal(e))))?;
|
||||
crate::cmd::rigid_matrix(value.get("matrix").unwrap_or(&Value::Null), tolerance, "").map_err(|e| D::Error::custom(whole(refusal(e))))?;
|
||||
return staged(&value, &[&["space"], &["path"]]).map(MoveParams);
|
||||
}
|
||||
value = without(value, &["tolerance"]);
|
||||
staged(&value, &[&["occurrence"], &["translate"], &["axis"], &["angle"], &["origin"], &["space"], &["path"]]).map(MoveParams)
|
||||
}
|
||||
}
|
||||
|
||||
wraps_keys!(MoveParams, MoveKeys, "MoveParams");
|
||||
|
||||
/// What `occurrence.move` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MoveResult {
|
||||
/// The occurrence, by id.
|
||||
pub occurrence: u64,
|
||||
/// Its placement in its parent now, as a 4×4 matrix (its pending one with `capture: false`).
|
||||
pub transform: [[f64; 4]; 4],
|
||||
/// Whether the move is pending, not captured.
|
||||
pub pending: bool,
|
||||
/// Present, and `true`, when joints hold the occurrence to something fixed and put it back:
|
||||
/// `transform` is where it is.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub reverted: bool,
|
||||
/// With `reverted`: the joint that holds it and what to change instead.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Drag a placement by `point - at`, in world coordinates, and capture its position: the grabbed
|
||||
/// point `at` goes to `point`. Any chain of joints it is in that is joined to nothing grounded
|
||||
/// moves with it; other pending moves stay pending; shared definitions move in all their
|
||||
/// placements. Grounded parts and parts that joints hold to something fixed are refused
|
||||
/// (`joint.reach` moves those).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = drag_schema)]
|
||||
pub struct DragKeys {
|
||||
// `occurrence` or `path` is needed, and `at` and `point` both: optional in the type and
|
||||
// required in the schema (`drag_schema`), so that the handler's message is what a caller sees.
|
||||
/// The placement, by id or name. Without it, `path` names the placement.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrenceRef")]
|
||||
pub occurrence: Option<OccurrenceRef>,
|
||||
/// The placement's exact path, as occurrence ids from the top of the design (1 to 64); with
|
||||
/// `occurrence` it must end in it.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrencePath")]
|
||||
pub path: Option<OccurrencePath>,
|
||||
/// The grabbed point on the placement, in world coordinates (mm).
|
||||
#[serde(default, deserialize_with = "at_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point3")]
|
||||
pub at: Option<Point3>,
|
||||
/// Where that point goes, in world coordinates (mm).
|
||||
#[serde(default, deserialize_with = "point_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point3")]
|
||||
pub point: Option<Point3>,
|
||||
/// Reply `continuous`, for a drag that goes on over several calls (the viewport's). It
|
||||
/// changes nothing else. Default: `false`.
|
||||
#[serde(default)]
|
||||
pub continuous: Option<bool>,
|
||||
}
|
||||
|
||||
/// `occurrence.drag`'s parameters: [`DragKeys`], read as the handler reads them: `path` stands
|
||||
/// for `occurrence` when it is missing, and `at` and `point` are asked for together.
|
||||
#[derive(Debug)]
|
||||
pub struct DragParams(pub DragKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for DragParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
if value.is_object() {
|
||||
if value.get("occurrence").is_none() && value.get("path").is_none_or(|path| OccurrencePath::deserialize(path).is_err()) {
|
||||
return Err(D::Error::custom(whole("give `occurrence` or `path` (1..64 occurrence ids)")));
|
||||
}
|
||||
read_stages::<DragKeys, D::Error>(&value, &[&["occurrence", "path"]])?;
|
||||
if value.get("at").is_none() || value.get("point").is_none() {
|
||||
return Err(D::Error::custom(whole("`at` and `point` must both be given")));
|
||||
}
|
||||
}
|
||||
staged(&value, &[&["at"], &["point"]]).map(DragParams)
|
||||
}
|
||||
}
|
||||
|
||||
wraps_keys!(DragParams, DragKeys, "DragParams");
|
||||
|
||||
/// What `occurrence.drag` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DragResult {
|
||||
/// The placement dragged, by id.
|
||||
pub occurrence: u64,
|
||||
/// Its exact path.
|
||||
pub path: Vec<u64>,
|
||||
/// The occurrences moved: it first, then those a chain of joints carries with it.
|
||||
pub moved: Vec<u64>,
|
||||
/// How far they moved, `point - at` (mm).
|
||||
pub translate: [f64; 3],
|
||||
/// What the call gave as `continuous`.
|
||||
pub continuous: 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>,
|
||||
}
|
||||
|
||||
/// Measure an occurrence where its placement puts it, in world coordinates, with what is inside
|
||||
/// it unless `descendants` is false: material-aware mass and centre of mass, and 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, transform = occurrence_required)]
|
||||
pub struct OccurrencePropertiesParams {
|
||||
// Optional in the type and required in the schema (`occurrence_required`).
|
||||
/// The occurrence, by id or name.
|
||||
#[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`. Needed when it is nested under several instances.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrencePath")]
|
||||
pub path: Option<OccurrencePath>,
|
||||
/// Include the 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>,
|
||||
}
|
||||
|
||||
/// What `occurrence.properties` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct OccurrencePropertiesResult {
|
||||
/// The occurrence, by id.
|
||||
pub occurrence: u64,
|
||||
/// Its name.
|
||||
pub name: Option<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>,
|
||||
}
|
||||
|
||||
/// Move bodies into another component, keeping them where they are 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 = component_and_bodies_required)]
|
||||
pub struct MoveBodiesParams {
|
||||
// Optional in the type and required in the schema (`component_and_bodies_required`).
|
||||
/// The bodies, by name.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "DefinitionBodies")]
|
||||
pub bodies: Option<DefinitionBodies>,
|
||||
/// The component they go to, by id or name.
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
/// The placement of that component to place them by, as occurrence ids from the top (up to
|
||||
/// 64). A component placed more than once gets them in every instance. Default: its only
|
||||
/// placement, else the active one.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PlacementPath")]
|
||||
pub component_path: Option<PlacementPath>,
|
||||
/// The placement of their component they move from, as occurrence ids from the top (up to
|
||||
/// 64). Default: its only placement, else its first, where its bodies have their own names.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PlacementPath")]
|
||||
pub from_path: Option<PlacementPath>,
|
||||
}
|
||||
|
||||
/// What `component.move_bodies` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MoveBodiesResult {
|
||||
/// The bodies that moved.
|
||||
pub bodies: Vec<String>,
|
||||
/// The component they went to, by id.
|
||||
pub component: u64,
|
||||
/// The Body Transfer features that moved them, by id, in the order of `bodies`.
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Move sketches into another component. They keep their own coordinates (`component.move_features`
|
||||
/// keeps them where they are in the world), except that a sketch on an exactly attached face
|
||||
/// stays where it is on the face.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = component_and_sketches_required)]
|
||||
pub struct MoveSketchesParams {
|
||||
// Optional in the type and required in the schema (`component_and_sketches_required`): left
|
||||
// out, `null` or empty, the handler refuses the call with its own message.
|
||||
#[serde(default, deserialize_with = "sketch_names")]
|
||||
#[schemars(schema_with = "sketch_names_schema")]
|
||||
pub sketches: Option<super::config::NameList>,
|
||||
/// The component they go to, by id or name.
|
||||
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ComponentRef")]
|
||||
pub component: Option<ComponentRef>,
|
||||
}
|
||||
|
||||
/// What `component.move_sketches` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MoveSketchesResult {
|
||||
/// The sketches that moved, by feature id.
|
||||
pub sketches: Vec<u64>,
|
||||
/// The component they went to, by id.
|
||||
pub component: 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;
|
||||
|
||||
@@ -4,12 +4,9 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, CopyParams, CopyResult, CreateParams, CreateResult, DeleteParams,
|
||||
DeleteResult, DragParams, DragResult, EditInPlaceParams, FinishEditParams, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult,
|
||||
ListParams, ListResult, MoveBodiesParams, MoveBodiesResult, MoveFeaturesParams, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
|
||||
MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams, OccurrencePropertiesResult, PasteNewParams,
|
||||
PasteNewResult, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams, RevertPositionParams, RevertPositionResult,
|
||||
UnlockParams, UnlockResult,
|
||||
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, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -48,19 +45,6 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<RenameParams>(), &["component", "name"]);
|
||||
assert_keys(input_schema_for::<DeleteParams>(), &["component"]);
|
||||
assert_keys(input_schema_for::<UnlockParams>(), &["component"]);
|
||||
assert_keys(input_schema_for::<CapturePositionParams>(), &[]);
|
||||
assert_keys(input_schema_for::<RevertPositionParams>(), &["occurrences"]);
|
||||
assert_keys(input_schema_for::<GroundParams>(), &["grounded", "occurrence", "path"]);
|
||||
assert_keys(input_schema_for::<CopyParams>(), &["component", "in_place", "occurrence", "parent", "parent_path", "path", "translate"]);
|
||||
assert_keys(input_schema_for::<PasteNewParams>(), &["component", "in_place", "occurrence", "parent", "parent_path", "path", "translate"]);
|
||||
assert_keys(
|
||||
input_schema_for::<MoveParams>(),
|
||||
&["angle", "axis", "base_transform", "capture", "matrix", "move_grounded", "occurrence", "origin", "path", "space", "tolerance", "translate"],
|
||||
);
|
||||
assert_keys(input_schema_for::<DragParams>(), &["at", "continuous", "occurrence", "path", "point"]);
|
||||
assert_keys(input_schema_for::<OccurrencePropertiesParams>(), &["about", "descendants", "occurrence", "path"]);
|
||||
assert_keys(input_schema_for::<MoveBodiesParams>(), &["bodies", "component", "component_path", "from_path"]);
|
||||
assert_keys(input_schema_for::<MoveSketchesParams>(), &["component", "sketches"]);
|
||||
}
|
||||
|
||||
/// The calls these commands already refuse (the handlers' own messages), refused alike in strict.
|
||||
@@ -524,587 +508,3 @@ fn sketches_moved_to_a_part_while_an_exploded_view_is_shown_save_what_they_save_
|
||||
assert!(at.dist(assembled_at) < 1e-9, "{at:?} vs {assembled_at:?}");
|
||||
assert!(plane.origin.dist(assembled_plane.origin) < 1e-9, "{plane:?} vs {assembled_plane:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and
|
||||
// Paste New (#13186).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Two ungrounded parts, `A` (with a box, `ABox`, and a sketch, `S`) and an empty `B`, in the
|
||||
/// design: the session at `level`, and the ids of their placements.
|
||||
fn two_parts(level: ParamCheck) -> (Session, u64, u64) {
|
||||
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}"));
|
||||
let a = run("component.create", json!({"name": "A", "parent": 0, "grounded": false}))["occurrence"].as_u64().unwrap_or(0);
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "ABox"}));
|
||||
run("sketch.create", json!({"name": "S", "plane": "XY"}));
|
||||
run("sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [3, 3]}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let b = run("component.create", json!({"name": "B", "parent": 0, "grounded": false, "activate": false}))["occurrence"].as_u64().unwrap_or(0);
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
/// Each case fails today (the handler's message, in Off) and fails the same in strict.
|
||||
fn strict_keeps(cases: impl Fn(u64, u64) -> Vec<(&'static str, Value)>) {
|
||||
let (_, a, b) = two_parts(ParamCheck::Off);
|
||||
for (id, p) in cases(a, b) {
|
||||
let error = |level: ParamCheck| two_parts(level).0.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}");
|
||||
}
|
||||
}
|
||||
|
||||
const FLAT_MATRIX: [i32; 12] = [1, 0, 0, 5, 0, 1, 0, 0, 0, 0, 1, 0];
|
||||
|
||||
/// What the occurrence commands already refuse (their handlers' own messages), refused alike in
|
||||
/// strict, including the keys they read only in some cases.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_of_the_occurrence_commands() {
|
||||
strict_keeps(|a, b| {
|
||||
let at = json!([0, 0, 0]);
|
||||
vec![
|
||||
("component.revert_position", json!({"occurrences": null})),
|
||||
("component.revert_position", json!({"occurrences": "1"})),
|
||||
("component.revert_position", json!({"occurrences": []})),
|
||||
("component.revert_position", json!({"occurrences": [a, "x"]})),
|
||||
("component.revert_position", json!({"occurrences": vec![1; 10_001]})),
|
||||
("component.revert_position", json!({"occurrences": [999]})),
|
||||
("occurrence.ground", json!({})),
|
||||
("occurrence.ground", json!({"occurrence": null})),
|
||||
("occurrence.ground", json!({"occurrence": true})),
|
||||
("occurrence.ground", json!({"occurrence": "Nope"})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": "x"})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": vec![a; 65]})),
|
||||
("occurrence.copy", json!({})),
|
||||
("occurrence.copy", json!({"component": null})),
|
||||
("occurrence.copy", json!({"component": [1]})),
|
||||
("occurrence.copy", json!({"occurrence": null})),
|
||||
("occurrence.copy", json!({"occurrence": true, "component": "A"})),
|
||||
("occurrence.copy", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.copy", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.copy", json!({"occurrence": a, "parent": null})),
|
||||
("occurrence.copy", json!({"occurrence": a, "parent": true})),
|
||||
("occurrence.copy", json!({"occurrence": a, "parent": "Nope"})),
|
||||
("occurrence.copy", json!({"occurrence": a, "translate": [1, 2]})),
|
||||
("occurrence.copy", json!({"occurrence": a, "translate": null})),
|
||||
("occurrence.copy", json!({"occurrence": a, "translate": [1e9, 0, 0]})),
|
||||
("occurrence.copy", json!({"occurrence": a, "in_place": true, "parent_path": "x"})),
|
||||
("occurrence.copy", json!({"occurrence": a, "in_place": true, "parent_path": vec![1; 65]})),
|
||||
("component.paste_new", json!({})),
|
||||
("component.paste_new", json!({"component": null})),
|
||||
("component.paste_new", json!({"component": true})),
|
||||
("component.paste_new", json!({"component": 0})),
|
||||
("component.paste_new", json!({"component": "A", "occurrence": null})),
|
||||
("component.paste_new", json!({"component": "A", "occurrence": b})),
|
||||
("component.paste_new", json!({"component": "A", "occurrence": a, "path": []})),
|
||||
("component.paste_new", json!({"component": "A", "translate": [1]})),
|
||||
("component.paste_new", json!({"component": "A", "parent": true})),
|
||||
("component.paste_new", json!({"component": "A", "parent": "A"})),
|
||||
("component.paste_new", json!({"component": "A", "in_place": true, "parent_path": 5})),
|
||||
("occurrence.move", json!({})),
|
||||
("occurrence.move", json!({"occurrence": null})),
|
||||
("occurrence.move", json!({"occurrence": [1]})),
|
||||
("occurrence.move", json!({"occurrence": a, "translate": [1, 2]})),
|
||||
("occurrence.move", json!({"occurrence": a, "translate": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "translate": [1e9, 0, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "axis": "z"})),
|
||||
("occurrence.move", json!({"occurrence": a, "origin": [1, 2, "x"]})),
|
||||
("occurrence.move", json!({"occurrence": a, "angle": true})),
|
||||
("occurrence.move", json!({"occurrence": a, "angle": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "angle": 1, "axis": [0, 0, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "space": "up"})),
|
||||
("occurrence.move", json!({"occurrence": a, "space": "World"})),
|
||||
("occurrence.move", json!({"occurrence": a, "space": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.move", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "base_transform": [[1]]})),
|
||||
("occurrence.move", json!({"occurrence": a, "base_transform": [[1, 0, 0, 5], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]})),
|
||||
("occurrence.move", json!({"occurrence": a, "base_transform": [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 1]]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [1, 2, 3]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "translate": [1, 0, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "axis": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "angle": 1})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "origin": at})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": 0})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": "x"})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": 0.01})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1]})),
|
||||
("occurrence.drag", json!({})),
|
||||
("occurrence.drag", json!({"path": "x", "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"path": [], "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": null, "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": 5})),
|
||||
("occurrence.drag", json!({"occurrence": a, "path": null, "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "path": [], "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": null, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": at, "point": [1, 2]})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": [1e9, 0, 0], "point": at})),
|
||||
("occurrence.properties", json!({})),
|
||||
("occurrence.properties", json!({"occurrence": null})),
|
||||
("occurrence.properties", json!({"occurrence": false})),
|
||||
("occurrence.properties", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.properties", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.properties", json!({"occurrence": a, "descendants": "yes"})),
|
||||
("occurrence.properties", json!({"occurrence": a, "descendants": null})),
|
||||
("occurrence.properties", json!({"occurrence": a, "about": [1, 2]})),
|
||||
("occurrence.properties", json!({"occurrence": a, "about": "origin"})),
|
||||
("component.move_bodies", json!({})),
|
||||
("component.move_bodies", json!({"bodies": ["ABox"]})),
|
||||
("component.move_bodies", json!({"component": null, "bodies": ["ABox"]})),
|
||||
("component.move_bodies", json!({"component": "B"})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": null})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": "ABox"})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": [1]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": []})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox", "ABox"]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": vec!["ABox"; 1001]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["Nope"]})),
|
||||
("component.move_bodies", json!({"component": "Nope", "bodies": ["ABox"]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": null})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": "x"})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": vec![b; 65]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "from_path": [b, "x"]})),
|
||||
("component.move_sketches", json!({})),
|
||||
("component.move_sketches", json!({"sketches": ["S"]})),
|
||||
("component.move_sketches", json!({"component": null, "sketches": ["S"]})),
|
||||
("component.move_sketches", json!({"component": true, "sketches": ["S"]})),
|
||||
("component.move_sketches", json!({"component": "B"})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": null})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": []})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": 5})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": [1]})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": ["S", true]})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": vec!["S"; 10_001]})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": ["Nope"]})),
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/// The contracts say what the handlers need, in the schemas: required keys are required though the
|
||||
/// types leave them to the handlers' messages, and the ways a command can be called are open.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_occurrence_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 matrix: Vec<i32> = FLAT_MATRIX.to_vec();
|
||||
takes(input_schema_for::<CapturePositionParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
|
||||
takes(
|
||||
input_schema_for::<RevertPositionParams>(),
|
||||
&[(json!({}), true), (json!({"occurrences": [1, 2]}), true), (json!({"occurrences": []}), false), (json!({"occurrences": ["1"]}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<GroundParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"occurrence": "A:1", "path": [1], "grounded": false}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "path": []}), false),
|
||||
(json!({"occurrence": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<CopyParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"component": "A"}), true),
|
||||
(json!({"occurrence": 1, "translate": {"x": 1, "y": 2, "z": 3}, "in_place": true, "parent_path": [], "path": [1], "parent": 0}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "translate": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<PasteNewParams>(),
|
||||
&[
|
||||
(json!({"component": "A"}), true),
|
||||
(
|
||||
json!({"component": 1, "occurrence": 2, "path": [2], "parent": "B", "translate": [1, 2, 3], "in_place": true, "parent_path": [1]}),
|
||||
true,
|
||||
),
|
||||
(json!({"occurrence": 1}), false),
|
||||
(json!({"component": "A", "translate": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MoveParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"occurrence": 1, "translate": [1, 2, 3], "axis": [0, 0, 1], "angle": "30 deg", "origin": [0, 0, 0], "space": "world"}), true),
|
||||
(json!({"occurrence": 1, "angle": 0.5, "capture": false, "move_grounded": true, "path": [1]}), true),
|
||||
(json!({"occurrence": 1, "matrix": matrix, "tolerance": 1e-6}), true),
|
||||
(json!({"occurrence": 1, "matrix": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}), true),
|
||||
(json!({"occurrence": 1, "base_transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "matrix": FLAT_MATRIX, "translate": [1, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "matrix": FLAT_MATRIX, "origin": [1, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "matrix": [1, 2, 3]}), false),
|
||||
(json!({"occurrence": 1, "space": "up"}), false),
|
||||
(json!({"occurrence": 1, "angle": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DragParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1, "at": [0, 0, 0], "point": [1, 0, 0]}), true),
|
||||
(json!({"path": [1, 2], "at": {"x": 0, "y": 0, "z": 0}, "point": [1, 0, 0], "continuous": true}), true),
|
||||
(json!({"at": [0, 0, 0], "point": [1, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "at": [0, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "point": [0, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "path": [], "at": [0, 0, 0], "point": [1, 0, 0]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<OccurrencePropertiesParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"occurrence": "A:1", "path": [1], "descendants": false, "about": [1, 2, 3]}), true),
|
||||
(json!({"occurrence": 1, "about": null}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "descendants": 1}), false),
|
||||
(json!({"occurrence": 1, "about": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MoveBodiesParams>(),
|
||||
&[
|
||||
(json!({"component": "B", "bodies": ["A"]}), true),
|
||||
(json!({"component": 2, "bodies": ["A", "B"], "component_path": [1], "from_path": []}), true),
|
||||
(json!({"bodies": ["A"]}), false),
|
||||
(json!({"component": "B"}), false),
|
||||
(json!({"component": "B", "bodies": "A"}), false),
|
||||
(json!({"component": "B", "bodies": ["A"], "from_path": vec![1; 65]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MoveSketchesParams>(),
|
||||
&[
|
||||
(json!({"component": "B", "sketches": ["S"]}), true),
|
||||
(json!({"component": "B", "sketches": "S"}), true),
|
||||
(json!({"component": 2, "sketches": ["S", "T"]}), true),
|
||||
(json!({"sketches": ["S"]}), false),
|
||||
(json!({"component": "B"}), false),
|
||||
(json!({"component": "B", "sketches": []}), false),
|
||||
(json!({"component": "B", "sketches": [1]}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The types read each key as its handler does: keys the handlers read only in some cases are
|
||||
/// read only in those, and a wrong key says what is wrong with it in the handler's words.
|
||||
#[test]
|
||||
fn the_check_reads_the_occurrence_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();
|
||||
// `occurrence.copy`: `component` only without `occurrence`, `parent_path` only with `in_place: true`.
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "component": [1], "parent_path": "x"})), none);
|
||||
assert_eq!(issues("occurrence.copy", json!({"component": [1]})), ["`component` must be a component id or name"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": null, "component": 1})), ["`occurrence` must be an id or name"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": false, "parent_path": "x"})), none);
|
||||
assert_eq!(
|
||||
issues("occurrence.copy", json!({"occurrence": 1, "in_place": true, "parent_path": "x"})),
|
||||
["`parent_path` must list 0..64 occurrence ids"]
|
||||
);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": true, "parent_path": []})), none);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "translate": [1, 2]})), ["translate must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": "yes"})).len(), 1);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurence": 1})), ["unknown key `occurence`; did you mean `occurrence`?"]);
|
||||
// `component.paste_new`: `parent_path` only with `in_place: true`.
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "parent_path": 5})), none);
|
||||
assert_eq!(
|
||||
issues("component.paste_new", json!({"component": 1, "in_place": true, "parent_path": 5})),
|
||||
["`parent_path` must list 0..64 occurrence ids"]
|
||||
);
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "occurrence": 2, "path": [2], "translate": {"x": 1, "y": 2, "z": 3}})), none);
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "parent": null})), ["`parent` must be a component id or name"]);
|
||||
// `occurrence.move`: `tolerance` only with `matrix`, which takes the place of the move keys.
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "tolerance": "x"})), none);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "tolerance": 1e-6})), none);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "tolerance": "x"})),
|
||||
["tolerance must be a number above 0 and at most 0.001"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "translate": "x"})),
|
||||
["give either matrix or translate, not both"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "origin": null})),
|
||||
["give either matrix or origin, not both"]
|
||||
);
|
||||
assert!(issues("occurrence.move", json!({"occurrence": 1, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}))[0].starts_with("matrix mirrors"));
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": [1]})),
|
||||
["matrix must be 12 numbers (3x4, row by row) or 16 (4x4), flat or as rows"]
|
||||
);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "translate": [1, 2]})), ["translate must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "axis": null})), ["axis must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "origin": "x"})), ["origin must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "angle": null})), ["angle must be a number or angle expression"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "angle": "30 deg", "axis": {"x": 0, "y": 1, "z": 0}})), none);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "space": "World"})), ["space must be local or world"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "base_transform": [[1]]})), ["base_transform must be a 4x4 affine matrix"]);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "base_transform": [[1, 0, 0, 2], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]})),
|
||||
["base_transform must be finite, affine and invertible"]
|
||||
);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "capture": "no"})).len(), 1);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "translte": [1, 0, 0]})), ["unknown key `translte`; did you mean `translate`?"]);
|
||||
// `occurrence.drag`: `path` stands in for `occurrence`, and `at` and `point` come together.
|
||||
let at = json!([0, 0, 0]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"path": [1], "at": at, "point": at})), none);
|
||||
assert_eq!(issues("occurrence.drag", json!({"at": at, "point": at})), ["give `occurrence` or `path` (1..64 occurrence ids)"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"path": [], "at": at, "point": at})), ["give `occurrence` or `path` (1..64 occurrence ids)"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "path": [], "at": at, "point": at})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": at})), ["`at` and `point` must both be given"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": 5})), ["`at` and `point` must both be given"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": 5, "point": at})), ["at must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": at, "point": [1]})), ["point must contain three finite numbers"]);
|
||||
// The rest.
|
||||
assert_eq!(issues("component.revert_position", json!({"occurrences": []})), ["occurrences must list 1..10000 ids"]);
|
||||
assert_eq!(issues("component.revert_position", json!({"occurrences": null})), ["occurrences must list 1..10000 ids"]);
|
||||
assert_eq!(issues("component.capture_position", json!({"capture": true})), ["unknown key `capture`"]);
|
||||
assert_eq!(issues("occurrence.ground", json!({"occurrence": 1, "grounded": null})), none);
|
||||
assert_eq!(issues("occurrence.ground", json!({"occurrence": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("occurrence.properties", json!({"occurrence": 1, "about": [1]})), ["`about` must be a point [x, y, z] in mm"]);
|
||||
assert_eq!(issues("occurrence.properties", json!({"occurrence": 1, "descendants": null})), ["`descendants` must be a boolean"]);
|
||||
assert_eq!(issues("component.move_bodies", json!({"component": 1, "bodies": "A"})), ["bodies must be an array of body names"]);
|
||||
assert_eq!(
|
||||
issues("component.move_bodies", json!({"component": 1, "bodies": ["A"], "from_path": null})),
|
||||
["`from_path` must list 0..64 occurrence ids"]
|
||||
);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": 5})), ["`sketches` must be a list of names; it is 5"]);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": ["S", 3]})), ["`sketches` must list names; the entry 2 is 3"]);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": null})), ["`sketches` must list sketches"]);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": "S"})), none);
|
||||
}
|
||||
|
||||
/// A part set up with its placements: the design's grounded `Base`, a free `Plate` (a box, and
|
||||
/// the sketch `S`) and an empty free `Spare`.
|
||||
fn plates(s: &mut Session) -> (Value, Value, Value) {
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let base = run("component.create", json!({"name": "Base"}));
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "BaseBox"}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let plate = run("component.create", json!({"name": "Plate", "parent": 0, "grounded": false}));
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "PlateBox"}));
|
||||
run("sketch.create", json!({"name": "S", "plane": "XY"}));
|
||||
run("sketch.rectangle.two_point", json!({"p0": [21, 1], "p1": [23, 3]}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let spare = run("component.create", json!({"name": "Spare", "parent": 0, "grounded": false, "activate": false}));
|
||||
(base, plate, spare)
|
||||
}
|
||||
|
||||
/// 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_occurrence_commands() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let (base, plate, spare) = plates(&mut s);
|
||||
let (base_at, pl, spare_at) = (base["occurrence"].clone(), plate["occurrence"].clone(), spare["occurrence"].clone());
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
|
||||
// The placement's name, to give it by name.
|
||||
let listed = run("component.list", json!({}));
|
||||
let plate_name = listed["components"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|c| c["occurrences"].as_array().into_iter().flatten())
|
||||
.find(|o| o["id"] == pl)
|
||||
.map(|o| o["name"].clone())
|
||||
.unwrap_or_default();
|
||||
assert!(plate_name.is_string(), "{listed}");
|
||||
|
||||
// Move Occurrence, in each form.
|
||||
let identity = json!([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]);
|
||||
for p in [
|
||||
json!({"occurrence": pl, "translate": [1, 0, 0]}),
|
||||
json!({"occurrence": pl, "translate": {"x": 0, "y": 1, "z": 0}, "space": "local"}),
|
||||
json!({"occurrence": pl, "space": "world", "path": [pl], "translate": [0, 0, 1]}),
|
||||
json!({"occurrence": pl, "path": [pl], "translate": [0, 0, -1]}),
|
||||
json!({"occurrence": pl, "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}),
|
||||
json!({"occurrence": pl, "axis": {"x": 0, "y": 1, "z": 0}, "angle": 0.5, "origin": {"x": 1, "y": 1, "z": 1}}),
|
||||
json!({"occurrence": pl, "angle": 0}),
|
||||
json!({"occurrence": pl, "tolerance": "ignored without a matrix"}),
|
||||
json!({"occurrence": pl, "matrix": FLAT_MATRIX}),
|
||||
json!({"occurrence": pl, "matrix": FLAT_MATRIX, "tolerance": 1e-6, "space": "world", "path": [pl]}),
|
||||
json!({"occurrence": pl, "matrix": [[1, 0, 0, 1], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}),
|
||||
json!({"occurrence": pl, "base_transform": identity, "translate": [3, 0, 0]}),
|
||||
json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}),
|
||||
json!({"occurrence": base_at, "translate": [0, 0, 0], "move_grounded": true}),
|
||||
] {
|
||||
let moved = run("occurrence.move", p.clone());
|
||||
matches(output_schema_for::<MoveResult>(), "occurrence.move", &moved);
|
||||
assert_eq!(moved["pending"], p.get("capture") == Some(&json!(false)), "{p}: {moved}");
|
||||
}
|
||||
|
||||
// Capture and Revert Position.
|
||||
let captured = run("component.capture_position", json!({}));
|
||||
assert_eq!(captured["captured"], 1, "{captured}");
|
||||
matches(output_schema_for::<CapturePositionResult>(), "component.capture_position", &captured);
|
||||
let captured = run("component.capture_position", json!({}));
|
||||
assert_eq!(captured["captured"], 0, "{captured}");
|
||||
matches(output_schema_for::<CapturePositionResult>(), "component.capture_position", &captured);
|
||||
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
|
||||
let reverted = run("component.revert_position", json!({"occurrences": [pl]}));
|
||||
assert_eq!(reverted["reverted"], 1, "{reverted}");
|
||||
matches(output_schema_for::<RevertPositionResult>(), "component.revert_position", &reverted);
|
||||
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
|
||||
let reverted = run("component.revert_position", json!({}));
|
||||
assert_eq!(reverted["reverted"], 1, "{reverted}");
|
||||
matches(output_schema_for::<RevertPositionResult>(), "component.revert_position", &reverted);
|
||||
|
||||
// Ground.
|
||||
for p in [
|
||||
json!({"occurrence": pl}),
|
||||
json!({"occurrence": pl, "grounded": true, "path": [pl]}),
|
||||
json!({"occurrence": pl, "grounded": null}),
|
||||
json!({"occurrence": pl, "grounded": false}),
|
||||
] {
|
||||
let grounded = run("occurrence.ground", p.clone());
|
||||
matches(output_schema_for::<GroundResult>(), "occurrence.ground", &grounded);
|
||||
assert_eq!(grounded["occurrence"], pl);
|
||||
}
|
||||
|
||||
// Copy (an instance), in each form. What the handler doesn't read for the form is no problem.
|
||||
for p in [
|
||||
json!({"component": "Plate"}),
|
||||
json!({"component": plate["component"], "translate": [0, 30, 0], "parent": 0}),
|
||||
json!({"occurrence": pl, "translate": [0, 40, 0]}),
|
||||
json!({"occurrence": pl, "component": [1, 2], "parent_path": "ignored"}),
|
||||
json!({"occurrence": plate_name, "path": [pl], "translate": {"x": 0, "y": 0, "z": 5}}),
|
||||
json!({"occurrence": pl, "path": [pl], "in_place": true, "parent_path": [], "translate": [0, 0, 5]}),
|
||||
json!({"occurrence": pl, "in_place": false, "parent_path": "ignored", "parent": 0}),
|
||||
] {
|
||||
let copy = run("occurrence.copy", p.clone());
|
||||
matches(output_schema_for::<CopyResult>(), "occurrence.copy", ©);
|
||||
assert_eq!(copy["component"], plate["component"], "{p}: {copy}");
|
||||
}
|
||||
|
||||
// Paste New.
|
||||
for p in [
|
||||
json!({"component": "Plate"}),
|
||||
json!({"component": plate["component"], "occurrence": pl, "translate": [0, 0, 60], "parent": 0}),
|
||||
json!({"component": "Plate", "occurrence": pl, "path": [pl]}),
|
||||
json!({"component": "Plate", "parent_path": "ignored"}),
|
||||
json!({"component": "Plate", "in_place": true, "parent_path": [], "translate": {"x": 5, "y": 0, "z": 0}}),
|
||||
] {
|
||||
let pasted = run("component.paste_new", p.clone());
|
||||
matches(output_schema_for::<PasteNewResult>(), "component.paste_new", &pasted);
|
||||
assert!(pasted["bodies"].as_u64().is_some_and(|n| n >= 1) && pasted["component"] != plate["component"], "{p}: {pasted}");
|
||||
}
|
||||
|
||||
// Drag.
|
||||
for p in [
|
||||
json!({"occurrence": pl, "at": [25, 5, 5], "point": [25, 15, 5]}),
|
||||
json!({"path": [pl], "at": {"x": 25, "y": 15, "z": 5}, "point": {"x": 25, "y": 5, "z": 5}, "continuous": true}),
|
||||
json!({"occurrence": pl, "path": [pl], "at": [0, 0, 0], "point": [0, 0, 0]}),
|
||||
] {
|
||||
let dragged = run("occurrence.drag", p.clone());
|
||||
matches(output_schema_for::<DragResult>(), "occurrence.drag", &dragged);
|
||||
assert_eq!(dragged["continuous"], p.get("continuous") == Some(&json!(true)), "{p}: {dragged}");
|
||||
}
|
||||
|
||||
// Occurrence Physical Properties.
|
||||
for p in [
|
||||
json!({"occurrence": pl}),
|
||||
json!({"occurrence": pl, "path": [pl], "descendants": false}),
|
||||
json!({"occurrence": pl, "about": [1, 2, 3]}),
|
||||
json!({"occurrence": plate_name, "about": {"x": 1, "y": 2, "z": 3}, "descendants": true}),
|
||||
json!({"occurrence": base_at, "about": null}),
|
||||
] {
|
||||
let measured = run("occurrence.properties", p.clone());
|
||||
matches(output_schema_for::<OccurrencePropertiesResult>(), "occurrence.properties", &measured);
|
||||
assert!(measured["name"].is_string() && measured["total"]["volume_mm3"].as_f64().is_some_and(|v| v > 0.0), "{p}: {measured}");
|
||||
assert_eq!(measured["total"].get("inertia_about").is_some(), p.get("about").is_some_and(|a| !a.is_null()), "{p}: {measured}");
|
||||
}
|
||||
|
||||
// Move Bodies to Component, and Move Sketches to Component.
|
||||
let moved = run("component.move_bodies", json!({"bodies": ["BaseBox"], "component": "Spare"}));
|
||||
assert_eq!(moved["features"].as_array().map(Vec::len), Some(1), "{moved}");
|
||||
matches(output_schema_for::<MoveBodiesResult>(), "component.move_bodies", &moved);
|
||||
let moved =
|
||||
run("component.move_bodies", json!({"bodies": ["BaseBox"], "component": "Base", "component_path": [base_at], "from_path": [spare_at]}));
|
||||
matches(output_schema_for::<MoveBodiesResult>(), "component.move_bodies", &moved);
|
||||
let moved = run("component.move_sketches", json!({"sketches": ["S"], "component": "Spare"}));
|
||||
assert_eq!(moved["component"], spare["component"], "{moved}");
|
||||
matches(output_schema_for::<MoveSketchesResult>(), "component.move_sketches", &moved);
|
||||
let moved = run("component.move_sketches", json!({"sketches": "S", "component": "Base"}));
|
||||
matches(output_schema_for::<MoveSketchesResult>(), "component.move_sketches", &moved);
|
||||
|
||||
// Misspelt keys still run in Warn, say so, and the reply still matches.
|
||||
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
|
||||
let cases: [(&str, Value, &str, schemars::Schema); 9] = [
|
||||
("component.capture_position", json!({"captrue": true}), "captrue", output_schema_for::<CapturePositionResult>()),
|
||||
("component.revert_position", json!({"occurence": [pl]}), "occurence", output_schema_for::<RevertPositionResult>()),
|
||||
("occurrence.copy", json!({"component": "Plate", "translat": [1, 0, 0]}), "translat", output_schema_for::<CopyResult>()),
|
||||
("component.paste_new", json!({"component": "Plate", "paretn": 0}), "paretn", output_schema_for::<PasteNewResult>()),
|
||||
("occurrence.move", json!({"occurrence": pl, "translte": [1, 0, 0]}), "translte", output_schema_for::<MoveResult>()),
|
||||
(
|
||||
"occurrence.drag",
|
||||
json!({"occurrence": pl, "at": [0, 0, 0], "point": [1, 0, 0], "contiuous": true}),
|
||||
"contiuous",
|
||||
output_schema_for::<DragResult>(),
|
||||
),
|
||||
("occurrence.properties", json!({"occurrence": pl, "descendents": false}), "descendents", output_schema_for::<OccurrencePropertiesResult>()),
|
||||
(
|
||||
"component.move_bodies",
|
||||
json!({"bodies": ["BaseBox"], "component": "Spare", "compnent_path": [spare_at]}),
|
||||
"compnent_path",
|
||||
output_schema_for::<MoveBodiesResult>(),
|
||||
),
|
||||
(
|
||||
"component.move_sketches",
|
||||
json!({"sketches": ["S"], "component": "Plate", "componnt": 1}),
|
||||
"componnt",
|
||||
output_schema_for::<MoveSketchesResult>(),
|
||||
),
|
||||
];
|
||||
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}");
|
||||
}
|
||||
// Last, as it toggles the placement's grounding.
|
||||
let warned = run("occurrence.ground", json!({"occurrence": pl, "grouned": true}));
|
||||
let schema = output_schema_for::<GroundResult>();
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("grouned"))), "{warned}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
|
||||
/// A move that joints undo replies with `reverted` and a `warning`, which the output schema
|
||||
/// describes.
|
||||
#[test]
|
||||
fn a_move_its_joints_undo_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 base = run("component.create", json!({"name": "Base"}))["occurrence"].clone();
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let plate = run("component.create", json!({"name": "Plate", "parent": 0, "grounded": false}))["occurrence"].clone();
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
run("joint.as_built", json!({"type": "rigid", "a": base, "b": plate, "name": "Mount"}));
|
||||
let moved = run("occurrence.move", json!({"occurrence": plate, "translate": [0, 0, 30]}));
|
||||
assert_eq!(moved["reverted"], true, "{moved}");
|
||||
assert!(moved["warning"].as_str().is_some_and(|w| w.contains("Mount")), "{moved}");
|
||||
matches(output_schema_for::<MoveResult>(), "occurrence.move", &moved);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -529,8 +529,7 @@ pub struct FilletParams {
|
||||
/// The radius; the chord's width with `type: chord`, the equivalent circular setback with
|
||||
/// `type: curvature`, the start radius with `type: variable` or `spline`.
|
||||
pub radius: LengthExpr,
|
||||
/// The body whose edges the points pick. Default: each point is looked up on every placed
|
||||
/// body, shown or hidden, and a point on edges of 2 bodies is refused, naming them.
|
||||
/// The body whose edges the points pick. Default: whichever body has an edge there.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// Round each picked edge's tangent chain with it. Default: true; false rounds exactly the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,9 @@ use solidcore_record::Vec3;
|
||||
use solidcraft_doc::joints::JointKind;
|
||||
|
||||
use super::{
|
||||
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams,
|
||||
DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName, JointLimitsParams,
|
||||
ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams, ReachParams, ReachResult,
|
||||
RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
|
||||
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, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -26,39 +25,11 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ListJointsParams>(), &["quiet"]);
|
||||
assert_keys(input_schema_for::<InspectJointParams>(), &["joint"]);
|
||||
assert_keys(input_schema_for::<SolveJointsParams>(), &["quiet"]);
|
||||
assert_keys(
|
||||
input_schema_for::<MakeJointOriginParams>(),
|
||||
&["body", "circle", "corner", "edge", "end", "face", "frame", "name", "occurrence", "on", "point", "x", "z"],
|
||||
);
|
||||
assert_keys(input_schema_for::<RigidGroupParams>(), &["occurrences"]);
|
||||
assert_keys(input_schema_for::<ReachParams>(), &["at", "axis", "continuous", "joints", "occurrence", "point", "quiet", "z"]);
|
||||
assert_keys(input_schema_for::<MotionLinkParams>(), &["a", "b", "ia", "ib", "offset", "quiet", "ratio"]);
|
||||
assert_keys(input_schema_for::<GearParams>(), &["a", "b", "internal", "link", "quiet", "ra", "rb", "reverse"]);
|
||||
assert_keys(input_schema_for::<RackPinionParams>(), &["a", "b", "link", "quiet", "radius", "reverse"]);
|
||||
assert_keys(input_schema_for::<ScrewParams>(), &["a", "b", "joint", "link", "pitch", "quiet", "reverse"]);
|
||||
assert_keys(input_schema_for::<CamParams>(), &["a", "b", "flip", "follower", "link", "profile", "quiet", "radius", "tip"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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",
|
||||
"joint.origin",
|
||||
"joint.rigid_group",
|
||||
"joint.reach",
|
||||
"joint.motion_link",
|
||||
"joint.gear",
|
||||
"joint.rack_pinion",
|
||||
"joint.screw",
|
||||
"joint.cam",
|
||||
] {
|
||||
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}");
|
||||
}
|
||||
@@ -142,66 +113,6 @@ fn strict_keeps_todays_errors() {
|
||||
("joint.delete", json!({"link": null})),
|
||||
("joint.inspect", json!({"joint": true})),
|
||||
("joint.inspect", json!({"joint": [1]})),
|
||||
// joint.origin
|
||||
("joint.origin", json!({})),
|
||||
("joint.origin", json!({"face": [1, 2]})),
|
||||
("joint.origin", json!({"face": 7, "name": "N"})),
|
||||
("joint.origin", json!({"frame": {"z": [0, 0, 1]}})),
|
||||
("joint.origin", json!({"frame": null})),
|
||||
("joint.origin", json!({"face": "", "occurrence": 0})),
|
||||
("joint.origin", json!({"circle": "x".repeat(16385)})),
|
||||
("joint.origin", json!({"point": [0, 0, 0], "occurrence": null})),
|
||||
("joint.origin", json!({"point": [0, 0, 0], "occurrence": true})),
|
||||
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "x": "nonsense"})),
|
||||
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "x": [1, 0, 0], "end": "F1"})),
|
||||
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "end": 5})),
|
||||
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "end": null})),
|
||||
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "x": [0, 0, 0]})),
|
||||
("joint.origin", json!({"face": [0, 0, 0], "occurrence": 0, "x": {"face": null}})),
|
||||
("joint.origin", json!({"face": [0, 0, 0], "occurrence": 0, "x": {}})),
|
||||
("joint.origin", json!({"face": [0, 0, 0], "occurrence": 0, "end": "F1"})),
|
||||
("joint.origin", json!({"circle": [0, 0, 0], "occurrence": 0, "z": [0, 0, 0]})),
|
||||
("joint.origin", json!({"circle": [0, 0, 0], "occurrence": 0, "z": "up"})),
|
||||
("joint.origin", json!({"edge": [0, 0, 0], "occurrence": 0, "z": 5})),
|
||||
("joint.origin", json!({"frame": {"origin": [0, 0, 0]}, "occurrence": 0})),
|
||||
// joint.rigid_group
|
||||
("joint.rigid_group", json!({})),
|
||||
("joint.rigid_group", json!({"occurrences": null})),
|
||||
("joint.rigid_group", json!({"occurrences": "A"})),
|
||||
("joint.rigid_group", json!({"occurrences": [0]})),
|
||||
("joint.rigid_group", json!({"occurrences": vec![0; 1001]})),
|
||||
("joint.rigid_group", json!({"occurrences": [0, true]})),
|
||||
// joint.reach
|
||||
("joint.reach", json!({})),
|
||||
("joint.reach", json!({"occurrence": null, "point": [0, 0, 0]})),
|
||||
("joint.reach", json!({"occurrence": [0], "point": [0, 0, 0]})),
|
||||
("joint.reach", json!({"occurrence": 0})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0]})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [1e9, 0, 0]})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "at": null})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "at": "here"})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 0]})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "z": null})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1], "axis": 5})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "axis": [0, 0, 1]})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": "Hinge"})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": null})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": [true]})),
|
||||
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": vec![1; 1001]})),
|
||||
// joint.motion_link, joint.gear, joint.rack_pinion, joint.screw and joint.cam
|
||||
("joint.motion_link", json!({})),
|
||||
("joint.motion_link", json!({"a": true, "b": 1, "ratio": 1})),
|
||||
("joint.motion_link", json!({"a": null, "b": 1, "ratio": 1})),
|
||||
("joint.gear", json!({})),
|
||||
("joint.gear", json!({"a": [1], "b": 1})),
|
||||
("joint.rack_pinion", json!({})),
|
||||
("joint.rack_pinion", json!({"a": {}, "b": 1})),
|
||||
("joint.screw", json!({})),
|
||||
("joint.screw", json!({"joint": null})),
|
||||
("joint.screw", json!({"joint": true, "pitch": 1})),
|
||||
("joint.screw", json!({"a": true, "b": 1, "pitch": 1})),
|
||||
("joint.cam", json!({})),
|
||||
("joint.cam", json!({"a": false, "b": 1})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -794,854 +705,3 @@ fn strict_may_name_another_problem_first_and_refuses_ignored_wrong_types() {
|
||||
assert!(works(ParamCheck::Off) && !works(ParamCheck::Strict), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// joint.origin, joint.rigid_group, joint.reach, joint.motion_link, joint.gear, joint.rack_pinion,
|
||||
// joint.screw and joint.cam (#13185)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn put(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A joint between two parts as they are, at a world point with its z along `z`.
|
||||
fn pin(s: &mut Session, kind: &str, name: &str, base: &str, part: &str, p: [f64; 3], z: [f64; 3]) {
|
||||
let at = |o: &str| json!({"occurrence": o, "point": p, "z": z});
|
||||
put(s, "joint.create", json!({"type": kind, "a": at(base), "b": at(part), "flip": true, "name": name}));
|
||||
}
|
||||
|
||||
/// A grounded base with two discs on hinges (G1, G2), a pinion's hinge (P) and its rack's slider
|
||||
/// (Rack), a screw's hinge and slider (K, Slide2), a cylindrical joint (Nut) and a spare hinge (X):
|
||||
/// a joint named for each part, for the couplings to tie.
|
||||
fn rig(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let root = |s: &mut Session| put(s, "component.activate", json!({"component": "root"}));
|
||||
root(&mut s);
|
||||
let base = put(&mut s, "component.create", json!({"name": "Base", "grounded": false}));
|
||||
put(&mut s, "solid.box", json!({"length": 200, "width": 120, "height": 20, "corner": [-100, -60, -20]}));
|
||||
put(&mut s, "occurrence.ground", json!({"occurrence": base["occurrence"], "grounded": true}));
|
||||
for (name, x, r) in [("G1", 0.0, 10.0), ("G2", 30.0, 20.0)] {
|
||||
root(&mut s);
|
||||
put(&mut s, "component.create", json!({"name": name, "grounded": false}));
|
||||
put(&mut s, "solid.cylinder", json!({"base": [x, 0, 0], "axis": [0, 0, 1], "radius": r, "height": 5}));
|
||||
}
|
||||
for (name, x, y) in
|
||||
[("P", -40.0, -5.0), ("Rack", -40.0, -35.0), ("K", 70.0, -5.0), ("Slide2", 70.0, 25.0), ("Nut", 100.0, -5.0), ("X", 130.0, -5.0)]
|
||||
{
|
||||
root(&mut s);
|
||||
put(&mut s, "component.create", json!({"name": name, "grounded": false}));
|
||||
put(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, y, 0]}));
|
||||
}
|
||||
let (z, x) = ([0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
|
||||
pin(&mut s, "revolute", "G1", "Base", "G1", [0.0, 0.0, 0.0], z);
|
||||
pin(&mut s, "revolute", "G2", "Base", "G2", [30.0, 0.0, 0.0], z);
|
||||
pin(&mut s, "revolute", "P", "Base", "P", [-40.0, 0.0, 0.0], z);
|
||||
pin(&mut s, "slider", "Rack", "Base", "Rack", [-40.0, -30.0, 0.0], x);
|
||||
pin(&mut s, "revolute", "K", "Base", "K", [70.0, 0.0, 0.0], z);
|
||||
pin(&mut s, "slider", "Slide2", "Base", "Slide2", [70.0, 30.0, 0.0], x);
|
||||
pin(&mut s, "cylindrical", "Nut", "Base", "Nut", [100.0, 0.0, 0.0], z);
|
||||
pin(&mut s, "revolute", "X", "Base", "X", [130.0, 0.0, 0.0], z);
|
||||
s
|
||||
}
|
||||
|
||||
/// Grounded base, a cam (a disc of radius 30 about (10, 0), in sketch `CamSketch`) on a hinge
|
||||
/// `Cam`, a tappet on a slider `Tappet` and an arm on a hinge `Arm`, for `joint.cam`.
|
||||
fn cam_rig(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
put(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let base = put(&mut s, "component.create", json!({"name": "Base", "grounded": false}));
|
||||
put(&mut s, "solid.box", json!({"length": 180, "width": 180, "height": 20, "corner": [-90, -90, -20]}));
|
||||
put(&mut s, "occurrence.ground", json!({"occurrence": base["occurrence"], "grounded": true}));
|
||||
put(&mut s, "component.activate", json!({"component": "root"}));
|
||||
put(&mut s, "component.create", json!({"name": "Cam"}));
|
||||
put(&mut s, "sketch.create", json!({"plane": "XY", "name": "CamSketch"}));
|
||||
put(&mut s, "sketch.circle.center", json!({"center": [10, 0], "radius": 30, "id": "c1"}));
|
||||
put(&mut s, "sketch.finish", json!({}));
|
||||
put(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
pin(&mut s, "revolute", "Cam", "Base", "Cam", [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
|
||||
put(&mut s, "component.activate", json!({"component": "root"}));
|
||||
put(&mut s, "component.create", json!({"name": "Tappet"}));
|
||||
put(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [45, -2, 0]}));
|
||||
pin(&mut s, "slider", "Tappet", "Base", "Tappet", [0.0, 0.0, 2.5], [1.0, 0.0, 0.0]);
|
||||
put(&mut s, "component.activate", json!({"component": "root"}));
|
||||
put(&mut s, "component.create", json!({"name": "Arm"}));
|
||||
put(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 5, "corner": [-52, 38, 0]}));
|
||||
pin(&mut s, "revolute", "Arm", "Base", "Arm", [-50.0, 40.0, 2.5], [0.0, 0.0, 1.0]);
|
||||
s
|
||||
}
|
||||
|
||||
/// Both of `rig`'s sessions give a call the same error, with the check off and in strict.
|
||||
fn assert_strict_keeps_todays_errors_in(rig: fn(ParamCheck) -> Session, cases: &[(&str, Value)]) {
|
||||
let (mut off, mut strict) = (rig(ParamCheck::Off), rig(ParamCheck::Strict));
|
||||
for (id, p) in cases {
|
||||
let today = off.execute(id, p).err().map(|e| e.to_string());
|
||||
assert!(today.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(strict.execute(id, p).err().map(|e| e.to_string()), today, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the handlers refuse with a design to refuse it for: strict says the same.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_with_the_couplings() {
|
||||
let long = "x".repeat(1001);
|
||||
assert_strict_keeps_todays_errors_in(
|
||||
rig,
|
||||
&[
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G1", "ratio": 1})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G2"})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": null})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": "2"})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1e12})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1, "offset": true})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1, "offset": null})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1, "offset": 1e12})),
|
||||
("joint.motion_link", json!({"a": "Missing", "b": "G2", "ratio": 1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G1", "ra": 1, "rb": 1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2"})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": true, "rb": 1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": null, "rb": 1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": long, "rb": 1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": {"circle": 5}})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": {"circle": ""}})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": {"point": [1, 2, 3]}})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": -1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": null})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": "0"})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": 0})),
|
||||
("joint.gear", json!({"a": "G1", "b": "Rack", "ra": 1, "rb": 1})),
|
||||
("joint.rack_pinion", json!({"a": "P", "b": "Rack"})),
|
||||
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": []})),
|
||||
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 1, "link": 1.5})),
|
||||
("joint.rack_pinion", json!({"a": "P", "b": "G1", "radius": 1})),
|
||||
("joint.screw", json!({"joint": "Nut"})),
|
||||
("joint.screw", json!({"joint": "Nut", "pitch": {}})),
|
||||
("joint.screw", json!({"joint": "Nut", "pitch": 1, "link": "x"})),
|
||||
("joint.screw", json!({"joint": "G1", "pitch": 1})),
|
||||
("joint.screw", json!({"joint": 99, "pitch": 1})),
|
||||
("joint.screw", json!({"a": "K", "b": "Slide2", "pitch": null})),
|
||||
("joint.screw", json!({"a": "K", "b": "K", "pitch": 1})),
|
||||
("joint.screw", json!({"a": "K", "pitch": 1})),
|
||||
("joint.reach", json!({"occurrence": "G1", "point": [0, 0, 0], "joints": ["G1", true]})),
|
||||
("joint.reach", json!({"occurrence": "Missing", "point": [0, 0, 0]})),
|
||||
("joint.origin", json!({"occurrence": "Base", "face": "Missing"})),
|
||||
("joint.origin", json!({"occurrence": "Missing", "point": [0, 0, 0]})),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// What `joint.cam` refuses, with a cam and its followers in the design.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_for_the_cam() {
|
||||
let edges = |n: usize| json!({"edges": vec![[10, 30, 5]; n]});
|
||||
assert_strict_keeps_todays_errors_in(
|
||||
cam_rig,
|
||||
&[
|
||||
("joint.cam", json!({"a": "Cam", "b": "Cam", "profile": {"sketch": "CamSketch"}})),
|
||||
("joint.cam", json!({"a": "Tappet", "b": "Cam", "profile": {"sketch": "CamSketch"}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet"})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": null})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": "CamSketch"})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": null}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": true}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": "0"}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": -1}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": null}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 7}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "Missing"}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": "x"}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": edges(65)})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[1, 2]]}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]], "occurrence": null}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]], "occurrence": [1]}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "round"})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller"})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": true})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": null})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": {"circle": 5}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": 5})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": null})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": {"face": ""}})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "link": 3})),
|
||||
("joint.cam", json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}})),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The couplings, reach, rigid groups and joint origins read each key as their handlers do, and in
|
||||
/// the cases the handlers read it.
|
||||
#[test]
|
||||
fn the_remaining_types_read_each_key_as_the_handlers_do() {
|
||||
let none = Vec::<String>::new;
|
||||
let why = "a joint origin needs face, circle, edge, point or frame [x, y, z], or a face's name";
|
||||
let x_form = "`x` is a direction [x, y, z], a face's name, or {face: [x, y, z]}";
|
||||
let end_name = "`end` is the name of a face a round end lies on";
|
||||
let end_round = "`end` is for a round origin (`circle`)";
|
||||
let origin = |p: Value| issues("joint.origin", p);
|
||||
|
||||
// joint.origin: the forms, each reading only its own keys.
|
||||
for p in [
|
||||
json!({"face": [1, 2, 3]}),
|
||||
json!({"face": {"x": 1, "y": 2, "z": 3}, "occurrence": "A", "x": [1, 0, 0], "name": "N"}),
|
||||
json!({"face": "F1"}),
|
||||
json!({"face": "F1", "x": "F2", "body": "B", "occurrence": 3, "name": null}),
|
||||
json!({"face": "F1", "x": {"face": [1, 2, 3]}}),
|
||||
json!({"face": "F1", "x": {"face": "F2"}}),
|
||||
json!({"face": "F1", "x": null, "z": "junk", "on": 1, "corner": 2, "frame": 3, "edge": 4, "point": 5}),
|
||||
json!({"circle": "F1", "end": "F2", "x": "F3"}),
|
||||
json!({"circle": [1, 2, 3], "end": "F2", "x": [0, 1, 0]}),
|
||||
// A circle with a z: its end is tried and then a point, and neither refuses a bad end.
|
||||
json!({"circle": [1, 2, 3], "z": [0, 0, 1], "end": 5}),
|
||||
json!({"circle": [1, 2, 3], "z": {"x": 0, "y": 0, "z": 1}, "end": "F2"}),
|
||||
json!({"circle": [1, 2, 3], "z": null}),
|
||||
// An edge reads on, corner and z; with a z, end is not read.
|
||||
json!({"edge": [1, 2, 3]}),
|
||||
json!({"edge": [1, 2, 3], "on": [0, 0, 0], "corner": [1, 1, 1], "z": [0, 0, 1], "end": 7, "x": "junk", "body": 3}),
|
||||
json!({"edge": [1, 2, 3], "on": null, "corner": null, "z": null}),
|
||||
json!({"point": [1, 2, 3]}),
|
||||
json!({"point": [1, 2, 3], "z": [0, 0, 1], "x": [1, 0, 0], "on": "junk"}),
|
||||
json!({"point": [1, 2, 3], "z": [0, 0, 0], "x": null}),
|
||||
json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": [1, 0, 0]}, "x": "junk", "z": "junk", "on": 1}),
|
||||
json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": null}}),
|
||||
// A point wins over a frame; a name over a point; the first of face and circle given by name.
|
||||
json!({"point": [1, 2, 3], "frame": 5}),
|
||||
json!({"face": [1, 2], "point": [1, 2, 3]}),
|
||||
json!({"face": [1, 2, 3], "circle": "C", "end": "E"}),
|
||||
json!({"face": null, "circle": null, "point": [1, 2, 3]}),
|
||||
] {
|
||||
assert_eq!(origin(p.clone()), none(), "{p}");
|
||||
}
|
||||
let refused = [
|
||||
(json!({}), why),
|
||||
(json!({"name": 5}), why),
|
||||
(json!({"face": [1, 2]}), why),
|
||||
(json!({"face": 7, "circle": null}), why),
|
||||
(json!({"point": "x"}), why),
|
||||
(json!({"frame": null}), "`frame` needs origin and z"),
|
||||
(json!({"frame": {"z": [0, 0, 1]}}), "`frame` needs origin and z"),
|
||||
(json!({"frame": {"origin": [0, 0, 0]}}), "`frame` needs a z axis"),
|
||||
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0]}}), "`frame` needs a z axis"),
|
||||
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": 5}}), "`frame`'s x must be a direction [x, y, z]"),
|
||||
(json!({"face": ""}), "a face name is 1…16384 characters"),
|
||||
(json!({"circle": "x".repeat(16385)}), "a face name is 1…16384 characters"),
|
||||
(json!({"face": "F", "body": 5}), "`body` must be text"),
|
||||
(json!({"face": "F", "occurrence": true}), "an occurrence must be an id or a name"),
|
||||
(json!({"face": "F", "occurrence": null}), "an occurrence must be an id or a name"),
|
||||
(json!({"point": [1, 2, 3], "occurrence": [1]}), "an occurrence must be an id or a name"),
|
||||
(json!({"face": "F", "x": 5}), x_form),
|
||||
(json!({"face": "F", "x": ""}), x_form),
|
||||
(json!({"face": "F", "x": [0, 0, 0]}), x_form),
|
||||
(json!({"face": "F", "x": {}}), x_form),
|
||||
(json!({"face": "F", "x": {"face": null}}), x_form),
|
||||
(json!({"face": "F", "x": {"face": ""}}), x_form),
|
||||
(json!({"face": [1, 2, 3], "x": true}), x_form),
|
||||
(json!({"circle": [1, 2, 3], "x": "x".repeat(16385)}), x_form),
|
||||
(json!({"face": "F", "end": "E"}), end_round),
|
||||
(json!({"face": [1, 2, 3], "end": "E"}), end_round),
|
||||
(json!({"point": [1, 2, 3], "end": "E"}), end_round),
|
||||
(json!({"edge": [1, 2, 3], "end": "E"}), end_round),
|
||||
(json!({"edge": [1, 2, 3], "z": null, "end": "E"}), end_round),
|
||||
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1]}, "end": "E"}), end_round),
|
||||
(json!({"point": [1, 2, 3], "end": 5}), end_name),
|
||||
(json!({"point": [1, 2, 3], "end": null}), end_name),
|
||||
(json!({"circle": "C", "end": ""}), end_name),
|
||||
(json!({"circle": "C", "end": 5}), end_name),
|
||||
(json!({"circle": [1, 2, 3], "end": null}), end_name),
|
||||
(json!({"circle": [1, 2, 3], "z": null, "end": 5}), end_name),
|
||||
(json!({"circle": [1, 2, 3], "z": [0, 0, 0]}), "`z` is a direction [x, y, z]"),
|
||||
(json!({"circle": [1, 2, 3], "z": "up", "end": "E"}), "`z` is a direction [x, y, z]"),
|
||||
(json!({"edge": [1, 2, 3], "z": [1, 2]}), "`z` is a direction [x, y, z]"),
|
||||
(json!({"edge": [1, 2, 3], "z": [0, 0, 0]}), "`z` is a direction [x, y, z]"),
|
||||
(json!({"edge": [1, 2, 3], "on": 5}), "`on` must be a point [x, y, z]"),
|
||||
(json!({"edge": [1, 2, 3], "corner": "c"}), "`corner` must be a point [x, y, z]"),
|
||||
(json!({"point": [1, 2, 3], "z": "up"}), "`z` is a direction [x, y, z]"),
|
||||
(json!({"point": [1, 2, 3], "x": 5}), "a point origin's `x` is a direction [x, y, z]"),
|
||||
(json!({"point": [1, 2, 3], "x": "F"}), "a point origin's `x` is a direction [x, y, z]"),
|
||||
(json!({"point": [1, 2, 3], "name": 5}), "`name` must be text"),
|
||||
];
|
||||
for (p, message) in refused {
|
||||
assert_eq!(origin(p.clone()), [message], "{p}");
|
||||
}
|
||||
assert_eq!(origin(json!({"face": "F", "circle": "C", "end": "E"})), [end_round]);
|
||||
assert_eq!(origin(json!({"point": [1, 2, 3], "nme": "N"})), ["unknown key `nme`; did you mean `name`?"]);
|
||||
|
||||
// joint.rigid_group.
|
||||
let group = |p: Value| issues("joint.rigid_group", p);
|
||||
let list = "`occurrences` must list 2…1000 occurrences";
|
||||
assert_eq!(group(json!({"occurrences": [0, "A", 3]})), none());
|
||||
assert_eq!(group(json!({})), none());
|
||||
assert_eq!(group(json!({"occurrences": vec![1; 1000]})), none());
|
||||
for p in [json!(null), json!("A"), json!([]), json!([1]), json!({"0": 1}), json!(vec![1; 1001])] {
|
||||
assert_eq!(group(json!({ "occurrences": p })), [list], "{p}");
|
||||
}
|
||||
assert_eq!(group(json!({"occurrences": [1, true]})), ["an occurrence must be an id or a name"]);
|
||||
assert_eq!(group(json!({"occurrences": [1, null, 2]})), ["an occurrence must be an id or a name"]);
|
||||
|
||||
// joint.reach: a point and a direction as finite numbers under 1e9, `null` refused.
|
||||
let reach = |extra: Value| {
|
||||
let mut p = json!({"occurrence": "Arm", "point": [1, 2, 3]});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.reach", p)
|
||||
};
|
||||
assert_eq!(reach(json!({})), none());
|
||||
assert_eq!(
|
||||
reach(json!({"at": {"x": 1, "y": 2, "z": 3}, "z": [0, 0, 5], "axis": [1, 1, 0], "joints": ["A", 1], "quiet": true, "continuous": false})),
|
||||
none()
|
||||
);
|
||||
assert_eq!(reach(json!({"joints": []})), none());
|
||||
assert_eq!(reach(json!({"axis": [1, 0, 0]})), none());
|
||||
assert_eq!(reach(json!({"quiet": null, "continuous": null})), none());
|
||||
assert_eq!(issues("joint.reach", json!({"point": [1, 2, 3]})), none());
|
||||
assert_eq!(issues("joint.reach", json!({"occurrence": 2})), none());
|
||||
assert_eq!(issues("joint.reach", json!({"occurrence": null, "point": [1, 2, 3]})), ["an occurrence must be an id or a name"]);
|
||||
assert_eq!(issues("joint.reach", json!({"occurrence": 2, "point": null})), ["`point` must be [x, y, z]"]);
|
||||
for bad in [json!([1, 2]), json!("x"), json!([1e9, 0, 0]), json!({"x": 1, "y": 2}), json!([1, 2, "3"])] {
|
||||
assert_eq!(reach(json!({"point": bad.clone()})), ["`point` must be [x, y, z]"], "{bad}");
|
||||
assert_eq!(reach(json!({"at": bad.clone()})), ["`at` must be [x, y, z]"], "{bad}");
|
||||
}
|
||||
assert_eq!(reach(json!({"at": null})), ["`at` must be [x, y, z]"]);
|
||||
for key in ["z", "axis"] {
|
||||
let message = format!("`{key}` must be a direction [x, y, z]");
|
||||
for bad in [json!(null), json!([0, 0, 0]), json!([1, 2]), json!("up")] {
|
||||
assert_eq!(reach(json!({ key: bad.clone() })), [message.as_str()], "{key} {bad}");
|
||||
}
|
||||
}
|
||||
let joints = "`joints` must be a list of joints (ids or names), at most 1000";
|
||||
for bad in [json!(null), json!("A"), json!(5), json!(vec![1; 1001])] {
|
||||
assert_eq!(reach(json!({"joints": bad.clone()})), [joints], "{bad}");
|
||||
}
|
||||
assert_eq!(reach(json!({"joints": [true]})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(reach(json!({"quiet": 1})), ["`quiet` must be true or false"]);
|
||||
assert_eq!(reach(json!({"continuous": "yes"})), ["`continuous` must be true or false"]);
|
||||
assert_eq!(reach(json!({"pont": 1})), ["unknown key `pont`; did you mean `point`?"]);
|
||||
|
||||
// joint.motion_link.
|
||||
let link = |extra: Value| {
|
||||
let mut p = json!({"a": "A", "b": 2, "ratio": 0.5});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.motion_link", p)
|
||||
};
|
||||
assert_eq!(link(json!({})), none());
|
||||
assert_eq!(link(json!({"ia": 1, "ib": -1.5, "offset": "w / 2", "quiet": true})), none());
|
||||
assert_eq!(link(json!({"ia": null, "ib": null, "quiet": null})), none());
|
||||
assert_eq!(link(json!({"ratio": -1e8})), none());
|
||||
assert_eq!(issues("joint.motion_link", json!({})), none());
|
||||
assert_eq!(issues("joint.motion_link", json!({"a": 1, "b": 2})), none());
|
||||
for bad in [json!(null), json!("2"), json!(true), json!(1e9), json!(-1e12)] {
|
||||
assert_eq!(link(json!({"ratio": bad.clone()})), ["`ratio` must be a number"], "{bad}");
|
||||
}
|
||||
assert_eq!(link(json!({"a": null})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(link(json!({"b": [1]})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(link(json!({"ia": "0"})), ["`ia` must be a number"]);
|
||||
assert_eq!(link(json!({"ib": true})), ["`ib` must be a number"]);
|
||||
assert_eq!(link(json!({"offset": null})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(link(json!({"offset": true})), ["a joint value must be a number or an expression"]);
|
||||
assert_eq!(link(json!({"offset": 1e12})), ["a joint value must be a number between -1e9 and 1e9"]);
|
||||
assert_eq!(link(json!({"quiet": "no"})), ["`quiet` must be true or false"]);
|
||||
|
||||
// The couplings: a length, a round face, a link to replace.
|
||||
let size = "must be a length (mm or an expression) or a round face ({circle: [x, y, z]})";
|
||||
let gear = |extra: Value| {
|
||||
let mut p = json!({"a": "A", "b": "B", "ra": 20, "rb": "15 mm"});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.gear", p)
|
||||
};
|
||||
assert_eq!(gear(json!({})), none());
|
||||
assert_eq!(gear(json!({"internal": true, "reverse": false, "link": 0, "quiet": true})), none());
|
||||
assert_eq!(gear(json!({"internal": null, "reverse": null})), none());
|
||||
assert_eq!(gear(json!({"ra": {"circle": [10, 0, 2.5]}, "rb": {"circle": "F1", "occurrence": "G2"}})), none());
|
||||
assert_eq!(gear(json!({"ra": {"circle": [10, 0, 2.5], "z": "junk"}})), none());
|
||||
assert_eq!(gear(json!({"ra": "x".repeat(1000), "rb": -5})), none());
|
||||
assert_eq!(issues("joint.gear", json!({"a": 1, "b": 2})), none());
|
||||
assert_eq!(issues("joint.gear", json!({})), none());
|
||||
for bad in [json!(null), json!(true), json!([1]), json!({}), json!({"point": [1, 2, 3]}), json!("x".repeat(1001))] {
|
||||
assert_eq!(gear(json!({"ra": bad.clone()})), [format!("`ra` {size}")], "{bad}");
|
||||
assert_eq!(gear(json!({"rb": bad.clone()})), [format!("`rb` {size}")], "{bad}");
|
||||
}
|
||||
assert_eq!(gear(json!({"ra": {"circle": 5}})), [why]);
|
||||
assert_eq!(gear(json!({"rb": {"circle": ""}})), ["a face name is 1…16384 characters"]);
|
||||
assert_eq!(gear(json!({"ra": {"circle": {"x": 1, "y": 2}}})), [why]);
|
||||
for bad in [json!(-1), json!(null), json!("0"), json!(1.5), json!(true)] {
|
||||
assert_eq!(gear(json!({"link": bad.clone()})), ["no such motion link"], "{bad}");
|
||||
}
|
||||
assert_eq!(gear(json!({"internal": 1})), ["`internal` must be true or false"]);
|
||||
assert_eq!(gear(json!({"reverse": "yes"})), ["`reverse` must be true or false"]);
|
||||
assert_eq!(gear(json!({"a": true})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(gear(json!({"ra": 1, "a": null, "link": -1})), ["`joint` must be an id or a name"]);
|
||||
let rack = |extra: Value| {
|
||||
let mut p = json!({"a": "P", "b": "Rack", "radius": "r"});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.rack_pinion", p)
|
||||
};
|
||||
assert_eq!(rack(json!({})), none());
|
||||
assert_eq!(rack(json!({"radius": {"circle": [0, 0, 0]}, "reverse": true, "link": 3, "quiet": false})), none());
|
||||
assert_eq!(rack(json!({"radius": null})), [format!("`radius` {size}")]);
|
||||
assert_eq!(rack(json!({"link": 2.5})), ["no such motion link"]);
|
||||
assert_eq!(issues("joint.rack_pinion", json!({"a": "P", "b": "Rack"})), none());
|
||||
|
||||
// joint.screw: `joint`, once given, is the form, and `a` and `b` are not read.
|
||||
let screw = |p: Value| issues("joint.screw", p);
|
||||
assert_eq!(screw(json!({"joint": "Nut", "pitch": 2})), none());
|
||||
assert_eq!(screw(json!({"joint": 3, "a": true, "b": null, "pitch": "2 mm", "reverse": true, "link": 1, "quiet": true})), none());
|
||||
assert_eq!(screw(json!({"a": "K", "b": 4, "pitch": {"circle": [1, 2, 3]}})), none());
|
||||
assert_eq!(screw(json!({})), none());
|
||||
assert_eq!(screw(json!({"joint": null, "pitch": 1})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(screw(json!({"joint": true, "a": "K", "b": 4, "pitch": 1})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(screw(json!({"a": true, "b": 4, "pitch": 1})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(screw(json!({"a": "K", "b": null, "pitch": 1})), ["`joint` must be an id or a name"]);
|
||||
assert_eq!(screw(json!({"joint": "Nut", "pitch": false})), [format!("`pitch` {size}")]);
|
||||
assert_eq!(screw(json!({"joint": "Nut", "pitch": 1, "link": "0"})), ["no such motion link"]);
|
||||
|
||||
// joint.cam: the follower, its radius only for a roller, the tip, the profile.
|
||||
let cam = |extra: Value| {
|
||||
let mut p = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "S"}});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
issues("joint.cam", p)
|
||||
};
|
||||
assert_eq!(cam(json!({})), none());
|
||||
assert_eq!(cam(json!({"follower": "knife", "radius": true, "flip": true, "link": 0, "quiet": true})), none());
|
||||
assert_eq!(cam(json!({"follower": "flat", "radius": null})), none());
|
||||
assert_eq!(cam(json!({"follower": null, "radius": []})), none());
|
||||
assert_eq!(cam(json!({"follower": "roller", "radius": "5 mm", "tip": {"circle": [1, 2, 3]}})), none());
|
||||
assert_eq!(cam(json!({"follower": "roller", "radius": {"circle": [1, 2, 3]}})), none());
|
||||
assert_eq!(cam(json!({"tip": "Origin1"})), none());
|
||||
assert_eq!(cam(json!({"tip": {"point": [1, 2, 3]}})), none());
|
||||
assert_eq!(cam(json!({"flip": null})), none());
|
||||
assert_eq!(issues("joint.cam", json!({"a": 1, "b": 2})), none());
|
||||
assert_eq!(issues("joint.cam", json!({})), none());
|
||||
assert_eq!(cam(json!({"follower": "round"})), ["`follower` is knife, roller or flat"]);
|
||||
assert_eq!(cam(json!({"follower": "Roller"})), ["`follower` is knife, roller or flat"]);
|
||||
assert_eq!(cam(json!({"follower": 5})), ["`follower` must be text"]);
|
||||
assert_eq!(cam(json!({"follower": "roller"})), none());
|
||||
assert_eq!(cam(json!({"follower": "roller", "radius": true})), [format!("`radius` {size}")]);
|
||||
assert_eq!(cam(json!({"follower": "roller", "radius": null})), [format!("`radius` {size}")]);
|
||||
assert_eq!(cam(json!({"tip": null})), [why]);
|
||||
assert_eq!(cam(json!({"tip": 5})), [why]);
|
||||
assert_eq!(cam(json!({"tip": {}})), [why]);
|
||||
assert_eq!(cam(json!({"tip": {"face": ""}})), ["a face name is 1…16384 characters"]);
|
||||
assert_eq!(cam(json!({"flip": "yes"})), ["`flip` must be true or false"]);
|
||||
assert_eq!(cam(json!({"link": null})), ["no such motion link"]);
|
||||
for ok in [
|
||||
json!({"sketch": "S"}),
|
||||
json!({"sketch": 4, "index": 2}),
|
||||
json!({"sketch": "S", "edges": [], "occurrence": true}),
|
||||
json!({"sketch": "S", "index": 0, "x": 1}),
|
||||
json!({"edges": [[1, 2, 3]]}),
|
||||
json!({"edges": [{"x": 1, "y": 2, "z": 3}], "occurrence": "Cam", "index": "x"}),
|
||||
json!({"edges": vec![[1, 2, 3]; 64]}),
|
||||
] {
|
||||
assert_eq!(cam(json!({"profile": ok.clone()})), none(), "{ok}");
|
||||
}
|
||||
let forms = "`profile` is {sketch: id|name} or {edges: [[x, y, z], …]}";
|
||||
for (bad, message) in [
|
||||
(json!(null), forms),
|
||||
(json!("S"), forms),
|
||||
(json!([1]), forms),
|
||||
(json!({}), forms),
|
||||
(json!({"edges": "x"}), forms),
|
||||
(json!({"sketch": null}), "`profile.sketch` must be a sketch id or name"),
|
||||
(json!({"sketch": [1]}), "`profile.sketch` must be a sketch id or name"),
|
||||
(json!({"sketch": "S", "index": -1}), "the sketch has no such closed profile (an open curve makes none)"),
|
||||
(json!({"sketch": "S", "index": "0"}), "the sketch has no such closed profile (an open curve makes none)"),
|
||||
(json!({"sketch": "S", "index": null}), "the sketch has no such closed profile (an open curve makes none)"),
|
||||
(json!({"edges": []}), "pick 1 to 64 edges"),
|
||||
(json!({"edges": vec![[1, 2, 3]; 65]}), "pick 1 to 64 edges"),
|
||||
(json!({"edges": [[1, 2]]}), "`edges` are finite [x, y, z] points"),
|
||||
(json!({"edges": [[1, 2, 3], "x"]}), "`edges` are finite [x, y, z] points"),
|
||||
(json!({"edges": [[1, 2, 3]], "occurrence": null}), "an occurrence must be an id or a name"),
|
||||
(json!({"edges": [[1, 2, 3]], "occurrence": {}}), "an occurrence must be an id or a name"),
|
||||
] {
|
||||
assert_eq!(cam(json!({"profile": bad.clone()})), [message], "{bad}");
|
||||
}
|
||||
assert_eq!(cam(json!({"profle": 1})), ["unknown key `profle`; did you mean `profile`?"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need, and take what they read.
|
||||
#[test]
|
||||
fn the_remaining_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::<MakeJointOriginParams>(),
|
||||
&[
|
||||
(json!({"face": [1, 2, 3]}), true),
|
||||
(json!({"face": "F1", "x": "F2", "name": "Top", "body": "B", "occurrence": "A"}), true),
|
||||
(json!({"circle": "F1", "end": "F2", "x": {"face": [1, 2, 3]}}), true),
|
||||
(json!({"circle": [1, 2, 3], "z": [0, 0, 1], "x": null}), true),
|
||||
(json!({"edge": [1, 2, 3], "on": [0, 0, 0], "corner": {"x": 1, "y": 2, "z": 3}, "z": [1, 0, 0]}), true),
|
||||
(json!({"point": [1, 2, 3], "z": [0, 0, 1], "x": [1, 0, 0]}), true),
|
||||
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": [1, 0, 0]}}), true),
|
||||
(json!({}), false),
|
||||
(json!({"name": "N"}), false),
|
||||
(json!({"face": [1, 2]}), false),
|
||||
(json!({"point": [1, 2, 3], "end": 5}), false),
|
||||
(json!({"frame": {"origin": [0, 0, 0]}}), false),
|
||||
(json!({"point": [1, 2, 3], "x": 5}), false),
|
||||
(json!({"point": [1, 2, 3], "nme": "N"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<RigidGroupParams>(),
|
||||
&[
|
||||
(json!({"occurrences": ["A", 2]}), true),
|
||||
(json!({"occurrences": ["A"]}), false),
|
||||
(json!({"occurrences": vec![1; 1001]}), false),
|
||||
(json!({"occurrences": [1, true]}), false),
|
||||
(json!({}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ReachParams>(),
|
||||
&[
|
||||
(json!({"occurrence": "Arm", "point": [1, 2, 3]}), true),
|
||||
(
|
||||
json!({"occurrence": 2, "point": {"x": 1, "y": 2, "z": 3}, "at": [0, 0, 0], "z": [0, 0, 1], "axis": [1, 0, 0], "joints": ["A", 3], "quiet": true, "continuous": true}),
|
||||
true,
|
||||
),
|
||||
(json!({"point": [1, 2, 3]}), false),
|
||||
(json!({"occurrence": "Arm"}), false),
|
||||
(json!({"occurrence": "Arm", "point": [1, 2]}), false),
|
||||
(json!({"occurrence": "Arm", "point": [1, 2, 3], "joints": "A"}), false),
|
||||
(json!({"occurrence": "Arm", "point": [1, 2, 3], "at": null}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MotionLinkParams>(),
|
||||
&[
|
||||
(json!({"a": "A", "b": 2, "ratio": 0.5}), true),
|
||||
(json!({"a": "A", "b": 2, "ratio": -2, "ia": 1, "ib": 0, "offset": "5 mm", "quiet": true}), true),
|
||||
(json!({"a": "A", "b": 2}), false),
|
||||
(json!({"a": "A", "ratio": 1}), false),
|
||||
(json!({"a": "A", "b": 2, "ratio": "2"}), false),
|
||||
(json!({"a": "A", "b": 2, "ratio": 1e12}), false),
|
||||
(json!({"a": "A", "b": 2, "ratio": 1, "offset": true}), false),
|
||||
],
|
||||
);
|
||||
let gear = json!({"a": "A", "b": "B", "ra": 20, "rb": {"circle": [1, 2, 3], "occurrence": "G"}});
|
||||
takes(
|
||||
input_schema_for::<GearParams>(),
|
||||
&[
|
||||
(gear.clone(), true),
|
||||
(json!({"a": 1, "b": 2, "ra": "r1", "rb": "15 mm", "internal": true, "reverse": true, "link": 0, "quiet": false}), true),
|
||||
(json!({"a": "A", "b": "B", "ra": 20}), false),
|
||||
(json!({"a": "A", "b": "B", "ra": 20, "rb": true}), false),
|
||||
(json!({"a": "A", "b": "B", "ra": 20, "rb": {"point": [1, 2, 3]}}), false),
|
||||
(json!({"a": "A", "b": "B", "ra": 20, "rb": 1, "link": -1}), false),
|
||||
(json!({"a": "A", "b": "B", "ra": 20, "rb": 1, "link": "0"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<RackPinionParams>(),
|
||||
&[
|
||||
(json!({"a": "P", "b": "Rack", "radius": 10}), true),
|
||||
(json!({"a": "P", "b": "Rack", "radius": "r", "reverse": true, "link": 2}), true),
|
||||
(json!({"a": "P", "b": "Rack"}), false),
|
||||
(json!({"a": "P", "radius": 10}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ScrewParams>(),
|
||||
&[
|
||||
(json!({"joint": "Nut", "pitch": 2}), true),
|
||||
(json!({"a": "K", "b": "S", "pitch": "2 mm", "reverse": true, "link": 1}), true),
|
||||
(json!({"joint": "Nut"}), false),
|
||||
(json!({"pitch": 2}), false),
|
||||
(json!({"a": "K", "pitch": 2}), false),
|
||||
(json!({"joint": "Nut", "pitch": 2, "link": -1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<CamParams>(),
|
||||
&[
|
||||
(json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "S"}}), true),
|
||||
(
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": 3, "index": 1}, "follower": "flat", "flip": true, "link": 0, "quiet": true}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
json!({"a": "Cam", "b": "Arm", "profile": {"edges": [[1, 2, 3]], "occurrence": "Cam"}, "follower": "roller", "radius": 5, "tip": {"circle": [1, 2, 3]}}),
|
||||
true,
|
||||
),
|
||||
(json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}}), false),
|
||||
(json!({"a": "Cam", "b": "Tappet", "profile": {"edges": vec![[1, 2, 3]; 65]}}), false),
|
||||
(json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "S", "index": -1}}), false),
|
||||
(json!({"a": "Cam", "b": "Tappet", "profile": {}}), false),
|
||||
(json!({"a": "Cam", "b": "Tappet"}), false),
|
||||
(json!({"a": "Cam", "profile": {"sketch": "S"}}), false),
|
||||
],
|
||||
);
|
||||
// A roller needs its radius, a screw its joint or its two, a reach its axis's z, a joint
|
||||
// origin one of its forms: said in the schema (`conforms` doesn't read these).
|
||||
let cam = input_schema_for::<CamParams>();
|
||||
assert_eq!(cam.as_value()["if"], json!({"properties": {"follower": {"const": "roller"}}, "required": ["follower"]}));
|
||||
assert_eq!(cam.as_value()["then"], json!({"required": ["radius"]}));
|
||||
let screw = input_schema_for::<ScrewParams>();
|
||||
assert_eq!(screw.as_value()["anyOf"], json!([{"required": ["joint"]}, {"required": ["a", "b"]}]));
|
||||
assert_eq!(input_schema_for::<ReachParams>().as_value()["dependentRequired"], json!({"axis": ["z"]}));
|
||||
assert_eq!(input_schema_for::<MakeJointOriginParams>().as_value()["anyOf"].as_array().map(Vec::len), Some(5));
|
||||
}
|
||||
|
||||
/// A design with the pieces the couplings join, and a tray with a hole for the joint origins by
|
||||
/// name: each command run in Warn in every form it takes gives a reply that matches its output
|
||||
/// schema, with no warning.
|
||||
#[test]
|
||||
fn replies_of_the_rest_match_the_output_schema() {
|
||||
let ok = |schema: schemars::Schema, id: &str, reply: &Value| matches(schema, id, reply);
|
||||
|
||||
// joint.origin, in the forms of a face, a circle, an edge, a point and a frame.
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
put(&mut s, "component.create", json!({"name": "Tray"}));
|
||||
put(&mut s, "solid.box", json!({"length": 300, "width": 100, "height": 1, "corner": [0, 0, 0]}));
|
||||
put(&mut s, "solid.hole", json!({"position": [280, 50, 1], "diameter": 6}));
|
||||
let name_at = |s: &mut Session, p: Vec3, circle: bool| {
|
||||
let st = s.model.state();
|
||||
let c = s.doc.find_component("Tray").unwrap_or(0);
|
||||
solidcraft_doc::joints::face_name_at(&s.doc, &st, c, p, circle).unwrap_or_default()
|
||||
};
|
||||
let wall = name_at(&mut s, Vec3::new(283.0, 50.0, 0.5), false);
|
||||
let top = name_at(&mut s, Vec3::new(150.0, 50.0, 1.0), false);
|
||||
let right = name_at(&mut s, Vec3::new(300.0, 50.0, 0.5), false);
|
||||
assert!(![&wall, &top, &right].iter().any(|n| n.is_empty()));
|
||||
let schema = || output_schema_for::<MakeJointOriginResult>();
|
||||
let mut made = Vec::new();
|
||||
for p in [
|
||||
json!({"occurrence": "Tray", "face": [150, 50, 1], "name": "TopPoint"}),
|
||||
json!({"face": [150, 50, 1], "x": [0, 1, 0]}),
|
||||
json!({"face": top.clone(), "name": "TopName", "x": right.clone()}),
|
||||
json!({"face": top.clone(), "occurrence": "Tray", "body": null, "x": {"face": [300, 50, 0.5]}, "name": " Padded "}),
|
||||
json!({"circle": wall.clone(), "end": top.clone(), "x": right.clone(), "name": "Hole"}),
|
||||
json!({"circle": [283, 50, 0.5], "name": "HoleAt"}),
|
||||
json!({"circle": [283, 50, 0.5], "z": [0, 0, 1], "name": "HoleOut", "end": "not checked here"}),
|
||||
json!({"circle": [283, 50, 0.5], "end": top.clone(), "x": [1, 0, 0], "name": "HoleEnd"}),
|
||||
json!({"edge": [150, 0, 1], "name": "Edge"}),
|
||||
json!({"edge": [150, 0, 1], "on": [150, 50, 1], "corner": [299, 0, 1], "name": "Corner"}),
|
||||
json!({"edge": [150, 0, 1], "z": [1, 0, 0], "end": 5, "name": "EdgeZ"}),
|
||||
json!({"point": [10, 10, 1], "z": [0, 0, 1], "x": [1, 0, 0], "name": "Point"}),
|
||||
json!({"point": [10, 10, 1], "occurrence": "Tray", "name": null}),
|
||||
json!({"frame": {"origin": [5, 5, 1], "z": [0, 0, 1], "x": [1, 0, 0]}, "occurrence": "Tray", "name": "Frame"}),
|
||||
json!({"frame": {"origin": [5, 5, 1], "z": [0, 0, 1]}, "occurrence": 0}),
|
||||
] {
|
||||
let r = put(&mut s, "joint.origin", p.clone());
|
||||
ok(schema(), "joint.origin", &r);
|
||||
made.push(r["origin"].as_str().unwrap_or_default().to_string());
|
||||
}
|
||||
assert_eq!(made[0], "TopPoint");
|
||||
assert_eq!(made[1], "Joint Origin1");
|
||||
assert_eq!(made[3], "Padded");
|
||||
assert_eq!(made[12], "Joint Origin2");
|
||||
assert_eq!(s.doc.assembly.origins.len(), made.len());
|
||||
|
||||
// joint.rigid_group, by name and by id.
|
||||
let mut g = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut ids = Vec::new();
|
||||
for (name, x) in [("A", 0), ("B", 20), ("C", 40), ("D", 60)] {
|
||||
put(&mut g, "component.activate", json!({"component": "root"}));
|
||||
let made = put(&mut g, "component.create", json!({"name": name, "grounded": false}));
|
||||
put(&mut g, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
ids.push(made["occurrence"].clone());
|
||||
}
|
||||
let group = put(&mut g, "joint.rigid_group", json!({"occurrences": ["A", "B", "C"]}));
|
||||
ok(output_schema_for::<RigidGroupResult>(), "joint.rigid_group", &group);
|
||||
assert!(group["joints"].as_array().is_some_and(|j| j.len() == 2 && j.iter().all(Value::is_u64)), "{group}");
|
||||
let by_id = put(&mut g, "joint.rigid_group", json!({"occurrences": [ids[3].clone(), 0]}));
|
||||
ok(output_schema_for::<RigidGroupResult>(), "joint.rigid_group", &by_id);
|
||||
assert!(by_id["joints"].as_array().is_some_and(|j| j.len() == 1), "{by_id}");
|
||||
|
||||
// The couplings and motion links.
|
||||
let mut s = rig(ParamCheck::Warn);
|
||||
let coupled = |s: &mut Session, id: &str, p: Value| {
|
||||
let r = put(s, id, p.clone());
|
||||
ok(output_schema_for::<CouplingResult>(), id, &r);
|
||||
let quiet = p["quiet"] == json!(true);
|
||||
assert!(r["link"].is_u64() && r["ratio"].is_number() && r.get("dof").is_some() != quiet, "{id} {p}: {r}");
|
||||
r
|
||||
};
|
||||
let gear = coupled(&mut s, "joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": "20 mm"}));
|
||||
assert!(gear["link"] == 0 && gear["ratio"].as_f64().is_some_and(|r| (r + 0.5).abs() < 1e-9), "{gear}");
|
||||
let again = coupled(
|
||||
&mut s,
|
||||
"joint.gear",
|
||||
json!({"a": "G1", "b": "G2", "ra": {"occurrence": "G1", "circle": [10, 0, 2.5]}, "rb": {"occurrence": "G2", "circle": [50, 0, 2.5]},
|
||||
"internal": true, "reverse": true, "link": 0}),
|
||||
);
|
||||
assert_eq!(again["link"], 0, "{again}");
|
||||
let quiet = coupled(&mut s, "joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": 20, "link": 0, "quiet": true, "internal": null}));
|
||||
assert!(quiet.get("dof").is_none() && quiet.get("fixed").is_none(), "{quiet}");
|
||||
let rack = coupled(&mut s, "joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 10}));
|
||||
assert_eq!(rack["link"], 1, "{rack}");
|
||||
coupled(&mut s, "joint.rack_pinion", json!({"a": "Rack", "b": "P", "radius": "10 mm", "reverse": true, "link": 1}));
|
||||
let screw = coupled(&mut s, "joint.screw", json!({"joint": "Nut", "pitch": 2}));
|
||||
assert_eq!(screw["link"], 2, "{screw}");
|
||||
coupled(&mut s, "joint.screw", json!({"joint": "Nut", "pitch": "2 mm", "reverse": true, "link": 2, "a": true}));
|
||||
let apart = coupled(&mut s, "joint.screw", json!({"a": "K", "b": "Slide2", "pitch": 3}));
|
||||
assert_eq!(apart["link"], 3, "{apart}");
|
||||
let linked = put(&mut s, "joint.motion_link", json!({"a": "G1", "b": "X", "ratio": 2}));
|
||||
ok(output_schema_for::<SolveResult>(), "joint.motion_link", &linked);
|
||||
let quiet = put(&mut s, "joint.motion_link", json!({"a": "G1", "b": "K", "ratio": 1, "offset": "5 deg", "ia": 0, "ib": 0, "quiet": true}));
|
||||
ok(output_schema_for::<SolveResult>(), "joint.motion_link", &quiet);
|
||||
assert!(quiet.get("dof").is_none() && quiet.get("fixed").is_none(), "{quiet}");
|
||||
let list = put(&mut s, "joint.list", json!({}));
|
||||
assert!(list["links"].as_array().is_some_and(|l| l.len() == 6), "{list}");
|
||||
|
||||
// joint.reach: an arm.
|
||||
let mut a = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
put(&mut a, "component.activate", json!({"component": "root"}));
|
||||
let base = put(&mut a, "component.create", json!({"name": "Base", "grounded": false}));
|
||||
put(&mut a, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [-1, -1, -1]}));
|
||||
put(&mut a, "occurrence.ground", json!({"occurrence": base["occurrence"], "grounded": true}));
|
||||
for (name, x) in [("Upper", 30), ("Fore", 80)] {
|
||||
put(&mut a, "component.activate", json!({"component": "root"}));
|
||||
put(&mut a, "component.create", json!({"name": name, "grounded": false}));
|
||||
put(&mut a, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [x, -1, -1]}));
|
||||
}
|
||||
pin(&mut a, "revolute", "Shoulder", "Base", "Upper", [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
|
||||
pin(&mut a, "revolute", "Elbow", "Upper", "Fore", [60.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
|
||||
put(&mut a, "joint.drive", json!({"joint": "Elbow", "value": 30}));
|
||||
let reach = |a: &mut Session, p: Value| {
|
||||
let r = put(a, "joint.reach", p.clone());
|
||||
ok(output_schema_for::<ReachResult>(), "joint.reach", &r);
|
||||
r
|
||||
};
|
||||
// Fore's tip, with the elbow at 30°.
|
||||
let tip = json!([60.0 + 40.0 * 30f64.to_radians().cos(), 20.0, 0.0]);
|
||||
let moved = reach(&mut a, json!({"occurrence": "Fore", "at": tip, "point": [50, 50, 0]}));
|
||||
assert!(moved["reached"] == json!(true) && moved["moved"] == json!(true) && moved["occurrence"] == "Fore:1", "{moved}");
|
||||
assert!(moved["stopped_by_limit"].is_null() && moved["stopped_by_loop"].is_null() && moved.get("error_deg").is_none(), "{moved}");
|
||||
assert!(moved["values"].as_array().is_some_and(|v| v.len() == 2 && v[0]["values"][0]["unit"] == "deg"), "{moved}");
|
||||
let default_at =
|
||||
reach(&mut a, json!({"occurrence": 2, "point": {"x": 40, "y": 30, "z": 0}, "quiet": true, "continuous": true, "joints": ["Shoulder"]}));
|
||||
assert!(default_at["continuous"] == json!(true) && default_at.get("dof").is_none(), "{default_at}");
|
||||
let turned = reach(&mut a, json!({"occurrence": "Fore", "point": [30, 70, 0], "z": [0, 1, 0], "axis": [1, 0, 0]}));
|
||||
assert!(turned["error_deg"].is_number(), "{turned}");
|
||||
let turned = reach(&mut a, json!({"occurrence": "Fore", "point": [0, 80, 0], "z": [0, 1, 0]}));
|
||||
assert!(turned["error_deg"].is_number() && turned["reached"].is_boolean(), "{turned}");
|
||||
put(&mut a, "joint.limits", json!({"joint": "Elbow", "min": 20, "max": 40}));
|
||||
let limited = reach(&mut a, json!({"occurrence": "Fore", "point": [-150, 0, 0]}));
|
||||
assert!(limited["stopped_by_limit"].as_array().is_some_and(|l| !l.is_empty() && l[0]["limit"] == "min"), "{limited}");
|
||||
// A four-bar's rocker pulled past its toggle position: the loop that stops it is named.
|
||||
let mut f = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let (o4, p0) = ((45.0f64, 0.0f64), (0.0f64, 20.0f64));
|
||||
let (dx, dy) = (o4.0 - p0.0, o4.1 - p0.1);
|
||||
let l = dx.hypot(dy);
|
||||
let along = (50.0f64.powi(2) - 40.0f64.powi(2) + l * l) / (2.0 * l);
|
||||
let off = (50.0f64.powi(2) - along * along).sqrt();
|
||||
let q0 = (p0.0 + dx * along / l - dy * off / l, p0.1 + dy * along / l + dx * off / l);
|
||||
for (name, c) in [("Ground", (0.0, 0.0)), ("Crank", p0), ("Coupler", ((p0.0 + q0.0) / 2.0, (p0.1 + q0.1) / 2.0)), ("Rocker", q0)] {
|
||||
put(&mut f, "component.activate", json!({"component": "root"}));
|
||||
let made = put(&mut f, "component.create", json!({"name": name, "grounded": false}));
|
||||
put(&mut f, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [c.0, c.1, -5]}));
|
||||
if name == "Ground" {
|
||||
put(&mut f, "occurrence.ground", json!({"occurrence": made["occurrence"], "grounded": true}));
|
||||
}
|
||||
}
|
||||
let z = [0.0, 0.0, 1.0];
|
||||
pin(&mut f, "revolute", "Crank", "Ground", "Crank", [0.0, 0.0, 0.0], z);
|
||||
pin(&mut f, "revolute", "Crank pin", "Crank", "Coupler", [p0.0, p0.1, 0.0], z);
|
||||
pin(&mut f, "revolute", "Rocker pin", "Coupler", "Rocker", [q0.0, q0.1, 0.0], z);
|
||||
pin(&mut f, "revolute", "Rocker", "Ground", "Rocker", [o4.0, o4.1, 0.0], z);
|
||||
let start = (q0.1 - o4.1).atan2(q0.0 - o4.0) + 100f64.to_radians();
|
||||
let past = [o4.0 + 40.0 * start.cos(), o4.1 + 40.0 * start.sin(), 0.0];
|
||||
let stuck = reach(&mut f, json!({"occurrence": "Rocker", "at": [q0.0, q0.1, 0.0], "point": past}));
|
||||
assert_eq!(stuck["stopped_by_loop"], json!({"joint": "Rocker pin"}), "{stuck}");
|
||||
let fixed = reach(&mut a, json!({"occurrence": "Base", "point": [50, 50, 0]}));
|
||||
assert!(fixed["reason"].is_string() && fixed["reached"] == json!(false) && fixed["values"] == json!([]), "{fixed}");
|
||||
}
|
||||
|
||||
/// A cam in every form: a sketch's profile or picked edges, a knife, a roller and a flat face, a
|
||||
/// translating and an oscillating follower, a link replaced. The replies match the output schema.
|
||||
#[test]
|
||||
fn cam_replies_match_the_output_schema() {
|
||||
let mut s = cam_rig(ParamCheck::Warn);
|
||||
let cam = |s: &mut Session, p: Value| {
|
||||
let r = put(s, "joint.cam", p.clone());
|
||||
matches(output_schema_for::<CamResult>(), "joint.cam", &r);
|
||||
assert!(r["link"].is_u64() && r["lift"]["unit"].is_string() && r["reverse"].is_boolean() && r.get("ratio").is_none(), "{p}: {r}");
|
||||
r
|
||||
};
|
||||
let knife = cam(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
assert!(knife["lift"]["unit"] == "mm" && knife["follower_offset"].is_number(), "{knife}");
|
||||
let sized = cam(
|
||||
&mut s,
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 0}, "follower": "roller", "radius": "5 mm", "link": 0, "quiet": true}),
|
||||
);
|
||||
assert!(sized.get("dof").is_none(), "{sized}");
|
||||
cam(
|
||||
&mut s,
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]], "occurrence": "Cam"}, "follower": "flat", "flip": true, "link": 0}),
|
||||
);
|
||||
cam(
|
||||
&mut s,
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]]}, "follower": "knife", "radius": true, "tip": {"occurrence": "Tappet", "point": [45, 0, 2.5]}, "link": 0}),
|
||||
);
|
||||
// An oscillating follower: its tip, no offset in the reply.
|
||||
// The roller's centre is where the arm's circle (50 about the pivot) meets the circle of
|
||||
// radius 30 + 5 about the cam's centre.
|
||||
let (pivot, centre) = ((-50.0f64, 40.0f64), (10.0f64, 0.0f64));
|
||||
let (dx, dy) = (centre.0 - pivot.0, centre.1 - pivot.1);
|
||||
let l = dx.hypot(dy);
|
||||
let a = (50.0f64.powi(2) - 35.0f64.powi(2) + l * l) / (2.0 * l);
|
||||
let h = (50.0f64.powi(2) - a * a).sqrt();
|
||||
let start = [pivot.0 + dx * a / l - dy * h / l, pivot.1 + dy * a / l + dx * h / l, 2.5];
|
||||
let swing = cam(
|
||||
&mut s,
|
||||
json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5, "tip": {"occurrence": "Arm", "point": start}}),
|
||||
);
|
||||
assert!(swing["lift"]["unit"] == "deg" && swing.get("follower_offset").is_none(), "{swing}");
|
||||
}
|
||||
|
||||
/// 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_for_the_rest() {
|
||||
let mut s = rig(ParamCheck::Warn);
|
||||
let calls = [
|
||||
("joint.origin", json!({"occurrence": "Base", "point": [0, 0, 0], "nme": "A"}), "name"),
|
||||
("joint.rigid_group", json!({"occurrences": ["G1", "G2"], "occurences": 1}), "occurrences"),
|
||||
("joint.reach", json!({"occurrence": "G2", "point": [30, 20, 0], "quiet": true, "quet": true}), "quiet"),
|
||||
("joint.motion_link", json!({"a": "P", "b": "X", "ratio": 1, "ratjo": 1}), "ratio"),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": 20, "internl": true}), "internal"),
|
||||
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 10, "reverce": true}), "reverse"),
|
||||
("joint.screw", json!({"joint": "Nut", "pitch": 2, "pich": 2}), "pitch"),
|
||||
];
|
||||
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());
|
||||
}
|
||||
let mut c = cam_rig(ParamCheck::Warn);
|
||||
let p = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follwer": "flat"});
|
||||
let reply = put(&mut c, "joint.cam", p);
|
||||
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("follower"))), "{reply}");
|
||||
let out = output_schema_for::<CamResult>();
|
||||
assert!(conforms(out.as_value(), out.as_value(), &reply), "{reply}");
|
||||
}
|
||||
|
||||
/// A wrong-typed key the handlers read as if it were left out is refused in strict (the stricter
|
||||
/// reading), and the call works with the check off.
|
||||
#[test]
|
||||
fn strict_refuses_wrong_types_the_handlers_ignore_in_the_rest() {
|
||||
let cases = [
|
||||
("joint.origin", json!({"occurrence": "Base", "point": [0, 0, 0], "name": 5})),
|
||||
("joint.origin", json!({"occurrence": "Base", "point": [0, 0, 0], "z": "up"})),
|
||||
("joint.reach", json!({"occurrence": "G2", "point": [30, 20, 0], "quiet": "yes"})),
|
||||
("joint.motion_link", json!({"a": "G1", "b": "X", "ratio": 1, "ia": "0"})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": 20, "reverse": "no"})),
|
||||
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 10, "quiet": 1})),
|
||||
("joint.screw", json!({"joint": "Nut", "pitch": 2, "reverse": "x"})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let works = |level| rig(level).execute(id, &p).is_ok();
|
||||
assert!(works(ParamCheck::Off) && !works(ParamCheck::Strict), "{id} {p}");
|
||||
}
|
||||
let cam = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": 5});
|
||||
let works = |level| cam_rig(level).execute("joint.cam", &cam).is_ok();
|
||||
assert!(works(ParamCheck::Off) && !works(ParamCheck::Strict), "{cam}");
|
||||
}
|
||||
|
||||
@@ -101,7 +101,6 @@ pub mod render_stage;
|
||||
pub mod render_surface_resources;
|
||||
pub mod render_ui;
|
||||
pub mod section;
|
||||
pub mod select;
|
||||
pub mod sheet;
|
||||
pub mod silhouette;
|
||||
pub mod sketch;
|
||||
@@ -111,11 +110,8 @@ pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
pub mod sketch_ends;
|
||||
pub mod sketch_equation;
|
||||
pub mod sketch_import;
|
||||
pub mod sketch_inspect;
|
||||
pub mod sketch_modify;
|
||||
pub mod sketch_project;
|
||||
pub mod sketch_session;
|
||||
pub mod sketch_shapes;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
//! Contracts and result types for the selection commands in `cmd/edit.rs`: `select.set` and
|
||||
//! `select.clear`.
|
||||
//!
|
||||
//! `select.set` reads `items` and `add` from the raw JSON. Each item reads as the handler reads
|
||||
//! it ([`read_item`] is the handler's own first step, so a call the handler takes is a call the
|
||||
//! contract takes): a face or edge with an `index` or a `point` (not both) is read as a partial
|
||||
//! pick, which the handler completes against the design, and anything else is read as a
|
||||
//! [`Sel`]. Whether a body, face or edge is there, and how many items are too many, stay the
|
||||
//! handler's to check. 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 handler item by item against the design).
|
||||
|
||||
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 solidcore_record::Vec3;
|
||||
|
||||
use super::check::whole;
|
||||
use crate::Sel;
|
||||
|
||||
/// A face or edge picked by an index or a point only, which the handler completes.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PartialPick {
|
||||
pub body: String,
|
||||
pub index: Option<usize>,
|
||||
pub point: Option<Vec3>,
|
||||
}
|
||||
|
||||
/// One item of `select.set`, as the handler first reads it.
|
||||
#[derive(Debug)]
|
||||
pub enum SelectItem {
|
||||
/// A complete selection item.
|
||||
Exact(Sel),
|
||||
/// A face (`face` true) or edge given by its body and one of an index or a point.
|
||||
Pick { face: bool, pick: PartialPick },
|
||||
}
|
||||
|
||||
/// Read one item of `select.set`; the error is the handler's message. A face or edge that has
|
||||
/// both `index` and `point` (even `null`) is read as a [`Sel`], so is any other type; a face or
|
||||
/// edge with at most one of them is read as a [`PartialPick`], which reads only `body`, `index`
|
||||
/// and `point`.
|
||||
pub(crate) fn read_item(value: Value) -> Result<SelectItem, String> {
|
||||
let kind = value.get("type").and_then(Value::as_str);
|
||||
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
|
||||
return serde_json::from_value(value).map(SelectItem::Exact).map_err(|e| e.to_string());
|
||||
}
|
||||
let face = kind == Some("face");
|
||||
serde_json::from_value(value).map(|pick| SelectItem::Pick { face, pick }).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SelectItem {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
read_item(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in world mm.
|
||||
fn point() -> Value {
|
||||
serde_json::json!({"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3})
|
||||
}
|
||||
|
||||
impl JsonSchema for SelectItem {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SelectItem".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let text = || serde_json::json!({"type": "string"});
|
||||
let count = || serde_json::json!({"type": "integer", "minimum": 0});
|
||||
let variant = |kind: &str, what: &str, properties: Value, required: &[&str]| {
|
||||
let required: Vec<&str> = std::iter::once("type").chain(required.iter().copied()).collect();
|
||||
let mut all = serde_json::json!({"type": {"const": kind}});
|
||||
if let (Some(all), Some(properties)) = (all.as_object_mut(), properties.as_object()) {
|
||||
all.extend(properties.clone());
|
||||
}
|
||||
serde_json::json!({"description": what, "type": "object", "properties": all, "required": required})
|
||||
};
|
||||
let pick = |kind: &str, what: &str| {
|
||||
let mut v = variant(kind, what, serde_json::json!({"body": text(), "index": count(), "point": point()}), &["body"]);
|
||||
v["anyOf"] = serde_json::json!([{"required": ["index"]}, {"required": ["point"]}]);
|
||||
v
|
||||
};
|
||||
json_schema!({
|
||||
"description": "What to select, by `type`. Keys a type doesn't read are ignored. A face or edge needs `body` and an `index` or a `point` (both are accepted); the engine finds the other.",
|
||||
"anyOf": [
|
||||
variant("body", "A body, by name.", serde_json::json!({"name": text()}), &["name"]),
|
||||
pick("face", "A face of a body, by index or by a point on it (world mm, within Measure's pick tolerance)."),
|
||||
pick("edge", "An edge of a body, by index or by a point on it (world mm, within Measure's pick tolerance)."),
|
||||
variant("vertex", "A vertex of a body, at a point.", serde_json::json!({"body": text(), "point": point()}), &["body", "point"]),
|
||||
variant("occurrence", "A placed component, by its path of occurrence ids.", serde_json::json!({"path": {"type": "array", "items": count()}}), &["path"]),
|
||||
variant("feature", "A timeline feature, by id.", serde_json::json!({"id": count()}), &["id"]),
|
||||
variant("sketch_curve", "A curve of the active sketch, by id; `sketch` names the sketch it is in.", serde_json::json!({"id": text(), "sketch": {"type": ["integer", "null"], "minimum": 0}}), &["id"]),
|
||||
variant("sketch_point", "A point of the active sketch, by id.", serde_json::json!({"id": text()}), &["id"]),
|
||||
variant("sketch_midpoint", "The midpoint of a line of the active sketch, by id.", serde_json::json!({"id": text()}), &["id"]),
|
||||
variant("external_sketch_curve", "A curve of another sketch.", serde_json::json!({"sketch": count(), "id": text()}), &["sketch", "id"]),
|
||||
variant("external_sketch_point", "A point of another sketch.", serde_json::json!({"sketch": count(), "id": text()}), &["sketch", "id"]),
|
||||
variant("sketch_constraint", "A constraint or dimension of the active sketch, by id.", serde_json::json!({"id": text()}), &["id"]),
|
||||
variant("profile", "A closed profile of a sketch, by sketch id and index.", serde_json::json!({"sketch": count(), "index": count()}), &["sketch", "index"]),
|
||||
variant("plane", "An origin plane (XY, XZ, YZ) or a construction plane, by name.", serde_json::json!({"name": text()}), &["name"]),
|
||||
variant("construction_point", "A construction point, by feature name.", serde_json::json!({"name": text()}), &["name"]),
|
||||
variant("axis", "An origin axis (X, Y, Z) or a construction axis, by name.", serde_json::json!({"name": text()}), &["name"])
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Make what is selected the given items, or add them to it. Nothing changes when an item is
|
||||
/// refused. A body, face or edge must exist in the design; a body or occurrence that isn't there
|
||||
/// is refused. At most 10,000 items. Not an undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SelectSetParams {
|
||||
/// What to select. Default: none (the selection is cleared, or left as it is with `add`).
|
||||
/// A face or edge may give just an `index` or just a `point`; the engine fills in the other.
|
||||
#[serde(default)]
|
||||
pub items: Option<Vec<SelectItem>>,
|
||||
/// Add to the current selection instead of replacing it. Items already selected are not
|
||||
/// added twice. Default: false.
|
||||
#[serde(default)]
|
||||
pub add: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `select.set` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SelectSetResult {
|
||||
/// How many items are selected now.
|
||||
pub selected: 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>,
|
||||
}
|
||||
|
||||
/// `select.clear` takes no parameters: it deselects everything. Not an undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SelectClearParams {}
|
||||
|
||||
/// What `select.clear` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SelectClearResult {
|
||||
/// How many items are selected now: always 0.
|
||||
pub selected: 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 = "select_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,223 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{SelectClearParams, SelectClearResult, SelectItem, SelectSetParams, SelectSetResult, read_item};
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
use crate::{Sel, Session};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<SelectSetParams>(), &["add", "items"]);
|
||||
assert_keys(input_schema_for::<SelectClearParams>(), &[]);
|
||||
}
|
||||
|
||||
/// Strict gives the handler's message for the items it refuses on their own.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("select.set", json!({"items": [{"type": "bogus"}]})),
|
||||
("select.set", json!({"items": [{}]})),
|
||||
("select.set", json!({"items": [7]})),
|
||||
("select.set", json!({"items": [{"type": "body"}]})),
|
||||
("select.set", json!({"items": [{"type": "body", "name": 5}]})),
|
||||
("select.set", json!({"items": [{"type": "feature", "id": -1}]})),
|
||||
("select.set", json!({"items": [{"type": "occurrence", "path": "1"}]})),
|
||||
("select.set", json!({"items": [{"type": "face"}]})),
|
||||
("select.set", json!({"items": [{"type": "face", "body": "Box1"}]})),
|
||||
("select.set", json!({"items": [{"type": "face", "index": 0}]})),
|
||||
("select.set", json!({"items": [{"type": "edge", "body": "Box1", "index": -1}]})),
|
||||
("select.set", json!({"items": [{"type": "edge", "body": "Box1", "point": [1, 2]}]})),
|
||||
("select.set", json!({"items": [{"type": "edge", "body": "Box1", "index": 0, "point": null}]})),
|
||||
("select.set", json!({"items": [{"type": "face", "body": "Box1", "index": null, "point": [1, 2, 3]}]})),
|
||||
("select.set", json!({"items": [{"type": "body", "name": "Missing"}]})),
|
||||
("select.set", json!({"items": [{"type": "face", "body": "Missing", "point": [1, 2, 3]}]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The item type reads each item as the handler does: face and edge items with one of `index`
|
||||
/// and `point` are partial picks, everything else a `Sel`.
|
||||
#[test]
|
||||
fn the_item_type_reads_like_the_handler() {
|
||||
let item = |v: Value| serde_json::from_value::<SelectItem>(v);
|
||||
assert!(matches!(item(json!({"type": "body", "name": "A", "colour": 1})), Ok(SelectItem::Exact(_))));
|
||||
assert!(matches!(item(json!({"type": "face", "body": "A", "index": 2})), Ok(SelectItem::Pick { face: true, .. })));
|
||||
assert!(matches!(item(json!({"type": "edge", "body": "A", "point": [1, 2, 3], "extra": 1})), Ok(SelectItem::Pick { face: false, .. })));
|
||||
assert!(matches!(item(json!({"type": "edge", "body": "A", "point": null})), Ok(SelectItem::Pick { .. })));
|
||||
// Neither: the handler refuses it against the design, after it has looked up the body.
|
||||
assert!(matches!(item(json!({"type": "face", "body": "A"})), Ok(SelectItem::Pick { .. })));
|
||||
assert!(matches!(item(json!({"type": "face", "body": "A", "index": 2, "point": [1, 2, 3]})), Ok(SelectItem::Exact(_))));
|
||||
assert!(matches!(item(json!({"type": "sketch_curve", "id": "c1"})), Ok(SelectItem::Exact(_))));
|
||||
assert!(matches!(item(json!({"type": "profile", "sketch": 3, "index": 0})), Ok(SelectItem::Exact(_))));
|
||||
// Both given: read whole, as a Sel, so a null refuses.
|
||||
assert!(item(json!({"type": "face", "body": "A", "index": 2, "point": null})).is_err());
|
||||
assert!(item(json!({"type": "face", "index": 2})).is_err());
|
||||
assert!(item(json!({"type": "face", "body": "A", "index": "2"})).is_err());
|
||||
assert!(item(json!({"type": "nothing"})).is_err() && item(json!(null)).is_err() && item(json!("body")).is_err());
|
||||
// The messages are the handler's (what serde says), whole.
|
||||
for bad in [json!({"type": "nothing"}), json!({"type": "face"}), json!({"type": "body"}), json!(3)] {
|
||||
let handler = read_item(bad.clone()).err().unwrap_or_default();
|
||||
let typed = item(bad.clone()).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(typed.ends_with(&handler), "{bad}: {typed} / {handler}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema takes what the type takes, and the keys the params type reads.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handler_reads() {
|
||||
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}");
|
||||
}
|
||||
};
|
||||
let cases = [
|
||||
(json!({}), true),
|
||||
(json!({"items": []}), true),
|
||||
(json!({"items": null, "add": null}), true),
|
||||
(json!({"items": [{"type": "body", "name": "A"}], "add": true}), true),
|
||||
(json!({"items": [{"type": "face", "body": "A", "index": 1}, {"type": "edge", "body": "A", "point": [1, 2, 3]}]}), true),
|
||||
(json!({"items": [{"type": "face", "body": "A", "index": 1, "point": [1, 2, 3]}]}), true),
|
||||
(json!({"items": [{"type": "plane", "name": "XY"}, {"type": "occurrence", "path": [1, 2]}, {"type": "feature", "id": 4}]}), true),
|
||||
(json!({"items": [{"type": "sketch_curve", "id": "c1", "sketch": 2}, {"type": "profile", "sketch": 2, "index": 0}]}), true),
|
||||
(json!({"items": [{"type": "face", "body": "A"}]}), false),
|
||||
(json!({"items": [{"type": "bogus"}]}), false),
|
||||
(json!({"items": [{"type": "body"}]}), false),
|
||||
(json!({"items": "A"}), false),
|
||||
(json!({"add": "yes"}), false),
|
||||
(json!({"item": []}), false),
|
||||
];
|
||||
check(input_schema_for::<SelectSetParams>(), &cases);
|
||||
check(input_schema_for::<SelectClearParams>(), &[(json!({}), true), (json!({"add": true}), false)]);
|
||||
// The schema differs from the type in two cases: a face or edge with neither `index` nor
|
||||
// `point`, which the type leaves to the handler (its message depends on the design), and an
|
||||
// unknown key, which the key check reports, not the type.
|
||||
let apart = [json!({"items": [{"type": "face", "body": "A"}]}), json!({"item": []})];
|
||||
for (p, takes) in cases.iter().filter(|(p, _)| !apart.contains(p)) {
|
||||
assert_eq!(serde_json::from_value::<SelectSetParams>(p.clone()).is_ok(), *takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Real calls of both commands in Warn: no warnings, and the replies match the output schemas
|
||||
/// and are what the handlers have always returned. A misspelt key gets its warning and the
|
||||
/// reply still matches; strict refuses it.
|
||||
#[test]
|
||||
fn replies_match_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": "Box1"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "body_name": "Box2"})).unwrap();
|
||||
let matches = |schema: schemars::Schema, r: &Value| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
};
|
||||
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
|
||||
};
|
||||
let set = |s: &mut Session, p: Value| {
|
||||
let r = run(s, "select.set", p);
|
||||
matches(output_schema_for::<SelectSetResult>(), &r);
|
||||
r
|
||||
};
|
||||
assert_eq!(set(&mut s, json!({})), json!({"selected": 0}));
|
||||
assert_eq!(set(&mut s, json!({"items": [{"type": "body", "name": "Box1"}]})), json!({"selected": 1}));
|
||||
assert_eq!(set(&mut s, json!({"items": [{"type": "body", "name": "Box2"}], "add": true})), json!({"selected": 2}));
|
||||
assert_eq!(set(&mut s, json!({"items": [{"type": "body", "name": "Box2"}], "add": true})), json!({"selected": 2}));
|
||||
assert_eq!(set(&mut s, json!({"items": null})), json!({"selected": 0}));
|
||||
assert_eq!(set(&mut s, json!({"items": [{"type": "face", "body": "Box1", "index": 0}], "add": null})), json!({"selected": 1}));
|
||||
assert_eq!(
|
||||
set(&mut s, json!({"items": [{"type": "face", "body": "Box1", "point": [5, 5, 10]}, {"type": "edge", "body": "Box1", "index": 0}]})),
|
||||
json!({"selected": 2})
|
||||
);
|
||||
let complete = s.selection[0].clone();
|
||||
assert_eq!(set(&mut s, json!({"items": [complete, {"type": "plane", "name": "XY", "colour": "red"}], "add": true})), json!({"selected": 3}));
|
||||
let r = run(&mut s, "select.clear", json!({}));
|
||||
matches(output_schema_for::<SelectClearResult>(), &r);
|
||||
assert_eq!(r, json!({"selected": 0}));
|
||||
assert!(s.selection.is_empty());
|
||||
// A misspelt key runs in warn and says so; the reply still matches.
|
||||
let p = json!({"items": [{"type": "body", "name": "Box1"}], "ad": true});
|
||||
let r = s.execute("select.set", &p).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
assert_eq!(r["selected"], 1);
|
||||
matches(output_schema_for::<SelectSetResult>(), &r);
|
||||
let r = s.execute("select.clear", &json!({"all": true})).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
matches(output_schema_for::<SelectClearResult>(), &r);
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("select.set", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`ad`"), "{error}");
|
||||
let error = s.execute("select.clear", &json!({"all": true})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`all`"), "{error}");
|
||||
}
|
||||
|
||||
/// One item of every `Sel` variant, as `Sel` serializes it (and the forms it also reads): each is
|
||||
/// a `Sel`, is read by the item type, matches the item schema, and is accepted in strict.
|
||||
#[test]
|
||||
fn every_selection_kind_is_taken() {
|
||||
let items = [
|
||||
json!({"type": "occurrence", "path": [1, 2]}),
|
||||
json!({"type": "body", "name": "A"}),
|
||||
json!({"type": "edge", "body": "A", "index": 1, "point": [1, 2, 3]}),
|
||||
json!({"type": "face", "body": "A", "index": 1, "point": [1, 2, 3]}),
|
||||
json!({"type": "feature", "id": 4}),
|
||||
json!({"type": "sketch_curve", "id": "c1"}),
|
||||
json!({"type": "sketch_curve", "id": "c1", "sketch": 2}),
|
||||
json!({"type": "sketch_curve", "id": "c1", "sketch": null}),
|
||||
json!({"type": "sketch_point", "id": "p1"}),
|
||||
json!({"type": "sketch_midpoint", "id": "l1"}),
|
||||
json!({"type": "external_sketch_curve", "sketch": 2, "id": "c1"}),
|
||||
json!({"type": "external_sketch_point", "sketch": 2, "id": "p1"}),
|
||||
json!({"type": "sketch_constraint", "id": "k1"}),
|
||||
json!({"type": "profile", "sketch": 2, "index": 0}),
|
||||
json!({"type": "plane", "name": "XY"}),
|
||||
json!({"type": "construction_point", "name": "P"}),
|
||||
json!({"type": "axis", "name": "X"}),
|
||||
json!({"type": "vertex", "body": "A", "point": [0, 0, 0]}),
|
||||
];
|
||||
let schema = input_schema_for::<SelectSetParams>();
|
||||
let schema = schema.as_value();
|
||||
// `conforms` doesn't check `const`, so each item is also matched to the branch of its `type`.
|
||||
let branches = schema["$defs"]["SelectItem"]["anyOf"].as_array().cloned().unwrap_or_default();
|
||||
assert!(!branches.is_empty(), "no SelectItem branches in {schema}");
|
||||
// Every branch is one of the kinds listed here.
|
||||
for b in &branches {
|
||||
assert!(items.iter().any(|i| i["type"] == b["properties"]["type"]["const"]), "{b} isn't tried");
|
||||
}
|
||||
for item in items {
|
||||
assert!(serde_json::from_value::<Sel>(item.clone()).is_ok(), "{item} isn't a Sel");
|
||||
assert!(serde_json::from_value::<SelectItem>(item.clone()).is_ok(), "{item}");
|
||||
let kind = item["type"].as_str().unwrap_or_default();
|
||||
let branch = branches.iter().find(|b| b["properties"]["type"]["const"] == kind);
|
||||
assert!(branch.is_some_and(|b| conforms(schema, b, &item)), "no `{kind}` branch takes {item}");
|
||||
let p = json!({"items": [item]});
|
||||
assert!(conforms(schema, schema, &p), "{p} doesn't match {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid calls are not refused in Strict, and a wrong-typed `items` or `add`, which the handler
|
||||
/// reads as if left out, is reported in Warn and refused in Strict.
|
||||
#[test]
|
||||
fn strict_takes_valid_calls_and_warn_reports_wrong_types() {
|
||||
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box1"})).unwrap();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"items": null, "add": null}),
|
||||
json!({"items": [{"type": "body", "name": "Box1"}], "add": true}),
|
||||
json!({"items": [{"type": "face", "body": "Box1", "index": 0}]}),
|
||||
json!({"items": [{"type": "edge", "body": "Box1", "point": [0, 0, 0]}], "add": true}),
|
||||
json!({"items": [{"type": "plane", "name": "XY", "colour": "red"}]}),
|
||||
] {
|
||||
let r = s.execute("select.set", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
}
|
||||
assert_eq!(s.execute("select.clear", &json!({})).unwrap(), json!({"selected": 0}));
|
||||
for p in [json!({"items": "Box1"}), json!({"add": "yes"})] {
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let r = s.execute("select.set", &p).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{p}: {r}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
assert!(s.execute("select.set", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Contracts for sheet metal feature, rule, flat pattern and bend table commands in
|
||||
//! `cmd/sheet.rs`. Feature commands return [`FeatureResult`](super::features::FeatureResult); the
|
||||
//! others have their own replies.
|
||||
//! Contracts for sheet metal feature and rule commands in `cmd/sheet.rs`. Feature commands
|
||||
//! return [`FeatureResult`](super::features::FeatureResult); rule commands have their own replies.
|
||||
//!
|
||||
//! A sheet metal rule sets the sheet's thickness, K-factor, bend radius and reliefs: the
|
||||
//! commands refuse those keys with a pointer to `sheet.manage_rules`, so they are no keys of
|
||||
@@ -753,301 +752,6 @@ pub enum ManageRulesResult {
|
||||
},
|
||||
}
|
||||
|
||||
/// A point in mm on a sheet edge, `[x, y, z]` or `{x, y, z}`, with a path flange's message for
|
||||
/// `edge`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SheetEdgePoint(pub Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for SheetEdgePoint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::params::vec3(&Value::deserialize(d)?).map(SheetEdgePoint).ok_or_else(|| D::Error::custom("must be a point [x, y, z] on a sheet edge"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SheetEdgePoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SheetEdgePoint".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
Point3::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a path flange: flanges along an open chain of sketch lines drawn on a plane across a
|
||||
/// sheet edge, from the edge out. Each line is one flange as long as the line (to the outer face
|
||||
/// from the virtual sharp) and each corner one bend, turning the way the lines turn. One
|
||||
/// feature, one body. `participants` changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PathFlangeParams {
|
||||
/// A point on a top or bottom edge of the sheet, where the path starts.
|
||||
pub edge: SheetEdgePoint,
|
||||
/// The sketch holding the path, by id or name.
|
||||
pub sketch: SketchRef,
|
||||
/// The path's lines: 1 to 64 lines of `sketch`, by id, in one open chain, in any order. Arcs
|
||||
/// are refused.
|
||||
pub curves: StringList,
|
||||
/// The inner bend radius. Default: the sheet's rule's.
|
||||
#[serde(default)]
|
||||
pub radius: Option<LengthExpr>,
|
||||
/// The sheet metal body. Default: the sheet with an edge nearest `edge`.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Read a sheet's flat pattern: its size, outline, cut-outs, pockets, bends and perforations, in
|
||||
/// flat coordinates (mm, on the base panel's bottom face). Refused while a feature that is not
|
||||
/// sheet metal (a fillet, a join) has changed the body.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct FlatPatternParams {
|
||||
/// The sheet metal body. Default: the last sheet.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// Export a sheet's bend table: a row per bend, in mm and degrees, with the rule and the bend's
|
||||
/// centre line in flat coordinates. A bend that cut-outs split has a row for each piece.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct BendTableParams {
|
||||
/// The sheet metal body. Default: the last sheet.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// csv (a header and a row per bend, quoted body and rule names, CRLF line ends) or json (the
|
||||
/// flat pattern that `sheet.flat_pattern` returns, as text). Default: csv.
|
||||
#[serde(default)]
|
||||
pub format: Option<BendTableFormatParam>,
|
||||
/// The file to write. Default (or blank): no file; the reply holds the table's text.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// The form of a bend table.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BendTableFormat {
|
||||
Csv,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// A bend table's `format`, in any case (read by `sheet::bend_table_format`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BendTableFormatParam(pub BendTableFormat);
|
||||
|
||||
impl<'de> Deserialize<'de> for BendTableFormatParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
v.as_str().and_then(crate::cmd::bend_table_format).map(BendTableFormatParam).ok_or_else(|| D::Error::custom("must be csv or json"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for BendTableFormatParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"BendTableFormat".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "csv or json, in any case.", "type": "string", "pattern": "^([Cc][Ss][Vv]|[Jj][Ss][Oo][Nn])$"})
|
||||
}
|
||||
}
|
||||
|
||||
/// A point `[x, y]` in flat coordinates, in mm.
|
||||
pub type FlatPoint = [f64; 2];
|
||||
|
||||
/// One curve of a cut-out's exact shape.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlatSegment {
|
||||
/// A straight line from its first point to its second.
|
||||
Line([FlatPoint; 2]),
|
||||
/// A circular arc.
|
||||
Arc {
|
||||
center: FlatPoint,
|
||||
radius: f64,
|
||||
/// Where the arc starts, in degrees counterclockwise from +X.
|
||||
start_deg: f64,
|
||||
/// How far it turns, in degrees: positive counterclockwise.
|
||||
sweep_deg: f64,
|
||||
},
|
||||
/// Any other curve (an ellipse, a spline), as points within 0.001 mm of it.
|
||||
Polyline(Vec<FlatPoint>),
|
||||
}
|
||||
|
||||
/// A circle's centre and radius, in mm.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
pub struct FlatCircle {
|
||||
pub center: FlatPoint,
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
/// A cut-out's exact shape: a whole circle, or its curves in order.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum FlatShape {
|
||||
Circle { circle: FlatCircle },
|
||||
Segments { segments: Vec<FlatSegment> },
|
||||
}
|
||||
|
||||
/// Which side of the sheet a pocket is machined from.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PocketSide {
|
||||
/// From the flat's bottom face.
|
||||
Bottom,
|
||||
/// From its top face.
|
||||
Top,
|
||||
/// Inside the sheet, open to neither face.
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// A pocket: material machined to a depth, not through the sheet.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
pub struct FlatPocket {
|
||||
/// Its outer contour, as points within 0.001 mm.
|
||||
pub outline: Vec<FlatPoint>,
|
||||
/// Islands inside it that keep their material, as points within 0.001 mm.
|
||||
pub cutouts: Vec<Vec<FlatPoint>>,
|
||||
/// The same islands exactly, in the same order.
|
||||
pub cutout_shapes: Vec<FlatShape>,
|
||||
pub side: PocketSide,
|
||||
/// `z1_mm` less `z0_mm`.
|
||||
pub depth_mm: f64,
|
||||
/// The pocket's bottom and top, measured from the flat's bottom face, in mm.
|
||||
pub z0_mm: f64,
|
||||
pub z1_mm: f64,
|
||||
}
|
||||
|
||||
/// Which way a bend folds: up toward the flat's top face (+z), down toward its bottom.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BendDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// A bend of the flat pattern.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
pub struct FlatBend {
|
||||
/// The bend's number, from 1. The stretches that cut-outs leave of one bend each have a row,
|
||||
/// with the same number.
|
||||
pub bend: usize,
|
||||
/// The bend angle, in degrees.
|
||||
pub angle_deg: f64,
|
||||
/// The angle left between the bend's sides, in degrees: 180 less `angle_deg`.
|
||||
pub included_angle_deg: f64,
|
||||
/// The inner bend radius, in mm.
|
||||
pub radius: f64,
|
||||
pub direction: BendDirection,
|
||||
/// The bend allowance (the bend zone's width in the flat), in mm.
|
||||
pub allowance: f64,
|
||||
/// The length of this stretch of the bend's centre line, in mm.
|
||||
pub length: f64,
|
||||
/// The bend's centre line, from `start` to `end`.
|
||||
pub start: FlatPoint,
|
||||
pub end: FlatPoint,
|
||||
}
|
||||
|
||||
/// How a perforation field lays out its holes.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlatPerforationPattern {
|
||||
/// Rows offset for a 60° pattern.
|
||||
Staggered,
|
||||
Square,
|
||||
}
|
||||
|
||||
/// A perforation field: round holes in the flat pattern, not cut into the solid.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
pub struct FlatPerforation {
|
||||
/// The `sheet.perforate` feature's id.
|
||||
pub feature: u64,
|
||||
/// The holes' diameter, the distance between their centres and how far they stay clear of
|
||||
/// edges, cut-outs and bends, in mm.
|
||||
pub diameter: f64,
|
||||
pub pitch: f64,
|
||||
pub margin: f64,
|
||||
pub pattern: FlatPerforationPattern,
|
||||
/// How many holes the field has.
|
||||
pub count: usize,
|
||||
/// The holes' area together, in mm².
|
||||
pub open_area_mm2: f64,
|
||||
}
|
||||
|
||||
/// A hole drilled along a panel into its rim, drilled after cutting and not in the outline.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
pub struct FlatBore {
|
||||
/// Where it starts, in flat coordinates with z up from the bottom face, in mm.
|
||||
pub start: [f64; 3],
|
||||
/// Its direction, a unit vector.
|
||||
pub axis: [f64; 3],
|
||||
/// Its diameter and length, in mm.
|
||||
pub diameter: f64,
|
||||
pub length: f64,
|
||||
}
|
||||
|
||||
/// What `sheet.flat_pattern` returns: the sheet's flat pattern in flat coordinates (mm, on the
|
||||
/// base panel's bottom face). The `json` form of `sheet.export_bend_table` is this as text.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
pub struct FlatPatternResult {
|
||||
/// The sheet metal body.
|
||||
pub body: String,
|
||||
/// Its sheet metal rule, by name.
|
||||
pub rule: String,
|
||||
/// The sheet's thickness, in mm.
|
||||
pub thickness: f64,
|
||||
/// The K-factor its bends unfold with.
|
||||
pub k_factor: f64,
|
||||
/// The flat pattern's width and height (its bounding box) and its thickness, in mm.
|
||||
pub flat_size_mm: [f64; 3],
|
||||
/// The outer contours, one per piece, as points within 0.001 mm.
|
||||
pub outline: Vec<Vec<FlatPoint>>,
|
||||
/// The cut-outs through the sheet, as points within 0.001 mm.
|
||||
pub cutouts: Vec<Vec<FlatPoint>>,
|
||||
/// The same cut-outs exactly, in the same order.
|
||||
pub cutout_shapes: Vec<FlatShape>,
|
||||
pub pockets: Vec<FlatPocket>,
|
||||
pub bends: Vec<FlatBend>,
|
||||
/// Whether the sheet is unfolded now.
|
||||
pub unfolded: bool,
|
||||
pub perforations: Vec<FlatPerforation>,
|
||||
/// Each bend zone's bounding box, `[min x, max x, min y, max y]` in mm.
|
||||
pub bend_zones: Vec<[f64; 4]>,
|
||||
/// Left out when there are none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub bores: Vec<FlatBore>,
|
||||
/// 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 `sheet.export_bend_table` returns: the file it wrote, or the table's text.
|
||||
#[derive(Debug, serde::Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum BendTableResult {
|
||||
/// The table went to `path`.
|
||||
Written {
|
||||
path: String,
|
||||
format: BendTableFormat,
|
||||
/// How many rows the table has: one per bend, or per stretch of a bend that cut-outs split.
|
||||
bends: usize,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
param_warnings: Vec<String>,
|
||||
},
|
||||
/// No `path`: the table's text.
|
||||
Text {
|
||||
format: BendTableFormat,
|
||||
text: String,
|
||||
/// How many rows the table has: one per bend, or per stretch of a bend that cut-outs split.
|
||||
bends: usize,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
param_warnings: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,9 +2,8 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
BendTableFormatParam, BendTableParams, BendTableResult, ConvertParams, FixedPoint, FlangeEdges, FlangeParams, FlangePosition, FlangeTypeParam,
|
||||
FlatPatternParams, FlatPatternResult, FoldParams, FoldPoints, FoldPosition, HeightFromParam, HemParams, PathFlangeParams, PerforateParams,
|
||||
PerforationPattern, ReliefShapeParam, SheetEdgePoint, SheetPoints, ThenSteps, UnfoldParams,
|
||||
ConvertParams, FixedPoint, FlangeEdges, FlangeParams, FlangePosition, FlangeTypeParam, FoldParams, FoldPoints, FoldPosition, HeightFromParam,
|
||||
HemParams, PerforateParams, PerforationPattern, ReliefShapeParam, SheetPoints, ThenSteps, UnfoldParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
@@ -70,9 +69,6 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<FoldParams>(), &keys(&["angle", "body", "curve", "fixed", "flip", "points", "position", "radius", "sketch"]));
|
||||
assert_keys(input_schema_for::<UnfoldParams>(), &keys(&["body"]));
|
||||
assert_keys(input_schema_for::<ConvertParams>(), &keys(&["bends", "body", "face", "rule"]));
|
||||
assert_keys(input_schema_for::<PathFlangeParams>(), &keys(&["body", "curves", "edge", "radius", "sketch"]));
|
||||
assert_keys(input_schema_for::<FlatPatternParams>(), &["body"]);
|
||||
assert_keys(input_schema_for::<BendTableParams>(), &["body", "format", "path"]);
|
||||
}
|
||||
|
||||
fn parse<T: DeserializeOwned>(v: Value) -> Option<T> {
|
||||
@@ -161,7 +157,6 @@ fn the_value_types_match_the_readers() {
|
||||
}
|
||||
for (v, ok) in [(json!([1, 2, 3]), true), (json!({"x": 1, "y": 2, "z": 3}), true), (json!([1, 2]), false), (json!(null), false)] {
|
||||
assert_eq!(takes::<FixedPoint>(&v), ok, "{v}");
|
||||
assert_eq!(takes::<SheetEdgePoint>(&v), ok, "{v}");
|
||||
}
|
||||
for (v, ok) in [
|
||||
(json!("staggered"), true),
|
||||
@@ -190,17 +185,6 @@ fn the_value_types_match_the_readers() {
|
||||
words(&["outer", " Inner ", "TANGENT"], &[json!("sharp"), json!(""), json!(5)], &|v| parse::<HeightFromParam>(v).is_some());
|
||||
words(&["centerline", "Start", "END", "mould", "Mold"], &[json!("center"), json!("side"), json!(null)], &|v| parse::<FoldPosition>(v).is_some());
|
||||
words(&["notch", " Round ", "rectangular", "TEAR"], &[json!("oval"), json!(""), json!(5)], &|v| parse::<ReliefShapeParam>(v).is_some());
|
||||
// A bend table's format: what the handler reads, in any case but without blanks.
|
||||
let formats = ["csv", "CSV", "Json", "jSoN"];
|
||||
words(&formats, &[json!(" csv"), json!("xml"), json!(""), json!(5), json!(null)], &|v| parse::<BendTableFormatParam>(v).is_some());
|
||||
for f in formats {
|
||||
assert!(crate::cmd::bend_table_format(f).is_some(), "{f}");
|
||||
}
|
||||
for f in [" csv", "csv ", "xml", "", "csv"] {
|
||||
assert!(crate::cmd::bend_table_format(f).is_none(), "{f}");
|
||||
}
|
||||
let format = input_schema_for::<BendTableFormatParam>();
|
||||
assert_eq!(format.as_value()["pattern"], "^([Cc][Ss][Vv]|[Jj][Ss][Oo][Nn])$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -234,14 +218,6 @@ fn strict_keeps_todays_errors() {
|
||||
("sheet.fold", json!({"points": [[0, 0, 0], [10, 0, 0]], "fixed": null})),
|
||||
("sheet.fold", json!({"sketch": true, "curve": "l1"})),
|
||||
("sheet.convert", json!({"body": "Nothing", "face": [0, 0, 0]})),
|
||||
("sheet.path_flange", json!({"edge": [1, 2], "sketch": "Path", "curves": ["p1"]})),
|
||||
("sheet.path_flange", json!({"edge": null, "sketch": "Path", "curves": ["p1"]})),
|
||||
("sheet.path_flange", json!({"edge": [1, 0, 20], "sketch": true, "curves": ["p1"]})),
|
||||
("sheet.flat_pattern", json!({})),
|
||||
("sheet.flat_pattern", json!({"body": "Nothing"})),
|
||||
("sheet.export_bend_table", json!({"format": "xml"})),
|
||||
("sheet.export_bend_table", json!({"format": " csv", "body": "Nothing"})),
|
||||
("sheet.export_bend_table", json!({"format": "JSON", "body": "Nothing"})),
|
||||
]);
|
||||
// Calls that get past the handler's first checks only on a sheet.
|
||||
for (id, p) in [
|
||||
@@ -251,10 +227,6 @@ fn strict_keeps_todays_errors() {
|
||||
("sheet.convert", json!({"body": "Plate", "face": [50, 130, 2.5], "bends": [[1, 2]]})),
|
||||
("sheet.convert", json!({"body": "Plate", "face": ""})),
|
||||
("sheet.convert", json!({"body": "Plate", "face": true})),
|
||||
("sheet.path_flange", json!({"edge": [50, 0, 2.5], "sketch": "Base", "curves": []})),
|
||||
("sheet.path_flange", json!({"edge": [50, 0, 2.5], "sketch": "Base", "curves": vec!["l1"; 65]})),
|
||||
("sheet.path_flange", json!({"edge": [50, 0, 2.5], "sketch": "Base", "curves": ["l1"], "radius": "2 deg"})),
|
||||
("sheet.export_bend_table", json!({"format": "yaml"})),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = sheet();
|
||||
@@ -519,171 +491,3 @@ fn rule_warnings_match_the_output_schema_and_strict_does_not_mutate() {
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `id` in `s` and returns its reply, which carries no warnings.
|
||||
fn quiet(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(r.get("param_warnings"), None, "{id} {p}: {r}");
|
||||
r
|
||||
}
|
||||
|
||||
/// The wall bracket of the path flange tests in `cmd/sheet_tests.rs`, in Warn: a 150 × 100 base
|
||||
/// of a 2 mm rule with a 40 mm wall up its edge at x = 0, and the lines `p1` and `p2` of sketch
|
||||
/// `Path` drawn across the wall's end edge (y = 0) at half its height.
|
||||
fn path_bracket() -> Session {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
for (id, p) in [
|
||||
(
|
||||
"sheet.manage_rules",
|
||||
json!({"name": "Demo", "thickness": "2 mm", "bend_radius": "2 mm", "gap": "0.5 mm", "k_factor": "0.4", "active": true}),
|
||||
),
|
||||
("sketch.create", json!({"plane": "XY", "name": "Base"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [150, 100]})),
|
||||
("sketch.finish", json!({})),
|
||||
("sheet.flange", json!({"sketch": "Base"})),
|
||||
("sheet.flange", json!({"edges": [[0, 50, 2]], "height": 40})),
|
||||
("sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Path"})),
|
||||
("sketch.line", json!({"points": [[0, 0], [20, -20], [100, -20]], "ids": ["p1", "p2"]})),
|
||||
("sketch.finish", json!({})),
|
||||
] {
|
||||
quiet(&mut s, id, p);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_flange_replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
// Each form, in Warn, without warnings. The path's two lines make two bends besides the
|
||||
// wall's; one line makes one.
|
||||
for (p, bends) in [
|
||||
(json!({"edge": [1, 0, 20], "sketch": "Path", "curves": ["p1", "p2"]}), 3),
|
||||
(json!({"edge": {"x": 1, "y": 0, "z": 20}, "sketch": "Path", "curves": "p1", "radius": "1 mm", "body": "?", "name": "Flap"}), 2),
|
||||
(json!({"edge": [1, 0, 20], "sketch": "Path", "curves": ["p2", "p1"], "radius": null, "body": null}), 3),
|
||||
] {
|
||||
let mut s = path_bracket();
|
||||
let body = s.model.state().sheets.first().map(|sh| sh.body.clone()).unwrap_or_default();
|
||||
let mut p = p;
|
||||
if p["body"] == "?" {
|
||||
p["body"] = json!(body);
|
||||
}
|
||||
let r = quiet(&mut s, "sheet.path_flange", p.clone());
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
let flat = quiet(&mut s, "sheet.flat_pattern", json!({}));
|
||||
assert_eq!(flat["bends"].as_array().map(Vec::len), Some(bends), "{p}: {flat}");
|
||||
}
|
||||
// A misspelt key: the call runs, and the reply carrying its warning still matches.
|
||||
let mut s = path_bracket();
|
||||
let r = s
|
||||
.execute("sheet.path_flange", &json!({"edge": [1, 0, 20], "sketch": "Path", "curves": ["p1"], "radiuss": 1}))
|
||||
.unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(r.get("param_warnings").is_some(), "{r}");
|
||||
assert!(conforms(schema, schema, &r), "{r}");
|
||||
}
|
||||
|
||||
/// The 100 × 60 sheet with a flange up its edge at y = 0, a 6 mm hole at (30, 30), a 1 mm deep
|
||||
/// pocket from below, a perforation field and a 1.5 mm bore into its rim at x = 100; in Warn.
|
||||
fn worked_sheet() -> Session {
|
||||
let mut s = sheet();
|
||||
for (id, p) in [
|
||||
("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})),
|
||||
("solid.hole", json!({"position": [30, 30, 2.5], "direction": [0, 0, -1], "diameter": 6})),
|
||||
("sketch.create", json!({"plane": "XY", "name": "Pocket"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [60, 20], "p1": [70, 30]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})),
|
||||
("sheet.perforate", json!({"diameter": 2, "pitch": 6})),
|
||||
("solid.hole", json!({"position": [100, 45, 1.25], "direction": [-1, 0, 0], "diameter": 1.5})),
|
||||
] {
|
||||
if let Err(e) = s.execute(id, &p) {
|
||||
panic!("{id}: {e}");
|
||||
}
|
||||
}
|
||||
s.param_check = ParamCheck::Warn;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_pattern_replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<FlatPatternResult>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = worked_sheet();
|
||||
let body = s.model.state().sheets.first().map(|sh| sh.body.clone()).unwrap_or_default();
|
||||
let f = quiet(&mut s, "sheet.flat_pattern", json!({}));
|
||||
assert!(conforms(schema, schema, &f), "{f} doesn't match {schema}");
|
||||
// Every part of the reply is there, from the features above.
|
||||
assert_eq!(f["body"], json!(body));
|
||||
assert_eq!(f["rule"], STEEL);
|
||||
assert_eq!(f["unfolded"], false);
|
||||
assert_eq!(f["bends"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
assert_eq!((&f["bends"][0]["angle_deg"], &f["bends"][0]["included_angle_deg"]), (&json!(90.0), &json!(90.0)), "{f}");
|
||||
let circles: Vec<&Value> = f["cutout_shapes"].as_array().into_iter().flatten().filter_map(|c| c.get("circle")).collect();
|
||||
assert_eq!(circles.len(), 1, "{f}");
|
||||
assert!((circles[0]["radius"].as_f64().unwrap_or_default() - 3.0).abs() < 1e-9, "{f}");
|
||||
assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
assert_eq!((&f["pockets"][0]["side"], &f["pockets"][0]["depth_mm"]), (&json!("bottom"), &json!(1.0)), "{f}");
|
||||
assert!(f["pockets"][0]["cutout_shapes"].is_array(), "{f}");
|
||||
let perforation = &f["perforations"][0];
|
||||
let count = perforation["count"].as_f64().unwrap_or_default();
|
||||
assert!(count > 0.0, "{f}");
|
||||
assert_eq!(perforation["pattern"], "staggered");
|
||||
assert!((perforation["open_area_mm2"].as_f64().unwrap_or_default() - count * std::f64::consts::PI).abs() < 1e-6, "{f}");
|
||||
assert_eq!(f["bores"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
assert!((f["bores"][0]["diameter"].as_f64().unwrap_or_default() - 1.5).abs() < 1e-9, "{f}");
|
||||
assert_eq!(f["bend_zones"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
// The same sheet by name, and with a null body.
|
||||
assert_eq!(quiet(&mut s, "sheet.flat_pattern", json!({"body": body})), f);
|
||||
assert_eq!(quiet(&mut s, "sheet.flat_pattern", json!({"body": null})), f);
|
||||
// Unfolded, and a plain sheet without bores, which the reply leaves out.
|
||||
quiet(&mut s, "sheet.unfold", json!({}));
|
||||
let unfolded = quiet(&mut s, "sheet.flat_pattern", json!({}));
|
||||
assert_eq!(unfolded["unfolded"], true);
|
||||
assert!(conforms(schema, schema, &unfolded), "{unfolded}");
|
||||
let mut plain = sheet();
|
||||
plain.param_check = ParamCheck::Warn;
|
||||
let r = quiet(&mut plain, "sheet.flat_pattern", json!({}));
|
||||
assert!(r.get("bores").is_none(), "{r}");
|
||||
assert!(conforms(schema, schema, &r), "{r}");
|
||||
// A misspelt key: the call runs, and the reply carrying its warning still matches.
|
||||
let r = plain.execute("sheet.flat_pattern", &json!({"bodyy": body})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(r.get("param_warnings").is_some(), "{r}");
|
||||
assert!(conforms(schema, schema, &r), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_table_replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<BendTableResult>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = worked_sheet();
|
||||
let body = s.model.state().sheets.first().map(|sh| sh.body.clone()).unwrap_or_default();
|
||||
let flat = quiet(&mut s, "sheet.flat_pattern", json!({}));
|
||||
let rows = flat["bends"].as_array().map_or(0, Vec::len);
|
||||
let table = |s: &mut Session, p: Value| {
|
||||
let r = quiet(s, "sheet.export_bend_table", p);
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert_eq!(r["bends"], json!(rows), "{r}");
|
||||
r
|
||||
};
|
||||
// csv: a header and a row per bend.
|
||||
let csv = table(&mut s, json!({}));
|
||||
assert_eq!(csv["format"], "csv");
|
||||
let text = csv["text"].as_str().unwrap_or_default().to_string();
|
||||
assert_eq!(text.matches("\r\n").count(), rows + 1, "{text}");
|
||||
assert_eq!(table(&mut s, json!({"format": "CSV", "path": " ", "body": null})), csv);
|
||||
// json: the flat pattern as text.
|
||||
let json = table(&mut s, json!({"format": "Json", "body": body}));
|
||||
assert_eq!(json["format"], "json");
|
||||
assert_eq!(serde_json::from_str::<Value>(json["text"].as_str().unwrap_or_default()).ok(), Some(flat));
|
||||
// To a file: the reply names it, and the file holds the text.
|
||||
let path = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-bend-table-{}.csv", std::process::id()));
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let written = table(&mut s, json!({"format": null, "path": path}));
|
||||
assert_eq!((written.get("text"), &written["path"], &written["format"]), (None, &json!(path), &json!("csv")), "{written}");
|
||||
assert_eq!(std::fs::read_to_string(&path).ok(), Some(text));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
// A misspelt key: the call runs, and the reply carrying its warning still matches.
|
||||
let r = s.execute("sheet.export_bend_table", &json!({"formatt": "json"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(r.get("param_warnings").is_some(), "{r}");
|
||||
assert!(conforms(schema, schema, &r), "{r}");
|
||||
}
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
//! Contracts and result types for the commands in `cmd/sketch_import.rs`: `sketch.insert_dxf`,
|
||||
//! `sketch.insert_svg` and `sketch.export_dxf`.
|
||||
//!
|
||||
//! 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 file can be read or written, a sketch
|
||||
//! exists or a drawing has anything to import stays the handler's to check. 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).
|
||||
//!
|
||||
//! Two insert keys are read only in some cases, and the contract reads them in the same cases:
|
||||
//! `path` only when `text` is not given, and of `width`, `height` and `scale` only the first one
|
||||
//! given, in that order (a `height` next to a `width`, or a `scale` next to either, is never read).
|
||||
//! A value of the wrong type that the handler reads as if the key were left out (`text: 5` next
|
||||
//! to a good `path`, a text for `scale`) is refused by the contract, as schemas.md has it: that
|
||||
//! difference is what warn and strict report.
|
||||
//! `plane` and `name` are read only when no sketch is being edited, which the check can't see
|
||||
//! (it has no session), so they take any value.
|
||||
|
||||
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 solidcraft_io::MAX_DRAWING_BYTES;
|
||||
|
||||
use super::check::{located, whole};
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{Point2, not_blank_pattern};
|
||||
|
||||
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
|
||||
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// The keys the two insert commands read. See [`InsertDxfParams`] and [`InsertSvgParams`], which
|
||||
/// leave `path`, `height` and `scale` unread in the cases the handler does.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = source_required)]
|
||||
pub struct InsertDrawingKeys {
|
||||
/// The drawing itself, as text. Wins over `path`. At most 16 MiB (`MAX_DRAWING_BYTES`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<DrawingText>,
|
||||
/// The drawing file to read (not blank, fewer than 4096 UTF-8 bytes). Used when `text` is
|
||||
/// not given; ignored when it is.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "path_schema")]
|
||||
pub path: Option<DrawingPath>,
|
||||
/// An offset, in mm, [x, y]. Default: [0, 0]. With `align` "origin" it is where the file's
|
||||
/// origin goes; with "center", where the drawing's middle goes.
|
||||
#[serde(default)]
|
||||
pub at: Option<Point2>,
|
||||
/// A size factor, 1e-9 to 1e6 (a value outside is read as 1). Default: 1. Used only when
|
||||
/// neither `width` nor `height` is given.
|
||||
#[serde(default)]
|
||||
pub scale: Option<f64>,
|
||||
/// The drawing's width in mm after sizing, before rotation; the aspect ratio is kept. Wins
|
||||
/// over `height` and `scale`. The drawing must have a width.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PositiveMm")]
|
||||
pub width: Option<PositiveMm>,
|
||||
/// The drawing's height in mm after sizing, before rotation; the aspect ratio is kept. Wins
|
||||
/// over `scale`; ignored when `width` is given. The drawing must have a height.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PositiveMm")]
|
||||
pub height: Option<PositiveMm>,
|
||||
/// A turn in degrees, about the drawing's origin (with `align` "center": about its middle).
|
||||
/// Default: 0.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "AngleDegrees")]
|
||||
pub angle: Option<AngleDegrees>,
|
||||
/// What `at` places: the file's origin ("origin") or the drawing's middle ("center", which it
|
||||
/// then also turns and scales about). Default: origin.
|
||||
#[serde(default)]
|
||||
pub align: Option<DrawingAlign>,
|
||||
/// The distance in mm within which ends join into one point, 1e-9 to 10 (a value outside is
|
||||
/// brought inside; one that is not above 0 reads as the default). Default: 0.0001.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<f64>,
|
||||
/// Read only when no sketch is being edited: the plane of the new sketch, as `sketch.create`
|
||||
/// takes it. Default: XY. Ignored (and not checked) while a sketch is being edited.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plane: Option<Value>,
|
||||
/// Read only when no sketch is being edited: the new sketch's name. Ignored (and not checked)
|
||||
/// while a sketch is being edited.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<Value>,
|
||||
/// The sketch to insert into: its feature id or its name. Default: the one being edited (a
|
||||
/// new one when none is).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Leaves out the keys the handler never reads for this call: `path` next to a `text` that is
|
||||
/// text, `height` next to a `width`, and `scale` next to either.
|
||||
fn leave_unread(value: &mut Value) {
|
||||
let Some(keys) = value.as_object_mut() else { return };
|
||||
if keys.get("text").is_some_and(Value::is_string) {
|
||||
keys.remove("path");
|
||||
}
|
||||
if keys.contains_key("width") {
|
||||
keys.remove("height");
|
||||
}
|
||||
if keys.contains_key("width") || keys.contains_key("height") {
|
||||
keys.remove("scale");
|
||||
}
|
||||
}
|
||||
|
||||
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<InsertDrawingKeys, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
leave_unread(&mut value);
|
||||
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
|
||||
/// Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none
|
||||
/// is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be
|
||||
/// sized, turned and placed. Text in the drawing becomes sketch text. One undo step.
|
||||
#[derive(Debug)]
|
||||
pub struct InsertDxfParams(pub InsertDrawingKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for InsertDxfParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
read_keys(d).map(InsertDxfParams)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for InsertDxfParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"InsertDxfParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = InsertDrawingKeys::json_schema(generator);
|
||||
schema.insert("description".into(), INSERT_DXF.into());
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
const INSERT_DXF: &str = "Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
|
||||
|
||||
/// Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is
|
||||
/// being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed.
|
||||
/// Text in the drawing becomes sketch text. One undo step.
|
||||
#[derive(Debug)]
|
||||
pub struct InsertSvgParams(pub InsertDrawingKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for InsertSvgParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
read_keys(d).map(InsertSvgParams)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for InsertSvgParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"InsertSvgParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = InsertDrawingKeys::json_schema(generator);
|
||||
schema.insert("description".into(), INSERT_SVG.into());
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
const INSERT_SVG: &str = "Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
|
||||
|
||||
/// The insert commands need `path` or `text`; the handler says so when both are left out. A
|
||||
/// `path` it reads (no `text`) is not blank and under 4096 bytes; `maxLength` counts
|
||||
/// characters, so 4095 is as close as the schema gets. `height` is read only without a `width`
|
||||
/// and `scale` only without either, so their shapes apply only then, as [`leave_unread`] has it.
|
||||
fn source_required(schema: &mut Schema) {
|
||||
let read_path = serde_json::json!({"type": "string", "maxLength": 4095, "pattern": not_blank_pattern()});
|
||||
schema.insert("anyOf".into(), serde_json::json!([{"required": ["text"]}, {"required": ["path"], "properties": {"path": read_path}}]));
|
||||
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
|
||||
let mut read_only_without = |key: &str| {
|
||||
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
|
||||
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
|
||||
shape
|
||||
};
|
||||
let height = read_only_without("height");
|
||||
let scale = read_only_without("scale");
|
||||
schema.insert(
|
||||
"allOf".into(),
|
||||
serde_json::json!([
|
||||
{"anyOf": [{"required": ["width"]}, {"properties": {"height": height}}]},
|
||||
{"anyOf": [{"required": ["width"]}, {"required": ["height"]}, {"properties": {"scale": scale}}]},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// `path` is checked by [`source_required`] only when it is read (no `text`).
|
||||
fn path_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({})
|
||||
}
|
||||
|
||||
/// A drawing's text, as `source_text` reads it: at most 16 MiB, or the handler's refusal.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawingText(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawingText {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let text = String::deserialize(d)?;
|
||||
if text.len() > MAX_DRAWING_BYTES {
|
||||
return Err(D::Error::custom(whole("the drawing is too large")));
|
||||
}
|
||||
Ok(DrawingText(text))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawingText {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawingText".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing file name, as `source_text` reads `path`: not blank and under 4096 bytes, or the
|
||||
/// handler's "give `path` or `text`" (anything but text reads as no `path`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawingPath(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawingPath {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(DrawingPath(s)),
|
||||
_ => Err(D::Error::custom(whole("give `path` or `text`"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A size in mm above 0, as the insert commands' `width` and `height` are read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PositiveMm(pub f64);
|
||||
|
||||
/// The message the insert commands give for `width` or `height`: the check puts the key first.
|
||||
const POSITIVE_MM: &str = "must be a positive number (mm)";
|
||||
|
||||
impl<'de> Deserialize<'de> for PositiveMm {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?.as_f64().filter(|w| w.is_finite() && *w > 0.0).map(PositiveMm).ok_or_else(|| D::Error::custom(POSITIVE_MM))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PositiveMm {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PositiveMm".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A size in mm, above 0.", "type": "number", "exclusiveMinimum": 0})
|
||||
}
|
||||
}
|
||||
|
||||
/// An angle in degrees as `angle` is read: a number, whatever its size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AngleDegrees(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for AngleDegrees {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?.as_f64().filter(|a| a.is_finite()).map(AngleDegrees).ok_or_else(|| D::Error::custom("must be a number (degrees)"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for AngleDegrees {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AngleDegrees".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An angle in degrees.", "type": "number"})
|
||||
}
|
||||
}
|
||||
|
||||
/// What an insert's `at` places, as `align` is read (the words are case-sensitive).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum DrawingAlign {
|
||||
/// `at` is where the file's origin goes.
|
||||
Origin,
|
||||
/// `at` is where the drawing's middle goes, and it turns and scales about it.
|
||||
Center,
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawingAlign {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawingAlign".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "What `at` places: \"origin\" (the file's origin goes there) or \"center\" (the drawing's middle goes there, and it turns and scales about it). Lower case only.",
|
||||
"type": "string",
|
||||
"enum": ["origin", "center"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawingAlign {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)?.as_str() {
|
||||
Some("origin") => Ok(DrawingAlign::Origin),
|
||||
Some("center") => Ok(DrawingAlign::Center),
|
||||
_ => Err(D::Error::custom("must be \"origin\" or \"center\"")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.insert_dxf` and `sketch.insert_svg` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DrawingInserted {
|
||||
/// The sketch the drawing went into, by feature id.
|
||||
pub sketch: u64,
|
||||
/// The new sketch's id when no sketch was being edited and one was made; else null.
|
||||
pub created_sketch: Option<u64>,
|
||||
/// How many curves were added.
|
||||
pub curves: usize,
|
||||
/// How many sketch texts were added.
|
||||
pub texts: usize,
|
||||
/// The sketch's solve afterwards; null when it was not solved.
|
||||
pub info: 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>,
|
||||
}
|
||||
|
||||
/// A sketch's solve, as the sketch commands report it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchSolve {
|
||||
/// Whether the solve kept the sketch's orientation.
|
||||
pub solved: bool,
|
||||
/// The degrees of freedom left (0 = fully constrained).
|
||||
pub dof: usize,
|
||||
}
|
||||
|
||||
/// Save a sketch's curves as an ASCII DXF: to the file `path`, or, without it, as text in the
|
||||
/// reply. Construction curves are left out; splines and other curves are written as line
|
||||
/// segments.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExportDxfParams {
|
||||
/// The sketch: its feature id or its name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The file to write. Default (also when blank): none, and the DXF text is returned.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// The drawing's units (any case): `design` (the design's length unit), `mm`, `in` or
|
||||
/// `inch`, `cm`, `m` or `ft`. Default: mm.
|
||||
#[serde(default, deserialize_with = "dxf_units")]
|
||||
#[schemars(schema_with = "dxf_units_schema")]
|
||||
pub units: Option<String>,
|
||||
/// The layer of the profiles (blank: the default). Default: `0`.
|
||||
#[serde(default, deserialize_with = "dxf_layer")]
|
||||
#[schemars(schema_with = "dxf_layer_schema")]
|
||||
pub layer: Option<String>,
|
||||
/// `single` (any case, the default): everything on `layer`; `roles`: outer loops on
|
||||
/// `OUTLINE` and holes on `CUT`.
|
||||
#[serde(default, deserialize_with = "dxf_layers")]
|
||||
#[schemars(schema_with = "dxf_layers_schema")]
|
||||
pub layers: Option<String>,
|
||||
/// `true` writes closed loops as LWPOLYLINE with arc bulges. Default: false (LINE and ARC).
|
||||
#[serde(default, deserialize_with = "dxf_join")]
|
||||
pub join: Option<bool>,
|
||||
}
|
||||
|
||||
/// The units `sketch.export_dxf` takes, in lower case (the handler reads them in any case).
|
||||
const DXF_UNITS: [&str; 7] = ["design", "mm", "in", "inch", "cm", "m", "ft"];
|
||||
|
||||
fn dxf_units<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(units)) => {
|
||||
let lower = units.to_ascii_lowercase();
|
||||
if DXF_UNITS.contains(&lower.as_str()) {
|
||||
Ok(Some(lower))
|
||||
} else {
|
||||
Err(D::Error::custom(whole(format!("unsupported unit `{lower}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")"))))
|
||||
}
|
||||
}
|
||||
Some(_) => Err(D::Error::custom(whole("`units` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn dxf_units_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"], "description": "design, mm, in, inch, cm, m or ft, in any case. Default: mm."})
|
||||
}
|
||||
|
||||
fn dxf_layer<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(layer)) => Ok(Some(layer)),
|
||||
Some(_) => Err(D::Error::custom(whole("`layer` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn dxf_layer_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"], "description": "The profiles' layer (blank: the default). Default: 0."})
|
||||
}
|
||||
|
||||
fn dxf_layers<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(layers)) if ["single", "roles"].contains(&layers.to_ascii_lowercase().as_str()) => Ok(Some(layers.to_ascii_lowercase())),
|
||||
Some(_) => Err(D::Error::custom(whole("`layers` must be \"single\" or \"roles\""))),
|
||||
}
|
||||
}
|
||||
|
||||
fn dxf_layers_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"], "description": "single or roles, in any case. Default: single."})
|
||||
}
|
||||
|
||||
fn dxf_join<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(join)) => Ok(Some(join)),
|
||||
Some(_) => Err(D::Error::custom(whole("`join` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.export_dxf` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExportDxfResult {
|
||||
#[serde(flatten)]
|
||||
pub exported: DxfExported,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Where the DXF went.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum DxfExported {
|
||||
/// With a `path`: the file was written.
|
||||
Written {
|
||||
/// The file written, as given.
|
||||
path: String,
|
||||
/// How many entities it holds.
|
||||
entities: usize,
|
||||
},
|
||||
/// Without one: the DXF as text.
|
||||
Text {
|
||||
/// The DXF file's text.
|
||||
dxf: String,
|
||||
/// How many entities it holds.
|
||||
entities: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_import_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,313 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_io::MAX_DRAWING_BYTES;
|
||||
|
||||
use super::{DrawingInserted, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams};
|
||||
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};
|
||||
|
||||
/// A 30 x 20 rectangle as DXF lines.
|
||||
const DXF: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n30\n21\n0\n0\nLINE\n10\n30\n20\n0\n11\n30\n21\n20\n0\nLINE\n10\n30\n20\n20\n11\n0\n21\n20\n0\nLINE\n10\n0\n20\n20\n11\n0\n21\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
|
||||
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10"><rect x="0" y="0" width="20" height="10"/><text x="2" y="5" font-size="3">Hi</text></svg>"#;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-12508-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
let insert = ["align", "angle", "at", "height", "name", "path", "plane", "scale", "sketch", "text", "tolerance", "width"];
|
||||
assert_keys(input_schema_for::<InsertDxfParams>(), &insert);
|
||||
assert_keys(input_schema_for::<InsertSvgParams>(), &insert);
|
||||
assert_keys(input_schema_for::<ExportDxfParams>(), &["join", "layer", "layers", "path", "sketch", "units"]);
|
||||
}
|
||||
|
||||
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `path` and
|
||||
/// `text` included: the handler's words, not "is required"). The session has a sketch being edited.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let too_big = "x".repeat(MAX_DRAWING_BYTES + 1);
|
||||
let mut cases: Vec<(&str, Value)> = vec![
|
||||
("sketch.insert_dxf", json!({})),
|
||||
("sketch.insert_dxf", json!({"path": ""})),
|
||||
("sketch.insert_dxf", json!({"path": " "})),
|
||||
("sketch.insert_dxf", json!({"path": 5})),
|
||||
("sketch.insert_dxf", json!({"path": null})),
|
||||
("sketch.insert_dxf", json!({"path": "x".repeat(4096)})),
|
||||
("sketch.insert_dxf", json!({"text": too_big})),
|
||||
("sketch.insert_svg", json!({})),
|
||||
("sketch.insert_svg", json!({"path": " "})),
|
||||
("sketch.insert_svg", json!({"text": null})),
|
||||
("sketch.export_dxf", json!({"sketch": true})),
|
||||
("sketch.export_dxf", json!({"sketch": [1]})),
|
||||
("sketch.export_dxf", json!({"sketch": -1})),
|
||||
("sketch.export_dxf", json!({"sketch": 1.5})),
|
||||
];
|
||||
for (id, text) in [("sketch.insert_dxf", DXF), ("sketch.insert_svg", SVG)] {
|
||||
for extra in [
|
||||
json!({"width": 0}),
|
||||
json!({"width": -2}),
|
||||
json!({"width": null}),
|
||||
json!({"width": "wide"}),
|
||||
json!({"width": [1]}),
|
||||
json!({"height": 0}),
|
||||
json!({"height": null}),
|
||||
json!({"height": true}),
|
||||
json!({"angle": "x"}),
|
||||
json!({"angle": null}),
|
||||
json!({"angle": {}}),
|
||||
json!({"align": "left"}),
|
||||
json!({"align": "Center"}),
|
||||
json!({"align": ""}),
|
||||
json!({"sketch": true}),
|
||||
json!({"sketch": null, "width": 0}),
|
||||
json!({"sketch": [1]}),
|
||||
json!({"sketch": 1.5}),
|
||||
] {
|
||||
let mut p = json!({"text": text});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
cases.push((id, p));
|
||||
}
|
||||
}
|
||||
assert_strict_keeps_todays_errors(&cases);
|
||||
}
|
||||
|
||||
/// The same refusals for a sketch that is named but doesn't exist, a width the drawing can't
|
||||
/// give (a drawing with no width to size is refused by the handler alone), and the export's
|
||||
/// options, which the handler reads only once a sketch is being edited.
|
||||
#[test]
|
||||
fn strict_keeps_the_handlers_own_refusals() {
|
||||
// A vertical line has no width to size.
|
||||
let vertical = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n0\n21\n9\n0\nENDSEC\n0\nEOF\n";
|
||||
for (id, p) in [
|
||||
("sketch.insert_dxf", json!({"text": DXF, "sketch": "Nope"})),
|
||||
("sketch.insert_dxf", json!({"text": DXF, "sketch": 9999})),
|
||||
("sketch.insert_dxf", json!({"text": vertical, "width": 10})),
|
||||
("sketch.insert_dxf", json!({"text": "0\nEOF\n"})),
|
||||
("sketch.export_dxf", json!({"sketch": "Nope"})),
|
||||
("sketch.export_dxf", json!({"sketch": 9999})),
|
||||
("sketch.export_dxf", json!({"units": 5})),
|
||||
("sketch.export_dxf", json!({"units": "Yard"})),
|
||||
("sketch.export_dxf", json!({"layer": 1})),
|
||||
("sketch.export_dxf", json!({"layers": "all"})),
|
||||
("sketch.export_dxf", json!({"layers": true})),
|
||||
("sketch.export_dxf", json!({"join": "yes"})),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The types read each key as the handler does: keys it never reads in a call are not read by the
|
||||
/// contract either, and the ones it reads fail with its words.
|
||||
#[test]
|
||||
fn the_types_read_like_the_handler() {
|
||||
let reads = |p: Value| serde_json::from_value::<InsertDxfParams>(p).is_ok();
|
||||
let issues = |p: Value| crate::schema::issues("sketch.insert_svg", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
// `path` is not read when `text` is given; `height` not when `width` is; `scale` not when
|
||||
// either is.
|
||||
assert!(reads(json!({"text": "x", "path": 5})) && reads(json!({"text": "x", "path": ""})));
|
||||
assert!(reads(json!({"width": 5, "height": "x", "scale": "y"})) && reads(json!({"width": 5, "height": null})));
|
||||
assert!(reads(json!({"height": 5, "scale": "y"})) && reads(json!({"height": 5, "scale": null})));
|
||||
assert!(reads(json!({"path": "a.dxf", "scale": 2, "tolerance": 0.01, "at": [1, 2], "angle": 90, "align": "center"})));
|
||||
assert!(reads(json!({"text": "x", "at": {"x": 1, "y": 2}, "angle": 720.5, "width": 1e12, "scale": 0, "tolerance": -1})));
|
||||
// They are read when the handler reads them.
|
||||
// Stricter than the handler, which reads a `text` that isn't text as left out.
|
||||
assert!(!reads(json!({"text": 5, "path": "a.dxf"})) && !reads(json!({"path": "a.dxf", "scale": "y"})));
|
||||
assert!(!reads(json!({"path": ""})) && !reads(json!({"path": 5, "scale": 1})));
|
||||
assert!(!reads(json!({"text": "x", "height": 0})) && !reads(json!({"text": "x", "height": "x", "scale": 2})));
|
||||
assert!(!reads(json!({"text": "x", "width": null, "height": 5})) && !reads(json!({"text": "x", "scale": "y", "width": 0})));
|
||||
// Only while no sketch is being edited does `plane` or `name` matter, which the check can't
|
||||
// see: they take anything.
|
||||
assert!(reads(json!({"text": "x", "plane": 5, "name": [1]})) && reads(json!({"text": "x", "plane": null, "name": null})));
|
||||
assert_eq!(issues(json!({"text": "x", "width": 0})), ["`width` must be a positive number (mm)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "height": -1})), ["`height` must be a positive number (mm)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "angle": "1"})), ["`angle` must be a number (degrees)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "align": "left"})), ["`align` must be \"origin\" or \"center\""]);
|
||||
assert_eq!(issues(json!({"path": " "})), ["give `path` or `text`"]);
|
||||
assert_eq!(issues(json!({"text": "x", "sketch": true})), ["`sketch` must be an id or a name"]);
|
||||
assert_eq!(issues(json!({"text": "x", "wdth": 5})), ["unknown key `wdth`; did you mean `width`?"]);
|
||||
assert!(issues(json!({"text": "x", "width": 5, "height": "x", "path": 5})).is_empty());
|
||||
let export = |p: Value| serde_json::from_value::<ExportDxfParams>(p).is_ok();
|
||||
assert!(export(json!({})) && export(json!({"sketch": null, "path": null})) && export(json!({"sketch": "S", "path": ""})));
|
||||
assert!(export(json!({"sketch": 3, "path": "a.dxf"})) && !export(json!({"sketch": true})) && !export(json!({"path": 5})));
|
||||
}
|
||||
|
||||
/// What the schemas take: a drawing from `path` or `text`, and the keys' own shapes.
|
||||
#[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}");
|
||||
}
|
||||
};
|
||||
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
|
||||
takes(
|
||||
schema,
|
||||
&[
|
||||
(json!({"text": "x"}), true),
|
||||
(json!({"path": "a.svg", "at": [1, 2], "scale": 2, "angle": 45, "align": "center", "tolerance": 0.01}), true),
|
||||
(json!({"text": "x", "width": 40, "plane": "XZ", "name": "Logo", "sketch": "Sketch1"}), true),
|
||||
(json!({"text": "x", "plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "sketch": 4}), true),
|
||||
(json!({}), false),
|
||||
(json!({"width": 40}), false),
|
||||
(json!({"text": "x", "width": 0}), false),
|
||||
(json!({"text": "x", "height": -1}), false),
|
||||
(json!({"text": "x", "angle": "x"}), false),
|
||||
(json!({"text": "x", "align": "left"}), false),
|
||||
(json!({"text": "x", "at": [1]}), false),
|
||||
(json!({"text": "x", "wdth": 5}), false),
|
||||
(json!({"path": "x".repeat(4096)}), false),
|
||||
(json!({"text": "x", "path": " "}), true),
|
||||
// Unread keys take anything, as in the handler; read ones keep their shapes.
|
||||
(json!({"text": "x", "path": 5}), true),
|
||||
(json!({"text": "x", "width": 5, "height": -1}), true),
|
||||
(json!({"text": "x", "width": 5, "scale": "y"}), true),
|
||||
(json!({"text": "x", "height": 5, "scale": "y"}), true),
|
||||
(json!({"path": 5}), false),
|
||||
(json!({"text": "x", "height": "x", "scale": 2}), false),
|
||||
(json!({"text": "x", "scale": "y"}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
// A `path` read without `text` is not blank (`conforms` doesn't check patterns).
|
||||
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
|
||||
let read_path = &schema.as_value()["anyOf"][1]["properties"]["path"];
|
||||
assert_eq!(read_path["pattern"], json!(crate::schema::types::not_blank_pattern()));
|
||||
}
|
||||
takes(
|
||||
input_schema_for::<ExportDxfParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"sketch": "S", "path": "a.dxf"}), true),
|
||||
(json!({"sketch": 2}), true),
|
||||
(json!({"sketch": true}), false),
|
||||
(json!({"file": "a.dxf"}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, r: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
|
||||
/// Every form of the three commands replies as its output schema says, in Warn without warnings;
|
||||
/// the replies carry the keys they always had; a misspelt key gets its warning and the reply still
|
||||
/// matches, and strict refuses it.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let dir = temp_dir("replies");
|
||||
let svg_file = dir.join("plate.svg");
|
||||
let dxf_file = dir.join("plate.dxf");
|
||||
std::fs::write(&svg_file, SVG).unwrap();
|
||||
std::fs::write(&dxf_file, DXF).unwrap();
|
||||
let (svg_path, dxf_path) = (svg_file.to_string_lossy().into_owned(), dxf_file.to_string_lossy().into_owned());
|
||||
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
|
||||
};
|
||||
// The session adds `created` (the ids a sketch command made) to every `sketch.*` reply, outside
|
||||
// the handlers; it is not part of what they build.
|
||||
let keys = |r: &Value| {
|
||||
r.as_object().map(|o| o.keys().filter(|k| *k != "created").cloned().collect::<std::collections::BTreeSet<_>>()).unwrap_or_default()
|
||||
};
|
||||
let insert_keys: std::collections::BTreeSet<String> = ["created_sketch", "curves", "info", "sketch", "texts"].map(String::from).into();
|
||||
|
||||
// With no sketch being edited: a new one is made, on the plane and with the name given.
|
||||
let r = run(&mut s, "sketch.insert_dxf", json!({"text": DXF, "plane": "XZ", "name": "Plate", "at": [5, 5]}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_eq!(keys(&r), insert_keys, "{r}");
|
||||
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(0)), "{r}");
|
||||
assert_eq!(r["created_sketch"], r["sketch"], "{r}");
|
||||
// Exactly what the handler returned before it built its reply from the type.
|
||||
let mut handler_reply = r.clone();
|
||||
handler_reply.as_object_mut().unwrap().remove("created");
|
||||
assert_eq!(
|
||||
handler_reply,
|
||||
json!({"sketch": r["sketch"], "created_sketch": r["sketch"], "curves": 4, "texts": 0, "info": {"solved": true, "dof": 8}})
|
||||
);
|
||||
assert!(r["info"]["solved"].is_boolean() && r["info"]["dof"].is_u64(), "{r}");
|
||||
// With one: into it, and `plane` and `name` (of any kind) are not read.
|
||||
let id = s.active_sketch.unwrap();
|
||||
for p in [
|
||||
json!({"text": DXF, "path": 5, "plane": 7, "name": [1]}),
|
||||
json!({"text": DXF, "width": 60, "height": "x", "scale": "y", "angle": 90, "align": "center", "at": {"x": 1, "y": 2}}),
|
||||
json!({"text": DXF, "height": 10, "scale": "y", "tolerance": 0.01}),
|
||||
json!({"text": DXF, "scale": 2, "align": "origin", "sketch": id}),
|
||||
json!({"text": DXF, "sketch": s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default()}),
|
||||
json!({"path": dxf_path, "text": null, "sketch": null}),
|
||||
] {
|
||||
let r = run(&mut s, "sketch.insert_dxf", p);
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_eq!(keys(&r), insert_keys);
|
||||
assert_eq!((r["sketch"].as_u64(), r["created_sketch"].clone(), r["curves"].as_u64()), (Some(id), Value::Null, Some(4)), "{r}");
|
||||
}
|
||||
let r = run(&mut s, "sketch.insert_svg", json!({"text": SVG, "angle": 15}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(1)), "{r}");
|
||||
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "width": 40, "align": "center", "at": [10, 10]}));
|
||||
assert_eq!(r["texts"], 1, "{r}");
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "plane": "YZ"}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_ne!(r["created_sketch"], Value::Null, "{r}");
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
|
||||
// Export: the text, or a file.
|
||||
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id}));
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
|
||||
assert!(r["dxf"].as_str().is_some_and(|t| t.contains("ENTITIES")), "{r}");
|
||||
assert!(r["entities"].as_u64().is_some_and(|n| n >= 4), "{r}");
|
||||
let out = dir.join("out.dxf");
|
||||
let out_path = out.to_string_lossy().into_owned();
|
||||
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id, "path": out_path}));
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
assert_eq!(r["path"], json!(out_path), "{r}");
|
||||
assert_eq!(keys(&r), ["entities", "path"].map(String::from).into(), "{r}");
|
||||
assert!(std::fs::read_to_string(&out).unwrap().contains("ENTITIES"));
|
||||
// A blank `path` and a null `sketch` read as left out.
|
||||
s.execute("sketch.edit", &json!({"sketch": id})).unwrap();
|
||||
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": null, "path": " "}));
|
||||
assert!(r.get("dxf").is_some(), "{r}");
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
// The units and layer options, in any case; null and a blank layer read as left out.
|
||||
for p in [
|
||||
json!({"units": "IN", "layer": "PART"}),
|
||||
json!({"units": "design", "layers": "Roles"}),
|
||||
json!({"join": true, "layer": " "}),
|
||||
json!({"units": null, "layer": null, "layers": null, "join": null}),
|
||||
] {
|
||||
let r = run(&mut s, "sketch.export_dxf", p);
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
|
||||
let p = json!({"text": DXF, "wdth": 5});
|
||||
let r = s.execute("sketch.insert_dxf", &p).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`wdth`")))), "{r}");
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
let r = s.execute("sketch.export_dxf", &json!({"pth": "a.dxf"})).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
let r = s.execute("sketch.insert_svg", &json!({"text": SVG, "angel": 5})).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("sketch.insert_dxf", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`wdth`") && error.contains("did you mean `width`"), "{error}");
|
||||
}
|
||||
@@ -1,794 +0,0 @@
|
||||
//! 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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `construction`: a boolean (`null` is refused too); left out, the session's preference decides.
|
||||
fn read_construction(o: &Map<String, Value>) -> Result<Option<bool>, String> {
|
||||
match o.get("construction") {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err("`construction` 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>,
|
||||
/// Make the created curves construction geometry (dashed, left out of profiles; they stay
|
||||
/// linked). Default: the "Project references as construction geometry" preference, off.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub construction: 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)))?;
|
||||
let construction = read_construction(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(ProjectParams { refs, reference, projection_link, construction })
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Make the created curves construction geometry (dashed, left out of profiles; they stay
|
||||
/// linked). Default: the "Project references as construction geometry" preference, off.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub construction: 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)))?;
|
||||
let construction = read_construction(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(IntersectParams { refs, reference, projection_link, construction })
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Make the created curves construction geometry (dashed, left out of profiles; they stay
|
||||
/// linked). Default: the "Project references as construction geometry" preference, off.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub construction: Option<bool>,
|
||||
}
|
||||
|
||||
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)))?;
|
||||
let construction = read_construction(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(Include3dParams { refs, reference, construction })
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -1,514 +0,0 @@
|
||||
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>(), &["construction", "projection_link", "ref", "refs"]);
|
||||
assert_keys(input_schema_for::<IntersectParams>(), &["construction", "projection_link", "ref", "refs"]);
|
||||
assert_keys(input_schema_for::<Include3dParams>(), &["construction", "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");
|
||||
}
|
||||
@@ -1,979 +0,0 @@
|
||||
//! Contracts and result types for the sketch session commands in `cmd/sketch.rs`:
|
||||
//! `sketch.create`, `sketch.construction`, `sketch.delete`, `sketch.move_point`, `sketch.drag`,
|
||||
//! `sketch.drag_end`, `sketch.dimension_text`, `sketch.options`, `sketch.chain` and
|
||||
//! `sketch.solve` (#13224).
|
||||
//!
|
||||
//! 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, a curve, a point or a
|
||||
//! dimension exists stays the handler's to check, and so does everything that needs the design.
|
||||
//! 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.
|
||||
//!
|
||||
//! Some handlers read a key only in some cases, and the types read it only there too, so a call
|
||||
//! that works today gets no warning:
|
||||
//!
|
||||
//! - `sketch.create` reads `project_edges` only when `plane` is a picked face, and `frame` is
|
||||
//! for an explicit plane only. It refuses `face`, `origin`, `x_dir` and `y_dir` next to
|
||||
//! `plane` with a message that says where they belong, so they are listed (and can never be
|
||||
//! used) to keep that message in strict;
|
||||
//! - `sketch.dimension_text` reads `at` only without `reset: true`;
|
||||
//! - `sketch.drag_end` reads a constraint's `command` and `params` only when `constraint` is not
|
||||
//! `null`.
|
||||
//!
|
||||
//! A call with `live: true` of `sketch.drag` goes to the live runner, which the check doesn't
|
||||
//! wrap, so live frames are not checked yet.
|
||||
//!
|
||||
//! `sketch.options` refuses a key that is not one of its options with "unknown option"; the
|
||||
//! check names it first ("unknown key", with a suggestion) in warn and strict.
|
||||
|
||||
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::canvas::CanvasPlane;
|
||||
use super::check::{located, whole};
|
||||
use super::frame::SketchSolve;
|
||||
use super::motion::require;
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{LengthExpr, Point2, present};
|
||||
use crate::params;
|
||||
|
||||
/// The words of `sketch.create`'s refusals, as `plane_frame` and `flip_param` give them.
|
||||
const FRAME_FORMS: &str = "`frame` must be \"world\" or \"component\"";
|
||||
const FRAME_NOT_EXPLICIT: &str =
|
||||
"`frame` is for an explicit plane {origin, x_dir, y_dir}: a named plane is the component's own, and a face is where it is";
|
||||
const FLIP_FORMS: &str = "`flip` must be true, false, \"x\" or \"y\"";
|
||||
const FLIP_ON_FACE: &str = "`flip` is for origin, construction and explicit planes; a face already faces outward";
|
||||
const ACTIVATE_FORMS: &str = "`activate_part` must be true or false";
|
||||
|
||||
/// The keys `sketch.create` refuses next to `plane`, and the form each belongs in.
|
||||
const MISPLACED: [(&str, &str); 4] = [
|
||||
("face", r#"{"plane": {"face": [x, y, z]}}"#),
|
||||
("origin", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
|
||||
("x_dir", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
|
||||
("y_dir", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
|
||||
];
|
||||
|
||||
/// The message of a handler's refusal.
|
||||
fn message(e: crate::EngineError) -> String {
|
||||
match e {
|
||||
crate::EngineError::BadParams { msg, .. } => msg,
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The handler's message for a value that is not what a key needs, kept whole.
|
||||
fn refuse<E: serde::de::Error>(message: impl std::fmt::Display) -> E {
|
||||
E::custom(whole(message))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared readers
|
||||
|
||||
/// `key` as text, the handler's `str_`-and-refuse: a string, or `message`.
|
||||
macro_rules! text_key {
|
||||
($($name:ident: $message:literal),+ $(,)?) => {$(
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(refuse($message)),
|
||||
}
|
||||
}
|
||||
)+};
|
||||
}
|
||||
|
||||
text_key!(
|
||||
point_id: "`point` must be a point reference",
|
||||
entity_id: "`entity` must be a sketch point or curve",
|
||||
curve_id: "`curve` must be a curve id",
|
||||
dimension_id: "`dimension` must be a dimension id or parameter name",
|
||||
);
|
||||
|
||||
/// A required point `[x, y]` as `req_vec2` reads it (`null` is refused).
|
||||
macro_rules! point_key {
|
||||
($($name:ident: $key:literal),+ $(,)?) => {$(
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point2>, D::Error> {
|
||||
params::vec2(&Value::deserialize(d)?)
|
||||
.map(|p| Some(Point2(p)))
|
||||
.ok_or_else(|| refuse(concat!("`", $key, "` must be a point [x, y]")))
|
||||
}
|
||||
)+};
|
||||
}
|
||||
|
||||
point_key!(to_point: "to", at_point: "at");
|
||||
|
||||
/// `from` of `sketch.drag`: a point, where `null` is read as left out (the grab is at `to`).
|
||||
fn from_point<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point2>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => params::vec2(&v).map(|p| Some(Point2(p))).ok_or_else(|| refuse("`from` must be a point [x, y]")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity ids, as `params::string_list` reads a list of names: a list of text, or one text. An
|
||||
/// empty list, `null` included, is the handler's own refusal (`empty`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EntityIds(pub Vec<String>);
|
||||
|
||||
fn entity_ids<'de, D: Deserializer<'de>>(d: D, key: &str, empty: &str) -> Result<Option<EntityIds>, D::Error> {
|
||||
let mut given = Map::new();
|
||||
given.insert(key.to_string(), Value::deserialize(d)?);
|
||||
let list = params::string_list("", &Value::Object(given), key).map_err(|e| refuse::<D::Error>(message(e)))?;
|
||||
if list.is_empty() { Err(refuse(empty)) } else { Ok(Some(EntityIds(list))) }
|
||||
}
|
||||
|
||||
fn curves<'de, D: Deserializer<'de>>(d: D) -> Result<Option<EntityIds>, D::Error> {
|
||||
entity_ids(d, "curves", "`curves` must list curve ids")
|
||||
}
|
||||
|
||||
fn entities<'de, D: Deserializer<'de>>(d: D) -> Result<Option<EntityIds>, D::Error> {
|
||||
entity_ids(d, "entities", "`entities` must list sketch entity ids")
|
||||
}
|
||||
|
||||
impl JsonSchema for EntityIds {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchEntityIds".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Ids, as a list of text (at least one, at most 10000) or a single id.",
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": params::MAX_STRING_LIST},
|
||||
{"type": "string"}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of what a drag takes along: a list of text, as the handler reads `with`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DragWith(pub Vec<String>);
|
||||
|
||||
fn with_ids<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DragWith>, D::Error> {
|
||||
const FORMS: &str = "`with` must be a list of sketch point or curve ids";
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Array(ids) => ids
|
||||
.into_iter()
|
||||
.map(|v| if let Value::String(id) = v { Some(id) } else { None })
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.map(|ids| Some(DragWith(ids)))
|
||||
.ok_or_else(|| refuse(FORMS)),
|
||||
_ => Err(refuse(FORMS)),
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DragWith {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DragWith".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Point and curve ids, as a list. No more than the sketch has points and curves together.",
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional flag the handler reads with `bool_`, which takes a wrong type as absent: `null` is
|
||||
/// absent, and any other value that is not true or false is refused here.
|
||||
fn flag<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(refuse(format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn live<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
flag(d, "live")
|
||||
}
|
||||
|
||||
fn reset<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
flag(d, "reset")
|
||||
}
|
||||
|
||||
/// One flag each, as the handler reads them (see [`flag`]).
|
||||
macro_rules! option_flags {
|
||||
($($name:ident),+ $(,)?) => {$(
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
flag(d, stringify!($name))
|
||||
}
|
||||
)+};
|
||||
}
|
||||
|
||||
option_flags!(value, show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// sketch.create
|
||||
|
||||
/// A plane for a new sketch, as `sketch.create` reads `plane`: XY, XZ or YZ (or Top, Front or
|
||||
/// Right), a construction plane by name or id, a picked planar face, or an explicit plane. Kept
|
||||
/// as given, for the handler.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SketchPlane(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchPlane {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
CanvasPlane::deserialize(d).map(|plane| SketchPlane(plane.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchPlane {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchPlane".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
CanvasPlane::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// `flip`: `true`, `false`, or the axis (`"x"` or `"y"`, any case) to turn the plane about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Flip {
|
||||
Flag(bool),
|
||||
AboutX,
|
||||
AboutY,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Flip {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Bool(b) => Ok(Flip::Flag(b)),
|
||||
Value::String(a) if a.eq_ignore_ascii_case("x") => Ok(Flip::AboutX),
|
||||
Value::String(a) if a.eq_ignore_ascii_case("y") => Ok(Flip::AboutY),
|
||||
_ => Err(refuse(FLIP_FORMS)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Flip {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Flip".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "false keeps the plane's own side; true turns it about its y axis (about x when y does not point upward) so the sketch's y axis stays upward; \"x\" or \"y\" (any case) turns it about that axis of the plane.",
|
||||
"anyOf": [{"type": "boolean"}, {"type": "string", "pattern": "^[xXyY]$"}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Flip {
|
||||
/// Whether the plane is turned at all.
|
||||
fn turns(self) -> bool {
|
||||
self != Flip::Flag(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which coordinates an explicit plane is in: `world` (the default) or the active component's
|
||||
/// own, `component`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Frame {
|
||||
// World coordinates (the default); the component's own coordinates.
|
||||
World,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// The frame `plane_frame` names.
|
||||
pub fn named(name: &str) -> Option<Frame> {
|
||||
match name {
|
||||
"world" => Some(Frame::World),
|
||||
"component" => Some(Frame::Component),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key `sketch.create` refuses next to `plane`: it belongs inside `plane`.
|
||||
#[derive(Debug)]
|
||||
pub struct InsidePlane;
|
||||
|
||||
impl<'de> Deserialize<'de> for InsidePlane {
|
||||
fn deserialize<D: Deserializer<'de>>(_: D) -> Result<Self, D::Error> {
|
||||
Err(refuse("this key belongs inside `plane`"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for InsidePlane {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"InsidePlane".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Not taken here, in any form: the call is refused, and the message says where it belongs. Put it inside `plane`.",
|
||||
"not": {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of a `sketch.create` call.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CreateKeys {
|
||||
/// The plane to sketch on: XY, XZ or YZ (also Top, Front, Right), a construction plane's
|
||||
/// name or id, a picked planar face `{face: [x, y, z], body?, index?, path?, fixed?}`, or an
|
||||
/// explicit plane `{origin?, x_dir, y_dir}` in mm. Default: XY. On XY, the sketch's x and y
|
||||
/// run along world +X and +Y; on XZ, +X and -Z; on YZ, +Y and +Z. A face of a sheet metal
|
||||
/// body attaches to its panel instead, in the flat pattern's coordinates; `fixed: true` keeps
|
||||
/// the plane where the face is now. A named plane is the active component's own.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchPlane")]
|
||||
pub plane: Option<SketchPlane>,
|
||||
/// Move the plane this far along its normal (mm or a length expression), before `flip`.
|
||||
#[serde(default)]
|
||||
pub offset: Option<LengthExpr>,
|
||||
/// Not on a face: sketch the plane from its other side, so the normal and the default
|
||||
/// extrude direction are reversed. Default: false.
|
||||
#[serde(default)]
|
||||
pub flip: Option<Flip>,
|
||||
/// Which coordinates an explicit plane is in. Default: world. Only for an explicit plane
|
||||
/// `{origin, x_dir, y_dir}`; the reply's `frame` says which, with a `note` when the active
|
||||
/// component is moved or turned.
|
||||
#[serde(default)]
|
||||
pub frame: Option<Frame>,
|
||||
/// Only on a picked face: project the face's edges into the sketch. Default: the session's
|
||||
/// Auto Project setting.
|
||||
#[serde(default)]
|
||||
pub project_edges: Option<bool>,
|
||||
/// Only on a face of a body of another component than the active one: activate that
|
||||
/// component at the picked placement first, as `component.activate` would, and make the
|
||||
/// sketch there. It stays active afterwards and the reply's `activated` names it. Default:
|
||||
/// false.
|
||||
#[serde(default)]
|
||||
pub activate_part: Option<bool>,
|
||||
/// The sketch's name. Default: numbered.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Refused: it belongs inside `plane`.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "InsidePlane")]
|
||||
pub face: Option<InsidePlane>,
|
||||
/// Refused: it belongs inside `plane`.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "InsidePlane")]
|
||||
pub origin: Option<InsidePlane>,
|
||||
/// Refused: it belongs inside `plane`.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "InsidePlane")]
|
||||
pub x_dir: Option<InsidePlane>,
|
||||
/// Refused: it belongs inside `plane`.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "InsidePlane")]
|
||||
pub y_dir: Option<InsidePlane>,
|
||||
}
|
||||
|
||||
/// `sketch.create`'s parameters: [`CreateKeys`], read in the handler's order, and `project_edges`
|
||||
/// only for a face, as the handler does.
|
||||
#[derive(Debug)]
|
||||
pub struct CreateParams(pub CreateKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for CreateParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
for (key, example) in MISPLACED {
|
||||
if value.get(key).is_some() {
|
||||
return Err(refuse(format!("`{key}` belongs inside `plane`: {example}")));
|
||||
}
|
||||
}
|
||||
if !matches!(value.get("activate_part"), None | Some(Value::Null | Value::Bool(_))) {
|
||||
return Err(refuse(ACTIVATE_FORMS));
|
||||
}
|
||||
let face = value.get("plane").and_then(|plane| plane.get("face")).is_some();
|
||||
let explicit = value.get("plane").is_some_and(Value::is_object) && !face;
|
||||
let frame = match value.get("frame") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::String(f)) if Frame::named(f).is_some() => true,
|
||||
Some(_) => return Err(refuse(FRAME_FORMS)),
|
||||
};
|
||||
if frame && !explicit {
|
||||
return Err(refuse(FRAME_NOT_EXPLICIT));
|
||||
}
|
||||
if let Some(plane) = value.get("plane") {
|
||||
SketchPlane::deserialize(plane).map_err(|e| D::Error::custom(e.to_string()))?;
|
||||
}
|
||||
match value.get("flip") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(flip) => {
|
||||
let flip = Flip::deserialize(flip).map_err(|e| D::Error::custom(e.to_string()))?;
|
||||
if flip.turns() && face {
|
||||
return Err(refuse(FLIP_ON_FACE));
|
||||
}
|
||||
}
|
||||
}
|
||||
// `project_edges` is read only for a picked face.
|
||||
if !face && let Some(keys) = value.as_object_mut() {
|
||||
keys.remove("project_edges");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(CreateParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CreateParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CreateParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
CreateKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a new sketch is attached to its plane: `exact_face`, `sheet_face`, `internal_legacy_face`
|
||||
/// or `fixed`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Attachment {
|
||||
// `exact_face`: a planar face of a body, exactly, so the sketch follows the face;
|
||||
// `sheet_face`: the panel of a sheet metal body's flat pattern;
|
||||
// `internal_legacy_face`: a face picked by a command that starts a sketch on a face
|
||||
// (Extrude, Revolve, Press Pull);
|
||||
// `fixed`: where the plane is (an origin, construction or explicit plane, or a face kept
|
||||
// with `fixed`).
|
||||
ExactFace,
|
||||
SheetFace,
|
||||
InternalLegacyFace,
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// The component `activate_part` made active.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Activated {
|
||||
/// The component, by id.
|
||||
pub component: u64,
|
||||
/// The placement's occurrence path (empty at the root).
|
||||
pub path: Vec<u64>,
|
||||
}
|
||||
|
||||
/// What `sketch.create` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CreateResult {
|
||||
/// The new sketch's feature id.
|
||||
pub sketch: u64,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// On a face, the link id of the face's projected edges; null when none were projected.
|
||||
pub projected: Option<String>,
|
||||
/// How the sketch is attached to its plane.
|
||||
pub attachment: Option<Attachment>,
|
||||
/// For an explicit plane: which coordinates it was read in.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<Frame>,
|
||||
/// With a world `frame`, when the active component is moved or turned: that the plane was
|
||||
/// stored at its world place in the component.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// With `activate_part`, when it made another component active.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activated: Option<Activated>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// sketch.construction, sketch.delete
|
||||
|
||||
fn curves_required(schema: &mut Schema) {
|
||||
require(schema, "curves");
|
||||
}
|
||||
|
||||
fn entities_required(schema: &mut Schema) {
|
||||
require(schema, "entities");
|
||||
}
|
||||
|
||||
/// Switch curves between normal and construction. The selection switches together (a slot or
|
||||
/// polygon as a whole): to construction unless all of it already is. Projected curves may
|
||||
/// switch too. A centerline that becomes construction stops being a centerline, and a normal
|
||||
/// line is a finite one. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = curves_required)]
|
||||
pub struct ConstructionParams {
|
||||
// Optional in the type and required in the schema (`curves_required`).
|
||||
/// The curves, by id.
|
||||
#[serde(default, deserialize_with = "curves", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "EntityIds")]
|
||||
pub curves: Option<EntityIds>,
|
||||
/// True makes them construction, false makes them normal. Default: construction unless all of
|
||||
/// them already are.
|
||||
#[serde(default, deserialize_with = "value", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub value: Option<bool>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.construction` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ConstructionResult {
|
||||
/// How many curves changed.
|
||||
pub changed: usize,
|
||||
/// Whether they are construction now.
|
||||
pub construction: bool,
|
||||
/// The sketch's solve; 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>,
|
||||
}
|
||||
|
||||
/// Delete curves, points and constraints (dimensions too) from a sketch, by id; a sketch's
|
||||
/// wires and linked geometry too. The sketch origin cannot be deleted. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = entities_required)]
|
||||
pub struct DeleteParams {
|
||||
// Optional in the type and required in the schema (`entities_required`).
|
||||
/// What to delete: curve, point, constraint, wire or link ids.
|
||||
#[serde(default, deserialize_with = "entities", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "EntityIds")]
|
||||
pub entities: Option<EntityIds>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.delete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DeleteResult {
|
||||
/// How many ids were given (all of them were found).
|
||||
pub deleted: usize,
|
||||
/// The sketch's solve; 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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// sketch.move_point, sketch.drag, sketch.drag_end
|
||||
|
||||
fn move_point_required(schema: &mut Schema) {
|
||||
require(schema, "point");
|
||||
require(schema, "to");
|
||||
}
|
||||
|
||||
/// Move a sketch point to a position and solve the sketch within its constraints: the point
|
||||
/// goes as far toward it as they allow. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = move_point_required)]
|
||||
pub struct MovePointParams {
|
||||
// `point` and `to` are optional in the type and required in the schema
|
||||
// (`move_point_required`).
|
||||
/// The point, by id (`p3`, `l1.end`, `origin`).
|
||||
#[serde(default, deserialize_with = "point_id", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub point: Option<String>,
|
||||
/// Where to move it, in sketch coordinates (mm).
|
||||
#[serde(default, deserialize_with = "to_point", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point2")]
|
||||
pub to: Option<Point2>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.move_point` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MovePointResult {
|
||||
/// The sketch's solve; 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>,
|
||||
}
|
||||
|
||||
fn drag_required(schema: &mut Schema) {
|
||||
require(schema, "entity");
|
||||
require(schema, "to");
|
||||
}
|
||||
|
||||
/// Drag a sketch point or curve from where it was grabbed to a position: points and the
|
||||
/// material point grabbed on a line move toward it within the design constraints, and when exact
|
||||
/// placement is impossible, bounded feasible steps minimize the cursor distance. Splines slide
|
||||
/// by `to` minus `from`; a circle or arc grabbed on its edge takes the radius to `to`. Long
|
||||
/// moves keep the solution branch, and arcs cannot sweep through their starts. With
|
||||
/// `live: true` (one frame of a drag; such calls are not checked against this schema yet), later
|
||||
/// features stay stale until `sketch.drag_end`, and `dof` stays from before the drag.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = drag_required)]
|
||||
pub struct DragParams {
|
||||
// `entity` and `to` are optional in the type and required in the schema (`drag_required`).
|
||||
/// The point or curve to drag, by id.
|
||||
#[serde(default, deserialize_with = "entity_id", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub entity: Option<String>,
|
||||
/// Where it was grabbed, in sketch coordinates (mm). Default: `to`.
|
||||
#[serde(default, deserialize_with = "from_point", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<Point2>")]
|
||||
pub from: Option<Point2>,
|
||||
/// Where it is dragged to, in sketch coordinates (mm).
|
||||
#[serde(default, deserialize_with = "to_point", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point2")]
|
||||
pub to: Option<Point2>,
|
||||
/// Other points and curves that move with the entity, as one translation (circles keep
|
||||
/// their radii).
|
||||
#[serde(default, deserialize_with = "with_ids", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<DragWith>")]
|
||||
pub with: Option<DragWith>,
|
||||
/// One frame of an interactive drag: the sketch is solved and shown, and the features after
|
||||
/// it stay as they were until `sketch.drag_end`. Default: false.
|
||||
#[serde(default, deserialize_with = "live", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub live: Option<bool>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.drag` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DragResult {
|
||||
/// The sketch's solve; null when it was not solved again.
|
||||
pub sketch: Option<SketchSolve>,
|
||||
/// How far the drag got: progress projected onto the requested move, 0 to 1.
|
||||
pub reached: f64,
|
||||
/// Where the grabbed position is now, for the next frame: [x, y].
|
||||
pub at: [f64; 2],
|
||||
/// Whether work stopped at its budget after making progress: another frame can continue.
|
||||
pub retry: 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>,
|
||||
}
|
||||
|
||||
/// The constraint a drag let go on, as `sketch.drag_end` reads `constraint`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DragConstraint {
|
||||
/// A `sketch.constraint.*` command.
|
||||
pub command: String,
|
||||
/// Its parameters, as that command takes them. Default: none.
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DragConstraint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
let name = value.get("command").and_then(Value::as_str).unwrap_or_default();
|
||||
if !crate::cmd::find_command(name).is_some_and(|spec| spec.id.starts_with("sketch.constraint.")) {
|
||||
return Err(refuse("`constraint.command` must be a sketch.constraint.* command"));
|
||||
}
|
||||
Ok(DragConstraint { command: name.to_string(), params: value.get("params").cloned() })
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DragConstraint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DragConstraint".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The constraint the drag let go on: a `sketch.constraint.*` command and its parameters. It is added when it does not over-constrain the sketch.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"description": "A sketch.constraint.* command id.", "type": "string", "pattern": "^sketch\\.constraint\\."},
|
||||
"params": {"description": "The command's parameters."}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// End a drag made of `sketch.drag` frames with `live: true`: add the constraint it let go on
|
||||
/// (not kept if it over-constrains the sketch), then evaluate the whole model once. Returns the
|
||||
/// constraint's result and the sketch's solved state and degrees of freedom. It reads only the
|
||||
/// design, not the evaluated model.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DragEndParams {
|
||||
/// The constraint to add, `{command, params}`. Default: none.
|
||||
#[serde(default)]
|
||||
pub constraint: Option<DragConstraint>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.drag_end` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DragEndResult {
|
||||
/// What the constraint command returned; `{"error": message}` when it could not be added (it
|
||||
/// is not kept); null when no constraint was given.
|
||||
pub constraint: Value,
|
||||
/// The sketch's solve; 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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// sketch.dimension_text
|
||||
|
||||
/// `sketch.dimension_text`'s keys.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = dimension_text_required)]
|
||||
pub struct DimensionTextKeys {
|
||||
// `dimension` is optional in the type and required in the schema, and so is `at` unless
|
||||
// `reset` is true (`dimension_text_required`).
|
||||
/// The dimension: a constraint id, or the name of the parameter it drives.
|
||||
#[serde(default, deserialize_with = "dimension_id", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub dimension: Option<String>,
|
||||
/// Where the text's centre goes, in sketch coordinates (mm). Not read with `reset: true`.
|
||||
#[serde(default, deserialize_with = "at_point", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point2")]
|
||||
pub at: Option<Point2>,
|
||||
/// True puts the text back in its default place; `at` is then not read. Default: false.
|
||||
#[serde(default, deserialize_with = "reset", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub reset: Option<bool>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
fn dimension_text_required(schema: &mut Schema) {
|
||||
require(schema, "dimension");
|
||||
schema.insert("anyOf".into(), json!([{"required": ["at"]}, {"required": ["reset"], "properties": {"reset": {"enum": [true]}}}]));
|
||||
}
|
||||
|
||||
/// Move the text of a dimension to a position in the sketch, or back to its default place. The
|
||||
/// text is kept relative to the dimension, so it follows the dimension when the sketch changes.
|
||||
/// One undo step.
|
||||
#[derive(Debug)]
|
||||
pub struct DimensionTextParams(pub DimensionTextKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for DimensionTextParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
// `at` is read only without `reset: true`.
|
||||
if value.get("reset").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(keys) = value.as_object_mut()
|
||||
{
|
||||
keys.remove("at");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(DimensionTextParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DimensionTextParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DimensionTextParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = DimensionTextKeys::json_schema(generator);
|
||||
schema.insert(
|
||||
"description".into(),
|
||||
"Move the text of a dimension to a position in the sketch, or back to its default place. The text is kept relative to the dimension, so it follows the dimension when the sketch changes. One undo step."
|
||||
.into(),
|
||||
);
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.dimension_text` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DimensionTextResult {
|
||||
/// The dimension's constraint id.
|
||||
pub dimension: String,
|
||||
/// The sketch's solve; 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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// sketch.options
|
||||
|
||||
/// Show or hide what a sketch's palette shows, and read the options. Without any option, only
|
||||
/// reads them. Changing an option is one undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct OptionsParams {
|
||||
/// Show the profiles (the filled regions).
|
||||
#[serde(default, deserialize_with = "show_profile", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub show_profile: Option<bool>,
|
||||
/// Show the points.
|
||||
#[serde(default, deserialize_with = "show_points", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub show_points: Option<bool>,
|
||||
/// Show the dimensions.
|
||||
#[serde(default, deserialize_with = "show_dimensions", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub show_dimensions: Option<bool>,
|
||||
/// Show the constraint marks.
|
||||
#[serde(default, deserialize_with = "show_constraints", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub show_constraints: Option<bool>,
|
||||
/// Show projected geometry.
|
||||
#[serde(default, deserialize_with = "show_projected", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub show_projected: Option<bool>,
|
||||
/// Show construction geometry.
|
||||
#[serde(default, deserialize_with = "show_construction", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub show_construction: Option<bool>,
|
||||
/// Show the grid.
|
||||
#[serde(default, deserialize_with = "grid", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub grid: Option<bool>,
|
||||
/// Clicks snap to the grid.
|
||||
#[serde(default, deserialize_with = "snap", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub snap: Option<bool>,
|
||||
/// Cut away the model in front of the sketch plane while editing.
|
||||
#[serde(default, deserialize_with = "slice", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub slice: Option<bool>,
|
||||
/// Make it a 3D sketch: drawing may leave the sketch plane.
|
||||
#[serde(default, deserialize_with = "sketch_3d", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<bool>")]
|
||||
pub sketch_3d: Option<bool>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// A sketch's palette options: true is shown (or on).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchOptions {
|
||||
/// The profiles are shown.
|
||||
pub show_profile: bool,
|
||||
/// The points are shown.
|
||||
pub show_points: bool,
|
||||
/// The dimensions are shown.
|
||||
pub show_dimensions: bool,
|
||||
/// The constraint marks are shown.
|
||||
pub show_constraints: bool,
|
||||
/// Projected geometry is shown.
|
||||
pub show_projected: bool,
|
||||
/// Construction geometry is shown.
|
||||
pub show_construction: bool,
|
||||
/// The grid is shown.
|
||||
pub grid: bool,
|
||||
/// Clicks snap to the grid.
|
||||
pub snap: bool,
|
||||
/// The model in front of the sketch plane is cut away while editing.
|
||||
pub slice: bool,
|
||||
/// It is a 3D sketch: drawing may leave the sketch plane.
|
||||
pub sketch_3d: bool,
|
||||
}
|
||||
|
||||
/// What `sketch.options` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct OptionsResult {
|
||||
/// The options as they are, or as they are after a change; with a change, also the sketch's
|
||||
/// solve.
|
||||
#[serde(flatten)]
|
||||
pub options: OptionsReply,
|
||||
/// 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 options as they are, or as they are after a change.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum OptionsReply {
|
||||
/// No option was given: only read.
|
||||
Read {
|
||||
/// All the options.
|
||||
options: SketchOptions,
|
||||
},
|
||||
/// Options were given: set.
|
||||
Changed {
|
||||
/// All the options, after the change.
|
||||
options: SketchOptions,
|
||||
/// The sketch's solve; null when it was not solved again.
|
||||
sketch: Option<SketchSolve>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// sketch.chain, sketch.solve
|
||||
|
||||
fn curve_required(schema: &mut Schema) {
|
||||
require(schema, "curve");
|
||||
}
|
||||
|
||||
/// The curves joined end to end to a curve, as a double-click selects them. The design is not
|
||||
/// changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = curve_required)]
|
||||
pub struct ChainParams {
|
||||
// Optional in the type and required in the schema (`curve_required`).
|
||||
/// The curve, by id.
|
||||
#[serde(default, deserialize_with = "curve_id", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub curve: Option<String>,
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.chain` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ChainResult {
|
||||
/// The chain's curves, by id, the given one included.
|
||||
pub curves: 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>,
|
||||
}
|
||||
|
||||
/// Report a sketch's solver status and degrees of freedom, as last evaluated. The design is not
|
||||
/// changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SolveParams {
|
||||
/// The sketch. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Whether the solver satisfied the constraints: `solved` or `failed`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SolveOutcome {
|
||||
// `solved`: all constraints are satisfied; `failed`: they conflict (or the solver could not
|
||||
// satisfy them) and the geometry stays as it was before solving.
|
||||
Solved,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl From<solidcraft_sketch::SolveStatus> for SolveOutcome {
|
||||
fn from(status: solidcraft_sketch::SolveStatus) -> Self {
|
||||
match status {
|
||||
solidcraft_sketch::SolveStatus::Solved => SolveOutcome::Solved,
|
||||
solidcraft_sketch::SolveStatus::Failed => SolveOutcome::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.solve` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SolveResult {
|
||||
/// The solver's status.
|
||||
pub status: SolveOutcome,
|
||||
/// The degrees of freedom left (0: fully constrained).
|
||||
pub dof: usize,
|
||||
/// The constraints still violated after a failed solve, by id.
|
||||
pub failing: Vec<String>,
|
||||
/// How many profiles the sketch has.
|
||||
pub profiles: 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 = "sketch_session_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,593 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DimensionTextParams,
|
||||
DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, MovePointParams, MovePointResult, OptionsParams, OptionsReply,
|
||||
OptionsResult, SketchOptions, SolveParams, SolveResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
/// A session with a sketch being edited that holds a square (10 mm either side of the origin) and
|
||||
/// the ids of its four sides.
|
||||
fn editing(level: ParamCheck) -> (Session, Vec<String>) {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let r = s.execute("sketch.rectangle.center", &json!({"center": [0, 0], "corner": [10, 10]})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sides = r["created"]["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
(s, sides)
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn error(level: ParamCheck, id: &str, p: &Value) -> Option<String> {
|
||||
let (mut s, _) = editing(level);
|
||||
s.execute(id, p).err().map(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A call the handler already refuses gets the same message in strict, with a sketch being edited.
|
||||
/// (`assert_strict_keeps_todays_errors` runs after the sketch is finished, so a refusal that comes
|
||||
/// from the design's side would read "no sketch is being edited" there.)
|
||||
fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
fn messages(id: &str, p: Value) -> Vec<String> {
|
||||
issues(id, &p).into_iter().map(|i| i.message).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<CreateParams>(),
|
||||
&["activate_part", "face", "flip", "frame", "name", "offset", "origin", "plane", "project_edges", "x_dir", "y_dir"],
|
||||
);
|
||||
assert_keys(input_schema_for::<ConstructionParams>(), &["curves", "sketch", "value"]);
|
||||
assert_keys(input_schema_for::<DeleteParams>(), &["entities", "sketch"]);
|
||||
assert_keys(input_schema_for::<MovePointParams>(), &["point", "sketch", "to"]);
|
||||
assert_keys(input_schema_for::<DragParams>(), &["entity", "from", "live", "sketch", "to", "with"]);
|
||||
assert_keys(input_schema_for::<DragEndParams>(), &["constraint", "sketch"]);
|
||||
assert_keys(input_schema_for::<DimensionTextParams>(), &["at", "dimension", "reset", "sketch"]);
|
||||
assert_keys(
|
||||
input_schema_for::<OptionsParams>(),
|
||||
&[
|
||||
"grid",
|
||||
"show_constraints",
|
||||
"show_construction",
|
||||
"show_dimensions",
|
||||
"show_points",
|
||||
"show_profile",
|
||||
"show_projected",
|
||||
"sketch",
|
||||
"sketch_3d",
|
||||
"slice",
|
||||
"snap",
|
||||
],
|
||||
);
|
||||
assert_keys(input_schema_for::<ChainParams>(), &["curve", "sketch"]);
|
||||
assert_keys(input_schema_for::<SolveParams>(), &["sketch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.create", json!({"face": [0, 0, 0]})),
|
||||
("sketch.create", json!({"origin": null})),
|
||||
("sketch.create", json!({"x_dir": [1, 0, 0]})),
|
||||
("sketch.create", json!({"y_dir": 3})),
|
||||
("sketch.create", json!({"activate_part": "yes"})),
|
||||
("sketch.create", json!({"frame": "local", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
|
||||
("sketch.create", json!({"frame": 1})),
|
||||
("sketch.create", json!({"frame": "world"})),
|
||||
("sketch.create", json!({"frame": "component", "plane": "XY"})),
|
||||
("sketch.create", json!({"plane": true})),
|
||||
("sketch.create", json!({"plane": null})),
|
||||
("sketch.create", json!({"plane": [1]})),
|
||||
("sketch.create", json!({"plane": 1.5})),
|
||||
("sketch.create", json!({"plane": -1})),
|
||||
("sketch.create", json!({"plane": "Nope"})),
|
||||
("sketch.create", json!({"plane": {"x_dir": [1, 0, 0]}})),
|
||||
("sketch.create", json!({"plane": {"y_dir": [1, 0, 0]}})),
|
||||
("sketch.create", json!({"plane": {"x_dir": [1, 0, 0], "y_dir": [2, 0, 0]}})),
|
||||
("sketch.create", json!({"plane": {"origin": "x", "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
|
||||
("sketch.create", json!({"plane": {"face": 5}})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 0], "fixed": "yes"}})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 0], "body": ""}})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 0], "body": "B"}})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 0], "index": -1}})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 0], "path": [0]}})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 0], "path": "x"}})),
|
||||
("sketch.create", json!({"flip": "z"})),
|
||||
("sketch.create", json!({"flip": 1})),
|
||||
("sketch.construction", json!({})),
|
||||
("sketch.construction", json!({"curves": []})),
|
||||
("sketch.construction", json!({"curves": null})),
|
||||
("sketch.construction", json!({"curves": [1]})),
|
||||
("sketch.construction", json!({"curves": ["l1", 2]})),
|
||||
("sketch.construction", json!({"curves": 5})),
|
||||
("sketch.construction", json!({"curves": ["nope"]})),
|
||||
("sketch.construction", json!({"curves": ["l1"], "sketch": true})),
|
||||
("sketch.construction", json!({"curves": ["l1"], "sketch": -1})),
|
||||
("sketch.construction", json!({"curves": ["l1"], "sketch": 99})),
|
||||
("sketch.delete", json!({})),
|
||||
("sketch.delete", json!({"entities": []})),
|
||||
("sketch.delete", json!({"entities": {"a": 1}})),
|
||||
("sketch.delete", json!({"entities": ["nope"]})),
|
||||
("sketch.delete", json!({"entities": ["origin"]})),
|
||||
("sketch.move_point", json!({})),
|
||||
("sketch.move_point", json!({"to": [1, 2]})),
|
||||
("sketch.move_point", json!({"to": [1, 2], "point": 5})),
|
||||
("sketch.move_point", json!({"to": [1, 2], "point": null})),
|
||||
("sketch.move_point", json!({"to": "x", "point": "origin"})),
|
||||
("sketch.move_point", json!({"to": null, "point": "origin"})),
|
||||
("sketch.move_point", json!({"to": [1], "point": "origin"})),
|
||||
("sketch.move_point", json!({"to": [1, 2], "point": "nope"})),
|
||||
("sketch.drag", json!({})),
|
||||
("sketch.drag", json!({"to": [1, 2]})),
|
||||
("sketch.drag", json!({"to": [1, 2], "entity": 3})),
|
||||
("sketch.drag", json!({"to": [1, 2], "entity": "nope"})),
|
||||
("sketch.drag", json!({"to": 3, "entity": "l1"})),
|
||||
("sketch.drag", json!({"to": [1, 2], "from": "a", "entity": "l1"})),
|
||||
("sketch.drag", json!({"to": [1, 2], "from": [1], "entity": "l1"})),
|
||||
("sketch.drag", json!({"to": [1, 2], "entity": "l1", "with": "l2"})),
|
||||
("sketch.drag", json!({"to": [1, 2], "entity": "l1", "with": [1]})),
|
||||
("sketch.drag", json!({"to": [1, 2], "entity": "l1", "with": ["nope"]})),
|
||||
("sketch.drag_end", json!({"constraint": {"command": "solid.box"}})),
|
||||
("sketch.drag_end", json!({"constraint": {"command": 5}})),
|
||||
("sketch.drag_end", json!({"constraint": {}})),
|
||||
("sketch.drag_end", json!({"constraint": 5})),
|
||||
("sketch.drag_end", json!({"constraint": "sketch.constraint.fix"})),
|
||||
("sketch.drag_end", json!({"sketch": true})),
|
||||
("sketch.dimension_text", json!({})),
|
||||
("sketch.dimension_text", json!({"at": [1, 2]})),
|
||||
("sketch.dimension_text", json!({"dimension": 5, "at": [1, 2]})),
|
||||
("sketch.dimension_text", json!({"dimension": null, "reset": true})),
|
||||
("sketch.dimension_text", json!({"dimension": "d1"})),
|
||||
("sketch.dimension_text", json!({"dimension": "d1", "at": "x"})),
|
||||
("sketch.dimension_text", json!({"dimension": "d1", "at": null})),
|
||||
("sketch.dimension_text", json!({"dimension": "nope", "at": [1, 2]})),
|
||||
("sketch.options", json!({"sketch": true})),
|
||||
("sketch.options", json!({"sketch": 99})),
|
||||
("sketch.options", json!({"show_points": false, "sketch": "nope"})),
|
||||
("sketch.chain", json!({})),
|
||||
("sketch.chain", json!({"curve": 5})),
|
||||
("sketch.chain", json!({"curve": null})),
|
||||
("sketch.chain", json!({"curve": "nope"})),
|
||||
("sketch.chain", json!({"curve": "l1", "sketch": [1]})),
|
||||
("sketch.solve", json!({"sketch": true})),
|
||||
("sketch.solve", json!({"sketch": 99})),
|
||||
("sketch.solve", json!({"sketch": "nope"})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A flip that is refused on a face gets the same message in strict.
|
||||
#[test]
|
||||
fn strict_keeps_the_flip_refusal_on_a_face() {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10}));
|
||||
let at = json!({"face": [10, 10, 10]});
|
||||
for flip in [json!(true), json!("x"), json!("Y")] {
|
||||
let e = s.execute("sketch.create", &json!({"plane": at, "flip": flip}));
|
||||
assert!(e.is_err(), "{flip}");
|
||||
}
|
||||
s.execute("sketch.create", &json!({"plane": at, "flip": true})).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.as_deref().is_some_and(|e| e.contains("a face already faces outward")), "{off:?}");
|
||||
assert_eq!(error(ParamCheck::Strict), off);
|
||||
}
|
||||
|
||||
/// `conforms` doesn't read patterns, so the text of the two that say what a reader checks.
|
||||
#[test]
|
||||
fn the_patterns_are_pinned() {
|
||||
let schema = input_schema_for::<CreateParams>();
|
||||
assert_eq!(schema.as_value().pointer("/$defs/Flip/anyOf/1/pattern"), Some(&json!("^[xXyY]$")));
|
||||
let schema = input_schema_for::<DragEndParams>();
|
||||
assert_eq!(schema.as_value().pointer("/$defs/DragConstraint/properties/command/pattern"), Some(&json!("^sketch\\.constraint\\.")));
|
||||
for (flip, ok) in [("x", true), ("Y", true), ("z", false), ("xy", false), ("", false)] {
|
||||
assert_eq!(messages("sketch.create", json!({"flip": flip})).is_empty(), ok, "{flip}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each key is read in the cases its handler reads it, so a call that works today gets no warning:
|
||||
/// `project_edges` is read only for a picked face, `frame` and `reset` as the handlers do, `at`
|
||||
/// only without `reset: true`, a constraint only when it is not `null`.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handler_does() {
|
||||
// sketch.create: `project_edges` is read only on a face; the handler ignores it elsewhere.
|
||||
assert!(messages("sketch.create", json!({"plane": "XY", "project_edges": "yes"})).is_empty());
|
||||
assert!(messages("sketch.create", json!({"project_edges": [1]})).is_empty());
|
||||
assert_eq!(messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "project_edges": "yes"})), ["`project_edges` must be true or false"]);
|
||||
assert!(messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "project_edges": false, "activate_part": true})).is_empty());
|
||||
// `frame` is for an explicit plane only, and `null` reads as left out.
|
||||
let explicit = json!({"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]});
|
||||
assert!(messages("sketch.create", json!({"plane": explicit, "frame": "component"})).is_empty());
|
||||
assert!(messages("sketch.create", json!({"plane": explicit, "frame": null, "flip": "X"})).is_empty());
|
||||
assert!(messages("sketch.create", json!({"plane": "XZ", "frame": null, "flip": null, "offset": "w / 2", "name": "S"})).is_empty());
|
||||
assert_eq!(messages("sketch.create", json!({"plane": explicit, "frame": "local"})), ["`frame` must be \"world\" or \"component\""]);
|
||||
assert!(messages("sketch.create", json!({"plane": "XY", "frame": "world"}))[0].starts_with("`frame` is for an explicit plane"));
|
||||
// The keys that belong inside `plane` are refused with their own message, not as unknown keys.
|
||||
assert_eq!(messages("sketch.create", json!({"face": [0, 0, 1]})), [r#"`face` belongs inside `plane`: {"plane": {"face": [x, y, z]}}"#]);
|
||||
assert_eq!(messages("sketch.create", json!({"plane": "XY", "x_dir": null})).len(), 1);
|
||||
// A face is not flipped, but `flip: false` is fine.
|
||||
assert!(messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "flip": false})).is_empty());
|
||||
assert_eq!(
|
||||
messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "flip": true})),
|
||||
["`flip` is for origin, construction and explicit planes; a face already faces outward"]
|
||||
);
|
||||
|
||||
// sketch.dimension_text: `at` is read only without `reset: true`.
|
||||
assert!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": true, "at": "anywhere"})).is_empty());
|
||||
assert!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": true})).is_empty());
|
||||
assert_eq!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": false, "at": 1})), ["`at` must be a point [x, y]"]);
|
||||
assert_eq!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": "yes"})), ["`reset` must be true or false"]);
|
||||
assert!(messages("sketch.dimension_text", json!({"dimension": "d1", "at": {"x": 1, "y": 2}})).is_empty());
|
||||
|
||||
// sketch.drag: `from` may be `null`; `with` too.
|
||||
assert!(messages("sketch.drag", json!({"entity": "l1", "to": [1, 2], "from": null, "with": null, "live": null})).is_empty());
|
||||
assert!(messages("sketch.drag", json!({"entity": "l1", "to": {"x": 1, "y": 2}, "from": [0, 0], "with": ["l2", "p3"], "live": false})).is_empty());
|
||||
assert_eq!(messages("sketch.drag", json!({"entity": "l1", "to": [1, 2], "live": 1})), ["`live` must be true or false"]);
|
||||
|
||||
// sketch.drag_end: a constraint only when it is not `null`; its params are the command's own.
|
||||
assert!(messages("sketch.drag_end", json!({"constraint": null, "sketch": null})).is_empty());
|
||||
assert!(messages("sketch.drag_end", json!({"constraint": {"command": "sketch.constraint.fix", "params": {"point": "origin"}}})).is_empty());
|
||||
assert!(messages("sketch.drag_end", json!({"constraint": {"command": "sketch.constraint.fix"}})).is_empty());
|
||||
assert_eq!(
|
||||
messages("sketch.drag_end", json!({"constraint": {"command": "sketch.line"}})),
|
||||
["`constraint.command` must be a sketch.constraint.* command"]
|
||||
);
|
||||
|
||||
// Lists of ids: one text or a list of text, as `string_list` reads them.
|
||||
assert!(messages("sketch.construction", json!({"curves": "l1", "value": null})).is_empty());
|
||||
assert!(messages("sketch.delete", json!({"entities": ["l1", "p2", "d1"]})).is_empty());
|
||||
assert_eq!(messages("sketch.construction", json!({"curves": ["l1", 2]})), ["`curves` must list names; the entry 2 is 2"]);
|
||||
assert_eq!(messages("sketch.construction", json!({"curves": ["l1"], "value": "yes"})), ["`value` must be true or false"]);
|
||||
assert_eq!(messages("sketch.delete", json!({"entities": true})), ["`entities` must be a list of names; it is true"]);
|
||||
|
||||
// sketch.options: the options are flags; anything else is named.
|
||||
assert!(messages("sketch.options", json!({"show_points": false, "grid": true, "slice": null, "sketch": 3})).is_empty());
|
||||
assert_eq!(messages("sketch.options", json!({"snap": "on"})), ["`snap` must be true or false"]);
|
||||
assert_eq!(messages("sketch.options", json!({"show_profil": true})), ["unknown key `show_profil`; did you mean `show_profile`?"]);
|
||||
}
|
||||
|
||||
/// What the types accept, the handlers accept in Off: each call below runs there and has no issue.
|
||||
#[test]
|
||||
fn what_the_types_accept_the_handlers_run() {
|
||||
let (mut s, sides) = editing(ParamCheck::Off);
|
||||
let bottom = sides[0].clone();
|
||||
for (id, p) in [
|
||||
("sketch.solve", json!({"sketch": null})),
|
||||
("sketch.chain", json!({"curve": bottom, "sketch": null})),
|
||||
("sketch.options", json!({"show_points": null, "grid": null})),
|
||||
("sketch.construction", json!({"curves": bottom, "value": null})),
|
||||
("sketch.move_point", json!({"point": "origin", "to": {"x": 0, "y": 0}})),
|
||||
("sketch.drag", json!({"entity": bottom, "to": [0, -10], "from": null, "with": null, "live": null})),
|
||||
("sketch.drag_end", json!({"constraint": null})),
|
||||
("sketch.create", json!({"plane": "XY", "frame": null, "flip": false, "project_edges": "ignored"})),
|
||||
] {
|
||||
assert!(messages(id, p.clone()).is_empty(), "{id} {p}");
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The contracts say what the handlers need: the keys a handler refuses without are required in
|
||||
/// the schemas, though the types leave them to the handlers' messages.
|
||||
#[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!({"plane": "XZ", "offset": 5, "flip": "x", "name": "S"}), true),
|
||||
(json!({"plane": 3, "flip": true}), true),
|
||||
(
|
||||
json!({"plane": {"face": [1, 2, 3], "body": "B", "index": 2, "path": [1], "fixed": true}, "project_edges": false, "activate_part": true}),
|
||||
true,
|
||||
),
|
||||
(json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "frame": "component"}), true),
|
||||
(json!({"plane": "XY", "frame": "nowhere"}), false),
|
||||
(json!({"plane": 1.5}), false),
|
||||
(json!({"plane": null}), false),
|
||||
(json!({"face": [1, 2, 3]}), false),
|
||||
(json!({"x_dir": null}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ConstructionParams>(),
|
||||
&[
|
||||
(json!({"curves": ["l1", "l2"]}), true),
|
||||
(json!({"curves": "l1", "value": false, "sketch": "S"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"curves": []}), false),
|
||||
(json!({"curves": [1]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DeleteParams>(),
|
||||
&[
|
||||
(json!({"entities": ["l1", "d1"]}), true),
|
||||
(json!({"entities": "p3", "sketch": 4}), true),
|
||||
(json!({}), false),
|
||||
(json!({"entities": []}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MovePointParams>(),
|
||||
&[
|
||||
(json!({"point": "l1.end", "to": [1, 2]}), true),
|
||||
(json!({"point": "p1", "to": {"x": 1, "y": 2}}), true),
|
||||
(json!({"to": [1, 2]}), false),
|
||||
(json!({"point": "p1"}), false),
|
||||
(json!({"point": 3, "to": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DragParams>(),
|
||||
&[
|
||||
(json!({"entity": "l1", "to": [1, 2]}), true),
|
||||
(json!({"entity": "c1", "from": [0, 0], "to": [1, 2], "with": ["p1"], "live": true, "sketch": "S"}), true),
|
||||
(json!({"entity": "c1", "from": null, "to": [1, 2], "with": null}), true),
|
||||
(json!({"entity": "l1"}), false),
|
||||
(json!({"to": [1, 2]}), false),
|
||||
(json!({"entity": "l1", "to": [1, 2], "with": "p1"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DragEndParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"constraint": {"command": "sketch.constraint.fix", "params": {"point": "p1"}}}), true),
|
||||
(json!({"constraint": {"params": {}}}), false),
|
||||
(json!({"constraint": 5}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DimensionTextParams>(),
|
||||
&[
|
||||
(json!({"dimension": "d1", "at": [1, 2]}), true),
|
||||
(json!({"dimension": "d1", "reset": true}), true),
|
||||
(json!({"dimension": "d1", "reset": true, "at": [1, 2]}), true),
|
||||
(json!({"dimension": "d1", "reset": false}), false),
|
||||
(json!({"dimension": "d1"}), false),
|
||||
(json!({"at": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<OptionsParams>(),
|
||||
&[(json!({}), true), (json!({"show_points": false, "sketch_3d": true, "sketch": "S"}), true), (json!({"grid": "yes"}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ChainParams>(),
|
||||
&[(json!({"curve": "l1"}), true), (json!({"curve": "l1", "sketch": 2}), true), (json!({}), false), (json!({"curve": 1}), false)],
|
||||
);
|
||||
takes(input_schema_for::<SolveParams>(), &[(json!({}), true), (json!({"sketch": "S"}), true), (json!({"sketch": true}), 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}");
|
||||
}
|
||||
|
||||
/// Every form of Create Sketch, in Warn: a reply that matches the output schema, no warning.
|
||||
#[test]
|
||||
fn create_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let made = |s: &mut Session, p: Value| {
|
||||
let r = run(s, "sketch.create", p);
|
||||
matches(output_schema_for::<CreateResult>(), "sketch.create", &r);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
r
|
||||
};
|
||||
let first = made(&mut s, json!({"plane": "XY"}));
|
||||
assert_eq!(first["attachment"], "fixed", "{first}");
|
||||
assert!(first["projected"].is_null() && first.get("frame").is_none(), "{first}");
|
||||
made(&mut s, json!({}));
|
||||
made(&mut s, json!({"plane": "xz", "offset": 5, "name": "Wall", "flip": "x"}));
|
||||
made(&mut s, json!({"plane": "YZ", "offset": "10 / 2", "flip": true, "activate_part": false}));
|
||||
made(&mut s, json!({"plane": "XY", "flip": false, "frame": null, "project_edges": 7}));
|
||||
let explicit = json!({"origin": [0, 0, 5], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]});
|
||||
let world = made(&mut s, json!({"plane": explicit}));
|
||||
assert_eq!(world["frame"], "world", "{world}");
|
||||
let own = made(&mut s, json!({"plane": explicit, "frame": "component", "flip": "Y"}));
|
||||
assert_eq!(own["frame"], "component", "{own}");
|
||||
// A construction plane by name and by id.
|
||||
let plane = run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 12, "name": "Mid"}));
|
||||
made(&mut s, json!({"plane": "Mid"}));
|
||||
made(&mut s, json!({"plane": plane["feature"]}));
|
||||
// A picked face, with its edges projected or not, and in another component.
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Block"}));
|
||||
let top = made(&mut s, json!({"plane": {"face": [10, 10, 10]}}));
|
||||
assert_eq!(top["attachment"], "exact_face", "{top}");
|
||||
assert!(top["projected"].is_string(), "{top}");
|
||||
let bare = made(&mut s, json!({"plane": {"face": [10, 10, 10], "fixed": true}, "project_edges": false, "flip": false}));
|
||||
assert!(bare["projected"].is_null(), "{bare}");
|
||||
// The exact pick: the face's index is the one of the six that holds the point.
|
||||
let exact = (0..6).find_map(|index| {
|
||||
let pick = json!({"plane": {"face": [10, 10, 10], "body": "Block", "index": index}, "activate_part": true, "name": "On face"});
|
||||
s.execute("sketch.create", &pick).ok().map(|r| (pick, r))
|
||||
});
|
||||
let (_, exact) = exact.unwrap_or_else(|| panic!("no face of the box holds the point"));
|
||||
matches(output_schema_for::<CreateResult>(), "sketch.create", &exact);
|
||||
assert_eq!(exact["attachment"], "exact_face", "{exact}");
|
||||
}
|
||||
|
||||
/// The reply's `note` (a world plane into a moved component) and `activated` (a face of another
|
||||
/// component's body), in Warn: both match the output schema and carry no warning.
|
||||
#[test]
|
||||
fn create_note_and_activated_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": part["occurrence"], "translate": [10, 0, 0]}));
|
||||
let r = run(&mut s, "sketch.create", json!({"plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
assert!(r["note"].as_str().is_some_and(|n| n.contains("world coordinates")) && r["frame"] == "world", "{r}");
|
||||
matches(output_schema_for::<CreateResult>(), "sketch.create", &r);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Another component with a body, then the root active: a face pick with `activate_part`.
|
||||
run(&mut s, "component.create", json!({"name": "Other", "grounded": true}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Block"}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
let r = (0..6)
|
||||
.find_map(|index| {
|
||||
let pick = json!({"plane": {"face": [10, 10, 10], "body": "Block", "index": index}, "activate_part": true});
|
||||
s.execute("sketch.create", &pick).ok()
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no face of the box holds the point"));
|
||||
assert!(r["activated"]["component"].is_u64() && r["activated"]["path"].is_array(), "{r}");
|
||||
matches(output_schema_for::<CreateResult>(), "sketch.create", &r);
|
||||
}
|
||||
|
||||
/// The editing commands, in Warn, on a sketch being edited: each reply matches its output schema
|
||||
/// and carries no warning.
|
||||
#[test]
|
||||
fn editing_replies_match_the_output_schema() {
|
||||
let (mut s, sides) = editing(ParamCheck::Warn);
|
||||
let (bottom, right) = (sides[0].clone(), sides[1].clone());
|
||||
let sketch = s.active_sketch.unwrap_or_default();
|
||||
|
||||
let r = run(&mut s, "sketch.solve", json!({}));
|
||||
assert!(r["status"] == "solved" && r["failing"] == json!([]), "{r}");
|
||||
matches(output_schema_for::<SolveResult>(), "sketch.solve", &r);
|
||||
matches(output_schema_for::<SolveResult>(), "sketch.solve", &run(&mut s, "sketch.solve", json!({"sketch": sketch})));
|
||||
|
||||
let r = run(&mut s, "sketch.chain", json!({"curve": bottom}));
|
||||
assert!(r["curves"].as_array().is_some_and(|c| c.iter().any(|id| id == bottom.as_str())), "{r}");
|
||||
matches(output_schema_for::<ChainResult>(), "sketch.chain", &r);
|
||||
matches(output_schema_for::<ChainResult>(), "sketch.chain", &run(&mut s, "sketch.chain", json!({"curve": right, "sketch": sketch})));
|
||||
|
||||
let r = run(&mut s, "sketch.options", json!({}));
|
||||
assert!(r["options"]["show_points"] == true && r.get("sketch").is_none(), "{r}");
|
||||
matches(output_schema_for::<OptionsResult>(), "sketch.options", &r);
|
||||
let r = run(&mut s, "sketch.options", json!({"show_points": false, "grid": false, "slice": true, "sketch": sketch}));
|
||||
assert!(r["options"]["show_points"] == false && r["options"]["slice"] == true && r["sketch"].is_object(), "{r}");
|
||||
matches(output_schema_for::<OptionsResult>(), "sketch.options", &r);
|
||||
let r = run(&mut s, "sketch.options", json!({"show_points": null, "snap": true}));
|
||||
matches(output_schema_for::<OptionsResult>(), "sketch.options", &r);
|
||||
|
||||
let r = run(&mut s, "sketch.construction", json!({"curves": [bottom]}));
|
||||
assert!(r["construction"] == true && r["changed"] == 1, "{r}");
|
||||
matches(output_schema_for::<ConstructionResult>(), "sketch.construction", &r);
|
||||
let r = run(&mut s, "sketch.construction", json!({"curves": bottom, "value": false, "sketch": sketch}));
|
||||
assert!(r["construction"] == false && r["changed"] == 1, "{r}");
|
||||
matches(output_schema_for::<ConstructionResult>(), "sketch.construction", &r);
|
||||
|
||||
let r = run(&mut s, "sketch.move_point", json!({"point": format!("{bottom}.start"), "to": [-12, -10]}));
|
||||
assert!(r["sketch"].is_object(), "{r}");
|
||||
matches(output_schema_for::<MovePointResult>(), "sketch.move_point", &r);
|
||||
matches(
|
||||
output_schema_for::<MovePointResult>(),
|
||||
"sketch.move_point",
|
||||
&run(&mut s, "sketch.move_point", json!({"point": "origin", "to": {"x": 1, "y": 1}})),
|
||||
);
|
||||
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": [right]}));
|
||||
let param = d["param"].as_str().unwrap_or_default().to_string();
|
||||
let r = run(&mut s, "sketch.dimension_text", json!({"dimension": param, "at": [30, 12]}));
|
||||
assert!(r["dimension"].is_string(), "{r}");
|
||||
let dimension = r["dimension"].clone();
|
||||
matches(output_schema_for::<DimensionTextResult>(), "sketch.dimension_text", &r);
|
||||
let r = run(&mut s, "sketch.dimension_text", json!({"dimension": dimension, "reset": true, "at": "ignored"}));
|
||||
matches(output_schema_for::<DimensionTextResult>(), "sketch.dimension_text", &r);
|
||||
let r = run(&mut s, "sketch.dimension_text", json!({"dimension": param, "reset": false, "at": [1, 2], "sketch": sketch}));
|
||||
matches(output_schema_for::<DimensionTextResult>(), "sketch.dimension_text", &r);
|
||||
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": right, "from": [10, 0], "to": [14, 0]}));
|
||||
assert!(r["reached"].is_number() && r["at"].is_array() && r["retry"] == false, "{r}");
|
||||
matches(output_schema_for::<DragResult>(), "sketch.drag", &r);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": bottom, "to": [0, -10], "with": [right], "live": false, "sketch": sketch}));
|
||||
matches(output_schema_for::<DragResult>(), "sketch.drag", &r);
|
||||
|
||||
let r = run(&mut s, "sketch.drag_end", json!({}));
|
||||
assert!(r["constraint"].is_null(), "{r}");
|
||||
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
|
||||
let fix = json!({"command": "sketch.constraint.fix", "params": {"entity": format!("{bottom}.start")}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({"constraint": fix, "sketch": sketch}));
|
||||
assert!(r["constraint"]["result"].is_object() && r["sketch"] == r["constraint"]["sketch"], "{r}");
|
||||
// The nested command is not warned about its own call by the outer one: its reply carries what
|
||||
// its own contract found, here nothing.
|
||||
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
|
||||
let r = run(&mut s, "sketch.drag_end", json!({"constraint": null}));
|
||||
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
|
||||
// A constraint that cannot be added is not kept; the drag still ends.
|
||||
let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "origin", "b": "origin"}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({"constraint": bad}));
|
||||
assert!(r["constraint"]["error"].is_string(), "{r}");
|
||||
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
|
||||
|
||||
let r = run(&mut s, "sketch.delete", json!({"entities": [dimension, bottom]}));
|
||||
assert_eq!(r["deleted"], 2, "{r}");
|
||||
matches(output_schema_for::<DeleteResult>(), "sketch.delete", &r);
|
||||
let r = run(&mut s, "sketch.delete", json!({"entities": right, "sketch": sketch}));
|
||||
matches(output_schema_for::<DeleteResult>(), "sketch.delete", &r);
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn and says so, with a reply that matches the output schema; in
|
||||
/// Strict the call is refused with the suggestion.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported() {
|
||||
let (mut s, sides) = editing(ParamCheck::Warn);
|
||||
let bottom = sides[0].clone();
|
||||
let cases = [
|
||||
("sketch.solve", json!({"skech": 1}), "skech", "sketch"),
|
||||
("sketch.chain", json!({"curve": bottom, "sketh": 1}), "sketh", "sketch"),
|
||||
("sketch.construction", json!({"curves": [bottom], "valu": true}), "valu", "value"),
|
||||
("sketch.move_point", json!({"point": "origin", "to": [1, 1], "too": [2, 2]}), "too", "to"),
|
||||
("sketch.drag", json!({"entity": bottom, "to": [0, -9], "frm": [0, -10]}), "frm", "from"),
|
||||
("sketch.drag_end", json!({"constrant": null}), "constrant", "constraint"),
|
||||
("sketch.delete", json!({"entities": [bottom], "sketch ": 1}), "sketch ", "sketch"),
|
||||
("sketch.create", json!({"plane": "XY", "ofset": 5}), "ofset", "offset"),
|
||||
];
|
||||
for (id, p, wrong, right) in cases {
|
||||
let issues = messages(id, p.clone());
|
||||
assert_eq!(issues, [format!("unknown key `{wrong}`; did you mean `{right}`?")], "{id}");
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(r["param_warnings"], json!(issues), "{id}: {r}");
|
||||
}
|
||||
// sketch.options refuses any key that is not an option itself, so it never replies with
|
||||
// warnings; the shape it would have (flattened untagged enum plus `param_warnings`) still
|
||||
// matches its schema.
|
||||
let opts = serde_json::to_value(OptionsResult {
|
||||
options: OptionsReply::Changed {
|
||||
options: SketchOptions {
|
||||
show_profile: true,
|
||||
show_points: true,
|
||||
show_dimensions: true,
|
||||
show_constraints: true,
|
||||
show_projected: true,
|
||||
show_construction: true,
|
||||
grid: true,
|
||||
snap: true,
|
||||
slice: false,
|
||||
sketch_3d: false,
|
||||
},
|
||||
sketch: None,
|
||||
},
|
||||
param_warnings: vec!["w".into()],
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let schema = output_schema_for::<OptionsResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &opts) && opts["sketch"].is_null() && opts["param_warnings"][0] == "w", "{opts}");
|
||||
// The warned reply still matches its schema.
|
||||
let r = run(&mut s, "sketch.solve", json!({"skech": 1}));
|
||||
let schema = output_schema_for::<SolveResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
let (mut strict, _) = editing(ParamCheck::Strict);
|
||||
let e = strict.execute("sketch.solve", &json!({"skech": 1})).err().map(|e| e.to_string());
|
||||
assert_eq!(e.as_deref(), Some("invalid parameters for `sketch.solve`: unknown key `skech`; did you mean `sketch`?"));
|
||||
let e = strict.execute("sketch.options", &json!({"show_profil": true})).err().map(|e| e.to_string());
|
||||
assert!(e.is_some_and(|e| e.contains("unknown key `show_profil`")));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Contracts and result types for the drawing commands in `cmd/sketch.rs`: a line, a point, the
|
||||
//! circles and the arcs (#12953), the rectangles and the polygons (#13196).
|
||||
//! circles and the arcs (#12953).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -585,333 +585,6 @@ pub struct PointResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A rectangle corner as `sketch::corner_arg` reads it: `[x, y]` or `{x, y}` of numbers, or
|
||||
/// `[x, y]` with a coordinate typed as an expression over the design's parameters. Not a point's
|
||||
/// id or a snap. Kept as given, for the handler, which evaluates the expressions.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CornerPoint(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for CornerPoint {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
if params::vec2(&v).is_some() {
|
||||
return Ok(CornerPoint(v));
|
||||
}
|
||||
// `corner_arg` reads a pair with a coordinate typed as text as `sketch::expr_point` does,
|
||||
// which is `point_arg_form`'s expression branch; anything else as `params::req_vec2`.
|
||||
let typed = v.as_array().is_some_and(|a| a.len() == 2 && a.iter().any(Value::is_string));
|
||||
if typed {
|
||||
return crate::cmd::point_arg_form(&v, false).map(|()| CornerPoint(v)).map_err(|why| D::Error::custom(whole(why)));
|
||||
}
|
||||
Err(D::Error::custom("must be a point [x, y]"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CornerPoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CornerPoint".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point2>();
|
||||
let coordinate = generator.subschema_for::<LengthExpr>();
|
||||
json_schema!({
|
||||
"description": "A corner: [x, y] or {x, y} in mm, or [x, y] where a coordinate is an expression over the design's parameters, such as [\"w / 2\", 5]: the corner then follows the parameters. Not a point's id or a snap.",
|
||||
"anyOf": [
|
||||
point,
|
||||
{"type": "array", "items": coordinate, "minItems": 2, "maxItems": 2}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as `params::num` reads it (a polygon's `radius`, `diameter`, `sides` and `angle`): a
|
||||
/// finite number, never an expression.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PlainNumber(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for PlainNumber {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?.as_f64().and_then(params::finite).map(PlainNumber).ok_or_else(|| D::Error::custom("must be a number"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PlainNumber {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PlainNumber".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "number"})
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw an axis-aligned rectangle from two opposite corners.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RectTwoPointParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// One corner. A coordinate typed as an expression holds the corner where the parameters put it.
|
||||
pub p0: CornerPoint,
|
||||
/// The opposite corner. It must differ from `p0` in both x and y.
|
||||
pub p1: CornerPoint,
|
||||
/// 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 rectangle from one edge and a point on the opposite edge, at any angle.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RectThreePointParams {
|
||||
/// 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 first edge, 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 first edge. Not the same point as `p0`.
|
||||
pub p1: PlainPoint,
|
||||
/// A point on the opposite edge: its distance from the first edge is the rectangle's width.
|
||||
/// Not on the first edge's line.
|
||||
pub p2: 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 an axis-aligned rectangle from its centre and one corner, with construction diagonals
|
||||
/// that hold the centre.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RectCenterParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The centre. A coordinate typed as an expression holds the centre where the parameters put it.
|
||||
pub center: CornerPoint,
|
||||
/// One corner, in any quadrant round the centre. It must differ from `center` in both x and y.
|
||||
pub corner: CornerPoint,
|
||||
/// 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 axis-aligned rectangle from two opposite corners, its four corners rounded alike, as
|
||||
/// one undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RectRoundedParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// One corner of the rectangle before rounding, as `sketch.rectangle.two_point` takes it.
|
||||
pub p0: CornerPoint,
|
||||
/// The opposite corner. It must differ from `p0` in both x and y.
|
||||
pub p1: CornerPoint,
|
||||
/// The corner radius, in mm: a number or an expression over the design's parameters. Positive
|
||||
/// and less than half the shorter side. One radius dimension holds it; the other arcs are
|
||||
/// equal to it.
|
||||
pub radius: LengthExpr,
|
||||
/// 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 regular polygon whose corners lie on a circle round its centre.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PolygonInscribedParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The centre, as [x, y] or {x, y} in mm. An expression, a point's id or a snap is not taken.
|
||||
pub center: PlainPoint,
|
||||
/// The distance from the centre to each corner, in mm. Positive. Wins over `diameter`. Give
|
||||
/// one of the two.
|
||||
#[serde(default)]
|
||||
pub radius: Option<PlainNumber>,
|
||||
/// Twice the distance from the centre to each corner, in mm. Used when `radius` is not.
|
||||
#[serde(default)]
|
||||
pub diameter: Option<PlainNumber>,
|
||||
/// The number of sides: a whole number from 3 to 200. Default: 6.
|
||||
#[serde(default)]
|
||||
pub sides: Option<PlainNumber>,
|
||||
/// The direction of the first corner from the centre, in degrees counter-clockwise from the
|
||||
/// sketch's x axis. Default: 90 (straight up).
|
||||
#[serde(default)]
|
||||
pub angle: Option<PlainNumber>,
|
||||
/// 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 regular polygon whose sides touch a circle round its centre.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PolygonCircumscribedParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The centre, as [x, y] or {x, y} in mm. An expression, a point's id or a snap is not taken.
|
||||
pub center: PlainPoint,
|
||||
/// The distance from the centre to the middle of each side, in mm. Positive. Wins over
|
||||
/// `diameter`. Give one of the two.
|
||||
#[serde(default)]
|
||||
pub radius: Option<PlainNumber>,
|
||||
/// Twice the distance from the centre to the middle of each side (the width across flats), in
|
||||
/// mm. Used when `radius` is not.
|
||||
#[serde(default)]
|
||||
pub diameter: Option<PlainNumber>,
|
||||
/// The number of sides: a whole number from 3 to 200. Default: 6.
|
||||
#[serde(default)]
|
||||
pub sides: Option<PlainNumber>,
|
||||
/// The direction of the middle of the first side from the centre, in degrees counter-clockwise
|
||||
/// from the sketch's x axis. Default: 90 (straight up).
|
||||
#[serde(default)]
|
||||
pub angle: Option<PlainNumber>,
|
||||
/// 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 regular polygon from one of its sides: the polygon lies to the left of the side from
|
||||
/// `p0` to `p1`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PolygonEdgeParams {
|
||||
/// 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 side, 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 side. Not the same point as `p0`.
|
||||
pub p1: PlainPoint,
|
||||
/// The number of sides: a whole number from 3 to 200. Default: 6.
|
||||
#[serde(default)]
|
||||
pub sides: Option<PlainNumber>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// An axis-aligned rectangle's sides by place, as line ids.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct RectSides {
|
||||
/// The bottom side (least y).
|
||||
pub bottom: Option<String>,
|
||||
/// The right side (greatest x).
|
||||
pub right: Option<String>,
|
||||
/// The top side (greatest y).
|
||||
pub top: Option<String>,
|
||||
/// The left side (least x).
|
||||
pub left: Option<String>,
|
||||
}
|
||||
|
||||
/// An axis-aligned rectangle's corners by place, as point ids.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct RectCorners {
|
||||
/// The corner with the least x and y.
|
||||
pub bottom_left: Option<String>,
|
||||
/// The corner with the greatest x and least y.
|
||||
pub bottom_right: Option<String>,
|
||||
/// The corner with the greatest x and y.
|
||||
pub top_right: Option<String>,
|
||||
/// The corner with the least x and greatest y.
|
||||
pub top_left: Option<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.rectangle.two_point` and `sketch.rectangle.center` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RectResult {
|
||||
/// The ids of the four sides, counter-clockwise from the bottom side.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the constraints added: horizontal and vertical sides, a coincident for a corner
|
||||
/// that landed on an existing point, the centre rectangle's midpoint.
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// The sides by place, so a caller can dimension them without inspecting the sketch.
|
||||
pub sides: RectSides,
|
||||
/// The corners by place.
|
||||
pub corners: RectCorners,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// What `sketch.rectangle.rounded` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RoundedRectResult {
|
||||
/// The ids of the four sides, then the four corner arcs.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the constraints added: the rectangle's, then the fillet's (tangencies and the
|
||||
/// arcs equal to the first).
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve after the rounding: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// The sides by place. They end at the arcs now; there are no sharp corners to name.
|
||||
pub sides: RectSides,
|
||||
/// The corner arcs: bottom-left, bottom-right, top-right, top-left.
|
||||
pub arcs: Vec<String>,
|
||||
/// The parameter that holds the corner radius.
|
||||
pub param: String,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// What the polygon commands return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PolygonResult {
|
||||
/// The ids of the sides, in order round the polygon.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the constraints added: equal sides, the corners on their construction circle
|
||||
/// (and, round a circumscribed polygon, its tangent side), a coincident for a corner that
|
||||
/// landed on an existing point.
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// The id of the construction circle that holds the polygon to its centre: through the
|
||||
/// corners of an inscribed or edge polygon, touching the sides of a circumscribed one.
|
||||
pub support_circle: String,
|
||||
/// 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;
|
||||
|
||||
@@ -2,8 +2,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, DrawResult, LineParams, PointParams,
|
||||
PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectResult,
|
||||
RectRoundedParams, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
|
||||
PointResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
@@ -33,14 +32,6 @@ fn schemas_and_the_key_check_agree() {
|
||||
input_schema_for::<ArcCenterPointParams>(),
|
||||
&["center", "clockwise", "connect", "construction", "end", "id", "sketch", "start", "sweep"],
|
||||
);
|
||||
assert_keys(input_schema_for::<RectTwoPointParams>(), &["connect", "construction", "p0", "p1", "sketch"]);
|
||||
assert_keys(input_schema_for::<RectThreePointParams>(), &["connect", "construction", "p0", "p1", "p2", "sketch"]);
|
||||
assert_keys(input_schema_for::<RectCenterParams>(), &["center", "connect", "construction", "corner", "sketch"]);
|
||||
assert_keys(input_schema_for::<RectRoundedParams>(), &["connect", "construction", "p0", "p1", "radius", "sketch"]);
|
||||
// The polygons draw no construction geometry: their handlers never read `construction`.
|
||||
assert_keys(input_schema_for::<PolygonInscribedParams>(), &["angle", "center", "connect", "diameter", "radius", "sides", "sketch"]);
|
||||
assert_keys(input_schema_for::<PolygonCircumscribedParams>(), &["angle", "center", "connect", "diameter", "radius", "sides", "sketch"]);
|
||||
assert_keys(input_schema_for::<PolygonEdgeParams>(), &["connect", "p0", "p1", "sides", "sketch"]);
|
||||
}
|
||||
|
||||
/// Calls the handler refuses fail in strict with the same message. (The shared helper finishes
|
||||
@@ -90,30 +81,6 @@ fn strict_keeps_todays_errors() {
|
||||
("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]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [0, 5]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": "l1.end", "p1": [5, 5]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, true]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": ["", 5]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": ["5", true]})),
|
||||
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [0, 5]})),
|
||||
("sketch.rectangle.center", json!({"center": {"ref": "l1.end"}, "corner": [5, 5]})),
|
||||
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [10, 10], "radius": 6})),
|
||||
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [10, 10], "radius": 0})),
|
||||
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [10, 10], "radius": true})),
|
||||
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [1], "radius": 1})),
|
||||
("sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [0, 0], "p2": [1, 1]})),
|
||||
("sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [5, 0], "p2": [9, 0]})),
|
||||
("sketch.rectangle.three_point", json!({"p0": "l1.end", "p1": [5, 0], "p2": [5, 5]})),
|
||||
("sketch.polygon.inscribed", json!({"center": [0, 0]})),
|
||||
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 0})),
|
||||
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": "5"})),
|
||||
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 2})),
|
||||
("sketch.polygon.inscribed", json!({"center": "l1.end", "radius": 5})),
|
||||
("sketch.polygon.circumscribed", json!({"center": [0, 0], "diameter": "x"})),
|
||||
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 5, "sides": 201})),
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [0, 0]})),
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 2.5})),
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": ["5", 0]})),
|
||||
];
|
||||
for (id, p) in &cases {
|
||||
let error = |level| session(level).execute(id, p).err().map(|e| e.to_string());
|
||||
@@ -248,85 +215,3 @@ fn wrong_typed_keys_are_reported() {
|
||||
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))
|
||||
}
|
||||
|
||||
/// The rectangles and polygons (#13196): every form each takes is accepted (run in warn, no
|
||||
/// warnings), and each reply matches its own output schema.
|
||||
#[test]
|
||||
fn rectangles_and_polygons_reply_with_their_types() {
|
||||
let schemas = [
|
||||
("rect", output_schema_for::<RectResult>()),
|
||||
("draw", output_schema_for::<DrawResult>()),
|
||||
("rounded", output_schema_for::<RoundedRectResult>()),
|
||||
("polygon", output_schema_for::<PolygonResult>()),
|
||||
];
|
||||
let calls = [
|
||||
("rect", "sketch.rectangle.two_point", json!({"p0": [100, 0], "p1": [110, 10]})),
|
||||
("rect", "sketch.rectangle.two_point", json!({"p0": {"x": 100, "y": 20}, "p1": ["110 mm", 30], "construction": true, "connect": false})),
|
||||
("rect", "sketch.rectangle.two_point", json!({"p0": [100, 40], "p1": [110, 50], "sketch": null})),
|
||||
("draw", "sketch.rectangle.three_point", json!({"p0": [120, 0], "p1": [130, 0], "p2": [125, 5]})),
|
||||
("draw", "sketch.rectangle.three_point", json!({"p0": {"x": 120, "y": 20}, "p1": [130, 25], "p2": [120, 30], "construction": true})),
|
||||
("rect", "sketch.rectangle.center", json!({"center": [150, 5], "corner": [155, 10]})),
|
||||
("rect", "sketch.rectangle.center", json!({"center": ["150 mm", 25], "corner": [145, 20], "connect": false})),
|
||||
("rounded", "sketch.rectangle.rounded", json!({"p0": [170, 0], "p1": [190, 10], "radius": 2})),
|
||||
("rounded", "sketch.rectangle.rounded", json!({"p0": [170, 20], "p1": [190, 30], "radius": "1 mm", "construction": true})),
|
||||
("polygon", "sketch.polygon.inscribed", json!({"center": [210, 5], "radius": 4})),
|
||||
("polygon", "sketch.polygon.inscribed", json!({"center": {"x": 210, "y": 25}, "diameter": 8, "sides": 5, "angle": 0})),
|
||||
("polygon", "sketch.polygon.circumscribed", json!({"center": [230, 5], "radius": 4, "sides": 8})),
|
||||
("polygon", "sketch.polygon.circumscribed", json!({"center": [230, 25], "diameter": 8, "angle": 45, "connect": false})),
|
||||
("polygon", "sketch.polygon.edge", json!({"p0": [250, 0], "p1": [255, 0]})),
|
||||
("polygon", "sketch.polygon.edge", json!({"p0": [250, 20], "p1": [255, 20], "sides": 4})),
|
||||
];
|
||||
let mut s = session(ParamCheck::Warn);
|
||||
for (kind, id, p) in &calls {
|
||||
let schema = schemas.iter().find(|(k, _)| k == kind).map(|(_, s)| s.as_value()).unwrap();
|
||||
let r = run(&mut s, id, p.clone());
|
||||
assert!(conforms(schema, schema, &r), "{id} {p}: {r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
assert!(r["created"]["curves"].is_array(), "{r}");
|
||||
}
|
||||
// The replies' keys, as the handlers wrote them before the types: a rounded rectangle names
|
||||
// its arcs and radius parameter, and no corners.
|
||||
let keys = |r: &Value| r.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default();
|
||||
let mut off = session(ParamCheck::Off);
|
||||
let r = run(&mut off, "sketch.rectangle.two_point", json!({"p0": [100, 0], "p1": [110, 10]}));
|
||||
assert_eq!(keys(&r), ["constraints", "corners", "created", "curves", "sides", "sketch"]);
|
||||
assert!(r["sides"]["bottom"].is_string() && r["corners"]["top_left"].is_string(), "{r}");
|
||||
let r = run(&mut off, "sketch.rectangle.rounded", json!({"p0": [170, 0], "p1": [190, 10], "radius": 2}));
|
||||
assert_eq!(keys(&r), ["arcs", "constraints", "created", "curves", "param", "sides", "sketch"]);
|
||||
assert_eq!((r["curves"].as_array().map(Vec::len), r["arcs"].as_array().map(Vec::len)), (Some(8), Some(4)), "{r}");
|
||||
assert!(r["param"].as_str().is_some_and(|name| !name.is_empty()), "{r}");
|
||||
let r = run(&mut off, "sketch.polygon.inscribed", json!({"center": [210, 5], "radius": 4}));
|
||||
assert_eq!(keys(&r), ["constraints", "created", "curves", "sketch", "support_circle"]);
|
||||
assert_eq!(r["curves"].as_array().map(Vec::len), Some(6), "six sides by default: {r}");
|
||||
// The same calls give the same JSON with the check off and in strict.
|
||||
let (mut off, mut strict) = (session(ParamCheck::Off), session(ParamCheck::Strict));
|
||||
for (_, id, p) in &calls {
|
||||
assert_eq!(run(&mut off, id, p.clone()), run(&mut strict, id, p.clone()), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rectangles and polygons ignore or read as left out today is reported: a number typed
|
||||
/// as text, a key a polygon never reads.
|
||||
#[test]
|
||||
fn rectangles_and_polygons_report_wrong_typed_keys() {
|
||||
let message = |id: &str, p: Value| issues(id, &p).first().map(|i| i.message.clone());
|
||||
let polygon = json!({"center": [0, 0], "radius": 5});
|
||||
for key in ["sides", "angle"] {
|
||||
let mut bad = polygon.clone();
|
||||
bad[key] = json!("6");
|
||||
assert_eq!(message("sketch.polygon.inscribed", bad), Some(format!("`{key}` must be a number")));
|
||||
}
|
||||
let mut bad = polygon.clone();
|
||||
bad["construction"] = json!(true);
|
||||
assert_eq!(issues("sketch.polygon.inscribed", &bad).len(), 1, "a polygon draws no construction geometry");
|
||||
assert_eq!(
|
||||
message("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, 5], "construction": "yes"})).as_deref(),
|
||||
Some("`construction` must be true or false")
|
||||
);
|
||||
assert_eq!(
|
||||
message("sketch.rectangle.center", json!({"center": [0, 0], "corner": [5, 5], "sketch": true})).as_deref(),
|
||||
Some("`sketch` must be an id or a name")
|
||||
);
|
||||
assert_eq!(message("sketch.rectangle.rounded", json!({"p0": ["w / 2", 0], "p1": [5, 5], "radius": "r"})), None);
|
||||
assert_eq!(message("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 4})), None);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
# (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.capture_position
|
||||
component.move_bodies
|
||||
component.move_sketches
|
||||
component.paste_new
|
||||
component.revert_position
|
||||
doc.open
|
||||
doc.recover
|
||||
doc.recovery_discard
|
||||
@@ -15,6 +20,14 @@ file.save
|
||||
file.save_as
|
||||
file.save_mesh
|
||||
file.slicers
|
||||
joint.cam
|
||||
joint.gear
|
||||
joint.motion_link
|
||||
joint.origin
|
||||
joint.rack_pinion
|
||||
joint.reach
|
||||
joint.rigid_group
|
||||
joint.screw
|
||||
joint.sweep
|
||||
mesh.convert
|
||||
motion.export_animation
|
||||
@@ -23,6 +36,11 @@ motion.plot
|
||||
motion.trace
|
||||
motion.trace.edit
|
||||
motion.trace.keep
|
||||
occurrence.copy
|
||||
occurrence.drag
|
||||
occurrence.ground
|
||||
occurrence.move
|
||||
occurrence.properties
|
||||
render.aging.describe
|
||||
render.backends
|
||||
render.cancel
|
||||
@@ -34,11 +52,46 @@ render.result.regrade
|
||||
render.scene
|
||||
render.start
|
||||
render.status
|
||||
select.clear
|
||||
select.set
|
||||
sheet.export_bend_table
|
||||
sheet.export_dxf
|
||||
sheet.flat_pattern
|
||||
sheet.path_flange
|
||||
sketch.auto_project
|
||||
sketch.break_link
|
||||
sketch.chain
|
||||
sketch.construction
|
||||
sketch.create
|
||||
sketch.delete
|
||||
sketch.dimension
|
||||
sketch.dimension_text
|
||||
sketch.drag
|
||||
sketch.drag_end
|
||||
sketch.draw.dimensioned
|
||||
sketch.export_dxf
|
||||
sketch.fit_curves_to_section
|
||||
sketch.include_3d
|
||||
sketch.insert_dxf
|
||||
sketch.insert_svg
|
||||
sketch.intersect
|
||||
sketch.intersection_curve
|
||||
sketch.isoparametric_curve
|
||||
sketch.move_point
|
||||
sketch.options
|
||||
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.slot.center_to_center
|
||||
sketch.slot.overall
|
||||
sketch.solve
|
||||
sketch.spun_profile
|
||||
solid.align
|
||||
solid.chamfer
|
||||
solid.coil
|
||||
@@ -76,3 +129,11 @@ surface.thicken
|
||||
surface.trim
|
||||
surface.unstitch
|
||||
surface.untrim
|
||||
timeline.group
|
||||
timeline.move_many
|
||||
timeline.move_to_group
|
||||
timeline.redefine
|
||||
timeline.rename_group
|
||||
timeline.reorder
|
||||
timeline.reorder_many
|
||||
timeline.ungroup
|
||||
|
||||
@@ -96,74 +96,6 @@ fn edge_picks_at_a_corner_are_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge picks on bodies apart: one pick keeps its body on the feature (the model has two), and
|
||||
/// picks on both keep none, each found where it lies.
|
||||
#[test]
|
||||
fn edge_picks_on_bodies_apart_keep_the_body_they_lie_on() {
|
||||
let boxes = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Low"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, 50], "operation": "new", "body_name": "High"}));
|
||||
s
|
||||
};
|
||||
let kept = |s: &Session| match &s.doc.features.last().unwrap().kind {
|
||||
solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let each = |s: &Session| ["Low", "High"].map(|b| s.model.state().body(b).unwrap().measure().unwrap().volume);
|
||||
let mut s = boxes();
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert_eq!(kept(&s).as_deref(), Some("Low"));
|
||||
let [low, high] = each(&s);
|
||||
assert!((low - 11980.0).abs() < 0.02 && high == 12000.0, "{low} {high}");
|
||||
let mut s = boxes();
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0], [20, 0, 60]], "distance": 1}));
|
||||
assert_eq!(kept(&s), None);
|
||||
let [low, high] = each(&s);
|
||||
assert!((low - 11980.0).abs() < 0.02 && (high - 11980.0).abs() < 0.02, "{low} {high}");
|
||||
}
|
||||
|
||||
/// The halves of a split box share the edges along the cut: a pick on one is refused naming both
|
||||
/// halves, and `body` cuts the half named.
|
||||
#[test]
|
||||
fn edge_picks_on_the_cut_of_a_split_body_name_both_halves() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Box"}));
|
||||
run(&mut s, "solid.split_body", json!({"body": "Box", "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
|
||||
let features = s.doc.features.len();
|
||||
let err = s.execute("solid.chamfer", &json!({"edges": [[20, 15, 10]], "distance": 1})).unwrap_err().to_string();
|
||||
assert!(err.contains("[20, 15, 10] lies on edges of 2 bodies, Box and Box (1)") && err.contains("with `body`"), "{err}");
|
||||
assert_eq!(s.doc.features.len(), features);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 15, 10]], "distance": 1, "body": "Box (1)"}));
|
||||
let half = |s: &Session, b: &str| s.model.state().body(b).unwrap().measure().unwrap().volume;
|
||||
assert!((half(&s, "Box (1)") - 5985.0).abs() < 0.02 && (half(&s, "Box") - 6000.0).abs() < 1e-6, "{} {}", half(&s, "Box"), half(&s, "Box (1)"));
|
||||
}
|
||||
|
||||
/// A box touching a cylinder along the cylinder's seam: the seam is not an edge to blend, so the
|
||||
/// box's edge there is cut.
|
||||
#[test]
|
||||
fn an_edge_along_another_bodys_seam_is_picked() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 10, "body_name": "Rod"}));
|
||||
let rod = s.model.state().body("Rod").unwrap().body.clone();
|
||||
let mesh = rod.mesh(&solidcraft_kernel::MeshTolerance::chord(0.06)).unwrap();
|
||||
let seam = mesh.edges.iter().zip(&mesh.seams).find(|(_, seam)| **seam).unwrap().0[0];
|
||||
// The box's quarter past the seam's line is outside the cylinder: they touch along it.
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 20, "width": 20, "height": 10, "corner": [seam.x, seam.y, 0], "operation": "new", "body_name": "Block"}),
|
||||
);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[seam.x, seam.y, 5]], "distance": 1}));
|
||||
let kept = match &s.doc.features.last().unwrap().kind {
|
||||
solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert_eq!(kept.as_deref(), Some("Block"));
|
||||
let v = |b: &str| s.model.state().body(b).unwrap().measure().unwrap().volume;
|
||||
assert!((v("Block") - 3995.0).abs() < 0.02 && (v("Rod") - PI * 1000.0).abs() < 1.0, "{} {}", v("Block"), v("Rod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_as_wide_as_a_face_rounds_it_off() {
|
||||
// #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces).
|
||||
|
||||
+4
-32
@@ -2223,7 +2223,7 @@ pub struct Renderer {
|
||||
/// Lines behind the model (depth test >).
|
||||
line_hidden: wgpu::RenderPipeline,
|
||||
overlay: wgpu::RenderPipeline,
|
||||
/// Reference planes, axes and the ground shadow: as `trans` and `line`, but not cut by a section.
|
||||
/// Reference planes and axes: as `trans` and `line`, but not cut by a section.
|
||||
guide_trans: wgpu::RenderPipeline,
|
||||
guide_line: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
@@ -4069,10 +4069,9 @@ impl Renderer {
|
||||
tris(pass, &res.ghost, &pv.ghost);
|
||||
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
|
||||
tris(pass, &res.ghost, &m.ghost);
|
||||
// The ground shadow is never cut by a section, like the reference planes (#10708).
|
||||
tris(pass, &res.guide_trans, &m.shadow);
|
||||
tris(pass, &res.trans, &m.shadow);
|
||||
pass.set_bind_group(0, scene_bind(sk, &res.bind), &[]);
|
||||
tris(pass, &res.guide_trans, &sk.shadow);
|
||||
tris(pass, &res.trans, &sk.shadow);
|
||||
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
|
||||
if phase == Phase::BeforeSeeThrough {
|
||||
return;
|
||||
@@ -5071,28 +5070,6 @@ mod glass_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// With Section Analysis on, the ground shadow stays whole: the section plane hides cut bodies,
|
||||
/// not the soft shadow under them, which a cut ended in a hard straight line (#10708).
|
||||
#[test]
|
||||
fn ground_shadow_is_not_cut_by_the_section_plane() {
|
||||
let mut scene = GpuScene::default();
|
||||
let square = [[-5.0, -5.0, -5.0], [5.0, -5.0, -5.0], [5.0, 5.0, -5.0], [-5.0, -5.0, -5.0], [5.0, 5.0, -5.0], [-5.0, 5.0, -5.0]];
|
||||
let shadow_color = scene.color([0, 0, 0, 128]);
|
||||
for p in square {
|
||||
scene.shadow_tri(p, shadow_color);
|
||||
}
|
||||
// Hides everything with x > -1, the middle pixel among it.
|
||||
let cut = [1.0, 0.0, 0.0, -1.0];
|
||||
for view_dependent in [false, true] {
|
||||
let (model, sketch) = if view_dependent { (GpuScene::default(), scene.clone()) } else { (scene.clone(), GpuScene::default()) };
|
||||
let Some(shadow) = middle_pixel_cut(model, sketch, true, 1, cut) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
assert!(shadow[..3].iter().all(|c| (125..=129).contains(c)), "view-dependent={view_dependent} shadow under a section: {shadow:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A large import's triangles once went into one buffer past the device's limit (524 MB
|
||||
/// against 256 MB) and the app crashed (#883): they are split into whole triangles.
|
||||
#[test]
|
||||
@@ -5257,11 +5234,6 @@ mod glass_tests {
|
||||
}
|
||||
|
||||
fn middle_pixel_in_slots_with_samples(scene: GpuScene, sketch: GpuScene, oit: bool, samples: u32) -> Option<[u8; 4]> {
|
||||
middle_pixel_cut(scene, sketch, oit, samples, [0.0; 4])
|
||||
}
|
||||
|
||||
/// [`middle_pixel_in_slots_with_samples`] with a section cut (`Frame::clip`).
|
||||
fn middle_pixel_cut(scene: GpuScene, sketch: GpuScene, oit: bool, samples: u32, clip: [f32; 4]) -> Option<[u8; 4]> {
|
||||
const SIZE: u32 = 64;
|
||||
let (_, device, queue) = crate::open_headless().ok()?;
|
||||
let depth = crate::depth_format(&device, true);
|
||||
@@ -5289,7 +5261,7 @@ mod glass_tests {
|
||||
view_proj: [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]],
|
||||
back: [0.0, 0.0, 1.0],
|
||||
size_px: [SIZE as f32; 2],
|
||||
clip,
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
clouds: Vec::new(),
|
||||
|
||||
@@ -161,16 +161,7 @@ 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(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 (Some(smlib_order), Some(body_order)) = (smlib_order, body_order) else { 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()];
|
||||
@@ -186,44 +177,6 @@ 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,69 +662,6 @@ 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);
|
||||
|
||||
@@ -8,3 +8,4 @@ cc 739d5cbd9e471e0ff65bc2b7c1b9a323c2e769939c73def2e74fb17b0536c1d0 # shrinks to
|
||||
cc 0f8ee4d4597db6bdec2ad2cc7e5cfe7d4ebba351e24642118135da34bf489d0c # shrinks to a = Sph { at: [20.0, 10.0, 27.5], r: 3.75 }, b = Box { at: [15.0, 0.0, 22.5], size: [7.5, 20.0, 5.0] }
|
||||
cc 2adfe5ac6b6916fa22a415348bdd7ece29ad33e8114b94dea86ba8bc39156dee # shrinks to a = Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] }, b = Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] }
|
||||
cc 00931f1657c5f31ab3593ef30eb6f452eddcf3fd0662d280ca2f6a86a409666b # shrinks to a = Cyl { at: [12.5, 5.0, 5.0], axis: 2, r: 2.5, h: 17.5 }, b = Box { at: [10.0, 5.0, 7.5], size: [12.5, 5.0, 10.0] }, k = 0.05
|
||||
cc 5b2daab79ffc39bc696b22704b0372147902f58c68cef280279ca38bf5d5d32f # shrinks to a = Sph { at: [0.0, 0.0, 10.0], r: 2.5 }, b = Box { at: [0.0, 0.0, 0.0], size: [5.0, 5.0, 5.0] }, off = 100000.0
|
||||
|
||||
@@ -318,6 +318,22 @@ fn grid_small_sphere_thin_box_union_is_sound() {
|
||||
check(identity(&a, &b));
|
||||
}
|
||||
|
||||
/// A sphere and a box apart from each other: moved 1e5 mm away, their union is refused ("a NURBS
|
||||
/// form that is not its analytic surface"), where at the origin it is the two solids.
|
||||
#[test]
|
||||
#[ignore = "kernel bug, solidcore#3131"]
|
||||
fn disjoint_sphere_box_union_moved_far() {
|
||||
let a = Prim::Sph { at: [0.0, 0.0, 10.0], r: 2.5 }.body();
|
||||
let b = Prim::Box { at: [0.0, 0.0, 0.0], size: [5.0, 5.0, 5.0] }.body();
|
||||
let t = Vec3::new(1.0e5, -1.0e5 * 0.7, 1.0e5 * 0.3);
|
||||
let mv = |x: &Body| transform(x, t, Vec3::ZERO, Vec3::Z, 0.0).expect("translate");
|
||||
let (va, vb) = (vol(&a), vol(&b));
|
||||
let here = run(&a, &b, BoolOp::Union).unwrap();
|
||||
assert!(close(here, va + vb, va + vb), "{here}");
|
||||
let there = run(&mv(&a), &mv(&b), BoolOp::Union).unwrap();
|
||||
assert!(close(there, here, va + vb), "{there} moved, {here} at the origin");
|
||||
}
|
||||
|
||||
/// Two crossing cylinders: the union moved 1000 mm away loses about a tenth of its volume.
|
||||
#[test]
|
||||
#[ignore = "kernel bug, #1649"]
|
||||
|
||||
@@ -49,7 +49,7 @@ use crate::i18n::{format as trf, tr};
|
||||
use crate::{ControlRequest, SolveApp};
|
||||
use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus};
|
||||
|
||||
pub(crate) use panel::{finish_shared, left_inset, owns_keyboard_input, prepare_shared, request_new_chat, shared_content, tool_space};
|
||||
pub(crate) use panel::{left_inset, owns_keyboard_input, tool_space};
|
||||
pub use panel::{show, tip as panel_tip, toggle, toggle_shortcut};
|
||||
|
||||
/// Most entries kept in the conversation; older ones are dropped.
|
||||
|
||||
@@ -56,39 +56,6 @@ const PREVIEW_MAX: (f32, f32) = (560.0, 380.0);
|
||||
|
||||
/// Show or hide the panel (a folded panel unfolds first).
|
||||
pub fn toggle(app: &mut SolveApp) {
|
||||
use solidcraft_engine::panels::{LayoutAction, Mutation};
|
||||
|
||||
let shared = app.panel_runtime().filter(|_| !app.preferences.agent_chat_popped).and_then(|runtime| {
|
||||
let layout = runtime.layout().layout();
|
||||
let panel = layout.instances.iter().find(|panel| panel.kind.0 == "solidcraft.assistant")?;
|
||||
let section = layout.containing(&panel.id).ok();
|
||||
let reveal = !app.agent_chat.open || !panel.requested_open || section.is_some_and(|section| section.collapsed || section.active != panel.id);
|
||||
let mutations = if reveal {
|
||||
let section = section.map_or_else(|| panel.memory.section.clone(), |section| section.id.clone());
|
||||
let mut mutations = Vec::new();
|
||||
if !panel.requested_open {
|
||||
mutations.push(Mutation::Open { kind: panel.kind.clone(), instance: panel.id.clone(), destination: None });
|
||||
}
|
||||
mutations.push(Mutation::Collapse { section, collapsed: false });
|
||||
mutations.push(Mutation::Activate { instance: panel.id.clone() });
|
||||
mutations
|
||||
} else {
|
||||
vec![Mutation::Close { instance: panel.id.clone() }]
|
||||
};
|
||||
Some((reveal, LayoutAction::Apply { expected_revision: None, mutations }))
|
||||
});
|
||||
if let Some((reveal, action)) = shared {
|
||||
let was_open = app.agent_chat.open;
|
||||
if reveal {
|
||||
app.agent_chat.open = true;
|
||||
}
|
||||
if let Err(reason) = app.dispatch_panel_layout(action) {
|
||||
app.agent_chat.open = was_open;
|
||||
let _ = crate::panel_runtime::refresh_production(app);
|
||||
app.set_status(solidcraft_engine::panels::commands::error_text(reason), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let c = &mut app.agent_chat;
|
||||
if !c.open {
|
||||
c.open = true;
|
||||
@@ -113,8 +80,7 @@ pub fn tip(app: &SolveApp) -> String {
|
||||
/// panel in this window, never label selections belonging to another native viewport.
|
||||
/// Called before layout so even a click and a shortcut in the same frame are routed here.
|
||||
pub(crate) fn owns_keyboard_input(app: &mut SolveApp, ctx: &egui::Context) -> bool {
|
||||
let shared = app.universal_panel_host_enabled() && !app.preferences.agent_chat_popped;
|
||||
let rect = app.agent_chat.panel_rect.filter(|_| shown(app) && app.agent_chat.open && (shared || !app.preferences.agent_chat_minimized));
|
||||
let rect = app.agent_chat.panel_rect.filter(|_| shown(app) && app.agent_chat.open && !app.preferences.agent_chat_minimized);
|
||||
let Some(rect) = rect else {
|
||||
app.agent_chat.keyboard_owned = false;
|
||||
return false;
|
||||
@@ -147,7 +113,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if !(app.agent_chat.open && app.preferences.agent_chat_popped) {
|
||||
app.agent_chat.window_placed = false;
|
||||
}
|
||||
new_chat_shortcut(app, &ctx);
|
||||
// New chat, when the chat has focus (its input, or the pointer over the panel).
|
||||
let in_chat = ctx.memory(|m| m.has_focus(Id::unique("sc_agent_chat_input")))
|
||||
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
|
||||
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||||
request_new_chat(app);
|
||||
}
|
||||
if !app.agent_chat.open {
|
||||
return;
|
||||
}
|
||||
@@ -209,48 +180,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare the shared docked presentation without polling its retained service.
|
||||
/// Read the previous visible rectangle before clearing this frame's geometry.
|
||||
pub(crate) fn prepare_shared(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if shown(app) {
|
||||
new_chat_shortcut(app, ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if app.agent_chat.open && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, ctx);
|
||||
}
|
||||
}
|
||||
app.agent_chat.panel_rect = None;
|
||||
app.agent_chat.drop_rect = None;
|
||||
app.agent_chat.dock_space = None;
|
||||
app.agent_chat.window_placed = false;
|
||||
}
|
||||
|
||||
/// Independent viewers and drop feedback keep their existing window policy.
|
||||
pub(crate) fn finish_shared(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !shown(app) || !app.agent_chat.open {
|
||||
app.agent_chat.keyboard_owned = false;
|
||||
return;
|
||||
}
|
||||
if app.agent_chat.panel_rect.is_none() {
|
||||
app.agent_chat.keyboard_owned = false;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
gallery_window(app, ctx);
|
||||
show_drop_hint(app, ctx);
|
||||
}
|
||||
|
||||
/// Existing Assistant controls and conversation, inside the shared host's body.
|
||||
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let rect = ui.max_rect().intersect(ui.clip_rect());
|
||||
if !shown(app) || !app.agent_chat.open || !rect.is_finite() || !rect.is_positive() {
|
||||
return;
|
||||
}
|
||||
app.agent_chat.panel_rect = Some(rect);
|
||||
app.agent_chat.drop_rect = Some(rect);
|
||||
crate::scenario::publish_rect("agent_chat:panel", rect);
|
||||
ui_kit::scope(ui, |ui| content(app, ui));
|
||||
}
|
||||
|
||||
/// Horizontal room reserved before the history and browser when Assistant is on the left.
|
||||
pub(crate) fn left_inset(app: &SolveApp, available: Rect) -> f32 {
|
||||
if Container::for_layout(app.ui.layout, Surface::Panel).floating()
|
||||
@@ -603,7 +532,7 @@ pub const NEW_CHAT_SHORTCUT: egui::KeyboardShortcut =
|
||||
egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::L);
|
||||
|
||||
/// Clear the chat, asking first when something would be lost.
|
||||
pub(crate) fn request_new_chat(app: &mut SolveApp) {
|
||||
fn request_new_chat(app: &mut SolveApp) {
|
||||
if new_chat_needs_confirm(app) {
|
||||
app.agent_chat.confirm_new = true;
|
||||
} else {
|
||||
@@ -611,14 +540,6 @@ pub(crate) fn request_new_chat(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_chat_shortcut(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let in_chat = ctx.memory(|m| m.has_focus(Id::unique("sc_agent_chat_input")))
|
||||
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
|
||||
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||||
request_new_chat(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved window place (`[x, y, width, height]`) that can be used: finite, a usable size, and
|
||||
/// not absurdly far away. `x` and `y` may be negative: a screen left of or above the main one.
|
||||
pub fn window_place(saved: Option<[f32; 4]>) -> Option<([f32; 2], [f32; 2])> {
|
||||
@@ -904,14 +825,8 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo
|
||||
|
||||
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
header(app, ui, left);
|
||||
content(app, ui);
|
||||
}
|
||||
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
}
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
ui.label(tr("ui.agent_chat_desktop_only"));
|
||||
@@ -922,8 +837,6 @@ fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let top = ui.cursor().top();
|
||||
let shared = crate::panel_runtime::shared_content_measurement(ui.ctx());
|
||||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, ui.ctx());
|
||||
}
|
||||
@@ -946,34 +859,16 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
}
|
||||
// Shared top controls must yield scroll space to the pinned composer, including
|
||||
// when discovery lists several agents or a long message grows the editor.
|
||||
let picker_max = if shared {
|
||||
let t = Tokens::get();
|
||||
let composer = crate::panel_runtime::shared_content_composer_height(ui.ctx())
|
||||
.unwrap_or(48.0 + t.control_height * 3.0 + t.space_8 * 2.0 + t.space_6 * 3.0);
|
||||
(ui.available_height() - composer - ui.spacing().item_spacing.y).max(0.0)
|
||||
} else {
|
||||
ui.available_height() / 3.0
|
||||
};
|
||||
let picker = egui::ScrollArea::vertical()
|
||||
// Discovery/install guidance must leave room for the pinned composer even when
|
||||
// several agents are listed, or when a long message grows the editor.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("sc_agent_chat_picker_scroll")
|
||||
.min_scrolled_height(0.0)
|
||||
.max_height(picker_max)
|
||||
.max_height(ui.available_height() / 3.0)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
if shared {
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
}
|
||||
picker(app, ui);
|
||||
if shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
});
|
||||
if !shared && state == State::NeedsAuth {
|
||||
.show(ui, |ui| picker(app, ui));
|
||||
if state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
let top_height = ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y;
|
||||
// The input sits at the bottom of the panel and grows upwards: a long message makes its box
|
||||
// taller up to `edit_max`, then it scrolls, and the conversation takes what is left above. It
|
||||
// is a panel of its own at the bottom, as high as its content this frame, so the buttons under
|
||||
@@ -992,7 +887,7 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let mut remove_picked_at = None;
|
||||
let frame = Container::new(ui.ctx(), Surface::Viewport).edge_frame(egui::Color32::TRANSPARENT);
|
||||
let composer = Container::new(ui.ctx(), Surface::Viewport).edge("sc_agent_chat_composer", Edge::Bottom);
|
||||
let composer = composer.resizable(false).show_separator_line(false).frame(frame).show(ui, |ui| {
|
||||
composer.resizable(false).show_separator_line(false).frame(frame).show(ui, |ui| {
|
||||
let footer = ui_kit::assistant_composer_footer(ui, |ui| {
|
||||
let boxed = ui_kit::assistant_composer(ui, focused, |ui| {
|
||||
// What Pick context chose for this message, each item with an ×.
|
||||
@@ -1024,7 +919,6 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
})
|
||||
.inner;
|
||||
crate::scenario::publish_rect("agent_chat:input", r.rect);
|
||||
crate::panel_runtime::register_content_focus(ui.ctx(), &r);
|
||||
// macOS shows a window's cursors only while it is the active window: over the text box of a
|
||||
// chat window that isn't, the pointer stays an arrow. Coming from the main window, the chat
|
||||
// window becomes active, so the text cursor shows and the first click places the caret.
|
||||
@@ -1137,20 +1031,15 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::scenario::publish_rect("agent_chat:composer_box", boxed.response.rect);
|
||||
});
|
||||
crate::scenario::publish_rect("agent_chat:composer_footer", footer.response.rect);
|
||||
// The enclosing bottom panel can clamp its response to the remaining
|
||||
// viewport. Reserve the actual footer content on the next frame.
|
||||
footer.response.rect.height()
|
||||
});
|
||||
crate::panel_runtime::report_content_composer_height(ui.ctx(), composer.inner);
|
||||
if let Some(at) = remove_picked_at {
|
||||
remove_picked(app, at);
|
||||
}
|
||||
if pick_clicked {
|
||||
start_pick(app);
|
||||
}
|
||||
let conversation = egui::ScrollArea::vertical()
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("sc_agent_chat_scroll")
|
||||
.min_scrolled_height(0.0)
|
||||
.max_height(ui.available_height())
|
||||
.auto_shrink([false, false])
|
||||
.stick_to_bottom(true)
|
||||
@@ -1162,8 +1051,6 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// Last, so the scroll goes to this area and not to one nested in the conversation.
|
||||
scroll_while_selecting(ui);
|
||||
});
|
||||
crate::scenario::publish_rect("agent_chat:conversation", conversation.inner_rect);
|
||||
crate::panel_runtime::report_content_extent(ui, top, top_height + composer.inner + conversation.content_size.y, false);
|
||||
if esc || stop_clicked {
|
||||
cancel(app);
|
||||
}
|
||||
@@ -2044,7 +1931,6 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
(Some((i, _)), Some(buf)) if *i == n => {
|
||||
let te = ui_kit::message_field(ui, Id::unique(("sc_agent_pending_input", n)), buf, "");
|
||||
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
|
||||
crate::panel_runtime::register_content_focus(ui.ctx(), &te);
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, tr("ui.agent_chat_save"), ui_kit::Button::Normal).clicked() {
|
||||
edit_done = Some((n, Some(buf.clone())));
|
||||
|
||||
@@ -1362,14 +1362,6 @@ fn render_card(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
if let Some(rect) = rect {
|
||||
crate::scenario::publish_rect("browser:card", rect);
|
||||
}
|
||||
render_appearances(app, ui, rect);
|
||||
}
|
||||
|
||||
/// Keep the existing appearances companion below either Browser host.
|
||||
pub(crate) fn render_appearances(app: &mut SolveApp, ui: &mut egui::Ui, browser: Option<Rect>) {
|
||||
if let Some(rect) = browser {
|
||||
let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap());
|
||||
crate::render_workspace::appearances_card(app, ui, crate::ui_kit::Panel::stack_next_bounds(bounds, rect));
|
||||
} else {
|
||||
crate::shell::register_panel(app, "render:appearances", None, None);
|
||||
@@ -1437,27 +1429,8 @@ fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
|
||||
t.secondary_hline(ui.painter(), ui.max_rect().x_range(), ui.cursor().top() + 1.0, t.toolbar.lerp_to_gamma(t.border, 0.5));
|
||||
ui.add_space(t.space_3);
|
||||
}
|
||||
rows_contents(app, ui, collapsible && !stacked);
|
||||
}
|
||||
|
||||
/// Existing Browser content without placement, title or collapse chrome.
|
||||
pub(crate) fn panel_contents(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
sync_reveals(app);
|
||||
let render = crate::render_workspace::active(app);
|
||||
if render {
|
||||
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("sc_compact_tree"), true));
|
||||
}
|
||||
if !crate::render_workspace::browser_mode(app, ui) {
|
||||
rows_contents(app, ui, true);
|
||||
}
|
||||
if render {
|
||||
ui.ctx().data_mut(|data| data.remove::<bool>(egui::Id::unique("sc_compact_tree")));
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_contents(app: &mut SolveApp, ui: &mut egui::Ui, shrink_height: bool) {
|
||||
crate::edit_in_place::browser_strip(app, ui);
|
||||
if shrink_height {
|
||||
if collapsible && !stacked {
|
||||
browser_rows_hugging(app, ui);
|
||||
} else {
|
||||
browser_rows(app, ui);
|
||||
@@ -1596,8 +1569,7 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
|
||||
prepare_model(app);
|
||||
let mut cache = app.tree.cache.take().unwrap_or_default();
|
||||
cache.prepare(app);
|
||||
let top = ui.cursor().top();
|
||||
let output = egui::ScrollArea::vertical()
|
||||
egui::ScrollArea::vertical()
|
||||
// The card may animate all the way down to its header.
|
||||
.min_scrolled_height(0.0)
|
||||
.auto_shrink([false, shrink_height])
|
||||
@@ -1614,7 +1586,6 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
|
||||
component_rows(app, ui, 0, 0, acts, &mut cache);
|
||||
}
|
||||
});
|
||||
crate::panel_runtime::report_content_extent(ui, top, output.content_size.y, true);
|
||||
app.tree.cache = Some(cache);
|
||||
}
|
||||
|
||||
@@ -3129,7 +3100,6 @@ fn inline_rename(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect, label_left: f
|
||||
ui.painter().rect_filled(edit_rect, 3.0, Tokens::get().field);
|
||||
let id = ui.scope_id().with(("browser_rename", &e.row_key));
|
||||
let response = crate::ui_kit::inline_name(ui, edit_rect, id, &mut rename.text);
|
||||
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
|
||||
crate::scenario::publish_handle(&format!("rename:{}", e.key), edit_rect.center());
|
||||
let was_focused = rename.focused;
|
||||
if !rename.focused {
|
||||
|
||||
+52
-255
@@ -27,7 +27,7 @@
|
||||
//! section the viewport shows (no window needed); the ground grid and the origin's axes as the
|
||||
//! window shows them, unless `grid` or `axes` says
|
||||
//! - `ui.icon_audit {reset?, stop?}`: collect/drain requested icon names, resolved masters and placeholder flags;
|
||||
//! - `ui.panel_fixture {enabled?, preset?, translucency_percent?, reduced_motion?}`: opt-in synthetic content in the real panel host;
|
||||
//! - `ui.panel_fixture {enabled?, preset?, translucency_percent?}`: opt-in synthetic content in the real panel host;
|
||||
//! `{}` observes the current host, probe and Session without ticking or model commands
|
||||
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
|
||||
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
|
||||
@@ -287,13 +287,6 @@ fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result<Value, String>
|
||||
for row in rows.iter_mut().filter_map(Value::as_object_mut) {
|
||||
let key = row.get("id").and_then(Value::as_str).and_then(|id| crate::keymap::effective(app, id));
|
||||
row.insert("shortcut".into(), json!(key));
|
||||
if let Some(action) =
|
||||
row.get("id").and_then(Value::as_str).and_then(solidcraft_engine::panels::ContextualPanelAction::from_command_id)
|
||||
{
|
||||
let reason = app.panel_contextual_disabled_reason(action);
|
||||
row.insert("enabled".into(), json!(reason.is_none()));
|
||||
row.insert("disabled_reason".into(), json!(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(commands);
|
||||
@@ -364,11 +357,6 @@ fn command(step: &Value) -> Option<&str> {
|
||||
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// A batch step's parameters: missing or null is no parameters.
|
||||
fn step_params(step: &Value) -> Value {
|
||||
step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Most steps in one `engine.batch` (as in the MCP `batch` tool).
|
||||
const MAX_BATCH: usize = 10_000;
|
||||
|
||||
@@ -439,7 +427,9 @@ fn batch(app: &mut SolveApp, p: &Value) -> Outcome {
|
||||
// checkpoint; previous application changes are truthfully retained.
|
||||
let mut results = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let result = command(step).ok_or_else(|| "missing command".to_string()).and_then(|id| execute(app, id, step_params(step)));
|
||||
let result = command(step)
|
||||
.ok_or_else(|| "missing command".to_string())
|
||||
.and_then(|id| execute(app, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))));
|
||||
match result {
|
||||
Ok(result) => results.push(result),
|
||||
Err(error) => return ok(solidcraft_engine::panels::commands::sequential_failure(i, command(step), error, results)),
|
||||
@@ -486,7 +476,7 @@ fn batch(app: &mut SolveApp, p: &Value) -> Outcome {
|
||||
for i in singles {
|
||||
let Some(step) = steps.get(i) else { continue };
|
||||
let (r, took) = timing::time(profile, || match command(step) {
|
||||
Some(id) => execute(app, id, step_params(step)),
|
||||
Some(id) => execute(app, id, step.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}))),
|
||||
None => Err("missing `command`".to_string()),
|
||||
});
|
||||
if timed {
|
||||
@@ -510,43 +500,6 @@ fn batch(app: &mut SolveApp, p: &Value) -> Outcome {
|
||||
ok(with_timings(json!({"completed": results.len(), "results": results, "undone": 0}), timed, rows, clock.ms()))
|
||||
}
|
||||
|
||||
/// `engine.script {commands}` (or the bare array): each step runs as `engine.execute` runs it, so
|
||||
/// the window follows it as it follows a batch step (#12629). The first failure stops the script,
|
||||
/// and the steps before it stay. Its words are the engine script's, or the panel error when a step
|
||||
/// is a panel command.
|
||||
fn script(app: &mut SolveApp, p: &Value) -> Outcome {
|
||||
let steps = match solidcraft_engine::script_steps(p) {
|
||||
Ok(steps) => steps,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
if let Some(c) = steps.iter().find_map(switches_design) {
|
||||
return err(format!("engine.script unavailable: {c} switches to another design (send it on its own)"));
|
||||
}
|
||||
let panels = steps.iter().any(|step| command(step).is_some_and(solidcraft_engine::panels::commands::is_command));
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let Some(id) = command(step) else {
|
||||
return if panels {
|
||||
Outcome::Done(json!({"ok": false, "error": "missing command", "step": i + 1}))
|
||||
} else {
|
||||
err(format!("script step {}: missing `command`", i + 1))
|
||||
};
|
||||
};
|
||||
match execute(app, id, step_params(step)) {
|
||||
Ok(result) => results.push(result),
|
||||
Err(error) if panels => {
|
||||
let mut reply = json!({"ok": false, "error": error, "step": i + 1});
|
||||
if let Some(reason) = solidcraft_engine::panels::commands::structured_error(&error) {
|
||||
reply["panel_error"] = reason;
|
||||
}
|
||||
return Outcome::Done(reply);
|
||||
}
|
||||
Err(error) => return err(format!("step {} ({id}): {error}", i + 1)),
|
||||
}
|
||||
}
|
||||
ok(json!(results))
|
||||
}
|
||||
|
||||
/// A batch reply with `timings` and `total_ms` when they were asked for.
|
||||
fn with_timings(mut reply: Value, timed: bool, rows: Vec<Value>, total_ms: f64) -> Value {
|
||||
if timed && let Some(o) = reply.as_object_mut() {
|
||||
@@ -838,7 +791,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
}
|
||||
"engine.execute" | "command" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
wrap(execute(app, id, step_params(p)))
|
||||
wrap(execute(app, id, p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}))))
|
||||
}
|
||||
"engine.batch" => batch(app, p),
|
||||
"ui.agent_cursor" => {
|
||||
@@ -857,7 +810,52 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
}
|
||||
ok(json!(app.ui.agent_cursor))
|
||||
}
|
||||
"engine.script" => script(app, p),
|
||||
"engine.script" => {
|
||||
let steps = p.as_array().or_else(|| p.get("commands").and_then(Value::as_array));
|
||||
if let Some(c) = steps.and_then(|steps| steps.iter().find_map(switches_design)) {
|
||||
return err(format!("engine.script unavailable: {c} switches to another design (send it on its own)"));
|
||||
}
|
||||
if let Some(steps) =
|
||||
steps.filter(|steps| steps.iter().any(|step| command(step).is_some_and(solidcraft_engine::panels::commands::is_command)))
|
||||
{
|
||||
if steps.len() > 100_000 {
|
||||
return err("script too long");
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let result = command(step)
|
||||
.ok_or_else(|| "missing command".to_string())
|
||||
.and_then(|id| execute(app, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))));
|
||||
match result {
|
||||
Ok(result) => results.push(result),
|
||||
Err(error) => {
|
||||
let mut reply = json!({"ok":false,"error":error,"step":i + 1});
|
||||
if let Some(reason) = solidcraft_engine::panels::commands::structured_error(&error) {
|
||||
reply["panel_error"] = reason;
|
||||
}
|
||||
return Outcome::Done(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok(json!(results));
|
||||
}
|
||||
let mut replaced = false;
|
||||
let r = app
|
||||
.session
|
||||
.run_script_each(p, &mut |i, _| {
|
||||
let id = steps.and_then(|steps| steps.get(i)).and_then(command).and_then(solidcraft_engine::find_command).map(|spec| spec.id);
|
||||
replaced |= matches!(id, Some("file.new" | "doc.open" | "doc.recover"));
|
||||
})
|
||||
.map(|v| json!(v))
|
||||
.map_err(|e| e.to_string());
|
||||
// A successful replacement still happened if a later script step failed.
|
||||
if replaced {
|
||||
crate::documents::reset_chat(app);
|
||||
app.edit_in_place.active = None;
|
||||
}
|
||||
app.follow_view_request(false);
|
||||
wrap(r)
|
||||
}
|
||||
"engine.commands" => wrap(execute(app, "engine.commands", json!({}))),
|
||||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||||
"document.revision" => wrap(app.session.execute("document.revision", p).map_err(|e| e.to_string())),
|
||||
@@ -1889,74 +1887,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
#[test]
|
||||
fn contextual_command_metadata_matches_live_focus_without_side_effects() {
|
||||
fn metadata(app: &mut SolveApp, reason_key: Option<&str>) {
|
||||
let enabled = reason_key.is_none();
|
||||
let document = app.session.doc.clone();
|
||||
let model = app.session.model.state();
|
||||
let revision = app.session.revision;
|
||||
let evaluations = app.session.evaluation_count();
|
||||
let undo = app.session.undo.len();
|
||||
let before = crate::panel_runtime::inspect_host_specimen(app).unwrap();
|
||||
let command = solidcraft_engine::find_command("panel.focus.next").unwrap();
|
||||
let interactive = crate::render_workspace::interactive_command_info(app, command);
|
||||
let commands = execute(app, "engine.commands", json!({})).unwrap();
|
||||
let row = commands.as_array().unwrap().iter().find(|row| row["id"] == "panel.focus.next").unwrap();
|
||||
assert_eq!(interactive.enabled, enabled);
|
||||
assert_eq!(row["enabled"], enabled);
|
||||
assert_eq!(row["disabled_reason"], json!(interactive.disabled_reason));
|
||||
assert_eq!(row["disabled_reason"], json!(reason_key.map(|key| app.tr(key))), "each context reports its exact localized refusal");
|
||||
assert_eq!(crate::panel_runtime::inspect_host_specimen(app).unwrap(), before);
|
||||
assert!(std::sync::Arc::ptr_eq(&document, &app.session.doc));
|
||||
assert!(std::sync::Arc::ptr_eq(&model, &app.session.model.state()));
|
||||
assert_eq!(app.session.revision, revision);
|
||||
assert_eq!(app.session.evaluation_count(), evaluations);
|
||||
assert_eq!(app.session.undo.len(), undo);
|
||||
}
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
metadata(&mut h.app, Some("ui.panel_host.rule.availability"));
|
||||
crate::panel_runtime::control_specimen(&mut h.app, &json!({"preset":"docks"})).unwrap();
|
||||
metadata(&mut h.app, Some("ui.panel_host.rule.availability")); // Host exists, but has no presented frame.
|
||||
h.frames(4);
|
||||
let header = h
|
||||
.app
|
||||
.universal_panel_frame()
|
||||
.unwrap()
|
||||
.controls
|
||||
.iter()
|
||||
.find(|control| {
|
||||
control.enabled
|
||||
&& matches!(
|
||||
control.target,
|
||||
crate::panel_runtime::HostControlTarget::Pointer(crate::panel_runtime::HostPointerTarget::Header { .. })
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
.clone();
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(header.id));
|
||||
metadata(&mut h.app, None);
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(egui::Id::unique("foreign-contextual-focus")));
|
||||
metadata(&mut h.app, Some("ui.panel_host.rule.availability"));
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(header.id));
|
||||
let old_document_id = h.app.session.document_id.clone();
|
||||
h.app.session.document_id = "metadata-incoming-document".into();
|
||||
metadata(&mut h.app, Some("ui.panel_host.rule.availability"));
|
||||
h.app.session.document_id = old_document_id;
|
||||
metadata(&mut h.app, None);
|
||||
let section = h.app.universal_panel_frame().unwrap().layout.sections[0].section.clone();
|
||||
let stale_revision = h.app.universal_panel_frame().unwrap().layout.revision;
|
||||
h.app
|
||||
.dispatch_panel_layout(solidcraft_engine::panels::LayoutAction::Apply {
|
||||
expected_revision: Some(stale_revision),
|
||||
mutations: vec![solidcraft_engine::panels::Mutation::Collapse { section, collapsed: true }],
|
||||
})
|
||||
.unwrap();
|
||||
assert_ne!(h.app.panel_runtime().unwrap().layout().inspect().revision, stale_revision);
|
||||
metadata(&mut h.app, Some("ui.panel_host.rule.revision"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_fixture_validates_whole_request_before_activation() {
|
||||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||||
@@ -1978,9 +1908,6 @@ mod tests {
|
||||
json!({"preset":"surfaces","translucency_percent":"50"}),
|
||||
json!({"preset":"surfaces","translucency_percent":null}),
|
||||
json!({"translucency_percent":50}),
|
||||
json!({"reduced_motion":true}),
|
||||
json!({"preset":"docks","reduced_motion":null}),
|
||||
json!({"enabled":false,"reduced_motion":true}),
|
||||
json!({"enabled":false,"translucency_percent":50}),
|
||||
] {
|
||||
assert!(crate::panel_runtime::control_specimen(&mut app, ¶ms).is_err(), "{params}");
|
||||
@@ -2110,60 +2037,6 @@ mod tests {
|
||||
assert!(crate::panel_runtime::control_specimen(&mut h.app, &json!({"translucency_percent":50})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_fixture_language_and_motion_refresh_preserve_layout_controllers_and_session() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.language = "en".into();
|
||||
crate::panel_runtime::control_specimen(&mut h.app, &json!({"preset":"surfaces"})).unwrap();
|
||||
h.frames(4);
|
||||
let runtime = h.app.panel_runtime().unwrap();
|
||||
let instance = runtime.layout().layout().instances.iter().find(|instance| instance.kind.0 == "fixture.editor").unwrap().id.clone();
|
||||
h.app.run("panel.focus", json!({"target":{"type":"instance","id":instance}})).unwrap();
|
||||
h.frames(4);
|
||||
let owner = h.app.panel_runtime().unwrap().instance_owner(&instance).unwrap();
|
||||
let draft_id = egui::Id::unique(("specimen-draft", &instance, &owner));
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(draft_id));
|
||||
h.frame_with(vec![egui::Event::Text("retained locale selection".into())]);
|
||||
let mut state = egui::TextEdit::load_state(&h.ctx, draft_id).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
|
||||
state.store(&h.ctx, draft_id);
|
||||
h.frames(1);
|
||||
let selection = egui::TextEdit::load_state(&h.ctx, draft_id).unwrap().cursor.char_range();
|
||||
assert!(!selection.unwrap().is_empty(), "exercise an actual nonempty draft selection");
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(draft_id));
|
||||
let before = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
|
||||
let document = h.app.session.doc.clone();
|
||||
let model = h.app.session.model.state();
|
||||
let camera = h.app.cam;
|
||||
for (language, expected) in [("ar", "rtl"), ("he", "rtl"), ("en", "ltr")] {
|
||||
h.app.preferences.language = language.into();
|
||||
h.frames(4);
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(draft_id), "locale changes retain the actual focused draft");
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, draft_id).unwrap().cursor.char_range(), selection);
|
||||
let after = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
|
||||
assert_eq!(after["host"]["direction"], expected);
|
||||
assert_eq!(after["runtime"]["layout"], before["runtime"]["layout"]);
|
||||
for (old, new) in
|
||||
before["runtime"]["probe"]["controllers"].as_array().unwrap().iter().zip(after["runtime"]["probe"]["controllers"].as_array().unwrap())
|
||||
{
|
||||
assert_eq!(new["controller_identity"], old["controller_identity"]);
|
||||
assert_eq!(new["draft"], old["draft"]);
|
||||
assert_eq!(new["transaction_live"], old["transaction_live"]);
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&document, &h.app.session.doc));
|
||||
assert!(std::sync::Arc::ptr_eq(&model, &h.app.session.model.state()));
|
||||
assert_eq!(h.app.cam, camera);
|
||||
}
|
||||
for reduced in [true, false] {
|
||||
let updated = crate::panel_runtime::control_specimen(&mut h.app, &json!({"reduced_motion":reduced})).unwrap();
|
||||
assert_eq!(updated["runtime"]["reduced_motion"], reduced);
|
||||
assert_eq!(updated["runtime"]["layout"], before["runtime"]["layout"]);
|
||||
h.frames(4);
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(draft_id), "motion updates retain the actual focused draft");
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, draft_id).unwrap().cursor.char_range(), selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_fixture_teardown_honors_live_editor_guard_atomically() {
|
||||
fn click_visible_guard(h: &mut crate::scenario::Harness, guard: &str) {
|
||||
@@ -2812,82 +2685,6 @@ mod tests {
|
||||
assert_eq!(inspect(&mut h)["sketches"].as_array().map(Vec::len), Some(40));
|
||||
}
|
||||
|
||||
/// #12629: each step of `engine.script` gets what the window does after the command, as the
|
||||
/// same step of `engine.batch` gets it: the grid, the tool, the spin, a preference, the echo.
|
||||
#[test]
|
||||
fn a_script_step_gets_the_windows_follow_up_like_a_batch_step() {
|
||||
let steps = json!([
|
||||
{"command": "view.grid", "params": {"on": false}},
|
||||
{"command": "view.tool", "params": {"tool": "pan"}},
|
||||
{"command": "view.spin", "params": {"on": true}},
|
||||
{"command": "view.angle_readout", "params": {"enabled": true}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
]);
|
||||
let window = |method: &str| {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
// No checkpoint, so the batch runs exactly the script's steps.
|
||||
let v = h.call(method, json!({"commands": steps, "rollback": false}));
|
||||
assert_eq!(v["ok"], true, "{method}: {v}");
|
||||
let app = &h.app;
|
||||
(app.ui.show_grid, app.viewport.nav, app.viewport.spin, app.preferences.show_view_angles, app.session.log.clone())
|
||||
};
|
||||
let batch = window("engine.batch");
|
||||
assert_eq!((batch.0, batch.1, batch.2, batch.3), (false, Some(crate::viewport::NavMode::Pan), true, true));
|
||||
assert_eq!(window("engine.script"), batch);
|
||||
}
|
||||
|
||||
/// The first failing step stops a script, and the steps before it stay with what the window
|
||||
/// did after them: a preference set by step 1 is kept when step 3 fails.
|
||||
#[test]
|
||||
fn a_script_stops_at_its_failing_step_and_keeps_the_ones_before() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
assert!(!h.app.preferences.show_view_angles && h.app.ui.show_grid);
|
||||
let v = h.call(
|
||||
"engine.script",
|
||||
json!({"commands": [
|
||||
{"command": "view.angle_readout", "params": {"enabled": true}},
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}},
|
||||
{"command": "no.such_command"},
|
||||
{"command": "view.grid", "params": {"on": false}},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(v["ok"], false, "{v}");
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("step 3 (no.such_command): ")), "{v}");
|
||||
assert!(h.app.preferences.show_view_angles, "step 1's preference stays");
|
||||
assert_eq!(inspect(&mut h)["bodies"].as_array().map(Vec::len), Some(1), "step 2's box stays");
|
||||
assert!(h.app.ui.show_grid, "nothing after the failing step ran");
|
||||
let v = h.call("engine.script", json!([{"params": {}}]));
|
||||
assert_eq!(v["error"], "script step 1: missing `command`", "{v}");
|
||||
}
|
||||
|
||||
/// A panel command among a script's steps no longer changes how the other steps run.
|
||||
#[test]
|
||||
fn a_script_with_and_without_a_panel_step_leaves_the_window_alike() {
|
||||
let window = |panel: bool| {
|
||||
let mut app = panel_command_app();
|
||||
let mut steps = vec![
|
||||
json!({"command": "view.grid", "params": {"on": false}}),
|
||||
json!({"command": "view.tool", "params": {"tool": "pan"}}),
|
||||
json!({"command": "view.angle_readout", "params": {"enabled": true}}),
|
||||
];
|
||||
if panel {
|
||||
steps.push(json!({"command": "layout.get"}));
|
||||
}
|
||||
let v = panel_control(&mut app, "engine.script", json!({"commands": steps}));
|
||||
assert_eq!(v["ok"], true, "{v}");
|
||||
(app.ui.show_grid, app.viewport.nav, app.preferences.show_view_angles)
|
||||
};
|
||||
assert_eq!(window(false), window(true));
|
||||
assert_eq!(window(false), (false, Some(crate::viewport::NavMode::Pan), true));
|
||||
// A failing step of such a script is named by its number, with the panel's own reason.
|
||||
let mut app = panel_command_app();
|
||||
let steps = json!([{"command": "view.grid", "params": {"on": false}}, {"command": "panel.list", "params": {"unknown": true}}]);
|
||||
let v = panel_control(&mut app, "engine.script", json!({"commands": steps}));
|
||||
assert_eq!((&v["ok"], &v["step"], &v["panel_error"]["rule"]), (&json!(false), &json!(2), &json!("schema")), "{v}");
|
||||
assert!(!app.ui.show_grid, "the step before it stays");
|
||||
}
|
||||
|
||||
/// #5822: `engine.batch {coalesce_parameters}` sets a run of parameter edits together, one
|
||||
/// rebuild, and answers each step; a refused edit is named by its own step and the batch goes
|
||||
/// back. Unasked, each edit runs on its own as before.
|
||||
|
||||
@@ -2631,18 +2631,6 @@ fn title(k: &Kind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the dialog is an extrude opened by Press Pull (the Q shortcut), which says
|
||||
/// PRESS PULL, not EXTRUDE (#11705).
|
||||
fn press_pull_extrude(d: &Dialog) -> bool {
|
||||
d.press_pull && matches!(d.kind, Kind::Extrude { .. })
|
||||
}
|
||||
|
||||
/// The dialog's title: an extrude opened by Press Pull (the Q shortcut) says PRESS PULL, not
|
||||
/// EXTRUDE (#11705).
|
||||
fn title_of(d: &Dialog) -> &'static str {
|
||||
if press_pull_extrude(d) { "PRESS PULL" } else { title(&d.kind) }
|
||||
}
|
||||
|
||||
fn hint(inp: &SelInput) -> &'static str {
|
||||
let a = inp.accept;
|
||||
if a & PROFILES != 0 {
|
||||
@@ -3843,7 +3831,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut value_fields = Vec::new();
|
||||
let wide = matches!(&d.kind, Kind::Motion(k) if k.wide());
|
||||
|
||||
let raw_title = title_of(&d);
|
||||
let raw_title = title(&d.kind);
|
||||
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
|
||||
let frame = crate::ui_kit::Panel::feature_frame(ctx, app.ui.layout, side);
|
||||
// OK is offered once the inputs make a feature.
|
||||
@@ -3878,7 +3866,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
);
|
||||
let grow_key = egui::Id::unique(("sc_dialog_label_grow", raw_title));
|
||||
let label_grow = ctx.data(|data| data.get_temp::<f32>(grow_key)).unwrap_or(0.0).min((vp.width() - t.space_24 - card_width(&d)).max(0.0));
|
||||
let panel = crate::ui_kit::Panel::new(&heading, egui::Id::unique(("sc_dialog", title_of(&d))), anchor, drag_bounds, side, frame)
|
||||
let panel = crate::ui_kit::Panel::new(&heading, egui::Id::unique(("sc_dialog", title(&d.kind))), anchor, drag_bounds, side, frame)
|
||||
.actions(ok_label, close_label);
|
||||
let panel = if let Some(column) = column {
|
||||
panel.width(column.width()).joined(crate::ui_kit::DockEdges { left: true, top: true, ..Default::default() })
|
||||
@@ -3900,7 +3888,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut painted = false;
|
||||
if let Kind::ConfirmDelete { with, fail, .. } = &d.kind {
|
||||
let detail = with.join(" · ");
|
||||
let (closed, action) = crate::ui_kit::Confirm::new(egui::Id::unique(("sc_dialog", title_of(&d))), "delete", &heading)
|
||||
let (closed, action) = crate::ui_kit::Confirm::new(egui::Id::unique(("sc_dialog", title(&d.kind))), "delete", &heading)
|
||||
.tone(crate::ui_kit::Tone::Danger)
|
||||
.actions("Delete", "Cancel", None)
|
||||
.detail(&detail)
|
||||
@@ -3919,12 +3907,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
let response = window.open(&mut keep).show(ctx, |ui| {
|
||||
painted = ui.is_visible() && !ui.is_sizing_pass();
|
||||
// A Press Pull's extrude shows the Press Pull command, not Extrude (#11705).
|
||||
let spec = if press_pull_extrude(&d) {
|
||||
solidcraft_engine::find_command("solid.press_pull")
|
||||
} else {
|
||||
dense::command(&d.kind).and_then(solidcraft_engine::find_command)
|
||||
};
|
||||
let spec = dense::command(&d.kind).and_then(solidcraft_engine::find_command);
|
||||
let icon = spec.as_ref().map_or("", |s| s.icon);
|
||||
let label = spec.as_ref().map_or(heading.as_str(), |s| tr(s.label));
|
||||
let label = if d.editing.is_some() { heading.as_str() } else { label };
|
||||
@@ -5008,7 +4991,7 @@ pub(crate) fn feature_frame(s: &Session, component: u64) -> solidcraft_engine::d
|
||||
|
||||
/// The feature dialog's current operation name for a reviewed issue draft.
|
||||
pub(crate) fn report_title(d: &Dialog) -> String {
|
||||
title_of(d).to_string()
|
||||
title(&d.kind).to_string()
|
||||
}
|
||||
|
||||
/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same.
|
||||
|
||||
@@ -309,7 +309,6 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
app.tree.picked_components.clear();
|
||||
// Preferences the session holds go with the user, not the tab (#680).
|
||||
app.session.auto_project = out.session.auto_project;
|
||||
app.session.project_construction = out.session.project_construction;
|
||||
app.session.set_validation(out.session.validation());
|
||||
// So does the host's pre-command check: every tab is held to it.
|
||||
app.session.set_command_check(out.session.command_check());
|
||||
@@ -2196,28 +2195,6 @@ mod tests {
|
||||
assert!(!a.session.auto_project, "a design adopted over a blank one");
|
||||
}
|
||||
|
||||
/// Projecting references as construction geometry is the user's too (#13142), like
|
||||
/// auto-projection: it holds in new tabs, the tab switched to, after the last tab closes and
|
||||
/// in a design adopted over a blank one, and the preferences file keeps it.
|
||||
#[test]
|
||||
fn project_construction_follows_the_user_across_tabs() {
|
||||
let mut a = app();
|
||||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
a.session.project_construction = true;
|
||||
new_design(&mut a);
|
||||
assert!(a.session.project_construction, "a new tab");
|
||||
a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).unwrap();
|
||||
switch(&mut a, 0);
|
||||
assert!(a.session.project_construction, "the tab switched to");
|
||||
let prefs: Value = serde_json::from_str(&a.prefs()).unwrap();
|
||||
assert_eq!(prefs["project_construction"], true);
|
||||
close(&mut a, 1, true);
|
||||
close(&mut a, 0, true);
|
||||
assert!(active_is_blank(&a) && a.session.project_construction, "after the last tab closes");
|
||||
adopt(&mut a, Session::default());
|
||||
assert!(a.session.project_construction, "a design adopted over a blank one");
|
||||
}
|
||||
|
||||
/// The host's pre-command check holds in every tab: new ones, the one switched to, a design
|
||||
/// adopted over a blank one and after the last tab closes.
|
||||
#[test]
|
||||
|
||||
@@ -570,7 +570,6 @@ pub(crate) fn render_offscreen(
|
||||
size,
|
||||
Offscreen {
|
||||
tess,
|
||||
product_lights: None,
|
||||
product: false,
|
||||
srgb: false,
|
||||
analysis,
|
||||
@@ -591,7 +590,6 @@ pub(crate) fn render_product_offscreen(
|
||||
eye: [f32; 4],
|
||||
size: u32,
|
||||
srgb: bool,
|
||||
product_lights: Option<Vec<[f32; 16]>>,
|
||||
) -> Option<Vec<u8>> {
|
||||
offscreen(
|
||||
scene,
|
||||
@@ -602,7 +600,6 @@ pub(crate) fn render_product_offscreen(
|
||||
size,
|
||||
Offscreen {
|
||||
tess: None,
|
||||
product_lights,
|
||||
product: true,
|
||||
srgb,
|
||||
analysis: [0.0; 8],
|
||||
@@ -617,9 +614,6 @@ pub(crate) fn render_product_offscreen(
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
struct Offscreen<'a> {
|
||||
tess: Option<&'a str>,
|
||||
/// The Render workspace's analytic preview lights (`Frame::product_lights`); `None` draws the
|
||||
/// product without them.
|
||||
product_lights: Option<Vec<[f32; 16]>>,
|
||||
product: bool,
|
||||
srgb: bool,
|
||||
analysis: [f32; 8],
|
||||
@@ -648,7 +642,6 @@ pub(crate) fn render_ao_offscreen(
|
||||
size,
|
||||
Offscreen {
|
||||
tess,
|
||||
product_lights: None,
|
||||
product: false,
|
||||
srgb: false,
|
||||
analysis: [0.0; 8],
|
||||
@@ -679,7 +672,6 @@ pub(crate) fn render_offscreen_ssaa(
|
||||
size,
|
||||
Offscreen {
|
||||
tess: None,
|
||||
product_lights: None,
|
||||
product: false,
|
||||
srgb: false,
|
||||
analysis: [0.0; 8],
|
||||
@@ -710,17 +702,7 @@ pub(crate) fn render_offscreen_ao(
|
||||
back,
|
||||
eye,
|
||||
size,
|
||||
Offscreen {
|
||||
tess: None,
|
||||
product_lights: None,
|
||||
product: false,
|
||||
srgb: false,
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
ssaa: 1.0,
|
||||
ao_radius,
|
||||
ao_samples,
|
||||
},
|
||||
Offscreen { tess: None, product: false, srgb: false, analysis: [0.0; 8], analysis_colors: [0.0; 36], ssaa: 1.0, ao_radius, ao_samples },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -734,7 +716,7 @@ fn offscreen(
|
||||
size: u32,
|
||||
options: Offscreen<'_>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let Offscreen { tess, product_lights, product, srgb, analysis, analysis_colors, ssaa, ao_radius, ao_samples } = options;
|
||||
let Offscreen { tess, product, srgb, analysis, analysis_colors, ssaa, ao_radius, ao_samples } = options;
|
||||
let (adapter, device, queue) = offscreen_device()?;
|
||||
eprintln!("offscreen adapter: {:?}", adapter.get_info().backend);
|
||||
let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm };
|
||||
@@ -745,7 +727,7 @@ fn offscreen(
|
||||
resources.insert(Renderer::new(&device, target, compute));
|
||||
let none = || Arc::new(Mutex::new(None));
|
||||
let cb = ViewportCallback(Frame {
|
||||
product_lights,
|
||||
product_lights: None,
|
||||
product,
|
||||
upload_error: None,
|
||||
key: 1,
|
||||
|
||||
@@ -202,16 +202,6 @@ pub fn effective_language(preference: &str) -> &'static str {
|
||||
Locale::for_language(preference).0.id
|
||||
}
|
||||
|
||||
/// Interface direction follows the selected catalog, including English fallback.
|
||||
/// Message contents and document coordinates never determine workspace direction.
|
||||
pub(crate) fn panel_direction(preference: &str) -> solidcraft_engine::panels::Direction {
|
||||
use solidcraft_engine::panels::Direction;
|
||||
match effective_language(preference) {
|
||||
"ar" | "he" => Direction::Rtl,
|
||||
_ => Direction::Ltr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable locale reference. Catalogs parse once; lookups are logarithmic.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Locale(&'static Catalog);
|
||||
@@ -478,20 +468,6 @@ pub fn tr_engine_error(error: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn panel_direction_follows_effective_catalog_including_missing_message_fallback() {
|
||||
use solidcraft_engine::panels::Direction;
|
||||
for language in ["ar", "he"] {
|
||||
assert_eq!(super::panel_direction(language), Direction::Rtl);
|
||||
// An untranslated message may be English without making the UI LTR.
|
||||
assert_eq!(super::Locale::for_language(language).text("unregistered.caption"), "unregistered.caption");
|
||||
}
|
||||
for language in ["en", "de", "ja", "unknown"] {
|
||||
assert_eq!(super::panel_direction(language), Direction::Ltr);
|
||||
}
|
||||
assert_eq!(super::panel_direction(super::SYSTEM), super::panel_direction(super::system_language()));
|
||||
}
|
||||
|
||||
/// The system's locale tags pick a catalog: exact tag, then base language, then English (#10138).
|
||||
#[test]
|
||||
fn system_tags_match_the_catalog_exact_then_base_then_english() {
|
||||
|
||||
@@ -187,8 +187,7 @@ impl Keymap {
|
||||
Value::Object(self.custom.iter().map(|(id, keys)| (id.clone(), keys_value(keys))).collect())
|
||||
}
|
||||
pub fn load(&mut self, v: &Value) {
|
||||
self.custom =
|
||||
v.as_object().map(|o| o.iter().take(2000).filter_map(|(k, v)| Some((k.clone(), saved_keys(k, v)?))).collect()).unwrap_or_default();
|
||||
self.custom = v.as_object().map(|o| o.iter().take(2000).filter_map(|(k, v)| Some((k.clone(), saved_keys(v)?))).collect()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +201,7 @@ pub(crate) fn keys_value(keys: &[String]) -> Value {
|
||||
}
|
||||
|
||||
/// A command's saved keys: `""` (none), one key, or a list of them.
|
||||
pub(crate) fn saved_keys(id: &str, v: &Value) -> Option<Vec<String>> {
|
||||
pub(crate) fn saved_keys(v: &Value) -> Option<Vec<String>> {
|
||||
let raw: Vec<&str> = match v {
|
||||
Value::String(key) => vec![key.as_str()],
|
||||
Value::Array(keys) => keys.iter().filter_map(Value::as_str).filter(|k| !k.is_empty()).take(MAX_KEYS).collect(),
|
||||
@@ -214,7 +213,7 @@ pub(crate) fn saved_keys(id: &str, v: &Value) -> Option<Vec<String>> {
|
||||
}
|
||||
let mut keys = Vec::new();
|
||||
for key in raw.into_iter().filter(|key| !key.is_empty()) {
|
||||
let Some(key) = canonical_key(key).filter(|key| !reserved_for(id, key)) else {
|
||||
let Some(key) = canonical_key(key).filter(|key| !RESERVED.contains(&key.as_str())) else {
|
||||
log::warn!("invalid or reserved saved shortcut ignored");
|
||||
continue;
|
||||
};
|
||||
@@ -225,41 +224,10 @@ pub(crate) fn saved_keys(id: &str, v: &Value) -> Option<Vec<String>> {
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Panel bindings own arrows only while shared chrome has actual keyboard focus.
|
||||
/// Every other application-reserved key remains protected for every command.
|
||||
fn reserved_for(id: &str, key: &str) -> bool {
|
||||
RESERVED.contains(&key)
|
||||
&& !(solidcraft_engine::panels::ContextualPanelAction::from_command_id(id).is_some()
|
||||
&& key.rsplit('+').next().is_some_and(|name| matches!(name, "Left" | "Right" | "Up" | "Down")))
|
||||
}
|
||||
|
||||
/// Effective registered chords for the host's deferred, focus-checked input path.
|
||||
pub(crate) fn panel_shortcuts(app: &SolveApp) -> Vec<(solidcraft_engine::panels::ContextualPanelAction, egui::KeyboardShortcut)> {
|
||||
use solidcraft_engine::panels::ContextualPanelAction;
|
||||
ContextualPanelAction::ALL
|
||||
.into_iter()
|
||||
.flat_map(|action| {
|
||||
keys(app, action.command_id()).into_iter().take(MAX_KEYS).filter_map(move |key| {
|
||||
let mut shortcut = chord(&key)?;
|
||||
// Ctrl is the existing primary-modifier alias; explicit Ctrl+Cmd
|
||||
// continues to require both physical modifiers.
|
||||
if shortcut.modifiers.command && !shortcut.modifiers.mac_cmd {
|
||||
shortcut.modifiers.ctrl = false;
|
||||
}
|
||||
Some((action, shortcut))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the same chord that recording a key produces, independent of case and modifier order.
|
||||
/// The last token is the key; the preceding tokens are modifiers. Keep the historical primary
|
||||
/// Ctrl/Cmd alias, explicit Control+Command chords and old physical modifier-key names.
|
||||
fn canonical_key(text: &str) -> Option<String> {
|
||||
chord(text).map(|shortcut| key_name(shortcut.logical_key, shortcut.modifiers))
|
||||
}
|
||||
|
||||
fn chord(text: &str) -> Option<egui::KeyboardShortcut> {
|
||||
if text.len() > 256 {
|
||||
return None;
|
||||
}
|
||||
@@ -297,7 +265,7 @@ fn chord(text: &str) -> Option<egui::KeyboardShortcut> {
|
||||
"return" => egui::Key::Enter,
|
||||
_ => egui::Key::ALL.iter().copied().find(|key| key.name().eq_ignore_ascii_case(name)).or_else(|| egui::Key::from_name(name))?,
|
||||
};
|
||||
Some(egui::KeyboardShortcut::new(mods, key))
|
||||
Some(key_name(key, mods))
|
||||
}
|
||||
|
||||
/// A key press as a shortcut name: `E`, `Shift+E`, `Ctrl+Alt+K`, `F5`, `Delete`.
|
||||
@@ -473,9 +441,7 @@ pub fn command_for(app: &SolveApp, key: &str) -> Option<&'static str> {
|
||||
let enabled: Vec<&'static str> = commands_for(app, key)
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
// Shared panel bindings run only in the host's late input path.
|
||||
solidcraft_engine::panels::ContextualPanelAction::from_command_id(id).is_none()
|
||||
&& (scope(id) != Scope::Render || here == Scope::Render)
|
||||
(scope(id) != Scope::Render || here == Scope::Render)
|
||||
&& (here != Scope::Render || scope(id) != Scope::Sketch)
|
||||
&& solidcraft_engine::find_command(id).is_some_and(|c| crate::render_workspace::interactive_command_info(app, c).enabled)
|
||||
})
|
||||
@@ -554,7 +520,7 @@ pub fn bind_at(app: &mut SolveApp, id: &str, slot: Option<usize>, key: &str, rep
|
||||
let spec = solidcraft_engine::find_command(id).ok_or_else(|| format!("no command `{id}`"))?;
|
||||
let canonical = if key.is_empty() { String::new() } else { canonical_key(key).ok_or("unknown shortcut key")? };
|
||||
let key = canonical.as_str();
|
||||
if reserved_for(id, key) {
|
||||
if RESERVED.contains(&key) {
|
||||
return Err(format!("{key} is used by SolidCraft itself"));
|
||||
}
|
||||
let mut list = keys(app, id);
|
||||
@@ -1084,45 +1050,6 @@ pub fn capturing(app: &SolveApp) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn panel_bindings_preserve_contextual_arrows_and_rebind_clear_conflict_and_reset() {
|
||||
use solidcraft_engine::panels::ContextualPanelAction;
|
||||
let mut a = app();
|
||||
let defaults = panel_shortcuts(&a);
|
||||
assert_eq!(defaults.len(), 4);
|
||||
for (action, shortcut) in defaults {
|
||||
assert_eq!(shortcut.modifiers, egui::Modifiers::NONE);
|
||||
assert!(keys(&a, action.command_id()).contains(&shortcut.logical_key.name().to_string()));
|
||||
assert_eq!(command_for(&a, shortcut.logical_key.name()), None, "arrows stay out of global dispatch");
|
||||
}
|
||||
bind(&mut a, "panel.sash.left", "Ctrl+Shift+Left", false).unwrap();
|
||||
assert!(bind(&mut a, "solid.box", "Ctrl+Left", true).is_err());
|
||||
assert!(bind(&mut a, "panel.focus.next", "Escape", true).is_err());
|
||||
assert!(bind(&mut a, "panel.focus.next", "Ctrl+Z", true).is_err());
|
||||
assert!(bind(&mut a, "panel.focus.next", "Right", false).is_err(), "existing sash binding conflicts");
|
||||
bind(&mut a, "panel.focus.next", "Right", true).unwrap();
|
||||
assert!(keys(&a, "panel.sash.right").is_empty());
|
||||
bind(&mut a, "panel.toggle", "Ctrl+Alt+F12", true).unwrap();
|
||||
assert_eq!(command_for(&a, "Ctrl+Alt+F12"), None);
|
||||
let saved = a.keymap.prefs();
|
||||
let mut restored = app();
|
||||
restored.keymap.load(&saved);
|
||||
assert_eq!(restored.keymap.prefs(), saved);
|
||||
assert_eq!(keys(&restored, "panel.sash.left"), ["Ctrl+Shift+Left"]);
|
||||
bind(&mut restored, "panel.sash.left", "", false).unwrap();
|
||||
assert!(!panel_shortcuts(&restored).iter().any(|(action, _)| *action == ContextualPanelAction::ResizeLeft));
|
||||
reset(&mut restored, Some("panel.sash.left"));
|
||||
assert_eq!(keys(&restored, "panel.sash.left"), ["Left"]);
|
||||
reset(&mut restored, None);
|
||||
assert_eq!(panel_shortcuts(&restored).len(), 4);
|
||||
assert!(keys(&restored, "panel.focus.next").is_empty());
|
||||
let saved = json!({"panel.sash.up":"Ctrl+Up", "solid.box":"Ctrl+Up", "panel.toggle":"Ctrl+S"});
|
||||
restored.keymap.load(&saved);
|
||||
assert_eq!(keys(&restored, "panel.sash.up"), ["Ctrl+Up"]);
|
||||
assert!(keys(&restored, "solid.box").is_empty());
|
||||
assert!(keys(&restored, "panel.toggle").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_shortcut_can_be_rebound_cleared_saved_and_reset() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -541,8 +541,6 @@ pub(crate) enum EscapeOwner {
|
||||
pub struct SolveApp {
|
||||
/// Common panel foundation, internal opt-in; stays on the app across document tabs.
|
||||
pub(crate) panel_runtime: Option<panel_runtime::AppPanelRuntime>,
|
||||
/// Current immutable geometry while production content borrows the app.
|
||||
pub(crate) panel_content_frame: Option<panel_runtime::HostFrame>,
|
||||
pub(crate) panel_layout_preferences: panel_runtime::PanelLayoutPreferences,
|
||||
pub blur: blur::State,
|
||||
pub(crate) blur_session: blur::Session,
|
||||
@@ -855,7 +853,6 @@ impl SolveApp {
|
||||
let (shot_tx, shot_rx) = std::sync::mpsc::channel();
|
||||
let mut app = SolveApp {
|
||||
panel_runtime: None,
|
||||
panel_content_frame: None,
|
||||
panel_layout_preferences: panel_runtime::PanelLayoutPreferences::default(),
|
||||
viewport_status: Default::default(),
|
||||
blur: Default::default(),
|
||||
@@ -1146,7 +1143,6 @@ impl SolveApp {
|
||||
"auto_ortho_faces": self.ui.auto_ortho_faces,
|
||||
"pick_bodies": self.ui.pick_bodies,
|
||||
"auto_project": self.session.auto_project,
|
||||
"project_construction": self.session.project_construction,
|
||||
"visual_style": self.ui.visual_style,
|
||||
"browser_collapsed": self.tree.collapsed,
|
||||
"browser_expanded": self.tree.expanded,
|
||||
@@ -1217,9 +1213,6 @@ impl SolveApp {
|
||||
if let Some(a) = flag("auto_project") {
|
||||
self.session.auto_project = a;
|
||||
}
|
||||
if let Some(c) = flag("project_construction") {
|
||||
self.session.project_construction = c;
|
||||
}
|
||||
if let Some(m) = v.get("autosave_minutes").and_then(Value::as_f64).filter(|m| m.is_finite()) {
|
||||
self.autosave_minutes = m.clamp(0.0, 600.0);
|
||||
}
|
||||
@@ -2183,12 +2176,12 @@ impl SolveApp {
|
||||
}
|
||||
let empty = sel.is_empty();
|
||||
self.start("solid.extrude");
|
||||
if let Some(d) = &mut self.dialog {
|
||||
// Opened by Press Pull, so the dialog says so, whatever was picked (#11705).
|
||||
if empty
|
||||
&& let Some(d) = &mut self.dialog
|
||||
&& let Some(input) = d.inputs.first_mut()
|
||||
{
|
||||
input.accept |= selection::FACES | selection::EDGES;
|
||||
d.press_pull = true;
|
||||
if empty && let Some(input) = d.inputs.first_mut() {
|
||||
input.accept |= selection::FACES | selection::EDGES;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3513,7 +3506,6 @@ impl SolveApp {
|
||||
// The status bar first: it spans the window's width below History and the side panels.
|
||||
if self.universal_panel_host_enabled() {
|
||||
self.universal_workspace_ui(ui);
|
||||
semantic_content::finalize(self, ui.ctx());
|
||||
// Browser-owned editors retain their independent legacy windows,
|
||||
// drafts and capture questions while the Browser host is suppressed.
|
||||
browser::editors(self, ui);
|
||||
|
||||
@@ -16,7 +16,6 @@ pub trait AppPanelServices {
|
||||
}
|
||||
|
||||
pub struct AppPanelRuntime {
|
||||
pub(super) production: Option<super::production::ProductionState>,
|
||||
runtime: PanelRuntime,
|
||||
services: Box<dyn AppPanelServices>,
|
||||
native: Box<dyn NativeHostAdapter>,
|
||||
@@ -34,7 +33,6 @@ impl AppPanelRuntime {
|
||||
return Err(error(Rule::Lifecycle, "install the app adapter before creating or restoring native placement"));
|
||||
}
|
||||
Ok(Self {
|
||||
production: None,
|
||||
runtime,
|
||||
services,
|
||||
native: Box::<InAppNativeAdapter>::default(),
|
||||
@@ -46,47 +44,6 @@ impl AppPanelRuntime {
|
||||
preferences: None,
|
||||
})
|
||||
}
|
||||
/// Native composition supplies preferences; standalone runtimes keep their explicit direction.
|
||||
fn sync_native_direction(&mut self, direction: Direction) -> PanelResult<()> {
|
||||
if self.runtime.environment().direction == direction {
|
||||
return Ok(());
|
||||
}
|
||||
let mut environment = self.runtime.environment().clone();
|
||||
environment.direction = direction;
|
||||
self.with_adapters(|runtime, _native, services| runtime.set_environment(environment, services.local))?;
|
||||
if let Some(host) = &mut self.host {
|
||||
host.invalidate_geometry();
|
||||
host.reconcile_scope(&self.runtime);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn set_motion(&mut self, motion: Motion) -> PanelResult<()> {
|
||||
let mut environment = self.runtime.environment().clone();
|
||||
environment.motion = motion;
|
||||
self.with_adapters(|runtime, _native, services| runtime.set_environment(environment, services.local))
|
||||
}
|
||||
|
||||
pub(crate) fn set_keyboard_shortcuts(&mut self, shortcuts: Vec<(ContextualPanelAction, egui::KeyboardShortcut)>) {
|
||||
if let Some(host) = &mut self.host {
|
||||
host.set_keyboard_shortcuts(shortcuts);
|
||||
}
|
||||
}
|
||||
|
||||
fn contextual_availability(&self, session: &Session, action: ContextualPanelAction) -> PanelResult<()> {
|
||||
self.runtime.require_live()?;
|
||||
if self.preferences.as_ref().is_some_and(|state| state.pending.is_some())
|
||||
|| self.runtime.environment().document.as_ref().map(|document| document.0.as_str()) != Some(session.document_id.as_str())
|
||||
{
|
||||
return Err(error(Rule::Availability, "panel command context is not reconciled"));
|
||||
}
|
||||
self.host
|
||||
.as_ref()
|
||||
.ok_or_else(|| error(Rule::Availability, "shared panel host unavailable"))?
|
||||
.contextual_action(&self.runtime, action)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn last_error(&self) -> Option<&PanelError> {
|
||||
self.last_error.as_ref()
|
||||
}
|
||||
@@ -557,27 +514,17 @@ impl AppPanelRuntime {
|
||||
pub fn show_host_deferred(&mut self, session: &Session, ui: &mut egui::Ui) -> PanelResult<()> {
|
||||
self.show_host_impl(session, ui, true)
|
||||
}
|
||||
fn show_production_host(&mut self, app: &mut crate::SolveApp, ui: &mut egui::Ui) -> PanelResult<()> {
|
||||
self.prepare_host_paint(&app.session, ui)?;
|
||||
app.panel_content_frame = self.host.as_ref().and_then(WorkspacePanelHost::frame).cloned();
|
||||
let mut services = self.services.services();
|
||||
let host = self.host.as_mut().ok_or_else(|| error(Rule::Lifecycle, "universal panel host disabled"))?;
|
||||
let pending = self.preferences.as_ref().is_some_and(|state| state.pending.is_some());
|
||||
if pending {
|
||||
host.cancel_gesture();
|
||||
}
|
||||
let state = self.production.clone().ok_or_else(|| error(Rule::Lifecycle, "production content binding unavailable"))?;
|
||||
let mut content = super::production::ProductionFrame { app, state };
|
||||
let result = ui
|
||||
.add_enabled_ui(!pending, |ui| host.show_deferred_with_content(ui, &mut self.runtime, self.native.as_mut(), &mut services, &mut content))
|
||||
.inner;
|
||||
if pending {
|
||||
host.cancel_gesture();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn show_host_impl(&mut self, session: &Session, ui: &mut egui::Ui, deferred: bool) -> PanelResult<()> {
|
||||
self.prepare_host_paint(session, ui)?;
|
||||
let geometry = self.host.as_ref().and_then(WorkspacePanelHost::prepared_geometry);
|
||||
self.reconcile(session)?;
|
||||
self.refresh_metrics()?;
|
||||
if let Some(host) = &mut self.host
|
||||
&& host.frame().is_none()
|
||||
&& let Some((domain, capacity, chrome)) = geometry
|
||||
{
|
||||
host.prepare_in_domain(&self.runtime, domain, capacity, &chrome)?;
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let mut services = self.services.services();
|
||||
let host = self.host.as_mut().ok_or_else(|| error(Rule::Lifecycle, "universal panel host disabled"))?;
|
||||
if self.preferences.as_ref().is_some_and(|state| state.pending.is_some()) {
|
||||
@@ -594,19 +541,6 @@ impl AppPanelRuntime {
|
||||
host.show(ui, &mut self.runtime, self.native.as_mut(), &mut services, self.catalogue.as_mut())
|
||||
}
|
||||
}
|
||||
fn prepare_host_paint(&mut self, session: &Session, ui: &egui::Ui) -> PanelResult<()> {
|
||||
let geometry = self.host.as_ref().and_then(WorkspacePanelHost::prepared_geometry);
|
||||
self.reconcile(session)?;
|
||||
self.refresh_metrics()?;
|
||||
if let Some(host) = &mut self.host
|
||||
&& host.frame().is_none()
|
||||
&& let Some((domain, capacity, chrome)) = geometry
|
||||
{
|
||||
host.prepare_in_domain(&self.runtime, domain, capacity, &chrome)?;
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Finish after every window/modal has registered its current layer. Refresh
|
||||
/// current Session again before dispatch, without a background update.
|
||||
pub fn finish_host(&mut self, session: &Session, ui: &mut egui::Ui) -> PanelResult<()> {
|
||||
@@ -802,9 +736,6 @@ impl AdmittedActionSink<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
impl ActionSink for AdmittedActionSink<'_, '_, '_> {
|
||||
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
|
||||
self.host.as_ref().ok_or_else(|| error(Rule::Availability, "shared panel host unavailable"))?.contextual_action(self.live.runtime, action)
|
||||
}
|
||||
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
|
||||
self.project_owned(action, None)
|
||||
}
|
||||
@@ -822,9 +753,6 @@ impl ActionSink for AdmittedActionSink<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
impl ActionSink for CommandActionSink<'_> {
|
||||
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
|
||||
self.live.resolve_contextual(action)
|
||||
}
|
||||
fn project_in_context(&self, action: LayoutAction, context: &LayoutPreparationContext) -> PanelResult<Layout> {
|
||||
self.live.project_in_context(action, context)
|
||||
}
|
||||
@@ -847,20 +775,7 @@ impl ActionSink for CommandActionSink<'_> {
|
||||
}
|
||||
|
||||
impl crate::SolveApp {
|
||||
/// Read-only command metadata uses the exact host eligibility used at execution.
|
||||
pub(crate) fn panel_contextual_disabled_reason(&self, action: ContextualPanelAction) -> Option<String> {
|
||||
let eligibility = self
|
||||
.panel_runtime
|
||||
.as_ref()
|
||||
.ok_or_else(|| error(Rule::Availability, "shared panel host unavailable"))
|
||||
.and_then(|integration| integration.contextual_availability(&self.session, action));
|
||||
eligibility
|
||||
.err()
|
||||
.map(|reason| crate::i18n::Locale::for_language(&self.preferences.language).text(super::workspace::rule_key(reason.rule)).to_owned())
|
||||
}
|
||||
|
||||
pub fn run_panel_command(&mut self, id: &str, params: &serde_json::Value) -> PanelResult<serde_json::Value> {
|
||||
super::production::refresh_production(self)?;
|
||||
// Parse even without a runtime: hostile/unknown fields never become a
|
||||
// misleading capability success or reach the document command path.
|
||||
match self.panel_runtime.as_mut() {
|
||||
@@ -887,14 +802,10 @@ impl crate::SolveApp {
|
||||
Ok(())
|
||||
}
|
||||
pub fn universal_panel_host_enabled(&self) -> bool {
|
||||
self.panel_content_frame.is_some() || self.panel_runtime.as_ref().is_some_and(|integration| integration.host.is_some())
|
||||
self.panel_runtime.as_ref().is_some_and(|integration| integration.host.is_some())
|
||||
}
|
||||
pub fn universal_panel_frame(&self) -> Option<&HostFrame> {
|
||||
self.panel_runtime
|
||||
.as_ref()
|
||||
.and_then(|integration| integration.host.as_ref())
|
||||
.and_then(WorkspacePanelHost::frame)
|
||||
.or(self.panel_content_frame.as_ref())
|
||||
self.panel_runtime.as_ref()?.host.as_ref()?.frame()
|
||||
}
|
||||
/// Internal opt-in only: no preference, control method, command or default change.
|
||||
pub fn enable_panel_runtime(&mut self, runtime: PanelRuntime, services: Box<dyn AppPanelServices>) -> PanelResult<()> {
|
||||
@@ -916,7 +827,6 @@ impl crate::SolveApp {
|
||||
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.set_native_adapter(native)
|
||||
}
|
||||
pub fn dispatch_panel_layout(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
super::production::refresh_production(self)?;
|
||||
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.dispatch(&self.session, action)
|
||||
}
|
||||
pub fn mount_panels(&mut self, resolved: &ResolvedLayout, host: &mut dyn PanelHost) -> PanelResult<()> {
|
||||
@@ -932,7 +842,6 @@ impl crate::SolveApp {
|
||||
)
|
||||
}
|
||||
pub fn panel_content_action(&mut self, instance: &InstanceId, action: &ControlId) -> PanelResult<()> {
|
||||
super::production::refresh_production(self)?;
|
||||
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.content_action(&self.session, instance, action)
|
||||
}
|
||||
pub fn tick_panel_runtime(&mut self) -> PanelResult<()> {
|
||||
@@ -949,36 +858,7 @@ impl crate::SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_production_setting(app: &mut crate::SolveApp) -> PanelResult<()> {
|
||||
let production = app.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some());
|
||||
if app.preferences.shared_panel_layout && app.panel_runtime.is_none() {
|
||||
super::production::enable_production(app)
|
||||
} else if !app.preferences.shared_panel_layout && production {
|
||||
app.disable_panel_runtime()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tick_app(app: &mut crate::SolveApp, ctx: &egui::Context) {
|
||||
// Switching placement systems waits until Preferences Apply/Cancel settles
|
||||
// its snapshot. Content state itself always remains owned by the app.
|
||||
if !app.prefs_window.open
|
||||
&& let Err(reason) = sync_production_setting(app)
|
||||
{
|
||||
app.set_status(crate::i18n::tr(super::workspace::rule_key(reason.rule)).to_owned(), true);
|
||||
if let Some(integration) = &mut app.panel_runtime {
|
||||
integration.last_error = Some(reason);
|
||||
}
|
||||
}
|
||||
if let Err(reason) = super::production::drain_production_actions(app)
|
||||
.and_then(|()| super::production::sync_production_visibility(app))
|
||||
.and_then(|()| super::production::refresh_production(app))
|
||||
&& let Some(integration) = &mut app.panel_runtime
|
||||
{
|
||||
integration.last_error = Some(reason);
|
||||
}
|
||||
let direction = crate::i18n::panel_direction(&app.preferences.language);
|
||||
if let Some(integration) = &mut app.panel_runtime {
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
if integration.last_tick_frame == Some(frame) {
|
||||
@@ -986,7 +866,7 @@ pub(crate) fn tick_app(app: &mut crate::SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
integration.last_tick_frame = Some(frame);
|
||||
// Errors remain inspectable without inventing untranslated production UI.
|
||||
let result = integration.sync_native_direction(direction).and_then(|()| integration.tick(&app.session));
|
||||
let result = integration.tick(&app.session);
|
||||
if result.is_err() || !integration.preferences.as_ref().is_some_and(|state| state.failed) {
|
||||
integration.last_error = result.err();
|
||||
}
|
||||
@@ -1039,24 +919,11 @@ pub(crate) fn reserve_host_input(app: &mut crate::SolveApp, ctx: &egui::Context)
|
||||
}
|
||||
|
||||
pub(crate) fn finish_app_host(app: &mut crate::SolveApp, ui: &mut egui::Ui) {
|
||||
if let Err(reason) = super::production::sync_production_visibility(app).and_then(|()| super::production::refresh_production(app))
|
||||
&& let Some(integration) = &mut app.panel_runtime
|
||||
{
|
||||
integration.last_error = Some(reason);
|
||||
return;
|
||||
}
|
||||
if let Some(integration) = &mut app.panel_runtime
|
||||
&& let Err(reason) = integration.finish_host(&app.session, ui)
|
||||
{
|
||||
integration.last_error = Some(reason);
|
||||
}
|
||||
if let Err(reason) = super::production::drain_production_actions(app)
|
||||
.and_then(|()| super::production::sync_production_visibility(app))
|
||||
.and_then(|()| super::production::refresh_production(app))
|
||||
&& let Some(integration) = &mut app.panel_runtime
|
||||
{
|
||||
integration.last_error = Some(reason);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::SolveApp {
|
||||
@@ -1092,15 +959,11 @@ impl crate::SolveApp {
|
||||
} else {
|
||||
// The root Assistant panel is suppressed. Forget only presentation
|
||||
// geometry and placement, so a later pop uses the saved window place.
|
||||
self.agent_chat.panel_rect = None;
|
||||
self.agent_chat.drop_rect = None;
|
||||
self.agent_chat.dock_space = None;
|
||||
self.agent_chat.window_placed = false;
|
||||
crate::agent_chat::poll(self, ui.ctx());
|
||||
if self.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some()) {
|
||||
crate::agent_chat::prepare_shared(self, ui.ctx());
|
||||
} else {
|
||||
self.agent_chat.panel_rect = None;
|
||||
self.agent_chat.drop_rect = None;
|
||||
self.agent_chat.dock_space = None;
|
||||
self.agent_chat.window_placed = false;
|
||||
}
|
||||
}
|
||||
self.shell.browser = None;
|
||||
self.shell.browser_hidden = false;
|
||||
@@ -1130,31 +993,6 @@ impl crate::SolveApp {
|
||||
} else {
|
||||
self.shell.cards.clear();
|
||||
}
|
||||
if self.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some()) && crate::render_workspace::active(self) {
|
||||
// These remain legacy content surfaces. Their actual edge reservations
|
||||
// constrain panel placement without changing the full render domain.
|
||||
crate::render_ui::shots_strip(self, ui);
|
||||
crate::render_workspace::panel(self, ui);
|
||||
if self.ui.show_browser && !crate::render_ui::focused(self) {
|
||||
// Keep the unmigrated library in its existing lower card slot.
|
||||
// Reserve its real top edge before admitting shared placements.
|
||||
let bounds = crate::shell::panel_bounds(self, ui.available_rect_before_wrap());
|
||||
let upper = crate::ui_kit::Panel::stack_first_bounds(bounds);
|
||||
crate::render_workspace::appearances_card(self, ui, crate::ui_kit::Panel::stack_next_bounds(bounds, upper));
|
||||
}
|
||||
for (id, (bounds, edge)) in &self.shell.workspace_panels {
|
||||
if *id == "render:appearances" {
|
||||
placement.max.y = bounds.top().clamp(placement.top(), placement.bottom());
|
||||
continue;
|
||||
}
|
||||
match edge {
|
||||
Some(crate::ui_kit::Edge::Left) => placement.min.x = bounds.right().clamp(placement.left(), placement.right()),
|
||||
Some(crate::ui_kit::Edge::Right) => placement.max.x = bounds.left().clamp(placement.left(), placement.right()),
|
||||
Some(crate::ui_kit::Edge::Bottom) => placement.max.y = bounds.top().clamp(placement.top(), placement.bottom()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let gap = crate::ui_kit::Container::new(ui.ctx(), crate::ui_kit::Surface::Panel).gap();
|
||||
placement.min.x += gap.min(placement.width() / 2.0);
|
||||
placement.max.x -= gap.min(placement.width());
|
||||
@@ -1168,19 +1006,14 @@ impl crate::SolveApp {
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(self.shell.status_bar)
|
||||
.chain(self.shell.workspace_panels.values().map(|(bounds, _)| *bounds))
|
||||
.map(|bounds| bounds.intersect(render_domain))
|
||||
.filter(|bounds| bounds.is_positive() && bounds.is_finite())
|
||||
.map(logical_rect)
|
||||
.collect();
|
||||
let force_opaque = self.services.reduce_transparency.as_ref().and_then(|read| read(true)) == Some(true);
|
||||
let direction = crate::i18n::panel_direction(&self.preferences.language);
|
||||
let shortcuts = crate::keymap::panel_shortcuts(self);
|
||||
let prepared = match &mut self.panel_runtime {
|
||||
Some(integration) => {
|
||||
let result = (|| {
|
||||
integration.sync_native_direction(direction)?;
|
||||
integration.set_keyboard_shortcuts(shortcuts);
|
||||
if let Some(host) = &mut integration.host {
|
||||
host.note_egui_context(ui.ctx(), force_opaque);
|
||||
host.set_gap(gap)?;
|
||||
@@ -1211,24 +1044,12 @@ impl crate::SolveApp {
|
||||
.central()
|
||||
.frame(crate::ui_kit::Container::new(ui.ctx(), crate::ui_kit::Surface::Viewport).edge_frame(egui::Color32::TRANSPARENT))
|
||||
.show(ui, |ui| {
|
||||
if prepared && let Some(mut integration) = self.panel_runtime.take() {
|
||||
let result = if integration.production.is_some() {
|
||||
// Content commands still see the true full-domain geometry
|
||||
// while the runtime and mutable content are borrowed apart.
|
||||
let result = integration.show_production_host(self, ui);
|
||||
self.panel_content_frame = None;
|
||||
result
|
||||
} else {
|
||||
integration.show_host_deferred(&self.session, ui)
|
||||
};
|
||||
if let Err(reason) = result {
|
||||
integration.last_error = Some(reason);
|
||||
}
|
||||
self.panel_runtime = Some(integration);
|
||||
if prepared
|
||||
&& let Some(integration) = &mut self.panel_runtime
|
||||
&& let Err(reason) = integration.show_host_deferred(&self.session, ui)
|
||||
{
|
||||
integration.last_error = Some(reason);
|
||||
}
|
||||
});
|
||||
if self.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some()) && !self.preferences.agent_chat_popped {
|
||||
crate::agent_chat::finish_shared(self, ui.ctx());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ impl PanelController for FixtureController {
|
||||
actions: if self.editor {
|
||||
["apply", "cancel", "finish"]
|
||||
.into_iter()
|
||||
.map(|id| RuntimeAction { id: ControlId(id.into()), label: id.into(), enabled: true, disabled_reason: None, pressed: None })
|
||||
.map(|id| RuntimeAction { id: ControlId(id.into()), label: id.into(), enabled: true, disabled_reason: None })
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
|
||||
@@ -145,13 +145,6 @@ pub trait PanelLocalServices {
|
||||
fn request_overlay_layer(&mut self, scope: &LocalScope, instance: &InstanceId, region: InputRegion, _layer: egui::LayerId) -> PanelResult<()> {
|
||||
self.request(scope, LocalRequest::Overlay { instance: instance.clone(), region })
|
||||
}
|
||||
/// Report a response just created by the current content render. The host
|
||||
/// uses this optional provenance to retain/restore focus; reporting never
|
||||
/// requests focus or stores editor state. Hidden or disabled controls are
|
||||
/// ineligible, and absence of a report must never fall back to an old widget.
|
||||
fn register_focus_response(&mut self, _scope: &LocalScope, _instance: &InstanceId, _response: &egui::Response) -> PanelResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn release(&mut self, scope: &LocalScope, instance: &InstanceId);
|
||||
}
|
||||
|
||||
@@ -161,8 +154,6 @@ pub struct RuntimeAction {
|
||||
pub label: String,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
/// Toggle state, when the action represents a persistent on/off choice.
|
||||
pub pressed: Option<bool>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PanelPresentation {
|
||||
@@ -206,25 +197,6 @@ pub enum ContentSlot {
|
||||
Inline,
|
||||
}
|
||||
|
||||
/// A frame-scoped content binding. Application bodies may borrow their existing
|
||||
/// owner for this paint without storing that borrow in a retained controller.
|
||||
/// The host supplies the same bounded slot and focus services as ordinary content.
|
||||
pub trait FramePanelContent {
|
||||
fn render(
|
||||
&mut self,
|
||||
slot: ContentSlot,
|
||||
ui: &mut egui::Ui,
|
||||
context: &PanelContext,
|
||||
content: &mut dyn super::PanelController,
|
||||
services: &mut super::ContentServices<'_>,
|
||||
) -> PanelResult<()> {
|
||||
if slot == ContentSlot::Inline { content.render_inline(ui, context, services) } else { content.render(slot, ui, context, services) }
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ControllerFrameContent;
|
||||
impl FramePanelContent for ControllerFrameContent {}
|
||||
|
||||
/// Shared host adapter seam. Geometry is resolved by the host; content chooses
|
||||
/// its own scrolling within slots. The egui adapter below owns one kit Container.
|
||||
pub trait PanelHost {
|
||||
@@ -344,9 +316,6 @@ impl PanelHost for EguiPanelHost<'_> {
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
if let Some(pressed) = action.pressed {
|
||||
ui.ctx().accesskit_node_builder(response.id, |node| node.set_toggled(pressed.into()));
|
||||
}
|
||||
let clicked = response.clicked();
|
||||
if let Some(reason) = &action.disabled_reason {
|
||||
response.backing_on_disabled_hover_text(reason);
|
||||
|
||||
@@ -1224,55 +1224,6 @@ fn begin_gesture(service: &LayoutService, layout: &HostLayout, target: &HostPoin
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard resizing uses the same pure candidate geometry as a pointer preview.
|
||||
/// It neither captures a pointer nor publishes a second allocation.
|
||||
pub(crate) fn keyboard_resize_action(
|
||||
service: &LayoutService,
|
||||
layout: &HostLayout,
|
||||
target: &HostPointerTarget,
|
||||
action: ContextualPanelAction,
|
||||
) -> PanelResult<LayoutAction> {
|
||||
use ContextualPanelAction::*;
|
||||
if action == ResetSash {
|
||||
return match target {
|
||||
HostPointerTarget::Sash { first, second } => {
|
||||
Ok(self::action(layout.revision, vec![Mutation::ResetPair { first: first.clone(), second: second.clone() }]))
|
||||
}
|
||||
_ => Err(error(Rule::Availability, "focused control has no neighbour split")),
|
||||
};
|
||||
}
|
||||
let step = crate::theme::Tokens::get().space_8.max(HOST_DRAG_THRESHOLD);
|
||||
let (dx, dy) = match action {
|
||||
ResizeLeft => (-step, 0.0),
|
||||
ResizeRight => (step, 0.0),
|
||||
ResizeUp => (0.0, -step),
|
||||
ResizeDown => (0.0, step),
|
||||
_ => return Err(error(Rule::Availability, "action is not a resize")),
|
||||
};
|
||||
let preview = match begin_gesture(service, layout, target)? {
|
||||
Some(GestureKind::Resize { section, edge, size, outer }) => {
|
||||
let horizontal = matches!(edge, HostResizeEdge::Left | HostResizeEdge::Right);
|
||||
if edge != HostResizeEdge::BottomRight && ((horizontal && dx == 0.0) || (!horizontal && dy == 0.0)) {
|
||||
return Err(error(Rule::Availability, "resize key is perpendicular to focused edge"));
|
||||
}
|
||||
resize_preview(service, layout, §ion, edge, size, outer, dx, dy)
|
||||
}
|
||||
Some(GestureKind::Pair { first, second, first_size, second_size, zone, outer }) => {
|
||||
if (zone == Zone::Bottom && dx == 0.0) || (zone != Zone::Bottom && dy == 0.0) {
|
||||
return Err(error(Rule::Availability, "resize key is perpendicular to focused split"));
|
||||
}
|
||||
let delta = if zone == Zone::Bottom { dx * if layout.direction == Direction::Rtl { -1.0 } else { 1.0 } } else { dy };
|
||||
pair_preview(service, layout, &first, &second, first_size, second_size, zone, outer, delta)
|
||||
}
|
||||
_ => return Err(error(Rule::Availability, "focused control is not a resize target")),
|
||||
};
|
||||
if let Some(reason) = preview.rejection {
|
||||
return Err(reason);
|
||||
}
|
||||
// A clamped split still owns the key, without manufacturing pointer input.
|
||||
preview.action.ok_or_else(|| error(Rule::Bounds, "focused resize has reached its bound"))
|
||||
}
|
||||
|
||||
fn preview_gesture(capture: &Gesture, service: &LayoutService, layout: &HostLayout, at: Point) -> (HostPreview, Option<PreparedHostLayout>) {
|
||||
let mut preview = match &capture.kind {
|
||||
GestureKind::Move { instance, section, targets, .. } => move_preview(service, layout, instance, section, at, targets),
|
||||
@@ -1424,16 +1375,6 @@ pub fn host_drop_targets(service: &LayoutService, layout: &HostLayout, instance:
|
||||
}
|
||||
service.availability(instance).require()?;
|
||||
let source = service.layout().containing(instance)?;
|
||||
// A side column supplies its current width. A wide Bottom or floating
|
||||
// presentation must not stretch a newly created side column to that width.
|
||||
let column_width = if source.location.zone().side() {
|
||||
let column = source.column.as_ref().ok_or_else(|| error(Rule::Placement, "side source has no column"))?;
|
||||
service.layout().column(column)?.width
|
||||
} else {
|
||||
let panel = service.layout().instance(instance)?;
|
||||
service.registry().get(&panel.kind)?.default_size.width
|
||||
};
|
||||
let column_size = Size { width: column_width, ..source.size };
|
||||
let new_section = if source.tabs.len() == 1 {
|
||||
source.id.clone()
|
||||
} else {
|
||||
@@ -1482,7 +1423,7 @@ pub fn host_drop_targets(service: &LayoutService, layout: &HostLayout, instance:
|
||||
placement,
|
||||
new_section: new_section.clone(),
|
||||
new_column: Some(new_column.clone()),
|
||||
size: column_size,
|
||||
size: source.size,
|
||||
},
|
||||
3,
|
||||
Some((true, at)),
|
||||
@@ -1589,16 +1530,11 @@ pub fn host_drop_targets(service: &LayoutService, layout: &HostLayout, instance:
|
||||
placement: RelativePlacement::BeforeColumn,
|
||||
new_section: new_section.clone(),
|
||||
new_column: Some(new_column.clone()),
|
||||
size: column_size,
|
||||
size: source.size,
|
||||
}
|
||||
} else {
|
||||
let index = service.layout().sections.iter().filter(|section| !(section.id == source.id && source.tabs.len() == 1)).count();
|
||||
Destination::NewSection {
|
||||
section: new_section.clone(),
|
||||
index,
|
||||
location: SectionLocation::Dock { zone },
|
||||
size: if zone.side() { column_size } else { source.size },
|
||||
}
|
||||
Destination::NewSection { section: new_section.clone(), index, location: SectionLocation::Dock { zone }, size: source.size }
|
||||
};
|
||||
let zone_key = match zone {
|
||||
Zone::Left => "Left",
|
||||
|
||||
@@ -655,7 +655,7 @@ pub struct HostSection {
|
||||
pub inline: bool,
|
||||
/// Editable inline content is separate from the shared title drag region.
|
||||
pub inline_content: Option<LogicalRect>,
|
||||
pub collapse: Option<LogicalRect>,
|
||||
pub collapse: LogicalRect,
|
||||
pub close: Option<LogicalRect>,
|
||||
pub actions: Vec<(ControlId, LogicalRect)>,
|
||||
pub footer: Option<LogicalRect>,
|
||||
@@ -862,7 +862,7 @@ impl HostLayout {
|
||||
Some(section.header),
|
||||
section.title,
|
||||
Some(section.menu),
|
||||
section.collapse,
|
||||
Some(section.collapse),
|
||||
section.close,
|
||||
section.tab_strip,
|
||||
section.body,
|
||||
@@ -1175,13 +1175,12 @@ fn append_section(
|
||||
outer.validate()?;
|
||||
let metrics = out.metrics;
|
||||
let spec = service.registry().get(&proposed.instance(instance)?.kind)?;
|
||||
let collapsible = section_collapsible(proposed, service.registry(), section)?;
|
||||
let (flow, _) = responsive(section.location.zone(), outer.size, None)?;
|
||||
// A single row owns inline controls AND content; no hidden second body.
|
||||
let inline = !section.collapsed && flow == Flow::Wide && outer.size.height <= metrics.inline_max_height;
|
||||
let header =
|
||||
rect(outer.origin.x, outer.origin.y, outer.size.width, if inline { outer.size.height } else { metrics.header_height.min(outer.size.height) });
|
||||
let control_count = spec.header_actions.len() + 1 + usize::from(collapsible) + usize::from(spec.features.hot_closable);
|
||||
let control_count = spec.header_actions.len() + 2 + usize::from(spec.features.hot_closable);
|
||||
let extent = metrics.control_extent.min(header.size.width / control_count.max(1) as f32);
|
||||
let mut cursor = if out.direction == Direction::Ltr { right(header) } else { header.origin.x };
|
||||
let take = |cursor: &mut f32| {
|
||||
@@ -1190,7 +1189,7 @@ fn append_section(
|
||||
rect(x, header.origin.y, extent, header.size.height)
|
||||
};
|
||||
let close = if spec.features.hot_closable { Some(take(&mut cursor)) } else { None };
|
||||
let collapse = collapsible.then(|| take(&mut cursor));
|
||||
let collapse = take(&mut cursor);
|
||||
let menu = take(&mut cursor);
|
||||
let mut actions = Vec::new();
|
||||
let (heading, inline_content) = if inline {
|
||||
@@ -1332,9 +1331,7 @@ fn append_section(
|
||||
add_control(out, HostPointerTarget::Header { instance: instance.clone() }, title, InputRole::Header, "ui.panel_host.header");
|
||||
}
|
||||
add_control(out, HostPointerTarget::Menu { instance: instance.clone() }, menu, InputRole::Action, "ui.panel_host.menu");
|
||||
if let Some(collapse) = collapse {
|
||||
add_control(out, HostPointerTarget::Collapse { instance: instance.clone() }, collapse, InputRole::Action, "ui.panel_host.collapse");
|
||||
}
|
||||
add_control(out, HostPointerTarget::Collapse { instance: instance.clone() }, collapse, InputRole::Action, "ui.panel_host.collapse");
|
||||
if let Some(close) = close {
|
||||
add_control(out, HostPointerTarget::Close { instance: instance.clone() }, close, InputRole::Action, "ui.panel_host.close");
|
||||
}
|
||||
@@ -1356,8 +1353,7 @@ fn append_section(
|
||||
Zone::Left => HostResizeEdge::Right,
|
||||
_ => HostResizeEdge::Left,
|
||||
};
|
||||
let (min, max) = tab_bounds(proposed, service.registry(), section)?;
|
||||
if !section.collapsed && !(section.location.zone() == Zone::Bottom && min.height == max.height) {
|
||||
if !section.collapsed {
|
||||
add_control(
|
||||
out,
|
||||
HostPointerTarget::Resize { section: section.id.clone(), edge },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Opt-in common panel runtime and retained production content bindings.
|
||||
//! Opt-in common panel runtime. No production registrations or shipping default changes.
|
||||
//! Model/commands, controller state, presentation and layout have separate owners.
|
||||
mod app;
|
||||
mod fixtures;
|
||||
@@ -6,7 +6,6 @@ mod host;
|
||||
mod interaction;
|
||||
mod layout;
|
||||
mod preferences;
|
||||
mod production;
|
||||
mod runtime;
|
||||
mod specimen;
|
||||
mod surfaces;
|
||||
@@ -19,10 +18,6 @@ pub use interaction::*;
|
||||
pub use layout::*;
|
||||
pub use preferences::*;
|
||||
pub(crate) use preferences::{PanelLayoutSnapshot, panel_layout_matches, panel_layout_snapshot};
|
||||
pub(crate) use production::{
|
||||
refresh_production, register_content_focus, report_content_composer_height, report_content_extent, shared_content_composer_height,
|
||||
shared_content_measurement,
|
||||
};
|
||||
pub use runtime::*;
|
||||
pub use specimen::*;
|
||||
pub use surfaces::*;
|
||||
|
||||
@@ -152,29 +152,20 @@ impl LegacyPanelPlacements {
|
||||
let prefs = &app.preferences;
|
||||
let vertical = app.ui.show_timeline && prefs.timeline_left;
|
||||
let stacked = vertical && prefs.timeline_under_browser;
|
||||
// Repair only the import snapshot; Classic keeps its saved preferences.
|
||||
let defaults = crate::prefs::Prefs::default();
|
||||
let width = |value: f32, fallback: f32| {
|
||||
if value.is_finite() && value > 0.0 && value <= MAX_LOGICAL { value } else { fallback }
|
||||
};
|
||||
Self {
|
||||
browser_width: width(prefs.browser_width, defaults.browser_width),
|
||||
browser_width: prefs.browser_width,
|
||||
browser_collapsed: if vertical { prefs.timeline_left_browser_collapsed } else { prefs.browser_collapsed },
|
||||
history_left: prefs.timeline_left,
|
||||
history_under_browser: stacked,
|
||||
history_width: width(prefs.timeline_left_width, defaults.timeline_left_width),
|
||||
history_width: prefs.timeline_left_width,
|
||||
history_collapsed: prefs.timeline_collapsed,
|
||||
browser_share: if prefs.browser_share.is_finite() && (0.0..=1.0).contains(&prefs.browser_share) {
|
||||
prefs.browser_share
|
||||
} else {
|
||||
defaults.browser_share
|
||||
},
|
||||
browser_share: prefs.browser_share,
|
||||
assistant_side: if prefs.agent_chat_side == "left" { "left" } else { "right" }.into(),
|
||||
assistant_width: width(prefs.agent_chat_width, defaults.agent_chat_width),
|
||||
assistant_width: prefs.agent_chat_width,
|
||||
assistant_collapsed: prefs.agent_chat_minimized,
|
||||
assistant_popped: prefs.agent_chat_popped,
|
||||
render_side: if prefs.render_browser_side == "right" { "right" } else { "left" }.into(),
|
||||
render_width: width(prefs.render_browser_width, defaults.render_browser_width),
|
||||
render_width: prefs.render_browser_width,
|
||||
imported: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,883 +0,0 @@
|
||||
//! Bind the shared host to existing application content, without owning that content.
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::panels::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
const VIEWPORT: &str = "solidcraft.panels";
|
||||
const MAX_ACTIONS: usize = 16;
|
||||
const WORKSPACES: [(&str, &str, &str); 5] = [
|
||||
("solidcraft.classic", "Classic", "classic"),
|
||||
("solidcraft.modelling", "Modelling", "modelling"),
|
||||
("solidcraft.sketch", "Sketch", "sketch"),
|
||||
("solidcraft.render", "Render", "render"),
|
||||
("solidcraft.review", "Review", "review"),
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Product {
|
||||
Browser,
|
||||
History,
|
||||
Assistant,
|
||||
RenderBrowser,
|
||||
}
|
||||
impl Product {
|
||||
const ALL: [Self; 4] = [Self::Browser, Self::History, Self::Assistant, Self::RenderBrowser];
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Browser => 0,
|
||||
Self::History => 1,
|
||||
Self::Assistant => 2,
|
||||
Self::RenderBrowser => 3,
|
||||
}
|
||||
}
|
||||
fn visible(self, app: &crate::SolveApp) -> bool {
|
||||
match self {
|
||||
Self::Browser | Self::RenderBrowser => app.ui.show_browser,
|
||||
Self::History => app.ui.show_timeline,
|
||||
Self::Assistant => app.agent_chat.open,
|
||||
}
|
||||
}
|
||||
fn set_visible(self, app: &mut crate::SolveApp, open: bool) {
|
||||
match self {
|
||||
Self::Browser | Self::RenderBrowser => app.ui.show_browser = open,
|
||||
Self::History => app.ui.show_timeline = open,
|
||||
Self::Assistant => app.agent_chat.open = open,
|
||||
}
|
||||
}
|
||||
|
||||
fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Browser => "browser",
|
||||
Self::History => "history",
|
||||
Self::Assistant => "assistant",
|
||||
Self::RenderBrowser => "render-browser",
|
||||
}
|
||||
}
|
||||
fn kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::Browser => "solidcraft.browser",
|
||||
Self::History => "solidcraft.history",
|
||||
Self::Assistant => "solidcraft.assistant",
|
||||
Self::RenderBrowser => "solidcraft.render_browser",
|
||||
}
|
||||
}
|
||||
fn title_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Browser => "ui.browser_title",
|
||||
Self::History => "ui.history_title",
|
||||
Self::Assistant => "ui.assistant",
|
||||
Self::RenderBrowser => "render.page_scene",
|
||||
}
|
||||
}
|
||||
fn from_instance(instance: &InstanceId) -> PanelResult<Self> {
|
||||
Self::ALL.into_iter().find(|product| product.id() == instance.0).ok_or_else(|| error(Rule::MissingTarget, "unknown application panel"))
|
||||
}
|
||||
fn available(self, app: &crate::SolveApp) -> bool {
|
||||
let render = crate::render_workspace::active(app);
|
||||
match self {
|
||||
Self::Browser => !render && !crate::render_ui::focused(app),
|
||||
Self::History => !render && !app.preferences.history_in_browser,
|
||||
Self::Assistant => crate::agent_chat::shown(app) && !app.preferences.agent_chat_popped,
|
||||
Self::RenderBrowser => render && !crate::render_ui::focused(app),
|
||||
}
|
||||
}
|
||||
fn header_actions(self) -> Vec<HeaderActionSpec> {
|
||||
let entries: Vec<(&str, &str, &str)> = match self {
|
||||
Self::History => vec![
|
||||
("first", "ui.history_first", "timeline_first"),
|
||||
("back", "ui.history_back", "timeline_back"),
|
||||
("play", "cmd.timeline_play", "timeline_play"),
|
||||
("forward", "ui.history_forward", "timeline_forward"),
|
||||
("last", "ui.history_last", "timeline_last"),
|
||||
],
|
||||
Self::Assistant => vec![("new-chat", "ui.agent_chat_new", "new")],
|
||||
Self::RenderBrowser => vec![
|
||||
("by-object", "render.tree_by_object", "list"),
|
||||
("by-look", "render.tree_by_look", "list"),
|
||||
("search", "render.tree_search", "search"),
|
||||
],
|
||||
Self::Browser => Vec::new(),
|
||||
};
|
||||
entries.into_iter().map(|(id, key, icon)| HeaderActionSpec { id: ControlId(id.into()), label_key: key.into(), icon: icon.into() }).collect()
|
||||
}
|
||||
fn presentation(self, app: &crate::SolveApp, play_button: bool) -> PanelPresentation {
|
||||
let mut presentation = self.empty_presentation();
|
||||
presentation.actions = match self {
|
||||
Self::History => crate::ui_kit::HistoryTransport::shown(play_button)
|
||||
.iter()
|
||||
.map(|control| {
|
||||
let availability = crate::timeline::transport_availability(app, *control);
|
||||
RuntimeAction {
|
||||
id: ControlId(control.id().into()),
|
||||
label: control.label(app.timeline_playback.is_some()).into(),
|
||||
enabled: availability.is_ok(),
|
||||
disabled_reason: availability.err(),
|
||||
pressed: (*control == crate::ui_kit::HistoryTransport::Play).then_some(app.timeline_playback.is_some()),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
Self::Assistant => vec![RuntimeAction {
|
||||
id: ControlId("new-chat".into()),
|
||||
label: crate::i18n::tr("ui.agent_chat_new").into(),
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
pressed: None,
|
||||
}],
|
||||
Self::RenderBrowser => [
|
||||
("by-object", "render.tree_by_object", !app.render_workspace.view.by_look),
|
||||
("by-look", "render.tree_by_look", app.render_workspace.view.by_look),
|
||||
("search", "render.tree_search", app.render_workspace.view.search_objects),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, key, pressed)| RuntimeAction {
|
||||
id: ControlId(id.into()),
|
||||
label: crate::i18n::tr(key).into(),
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
pressed: Some(pressed),
|
||||
})
|
||||
.collect(),
|
||||
Self::Browser => Vec::new(),
|
||||
};
|
||||
presentation
|
||||
}
|
||||
fn empty_presentation(self) -> PanelPresentation {
|
||||
PanelPresentation {
|
||||
title: crate::i18n::tr(self.title_key()).into(),
|
||||
icon: "list".into(),
|
||||
status: None,
|
||||
attention: false,
|
||||
actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PanelSnapshot {
|
||||
product: Product,
|
||||
available: bool,
|
||||
presentation: PanelPresentation,
|
||||
extent: Option<ContentExtent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ContentExtent {
|
||||
width: f32,
|
||||
height: f32,
|
||||
width_independent: bool,
|
||||
flow: Flow,
|
||||
composer: Option<f32>,
|
||||
}
|
||||
struct ProductionSnapshot {
|
||||
document: DocumentId,
|
||||
play_button: bool,
|
||||
visible: [bool; 4],
|
||||
visibility: [Option<VisibilityIntent>; 4],
|
||||
panels: [PanelSnapshot; 4],
|
||||
actions: VecDeque<QueuedAction>,
|
||||
}
|
||||
struct VisibilityIntent {
|
||||
document: DocumentId,
|
||||
open: bool,
|
||||
}
|
||||
struct QueuedAction {
|
||||
document: DocumentId,
|
||||
product: Product,
|
||||
action: ControlId,
|
||||
}
|
||||
|
||||
/// Only bounded presentation metadata and explicit pending actions are shared.
|
||||
/// Trees, drafts, chat services and selection remain on their existing owners.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ProductionState(Rc<RefCell<ProductionSnapshot>>);
|
||||
impl ProductionState {
|
||||
fn new(app: &crate::SolveApp) -> PanelResult<Self> {
|
||||
Ok(Self(Rc::new(RefCell::new(ProductionSnapshot {
|
||||
document: DocumentId::new(app.session.document_id.clone())?,
|
||||
play_button: app.preferences.history_play_button,
|
||||
visible: Product::ALL.map(|product| product.visible(app)),
|
||||
visibility: [None, None, None, None],
|
||||
panels: Product::ALL.map(|product| PanelSnapshot {
|
||||
product,
|
||||
available: product.available(app),
|
||||
presentation: product.presentation(app, app.preferences.history_play_button),
|
||||
extent: None,
|
||||
}),
|
||||
actions: VecDeque::new(),
|
||||
}))))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enable_production(app: &mut crate::SolveApp) -> PanelResult<()> {
|
||||
if app.panel_runtime.is_some() {
|
||||
return Err(error(Rule::Lifecycle, "application panel runtime is already enabled"));
|
||||
}
|
||||
let state = ProductionState::new(app)?;
|
||||
let registry = registry(app)?;
|
||||
let workspace = WorkspaceId::new(WORKSPACES[0].0)?;
|
||||
let layout = default_layout(app, workspace.clone(), ®istry)?;
|
||||
let mut runtime = PanelRuntime::new(
|
||||
LayoutService::new(registry, layout, HostCapabilities { persistence: true, ..Default::default() })?,
|
||||
RuntimeEnvironment {
|
||||
workspace: workspace.clone(),
|
||||
document: Some(DocumentId::new(app.session.document_id.clone())?),
|
||||
window: HostId::new(VIEWPORT)?,
|
||||
viewport: ViewportId::new(VIEWPORT)?,
|
||||
direction: crate::i18n::panel_direction(&app.preferences.language),
|
||||
motion: Motion::Animated,
|
||||
},
|
||||
)?;
|
||||
for product in Product::ALL {
|
||||
let state = state.clone();
|
||||
runtime.register(
|
||||
PanelKind::new(product.kind())?,
|
||||
ControllerBinding {
|
||||
ownership: Ownership::Document,
|
||||
create: Box::new(move |_instance, owner| {
|
||||
let ContentOwner::Document(document) = owner else {
|
||||
return Err(error(Rule::Placement, "application panel requires its document owner"));
|
||||
};
|
||||
Ok(Box::new(ProductionController { product, document: document.clone(), state: state.clone() }))
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
let services = ProductionServices {
|
||||
models: ProductionModels,
|
||||
commands: ProductionCommands,
|
||||
local: ProductionLocal { workspace, document: DocumentId::new(app.session.document_id.clone())?, keyboard: None },
|
||||
};
|
||||
let mut integration = AppPanelRuntime::new(runtime, Box::new(services))?;
|
||||
integration.production = Some(state);
|
||||
integration.tick(&app.session)?;
|
||||
let mut options = HostOptions { content_header_height: 0.0, ..Default::default() };
|
||||
options.metrics.footer_height = 0.0;
|
||||
integration.enable_host(options)?;
|
||||
let mut catalogue = commands::InMemoryCatalogue::default();
|
||||
for (id, name, suffix) in WORKSPACES {
|
||||
let workspace = WorkspaceId::new(id)?;
|
||||
let layout = default_layout(app, workspace.clone(), integration.runtime().layout().registry())?;
|
||||
catalogue.register_builtin(name, &LayoutEnvelope::new(layout), integration.runtime().layout().registry())?;
|
||||
catalogue.register_workspace(commands::WorkspaceDescriptor {
|
||||
id: workspace,
|
||||
title_key: format!("ui.panel_layout.workspace.{suffix}"),
|
||||
default_preset: name.into(),
|
||||
})?;
|
||||
}
|
||||
let repaired = super::preferences::configure_preferences(&mut integration, &app.panel_layout_preferences, &mut catalogue)?;
|
||||
app.panel_runtime = Some(integration);
|
||||
if repaired {
|
||||
super::preferences::report_notice(app);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_production(app: &crate::SolveApp) -> PanelResult<()> {
|
||||
let Some(state) = app.panel_runtime.as_ref().and_then(|integration| integration.production.as_ref()) else { return Ok(()) };
|
||||
let mut snapshot = state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel snapshot is borrowed"))?;
|
||||
let document = DocumentId::new(app.session.document_id.clone())?;
|
||||
if snapshot.document != document {
|
||||
for panel in &mut snapshot.panels {
|
||||
panel.extent = None;
|
||||
}
|
||||
}
|
||||
snapshot.document = document;
|
||||
// Keep an explicit view-toggle edge pending while its content context is
|
||||
// unavailable. Ordinary workspace changes do not create a new toggle edge.
|
||||
for product in Product::ALL {
|
||||
let visible = product.visible(app);
|
||||
if app.panel_runtime.as_ref().is_some_and(|integration| {
|
||||
integration
|
||||
.runtime()
|
||||
.layout()
|
||||
.layout()
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| instance.id.0 == product.id() && instance.requested_open == visible)
|
||||
}) {
|
||||
snapshot.visible[product.index()] = visible;
|
||||
}
|
||||
}
|
||||
let play_button = snapshot.play_button;
|
||||
for panel in &mut snapshot.panels {
|
||||
panel.available = panel.product.available(app);
|
||||
panel.presentation = panel.product.presentation(app, play_button);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn drain_production_actions(app: &mut crate::SolveApp) -> PanelResult<()> {
|
||||
let Some(state) = app.panel_runtime.as_ref().and_then(|integration| integration.production.clone()) else { return Ok(()) };
|
||||
let (actions, visibility, play_button) = {
|
||||
let mut snapshot = state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel actions are borrowed"))?;
|
||||
(std::mem::take(&mut snapshot.actions), std::mem::take(&mut snapshot.visibility), snapshot.play_button)
|
||||
};
|
||||
for (product, intent) in Product::ALL.into_iter().zip(visibility) {
|
||||
if let Some(intent) = intent
|
||||
&& intent.document.0 == app.session.document_id
|
||||
{
|
||||
product.set_visible(app, intent.open);
|
||||
}
|
||||
}
|
||||
for action in actions {
|
||||
if action.document.0 != app.session.document_id || !action.product.available(app) {
|
||||
continue;
|
||||
}
|
||||
let presentation = action.product.presentation(app, play_button);
|
||||
if !presentation.actions.iter().any(|binding| binding.id == action.action && binding.enabled) {
|
||||
continue;
|
||||
}
|
||||
match action.product {
|
||||
Product::History => {
|
||||
if let Some(control) = crate::ui_kit::HistoryTransport::WITH_PLAY.into_iter().find(|control| control.id() == action.action.0) {
|
||||
crate::timeline::run_transport(app, control);
|
||||
}
|
||||
}
|
||||
Product::Assistant => crate::agent_chat::request_new_chat(app),
|
||||
Product::RenderBrowser => {
|
||||
let params = match action.action.0.as_str() {
|
||||
"by-object" => json!({"by_look":false}),
|
||||
"by-look" => json!({"by_look":true}),
|
||||
"search" => json!({"search":!app.render_workspace.view.search_objects}),
|
||||
_ => continue,
|
||||
};
|
||||
app.run("view.render_browser", params).map_err(|reason| error(Rule::Availability, reason))?;
|
||||
}
|
||||
Product::Browser => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Existing view toggles update remembered placement only when their raw flag
|
||||
/// changes. Context changes and hidden bodies never reopen closed panels.
|
||||
pub(crate) fn sync_production_visibility(app: &mut crate::SolveApp) -> PanelResult<()> {
|
||||
let Some(integration) = app.panel_runtime.as_ref() else { return Ok(()) };
|
||||
let Some(state) = integration.production.as_ref() else { return Ok(()) };
|
||||
let snapshot = state.0.try_borrow().map_err(|_| error(Rule::Lifecycle, "application panel visibility is borrowed"))?;
|
||||
if snapshot.document.0 != app.session.document_id {
|
||||
return Ok(());
|
||||
}
|
||||
let layout = integration.runtime().layout().layout();
|
||||
let mut mutations = Vec::new();
|
||||
for product in Product::ALL {
|
||||
let visible = product.visible(app);
|
||||
if snapshot.visible.get(product.index()).copied() != Some(visible) {
|
||||
let instance = layout.instance(&InstanceId::new(product.id())?)?;
|
||||
if instance.requested_open != visible && (!visible || product.available(app)) {
|
||||
mutations.push(if visible {
|
||||
Mutation::Open { kind: instance.kind.clone(), instance: instance.id.clone(), destination: None }
|
||||
} else {
|
||||
Mutation::Close { instance: instance.id.clone() }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let revision = integration.runtime().layout().inspect().revision;
|
||||
drop(snapshot);
|
||||
if !mutations.is_empty() {
|
||||
refresh_production(app)?;
|
||||
app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: Some(revision), mutations })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn registry(app: &crate::SolveApp) -> PanelResult<PanelRegistry> {
|
||||
let mut registry = PanelRegistry::default();
|
||||
let tokens = crate::theme::Tokens::get();
|
||||
// The existing strip has a 28-point chip and six-point inset on each side;
|
||||
// the shared content slot adds its own symmetric two-point inset.
|
||||
let bottom_height = tokens.form_header_height.max(tokens.space_24 + tokens.space_4 + 2.0 * tokens.space_6 + 2.0 * tokens.space_2);
|
||||
for product in Product::ALL {
|
||||
let history = product == Product::History;
|
||||
let assistant = product == Product::Assistant;
|
||||
let mut allowed_zones = [Zone::Left, Zone::Right, Zone::Floating].into_iter().collect::<std::collections::BTreeSet<_>>();
|
||||
if history {
|
||||
allowed_zones.insert(Zone::Bottom);
|
||||
}
|
||||
let mut header_actions = product.header_actions();
|
||||
if history && !app.preferences.history_play_button {
|
||||
header_actions.retain(|action| action.id.0 != "play");
|
||||
}
|
||||
registry.register(PanelSpec {
|
||||
kind: PanelKind::new(product.kind())?,
|
||||
title_key: product.title_key().into(),
|
||||
icon: "list".into(),
|
||||
allowed_zones,
|
||||
default_zone: if history { Zone::Bottom } else { Zone::Left },
|
||||
min_size: Size { width: 240.0, height: if assistant { 120.0 } else { crate::theme::Tokens::get().control_height } },
|
||||
default_size: Size { width: crate::prefs::DEFAULT_BROWSER_WIDTH, height: if history { bottom_height } else { 320.0 } },
|
||||
max_size: Size { width: MAX_LOGICAL, height: MAX_LOGICAL },
|
||||
edge_rule: if assistant { EdgeRule::PinOuter } else { EdgeRule::None },
|
||||
surface: PanelSurface::FollowZone,
|
||||
features: PanelFeatures { popout: false, hot_closable: assistant, ..Default::default() },
|
||||
side_height_policy: if assistant { SideHeightPolicy::Fill } else { SideHeightPolicy::Content },
|
||||
fixed_bottom_height: history.then_some(bottom_height),
|
||||
header_actions,
|
||||
text_heavy: assistant,
|
||||
})?;
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
fn default_layout(app: &crate::SolveApp, workspace: WorkspaceId, registry: &PanelRegistry) -> PanelResult<Layout> {
|
||||
let mut layout = Layout::empty(workspace);
|
||||
let vertical = app.ui.show_timeline && app.preferences.timeline_left;
|
||||
let stacked = vertical && app.preferences.timeline_under_browser;
|
||||
let share = if app.preferences.browser_share.is_finite() { app.preferences.browser_share.clamp(0.05, 0.95) } else { 0.5 };
|
||||
for product in Product::ALL {
|
||||
let id = InstanceId::new(product.id())?;
|
||||
let requested_open = product.visible(app);
|
||||
let kind = PanelKind::new(product.kind())?;
|
||||
let spec = registry.get(&kind)?;
|
||||
let zone = match product {
|
||||
Product::History if !app.preferences.timeline_left => Zone::Bottom,
|
||||
Product::Assistant if app.preferences.agent_chat_side == "right" => Zone::Right,
|
||||
Product::RenderBrowser if app.preferences.render_browser_side == "right" => Zone::Right,
|
||||
_ => Zone::Left,
|
||||
};
|
||||
let width = match product {
|
||||
Product::Browser => app.preferences.browser_width,
|
||||
Product::History if stacked => app.preferences.browser_width,
|
||||
Product::History => app.preferences.timeline_left_width,
|
||||
Product::Assistant => app.preferences.agent_chat_width,
|
||||
Product::RenderBrowser => app.preferences.render_browser_width,
|
||||
};
|
||||
let width = if width.is_finite() { width.clamp(spec.min_size.width, 1200.0) } else { spec.default_size.width };
|
||||
let width = if product == Product::History && stacked {
|
||||
layout.columns.iter().find(|column| column.id.0 == "panel-column-browser").map_or(width, |column| column.width)
|
||||
} else {
|
||||
width
|
||||
};
|
||||
let size = Size { width, height: spec.default_size.height };
|
||||
let section = SectionId::new(format!("panel-{}", product.id()))?;
|
||||
let location = SectionLocation::Dock { zone };
|
||||
let column = zone
|
||||
.side()
|
||||
.then(|| ColumnId(format!("panel-column-{}", if product == Product::History && stacked { Product::Browser.id() } else { product.id() })));
|
||||
if requested_open
|
||||
&& let Some(column) = &column
|
||||
&& !layout.columns.iter().any(|existing| existing.id == *column)
|
||||
{
|
||||
layout.columns.push(Column { id: column.clone(), zone, width });
|
||||
}
|
||||
let collapsed = spec.collapsible_in(zone)
|
||||
&& match product {
|
||||
Product::Browser if vertical => app.preferences.timeline_left_browser_collapsed,
|
||||
Product::Browser => app.preferences.browser_collapsed,
|
||||
Product::History => app.preferences.timeline_collapsed,
|
||||
Product::Assistant => app.preferences.agent_chat_minimized,
|
||||
Product::RenderBrowser => false,
|
||||
};
|
||||
let weight = match product {
|
||||
Product::Browser if stacked => share,
|
||||
Product::History if stacked => 1.0 - share,
|
||||
_ => 1.0,
|
||||
};
|
||||
let section_index = if product == Product::Assistant && !requested_open { 0 } else { layout.sections.len() };
|
||||
if requested_open {
|
||||
layout.sections.push(Section {
|
||||
id: section.clone(),
|
||||
location: location.clone(),
|
||||
column: column.clone(),
|
||||
weight,
|
||||
preferred_extent: None,
|
||||
extent_priority: 0,
|
||||
tabs: vec![id.clone()],
|
||||
active: id.clone(),
|
||||
size,
|
||||
collapsed,
|
||||
});
|
||||
}
|
||||
let column_index = if product == Product::Assistant && !requested_open {
|
||||
0
|
||||
} else {
|
||||
layout
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|existing| existing.zone == zone)
|
||||
.position(|existing| Some(&existing.id) == column.as_ref())
|
||||
.unwrap_or_else(|| layout.columns.iter().filter(|existing| existing.zone == zone).count())
|
||||
};
|
||||
layout.instances.push(PanelInstance {
|
||||
id,
|
||||
kind,
|
||||
requested_open,
|
||||
memory: PlacementMemory {
|
||||
section,
|
||||
location,
|
||||
section_index,
|
||||
tab_index: 0,
|
||||
size,
|
||||
collapsed,
|
||||
column: column.clone(),
|
||||
column_index,
|
||||
column_width: column.map(|_| width),
|
||||
weight,
|
||||
preferred_extent: None,
|
||||
extent_priority: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
// A pinned Assistant precedes ordinary columns on its chosen logical side.
|
||||
layout.columns.sort_by_key(|column| (column.zone != Zone::Left, column.id.0 != "panel-column-assistant"));
|
||||
layout.sections.sort_by_key(|section| {
|
||||
section.column.as_ref().and_then(|id| layout.columns.iter().position(|column| column.id == *id)).unwrap_or(MAX_SECTIONS)
|
||||
});
|
||||
for (section_index, section) in layout.sections.iter().enumerate() {
|
||||
let column_index = layout
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| column.zone == section.location.zone())
|
||||
.position(|column| Some(&column.id) == section.column.as_ref())
|
||||
.unwrap_or(0);
|
||||
if let Some(instance) = layout.instances.iter_mut().find(|instance| instance.id == section.active) {
|
||||
instance.memory.section_index = section_index;
|
||||
instance.memory.column_index = column_index;
|
||||
}
|
||||
}
|
||||
validate(&layout, registry)?;
|
||||
Ok(layout)
|
||||
}
|
||||
|
||||
struct ProductionController {
|
||||
product: Product,
|
||||
document: DocumentId,
|
||||
state: ProductionState,
|
||||
}
|
||||
impl PanelController for ProductionController {
|
||||
fn availability(&self, owner: &ContentOwner, _models: &dyn PanelModels) -> Availability {
|
||||
if self.state.0.try_borrow().is_ok_and(|snapshot| {
|
||||
*owner == ContentOwner::Document(snapshot.document.clone())
|
||||
&& snapshot.panels.iter().any(|panel| panel.product == self.product && panel.available)
|
||||
}) {
|
||||
Availability::Available
|
||||
} else {
|
||||
Availability::Unavailable { reason: crate::i18n::tr("ui.panel_host.rule.availability").into() }
|
||||
}
|
||||
}
|
||||
fn update(&mut self, _owner: &ContentOwner, _services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn presentation(&self) -> PanelPresentation {
|
||||
self.state
|
||||
.0
|
||||
.try_borrow()
|
||||
.ok()
|
||||
.and_then(|snapshot| snapshot.panels.iter().find(|panel| panel.product == self.product).map(|panel| panel.presentation.clone()))
|
||||
.unwrap_or_else(|| self.product.empty_presentation())
|
||||
}
|
||||
fn measure(&self, _owner: &ContentOwner, _models: &dyn PanelModels, spec: &PanelSpec) -> PanelResult<Arc<dyn HostContentMetrics>> {
|
||||
let snapshot = self.state.0.try_borrow().map_err(|_| error(Rule::Lifecycle, "content measurement is borrowed"))?;
|
||||
Ok(Arc::new(ProductionMetrics {
|
||||
extent: snapshot.panels.iter().find(|panel| panel.product == self.product).and_then(|panel| panel.extent),
|
||||
inset: crate::theme::Tokens::get().space_2 * 2.0,
|
||||
policy: spec.side_height_policy,
|
||||
}))
|
||||
}
|
||||
fn prepare_lifecycle(&self, event: &LifecycleEvent) -> PanelResult<()> {
|
||||
if matches!(event, LifecycleEvent::Close | LifecycleEvent::Reopen) {
|
||||
let _snapshot = self.state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel visibility is borrowed"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn lifecycle(&mut self, event: &LifecycleEvent) {
|
||||
let open = match event {
|
||||
LifecycleEvent::Close => false,
|
||||
LifecycleEvent::Reopen => true,
|
||||
_ => return,
|
||||
};
|
||||
if let Ok(mut snapshot) = self.state.0.try_borrow_mut()
|
||||
&& snapshot.document == self.document
|
||||
&& let Some(slot) = snapshot.visibility.get_mut(self.product.index())
|
||||
{
|
||||
*slot = Some(VisibilityIntent { document: self.document.clone(), open });
|
||||
}
|
||||
}
|
||||
fn render(&mut self, _slot: ContentSlot, _ui: &mut egui::Ui, _context: &PanelContext, _services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
Err(error(Rule::Lifecycle, "application panel content requires its current frame binding"))
|
||||
}
|
||||
fn action(&mut self, owner: &ContentOwner, action: &ControlId, services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
self.availability(owner, services.models).require()?;
|
||||
self.prepare_action(owner, action, services.models)?;
|
||||
let mut snapshot = self.state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel actions are borrowed"))?;
|
||||
if snapshot.actions.len() >= MAX_ACTIONS {
|
||||
return Err(error(Rule::CollectionLimit, "application panel action queue is full"));
|
||||
}
|
||||
let document = snapshot.document.clone();
|
||||
snapshot.actions.push_back(QueuedAction { document, product: self.product, action: action.clone() });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProductionMetrics {
|
||||
extent: Option<ContentExtent>,
|
||||
inset: f32,
|
||||
policy: SideHeightPolicy,
|
||||
}
|
||||
impl HostContentMetrics for ProductionMetrics {
|
||||
fn measure(&self, request: &ContentMeasureRequest) -> PanelResult<SideContentMeasure> {
|
||||
// Bootstrap a newly visible width at capacity. Only actual content extent,
|
||||
// never the allocated viewport, can subsequently shorten the panel.
|
||||
let needed_height = self
|
||||
.extent
|
||||
.filter(|extent| extent.width_independent || (extent.flow == Flow::Tall && (extent.width - request.width).abs() < 0.5))
|
||||
.map_or(MAX_LOGICAL, |extent| (request.chrome_height + extent.height + self.inset).min(MAX_LOGICAL));
|
||||
Ok(SideContentMeasure { request: request.clone(), needed_height, policy: self.policy })
|
||||
}
|
||||
fn revision(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
self.extent
|
||||
.filter(|_| self.policy == SideHeightPolicy::Content)
|
||||
.map(|extent| {
|
||||
(
|
||||
if extent.width_independent { 0 } else { extent.width.to_bits() },
|
||||
extent.height.to_bits(),
|
||||
extent.width_independent,
|
||||
if extent.width_independent { 0 } else { extent.flow as u8 },
|
||||
)
|
||||
})
|
||||
.hash(&mut hash);
|
||||
self.inset.to_bits().hash(&mut hash);
|
||||
matches!(self.policy, SideHeightPolicy::Fill).hash(&mut hash);
|
||||
hash.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProductionModels;
|
||||
impl PanelModels for ProductionModels {
|
||||
fn read(&self, _owner: &ContentOwner, _key: &str) -> PanelResult<Value> {
|
||||
Err(error(Rule::MissingTarget, "application content reads its existing owner directly"))
|
||||
}
|
||||
}
|
||||
struct ProductionCommands;
|
||||
impl ContentCommandSink for ProductionCommands {
|
||||
fn execute(&mut self, _owner: &ContentOwner, _command: &str, _input: Value) -> PanelResult<Value> {
|
||||
Err(error(Rule::Capability, "application content uses its existing command path"))
|
||||
}
|
||||
}
|
||||
struct ProductionServices {
|
||||
models: ProductionModels,
|
||||
commands: ProductionCommands,
|
||||
local: ProductionLocal,
|
||||
}
|
||||
impl AppPanelServices for ProductionServices {
|
||||
fn refresh_workspace(&mut self, workspace: &WorkspaceId) -> PanelResult<()> {
|
||||
if !WORKSPACES.iter().any(|(id, _, _)| *id == workspace.0) {
|
||||
return Err(error(Rule::Placement, "unregistered application layout workspace"));
|
||||
}
|
||||
self.local.workspace = workspace.clone();
|
||||
Ok(())
|
||||
}
|
||||
fn refresh_read_only(&mut self, session: &Session) -> PanelResult<()> {
|
||||
self.local.document = DocumentId::new(session.document_id.clone())?;
|
||||
Ok(())
|
||||
}
|
||||
fn services(&mut self) -> ContentServices<'_> {
|
||||
ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local }
|
||||
}
|
||||
}
|
||||
struct ProductionLocal {
|
||||
workspace: WorkspaceId,
|
||||
document: DocumentId,
|
||||
keyboard: Option<(LocalScope, InstanceId)>,
|
||||
}
|
||||
impl ProductionLocal {
|
||||
fn validate(&self, scope: &LocalScope, instance: &InstanceId) -> PanelResult<()> {
|
||||
scope.validate()?;
|
||||
Product::from_instance(instance)?;
|
||||
if scope.workspace != self.workspace || scope.document.as_ref() != Some(&self.document) || scope.viewport.0 != VIEWPORT {
|
||||
return Err(error(Rule::Placement, "application panel service has a different owner scope"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl PanelLocalServices for ProductionLocal {
|
||||
fn request(&mut self, scope: &LocalScope, request: LocalRequest) -> PanelResult<()> {
|
||||
request.validate()?;
|
||||
self.validate(scope, request.instance())?;
|
||||
match request {
|
||||
LocalRequest::KeyboardOwner { instance, .. } => {
|
||||
if self.keyboard.as_ref().is_some_and(|(owner_scope, owner)| owner_scope == scope && *owner != instance) {
|
||||
return Err(error(Rule::Lifecycle, "another application panel owns the keyboard"));
|
||||
}
|
||||
self.keyboard = Some((scope.clone(), instance));
|
||||
}
|
||||
LocalRequest::ReleaseKeyboard { instance } => self.release(scope, &instance),
|
||||
LocalRequest::Overlay { .. } | LocalRequest::ClearOverlays { .. } => {}
|
||||
LocalRequest::Focus { .. }
|
||||
| LocalRequest::FileDrop { .. }
|
||||
| LocalRequest::PointerDrop { .. }
|
||||
| LocalRequest::Clipboard { .. }
|
||||
| LocalRequest::Pick { .. } => {
|
||||
return Err(error(Rule::Capability, "application content uses its current viewport input service"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn register_focus_response(&mut self, scope: &LocalScope, instance: &InstanceId, _response: &egui::Response) -> PanelResult<()> {
|
||||
self.validate(scope, instance)
|
||||
}
|
||||
fn release(&mut self, scope: &LocalScope, instance: &InstanceId) {
|
||||
if self.keyboard.as_ref().is_some_and(|(owner_scope, owner)| owner_scope == scope && owner == instance) {
|
||||
self.keyboard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FocusCapture {
|
||||
layer: egui::LayerId,
|
||||
responses: Vec<egui::Response>,
|
||||
top: f32,
|
||||
extent: Option<ContentExtent>,
|
||||
composer: Option<f32>,
|
||||
}
|
||||
fn focus_capture_id() -> egui::Id {
|
||||
egui::Id::unique("shared-panel-current-content-focus")
|
||||
}
|
||||
|
||||
pub(crate) fn shared_content_measurement(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|data| data.get_temp::<Option<FocusCapture>>(focus_capture_id()).flatten().is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn shared_content_composer_height(ctx: &egui::Context) -> Option<f32> {
|
||||
ctx.data(|data| data.get_temp::<Option<FocusCapture>>(focus_capture_id()).flatten().and_then(|capture| capture.composer))
|
||||
}
|
||||
|
||||
pub(crate) fn report_content_composer_height(ctx: &egui::Context, height: f32) {
|
||||
if !height.is_finite() || height < 0.0 {
|
||||
return;
|
||||
}
|
||||
ctx.data_mut(|data| {
|
||||
if let Some(Some(mut capture)) = data.remove_temp::<Option<FocusCapture>>(focus_capture_id()) {
|
||||
capture.composer = Some(height.min(MAX_LOGICAL));
|
||||
data.insert_temp(focus_capture_id(), Some(capture));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Natural scroll content and fixed bands from the current visible body only.
|
||||
pub(crate) fn report_content_extent(ui: &egui::Ui, top: f32, height: f32, width_independent: bool) {
|
||||
if !top.is_finite() || !height.is_finite() || height < 0.0 {
|
||||
return;
|
||||
}
|
||||
ui.ctx().data_mut(|data| {
|
||||
if let Some(Some(mut capture)) = data.remove_temp::<Option<FocusCapture>>(focus_capture_id()) {
|
||||
if capture.layer == ui.layer_id() {
|
||||
capture.extent = Some(ContentExtent {
|
||||
width: 0.0,
|
||||
flow: Flow::Tall,
|
||||
composer: None,
|
||||
height: ((top - capture.top).max(0.0) + height).min(MAX_LOGICAL),
|
||||
width_independent,
|
||||
});
|
||||
}
|
||||
data.insert_temp(focus_capture_id(), Some(capture));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Report the actual editor response while its existing body is painting.
|
||||
pub(crate) fn register_content_focus(ctx: &egui::Context, response: &egui::Response) {
|
||||
if !response.enabled() || !response.sense.is_focusable() {
|
||||
return;
|
||||
}
|
||||
ctx.data_mut(|data| {
|
||||
if let Some(Some(mut capture)) = data.remove_temp::<Option<FocusCapture>>(focus_capture_id()) {
|
||||
if response.layer_id == capture.layer && capture.responses.len() < 64 {
|
||||
capture.responses.push(response.clone());
|
||||
}
|
||||
data.insert_temp(focus_capture_id(), Some(capture));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) struct ProductionFrame<'a> {
|
||||
pub app: &'a mut crate::SolveApp,
|
||||
pub(super) state: ProductionState,
|
||||
}
|
||||
impl FramePanelContent for ProductionFrame<'_> {
|
||||
fn render(
|
||||
&mut self,
|
||||
slot: ContentSlot,
|
||||
ui: &mut egui::Ui,
|
||||
context: &PanelContext,
|
||||
_content: &mut dyn PanelController,
|
||||
services: &mut ContentServices<'_>,
|
||||
) -> PanelResult<()> {
|
||||
if !matches!(slot, ContentSlot::Body | ContentSlot::Inline) {
|
||||
return Ok(());
|
||||
}
|
||||
context.validate()?;
|
||||
let product = Product::from_instance(&context.instance)?;
|
||||
if context.owner != ContentOwner::Document(DocumentId::new(self.app.session.document_id.clone())?) || !product.available(self.app) {
|
||||
return Err(error(Rule::Availability, "application panel content owner changed"));
|
||||
}
|
||||
match product {
|
||||
Product::Browser | Product::RenderBrowser => self.app.shell.browser = Some(super::workspace::egui_rect(context.outer)),
|
||||
Product::History => self.app.shell.history = Some(super::workspace::egui_rect(context.outer)),
|
||||
Product::Assistant => {}
|
||||
}
|
||||
let previous = self
|
||||
.state
|
||||
.0
|
||||
.try_borrow()
|
||||
.map_err(|_| error(Rule::Lifecycle, "content measurement is borrowed"))?
|
||||
.panels
|
||||
.iter()
|
||||
.find(|panel| panel.product == product)
|
||||
.and_then(|panel| panel.extent)
|
||||
.filter(|extent| extent.flow == context.flow && (extent.width - context.outer.size.width).abs() < 0.5);
|
||||
ui.ctx().data_mut(|data| {
|
||||
data.insert_temp(
|
||||
focus_capture_id(),
|
||||
Some(FocusCapture {
|
||||
layer: ui.layer_id(),
|
||||
responses: Vec::new(),
|
||||
top: ui.cursor().top(),
|
||||
extent: None,
|
||||
composer: previous.and_then(|extent| extent.composer),
|
||||
}),
|
||||
)
|
||||
});
|
||||
match product {
|
||||
Product::Browser | Product::RenderBrowser => crate::browser::panel_contents(self.app, ui),
|
||||
Product::History if context.flow == Flow::Wide => crate::timeline::panel_inline(self.app, ui),
|
||||
Product::History => crate::timeline_left::panel_contents(self.app, ui),
|
||||
Product::Assistant => crate::agent_chat::shared_content(self.app, ui),
|
||||
}
|
||||
let capture = ui.ctx().data_mut(|data| data.remove_temp::<Option<FocusCapture>>(focus_capture_id()).flatten());
|
||||
if context.document.as_ref().map(|document| document.0.as_str()) != Some(self.app.session.document_id.as_str()) {
|
||||
return Err(error(Rule::Availability, "application panel document changed while painting"));
|
||||
}
|
||||
let scope = LocalScope { workspace: context.workspace.clone(), document: context.document.clone(), viewport: context.viewport.clone() };
|
||||
if let Some(capture) = capture {
|
||||
if let Some(mut extent) = capture.extent {
|
||||
extent.width = context.outer.size.width;
|
||||
extent.flow = context.flow;
|
||||
extent.composer = capture.composer;
|
||||
let mut snapshot = self.state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "content measurement is borrowed"))?;
|
||||
if let Some(panel) = snapshot.panels.iter_mut().find(|panel| panel.product == product)
|
||||
&& panel.extent != Some(extent)
|
||||
{
|
||||
panel.extent = Some(extent);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
for response in capture.responses {
|
||||
services.local.register_focus_response(&scope, &context.instance, &response)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ fn enable_host_specimen_remembered(app: &mut crate::SolveApp, name: &str, rememb
|
||||
document: Some(DocumentId::new(app.session.document_id.clone())?),
|
||||
window: HostId::new("panel_fixture.window")?,
|
||||
viewport: ViewportId::new("panel_fixture.viewport")?,
|
||||
direction: crate::i18n::panel_direction(&app.preferences.language),
|
||||
direction: if crate::bidi::has_rtl(app.tr("ui.panel_fixture.caption")) { Direction::Rtl } else { Direction::Ltr },
|
||||
motion: Motion::Animated,
|
||||
},
|
||||
)?;
|
||||
@@ -170,7 +170,6 @@ fn specimen_layout(name: &str) -> PanelResult<(Layout, PanelRegistry)> {
|
||||
},
|
||||
features: PanelFeatures { multi_instance: !editor, popout: false, hot_closable: editor, ..PanelFeatures::default() },
|
||||
side_height_policy: if fill { SideHeightPolicy::Fill } else { SideHeightPolicy::Content },
|
||||
fixed_bottom_height: None,
|
||||
header_actions: if editor {
|
||||
vec![HeaderActionSpec { id: ControlId::new("apply")?, label_key: "ui.panel_fixture.apply".into(), icon: "list".into() }]
|
||||
} else {
|
||||
@@ -357,7 +356,6 @@ impl PanelController for VisibleSpecimen {
|
||||
[("apply", "ui.panel_fixture.apply"), ("cancel", "ui.panel_fixture.cancel"), ("finish", "ui.panel_fixture.finish")]
|
||||
.into_iter()
|
||||
.map(|(id, key)| RuntimeAction {
|
||||
pressed: None,
|
||||
id: ControlId(id.into()),
|
||||
label: crate::i18n::tr(key).into(),
|
||||
enabled: true,
|
||||
@@ -394,7 +392,6 @@ impl PanelController for VisibleSpecimen {
|
||||
ContentSlot::Body => {
|
||||
// Content owns its scrolling; the host still clips all three slots.
|
||||
let mut result = Ok(());
|
||||
let mut focus_result = Ok(());
|
||||
egui::ScrollArea::vertical().id_salt(("specimen-body", &self.key.instance.0)).show(ui, |ui| {
|
||||
result = self.probe.edit(&self.key, |state| {
|
||||
let t = crate::theme::Tokens::get();
|
||||
@@ -409,29 +406,9 @@ impl PanelController for VisibleSpecimen {
|
||||
let label = ui.add_sized([ui.available_width(), t.control_height], egui::Label::new(draft_label).truncate());
|
||||
let draft_id = egui::Id::unique(("specimen-draft", &context.instance, &context.owner));
|
||||
let edit = crate::ui_kit::text_area(ui, draft_label, draft_id, &mut state.draft, 3, 1024);
|
||||
focus_result = services.local.register_focus_response(
|
||||
&LocalScope {
|
||||
workspace: context.workspace.clone(),
|
||||
document: context.document.clone(),
|
||||
viewport: context.viewport.clone(),
|
||||
},
|
||||
&context.instance,
|
||||
&edit,
|
||||
);
|
||||
let _ = edit.labelled_by(label.id);
|
||||
if self.editor {
|
||||
let guard = crate::ui_kit::checkbox(ui, crate::i18n::tr("ui.panel_fixture.guard"), &mut state.reject_cancel);
|
||||
if focus_result.is_ok() {
|
||||
focus_result = services.local.register_focus_response(
|
||||
&LocalScope {
|
||||
workspace: context.workspace.clone(),
|
||||
document: context.document.clone(),
|
||||
viewport: context.viewport.clone(),
|
||||
},
|
||||
&context.instance,
|
||||
&guard,
|
||||
);
|
||||
}
|
||||
crate::ui_kit::checkbox(ui, crate::i18n::tr("ui.panel_fixture.guard"), &mut state.reject_cancel);
|
||||
ui.add_sized(
|
||||
[ui.available_width(), t.control_height],
|
||||
egui::Label::new(crate::i18n::tr(if state.transaction_live {
|
||||
@@ -445,7 +422,6 @@ impl PanelController for VisibleSpecimen {
|
||||
});
|
||||
});
|
||||
result?;
|
||||
focus_result?;
|
||||
}
|
||||
ContentSlot::Footer => {
|
||||
ui.label(crate::i18n::tr("ui.panel_fixture.footer"));
|
||||
@@ -461,22 +437,15 @@ impl PanelController for VisibleSpecimen {
|
||||
}
|
||||
fn render_inline(&mut self, ui: &mut egui::Ui, context: &PanelContext, services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
self.inner.render(ContentSlot::Body, ui, context, services)?;
|
||||
let mut focus_result = Ok(());
|
||||
self.probe.edit(&self.key, |state| {
|
||||
// A real editable synthetic value occupies the same total row as
|
||||
// shared actions. This is not a production History timeline.
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.add(egui::Label::new(crate::i18n::tr("ui.panel_fixture.inline")).truncate());
|
||||
let id = egui::Id::unique(("specimen-draft", &context.instance, &context.owner));
|
||||
let response = crate::ui_kit::cell_editor(ui, id, &mut state.draft, crate::i18n::tr("ui.panel_fixture.draft"), false);
|
||||
focus_result = services.local.register_focus_response(
|
||||
&LocalScope { workspace: context.workspace.clone(), document: context.document.clone(), viewport: context.viewport.clone() },
|
||||
&context.instance,
|
||||
&response,
|
||||
);
|
||||
crate::ui_kit::cell_editor(ui, id, &mut state.draft, crate::i18n::tr("ui.panel_fixture.draft"), false);
|
||||
});
|
||||
})?;
|
||||
focus_result
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,13 +563,12 @@ fn session_observation(app: &crate::SolveApp) -> Value {
|
||||
/// An empty object inspects without ticking services or dispatching layout/model actions.
|
||||
pub(crate) fn control_specimen(app: &mut crate::SolveApp, params: &Value) -> Result<Value, String> {
|
||||
let object = params.as_object().ok_or("panel_fixture params must be an object")?;
|
||||
if object.keys().any(|key| !matches!(key.as_str(), "enabled" | "preset" | "remember" | "translucency_percent" | "reduced_motion")) {
|
||||
if object.keys().any(|key| !matches!(key.as_str(), "enabled" | "preset" | "remember" | "translucency_percent")) {
|
||||
return Err("unknown panel_fixture parameter".into());
|
||||
}
|
||||
let enabled = object.get("enabled").map(|v| v.as_bool().ok_or("enabled must be a boolean")).transpose()?;
|
||||
let name = object.get("preset").map(|v| v.as_str().ok_or("preset must be a string")).transpose()?;
|
||||
let remember = object.get("remember").map(|v| v.as_bool().ok_or("remember must be a boolean")).transpose()?;
|
||||
let reduced_motion = object.get("reduced_motion").map(|v| v.as_bool().ok_or("reduced_motion must be a boolean")).transpose()?;
|
||||
let requested_opacity = object
|
||||
.get("translucency_percent")
|
||||
.map(|value| {
|
||||
@@ -614,31 +582,23 @@ pub(crate) fn control_specimen(app: &mut crate::SolveApp, params: &Value) -> Res
|
||||
if let Some(name) = name {
|
||||
preset(name).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if enabled == Some(false) && (name.is_some() || remember.is_some() || requested_opacity.is_some() || reduced_motion.is_some()) {
|
||||
return Err("preset, remember, translucency_percent or reduced_motion cannot be combined with enabled=false".into());
|
||||
if enabled == Some(false) && (name.is_some() || remember.is_some() || requested_opacity.is_some()) {
|
||||
return Err("preset, remember or translucency_percent cannot be combined with enabled=false".into());
|
||||
}
|
||||
if remember.is_some() && enabled != Some(true) && name.is_none() {
|
||||
return Err("remember requires enabling a panel specimen".into());
|
||||
}
|
||||
if enabled == Some(true) || name.is_some() {
|
||||
enable_host_specimen_remembered(app, name.unwrap_or("docks"), remember.unwrap_or(false), requested_opacity).map_err(|e| e.to_string())?;
|
||||
} else if requested_opacity.is_some() || reduced_motion.is_some() {
|
||||
} else if let Some(opacity) = requested_opacity {
|
||||
if !app.universal_panel_host_enabled() || !app.panel_runtime().is_some_and(|runtime| specimen_workspace(&runtime.environment().workspace)) {
|
||||
return Err("presentation options require an enabled panel specimen host".into());
|
||||
return Err("translucency_percent requires an enabled panel specimen host".into());
|
||||
}
|
||||
let integration = app.panel_runtime_mut().ok_or("panel specimen runtime disappeared")?;
|
||||
// Confirm the provider as well as the workspace marker before changing
|
||||
// an existing host. Updating presentation retains controllers and drafts.
|
||||
integration.with_services(|_, services| services.models.read(&ContentOwner::Application, INSPECT)).map_err(|e| e.to_string())?;
|
||||
if let Some(opacity) = requested_opacity {
|
||||
integration.set_requested_opacity(Some(opacity)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
if let Some(reduced) = reduced_motion {
|
||||
app.panel_runtime_mut()
|
||||
.ok_or("panel specimen runtime disappeared")?
|
||||
.set_motion(if reduced { Motion::Reduced } else { Motion::Animated })
|
||||
.map_err(|e| e.to_string())?;
|
||||
integration.set_requested_opacity(Some(opacity)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let owns_runtime = app.panel_runtime().is_some_and(|runtime| specimen_workspace(&runtime.environment().workspace));
|
||||
if enabled == Some(false) && app.panel_runtime().is_some() {
|
||||
@@ -669,7 +629,7 @@ pub fn inspect_host_specimen(app: &mut crate::SolveApp) -> PanelResult<Value> {
|
||||
let integration = app.panel_runtime_mut().ok_or_else(|| error(Rule::Lifecycle, "panel specimen runtime disappeared"))?;
|
||||
let probe = integration.with_services(|runtime, services| {
|
||||
let probe = services.models.read(&ContentOwner::Application, INSPECT)?;
|
||||
Ok(json!({"probe":probe,"layout":runtime.layout().inspect(),"controller_count":runtime.controller_count(),"reduced_motion":runtime.environment().motion == Motion::Reduced}))
|
||||
Ok(json!({"probe":probe,"layout":runtime.layout().inspect(),"controller_count":runtime.controller_count()}))
|
||||
})?;
|
||||
reply["enabled"] = json!(app.universal_panel_host_enabled());
|
||||
reply["runtime"] = probe;
|
||||
|
||||
@@ -2127,7 +2127,7 @@ fn allocation_full_domain_and_short_wide_inline_roles_keep_real_usable_contours_
|
||||
let card = &host.sections[0];
|
||||
assert!(card.inline && card.body.is_none() && card.footer.is_none());
|
||||
assert_eq!(card.header, card.outer, "one total row is shared by controls and inline content");
|
||||
let controls = [card.actions[0].1, card.menu, card.collapse.unwrap(), card.close.unwrap()];
|
||||
let controls = [card.actions[0].1, card.menu, card.collapse, card.close.unwrap()];
|
||||
for pair in controls.windows(2) {
|
||||
assert!(if direction == Direction::Ltr { pair[0].origin.x < pair[1].origin.x } else { pair[0].origin.x > pair[1].origin.x });
|
||||
}
|
||||
@@ -2291,7 +2291,7 @@ fn assert_host_geometry(h: &PanelMountHarness, host: &HostLayout) {
|
||||
.chain(card.tabs.iter().map(|tab| tab.bounds))
|
||||
.chain(card.tab_overflow)
|
||||
.chain(card.actions.iter().map(|(_, bounds)| *bounds))
|
||||
.chain([card.menu, card.collapse.unwrap()])
|
||||
.chain([card.menu, card.collapse])
|
||||
.chain(card.close)
|
||||
.chain(card.inline_content)
|
||||
.collect();
|
||||
@@ -2505,10 +2505,9 @@ impl RawPanelHost {
|
||||
let ctx = crate::fonts::test_context();
|
||||
ctx.enable_accesskit();
|
||||
ctx.set_pixels_per_point(scale);
|
||||
let host = WorkspacePanelHost::new(HostOptions::default()).unwrap();
|
||||
Self {
|
||||
content,
|
||||
host,
|
||||
host: WorkspacePanelHost::new(HostOptions::default()).unwrap(),
|
||||
ctx,
|
||||
time: 0.0,
|
||||
capacity: capacity(),
|
||||
@@ -4720,7 +4719,7 @@ fn raw_host_inactive_tab_widget_drag_obeys_threshold_and_moves_only_its_retained
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|(_, node)| { node.role() == egui::accesskit::Role::Tab && node.label() == Some(widget.accessible_name.as_str()) }),
|
||||
.any(|(_, node)| { node.role() == egui::accesskit::Role::Button && node.label() == Some(widget.accessible_name.as_str()) }),
|
||||
"published Tab metadata must correspond to an actual egui widget"
|
||||
);
|
||||
let start = raw.at(target);
|
||||
@@ -9348,7 +9347,6 @@ struct ReviewCohortTrace {
|
||||
editors: Vec<(egui::Id, ContentSlot, egui::Rect, String)>,
|
||||
wrap_rows: Vec<usize>,
|
||||
consume_wheel: bool,
|
||||
omit_editor: bool,
|
||||
child_scroll: std::collections::BTreeMap<InstanceId, (egui::Rect, f32)>,
|
||||
}
|
||||
type ReviewTrace = std::rc::Rc<std::cell::RefCell<ReviewCohortTrace>>;
|
||||
@@ -9406,7 +9404,6 @@ impl PanelController for ReviewController {
|
||||
fn presentation(&self) -> PanelPresentation {
|
||||
let mut presentation = self.inner.presentation();
|
||||
presentation.actions.push(RuntimeAction {
|
||||
pressed: Some(!self.trace.borrow().disabled),
|
||||
id: ControlId::new("pulse").unwrap(),
|
||||
label: "review pulse".into(),
|
||||
enabled: !self.trace.borrow().disabled,
|
||||
@@ -9425,9 +9422,6 @@ impl PanelController for ReviewController {
|
||||
self.inner.render(slot, ui, context, services)?;
|
||||
let consume_wheel = self.trace.borrow().consume_wheel;
|
||||
if slot == ContentSlot::Body {
|
||||
if self.trace.borrow().omit_editor {
|
||||
return Ok(());
|
||||
}
|
||||
if self.key.instance.0 == "inline-editor" || self.key.instance.0 == "wrapping-editor" {
|
||||
let editor_id = egui::Id::unique(("review-inline-editor", &self.key));
|
||||
let response = if self.key.instance.0 == "wrapping-editor" {
|
||||
@@ -9442,11 +9436,6 @@ impl PanelController for ReviewController {
|
||||
ui.fonts_mut(|fonts| fonts.layout(self.draft.clone(), egui::FontId::monospace(tokens.font_label), tokens.form_ink, width));
|
||||
self.trace.borrow_mut().wrap_rows.push(galley.rows.len());
|
||||
}
|
||||
services.local.register_focus_response(
|
||||
&LocalScope { workspace: context.workspace.clone(), document: context.document.clone(), viewport: context.viewport.clone() },
|
||||
&context.instance,
|
||||
&response,
|
||||
)?;
|
||||
self.trace.borrow_mut().editors.push((response.id, slot, response.rect, self.draft.clone()));
|
||||
} else if consume_wheel {
|
||||
let child = egui::ScrollArea::vertical()
|
||||
@@ -10148,7 +10137,7 @@ fn raw_inline_title_actions_editor_and_system_slots_mirror_and_keep_editor_ident
|
||||
assert!(card.inline && card.body.is_none() && card.footer.is_none());
|
||||
assert_eq!(card.outer.size.height, 64.0);
|
||||
let shared_title = card.title.expect("inline composition retains the actual shared title/grip");
|
||||
let slots = [shared_title, card.actions[0].1, card.inline_content.unwrap(), card.menu, card.collapse.unwrap(), card.close.unwrap()];
|
||||
let slots = [shared_title, card.actions[0].1, card.inline_content.unwrap(), card.menu, card.collapse, card.close.unwrap()];
|
||||
for pair in slots.windows(2) {
|
||||
if direction == Direction::Ltr {
|
||||
assert!(pair[0].origin.x + pair[0].size.width <= pair[1].origin.x);
|
||||
@@ -12517,881 +12506,3 @@ fn captured_renderer_alpha_reaches_new_sections_and_alpha_only_drift_invalidates
|
||||
assert_eq!(h.app.session.is_dirty(), dirty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_host_accessibility_exposes_tabs_disclosure_and_oriented_splits() {
|
||||
for scale in [1.0, 2.0] {
|
||||
let mut content = host_logs(2, Zone::Left);
|
||||
content
|
||||
.dispatch(vec![Mutation::Open {
|
||||
kind: PanelKind::new("fixture.log").unwrap(),
|
||||
instance: id("extra-tab"),
|
||||
destination: Some(Destination::JoinTab { section: section("section-0"), index: 1 }),
|
||||
}])
|
||||
.unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
let (frame, output) = raw.frame(Vec::new(), false);
|
||||
let nodes = &output.platform_output.accesskit_update.as_ref().unwrap().nodes;
|
||||
let active = frame.layout.sections.iter().find(|card| card.section == section("section-0")).unwrap().instance.clone();
|
||||
let tabs: Vec<_> =
|
||||
frame.controls.iter().filter(|control| matches!(control.target, HostControlTarget::Pointer(HostPointerTarget::Tab { .. }))).collect();
|
||||
assert_eq!(tabs.len(), 2);
|
||||
for tab in tabs {
|
||||
let node = &nodes.iter().find(|(id, _)| *id == tab.id.accesskit_id()).unwrap().1;
|
||||
assert_eq!(node.role(), egui::accesskit::Role::Tab);
|
||||
assert_eq!(
|
||||
node.is_selected(),
|
||||
Some(matches!(&tab.target, HostControlTarget::Pointer(HostPointerTarget::Tab { instance }) if *instance == active))
|
||||
);
|
||||
}
|
||||
let tablist = nodes.iter().find(|(_, node)| node.role() == egui::accesskit::Role::TabList).unwrap();
|
||||
let mut descendants = vec![tablist.0];
|
||||
let mut next = 0;
|
||||
while next < descendants.len() && next < nodes.len() {
|
||||
if let Some((_, node)) = nodes.iter().find(|(id, _)| *id == descendants[next]) {
|
||||
for child in node.children() {
|
||||
if !descendants.contains(child) {
|
||||
descendants.push(*child);
|
||||
}
|
||||
}
|
||||
}
|
||||
next += 1;
|
||||
}
|
||||
for tab in frame.controls.iter().filter(|control| matches!(control.target, HostControlTarget::Pointer(HostPointerTarget::Tab { .. }))) {
|
||||
assert!(descendants.contains(&tab.id.accesskit_id()), "the actual Tab must descend from the actual TabList");
|
||||
}
|
||||
let collapse = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Collapse { instance: active }));
|
||||
assert_eq!(nodes.iter().find(|(id, _)| *id == collapse.id.accesskit_id()).unwrap().1.is_expanded(), Some(true));
|
||||
assert!(
|
||||
nodes
|
||||
.iter()
|
||||
.any(|(_, node)| node.role() == egui::accesskit::Role::Splitter && node.orientation().is_some() && node.numeric_value().is_some())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn sash_numeric_value(frame: &HostFrame, output: &egui::FullOutput, target: &HostControlTarget) -> f64 {
|
||||
let control = frame.controls.iter().find(|control| control.target == *target).unwrap();
|
||||
output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(id, _)| *id == control.id.accesskit_id())
|
||||
.unwrap()
|
||||
.1
|
||||
.numeric_value()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_keyboard_resize_reuses_candidates_and_rejects_foreign_focus() {
|
||||
for scale in [1.0, 2.0] {
|
||||
for direction in [Direction::Ltr, Direction::Rtl] {
|
||||
let mut content = host_logs(2, Zone::Left);
|
||||
let mut environment = content.runtime.environment().clone();
|
||||
environment.direction = direction;
|
||||
content.runtime.set_environment(environment, &mut content.local).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
let (frame, output) = raw.frame(Vec::new(), false);
|
||||
let sash =
|
||||
frame.controls.iter().find(|control| matches!(control.target, HostControlTarget::Pointer(HostPointerTarget::Sash { .. }))).unwrap();
|
||||
let target = sash.target.clone();
|
||||
let before_value = sash_numeric_value(&frame, &output, &target);
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(sash.id));
|
||||
raw.frame(Vec::new(), false);
|
||||
let candidate = raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::ResizeDown).unwrap();
|
||||
let before = raw.content.runtime.layout().inspect();
|
||||
raw.frame(vec![host_key(egui::Key::ArrowDown, true)], false);
|
||||
let (after_frame, output) = raw.frame(vec![host_key(egui::Key::ArrowDown, false)], false);
|
||||
assert_ne!(raw.content.runtime.layout().inspect().revision, before.revision);
|
||||
assert!(matches!(candidate, LayoutAction::Apply { .. }));
|
||||
let value = sash_numeric_value(&after_frame, &output, &target);
|
||||
assert_eq!(value, f64::from(after_frame.layout.sections[0].outer.size.height));
|
||||
assert!(value > before_value, "the default standalone arrow changes the controlled height and accessible value");
|
||||
raw.host.set_keyboard_shortcuts(Vec::new());
|
||||
let unbound = raw.content.runtime.layout().inspect();
|
||||
raw.frame(vec![host_key(egui::Key::ArrowDown, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::ArrowDown, false)], false);
|
||||
assert_eq!(raw.content.runtime.layout().inspect(), unbound, "application bindings replace rather than extend standalone defaults");
|
||||
raw.foreign_cover = Some(egui_rect(raw.capacity));
|
||||
raw.foreign_takes_focus = true;
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
assert!(raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::ResizeDown).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitter_accessible_extent_keeps_its_axis_and_value_when_the_host_scrolls() {
|
||||
for scale in [1.0, 2.0] {
|
||||
for direction in [Direction::Ltr, Direction::Rtl] {
|
||||
for zone in [Zone::Left, Zone::Bottom] {
|
||||
let mut content = host_logs(2, zone);
|
||||
let mut environment = content.runtime.environment().clone();
|
||||
environment.direction = direction;
|
||||
content.runtime.set_environment(environment, &mut content.local).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.capacity.size = if zone == Zone::Bottom { Size { width: 250.0, height: 400.0 } } else { Size { width: 800.0, height: 200.0 } };
|
||||
raw.frame(Vec::new(), false);
|
||||
let (before_frame, output) = raw.frame(Vec::new(), false);
|
||||
let target = HostControlTarget::Pointer(HostPointerTarget::Sash { first: section("section-0"), second: section("section-1") });
|
||||
let value = sash_numeric_value(&before_frame, &output, &target);
|
||||
let before_control = raw_control(&raw, &target);
|
||||
let horizontal_scroll = zone == Zone::Bottom;
|
||||
let track = raw_control(&raw, &HostControlTarget::Scroll { horizontal: horizontal_scroll });
|
||||
let bounds = egui_rect(track.bounds);
|
||||
let at = if horizontal_scroll { bounds.right_center() - egui::vec2(1.0, 0.0) } else { bounds.center_bottom() - egui::vec2(0.0, 1.0) };
|
||||
let before = raw.content.runtime.layout().inspect();
|
||||
raw.frame(vec![egui::Event::PointerMoved(at)], false);
|
||||
raw.pointer(at, true, false);
|
||||
raw.pointer(at, false, false);
|
||||
let (after_frame, output) = raw.frame(Vec::new(), false);
|
||||
let after_control = raw_control(&raw, &target);
|
||||
assert_ne!(after_control.bounds.origin, before_control.bounds.origin, "the actual sash moved with presentation scrolling");
|
||||
assert_eq!(sash_numeric_value(&after_frame, &output, &target), value, "scrolling cannot announce a panel resize");
|
||||
let node = &output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(id, _)| *id == after_control.id.accesskit_id())
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(
|
||||
node.orientation(),
|
||||
Some(if horizontal_scroll { egui::accesskit::Orientation::Vertical } else { egui::accesskit::Orientation::Horizontal })
|
||||
);
|
||||
assert_eq!(raw.content.runtime.layout().inspect(), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_panel_focus_reveals_last_tiny_header_without_a_canvas_shield() {
|
||||
for scale in [1.0, 2.0] {
|
||||
let mut content = host_logs(24, Zone::Left);
|
||||
content
|
||||
.dispatch((0..24).map(|index| Mutation::Collapse { section: section(&format!("section-{index}")), collapsed: true }).collect())
|
||||
.unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.capacity.size = Size { width: 480.0, height: 320.0 };
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let first = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Header { instance: id("log-0") }));
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(first.id));
|
||||
raw.frame(Vec::new(), false);
|
||||
let action = raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::FocusPrevious).unwrap();
|
||||
let reply = raw
|
||||
.content
|
||||
.dispatch(match action {
|
||||
LayoutAction::Apply { mutations, .. } => mutations,
|
||||
_ => panic!("focus must be a typed mutation"),
|
||||
})
|
||||
.unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.frame(Vec::new(), false);
|
||||
let frame = raw.frame(Vec::new(), false).0;
|
||||
let last = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Header { instance: id("log-23") }));
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(last.id));
|
||||
assert!(raw.capacity.contains_rect(last.bounds));
|
||||
assert!(frame.layout.input.iter().all(|bounds| frame.layout.painted.contains(bounds)));
|
||||
assert!(frame.layout.sections.iter().filter(|card| card.instance != id("log-23")).all(|card| card.collapsed && card.body.is_none()));
|
||||
let target = frame.layout.sections.iter().find(|card| card.instance == id("log-23")).unwrap();
|
||||
assert!(!target.collapsed && target.body.is_some(), "Focus deliberately expands only its target");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_menu_keyboard_entry_end_and_escape_restore_actual_opener() {
|
||||
for scale in [1.0, 2.0] {
|
||||
let mut raw = RawPanelHost::new(host_logs(1, Zone::Left), scale);
|
||||
raw.ctx.global_style_mut(|style| style.scroll_animation = egui::style::ScrollAnimation::none());
|
||||
raw.capacity.size = Size { width: 480.0, height: 100.0 };
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let opener = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Menu { instance: id("log-0") }));
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(opener.id));
|
||||
raw.frame(vec![host_key(egui::Key::Enter, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(opener.id));
|
||||
// Settle the entry reveal, then prove a retained first-row focus does
|
||||
// not undo manual wheel scrolling on subsequent ordinary paints.
|
||||
raw.frame(Vec::new(), false);
|
||||
let first = raw.focused_response.as_ref().unwrap().clone();
|
||||
scroll_real_host_popup_to_end(&mut raw);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(first.id));
|
||||
let scrolled = raw.focused_response.as_ref().unwrap().clone();
|
||||
let inner = egui_rect(raw.host.frame().unwrap().menu_bounds.unwrap()).shrink(crate::theme::Tokens::get().space_6);
|
||||
assert!(scrolled.rect.bottom() < inner.top(), "manual scrolling can leave the focused first item outside the visible menu");
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.focused_response.as_ref().unwrap().rect, scrolled.rect, "idle repaint must not pull manual scroll back to the focused row");
|
||||
let (_, output) = raw.frame(vec![host_key(egui::Key::End, true)], false);
|
||||
assert!(output.platform_output.accesskit_update.as_ref().unwrap().nodes.iter().any(|(_, node)| node.role() == egui::accesskit::Role::Menu));
|
||||
raw.frame(vec![host_key(egui::Key::End, false)], false);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let focused = raw.ctx.memory(|memory| memory.focused()).unwrap();
|
||||
// read_response after run_ui can return the preceding pass: egui swaps
|
||||
// its current/previous widget stores at end_pass. Use the actual
|
||||
// response already captured inside this frame after menu painting.
|
||||
let response = raw.focused_response.as_ref().unwrap();
|
||||
assert_eq!(response.id, focused);
|
||||
let menu = egui_rect(raw.host.frame().unwrap().menu_bounds.unwrap());
|
||||
let inner = menu.shrink(crate::theme::Tokens::get().space_6);
|
||||
assert!(inner.contains_rect(response.rect), "the final enabled action scrolls fully into the tiny menu's visible inner rectangle");
|
||||
assert!(response.interact_rect.is_positive());
|
||||
assert_eq!(response.interact_rect, response.rect, "the focused row must be fully interactable too");
|
||||
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::Escape, false)], false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(opener.id));
|
||||
assert!(raw.host.frame().unwrap().menu_bounds.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_menu_keyboard_entry_and_escape_keep_the_live_root_opener() {
|
||||
for scale in [1.0, 2.0] {
|
||||
for fixture in [PopupFixture::Tabs, PopupFixture::CompactTabs] {
|
||||
let (mut raw, source, button) = raw_popup_fixture(scale, fixture);
|
||||
let root = raw_control(&raw, &source);
|
||||
open_root_popup(&mut raw, &source, button, false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let nested = raw_control(&raw, &HostControlTarget::PanelMenu { instance: id("log-0") });
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(nested.id));
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(vec![host_key(egui::Key::Enter, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let first = raw_control(&raw, &HostControlTarget::Shared { instance: id("log-0"), action: HostSharedAction::Focus });
|
||||
assert_eq!(
|
||||
raw.ctx.memory(|memory| memory.focused()),
|
||||
Some(first.id),
|
||||
"the replacement panel menu takes keyboard focus from its immediate submenu source"
|
||||
);
|
||||
assert!(
|
||||
!raw.host.frame().unwrap().controls.iter().any(|control| control.target == HostControlTarget::PanelMenu { instance: id("log-0") })
|
||||
);
|
||||
raw.frame(vec![host_key(egui::Key::ArrowDown, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::ArrowDown, false)], false);
|
||||
let second = raw_control(&raw, &HostControlTarget::Shared { instance: id("log-0"), action: HostSharedAction::Collapse });
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(second.id), "the entered submenu is immediately keyboard navigable");
|
||||
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::Escape, false)], false);
|
||||
assert_no_popup(raw.host.frame().unwrap());
|
||||
assert_eq!(
|
||||
raw.ctx.memory(|memory| memory.focused()),
|
||||
Some(root.id),
|
||||
"Escape restores the live overflow launcher, not the vanished submenu row"
|
||||
);
|
||||
// A separate fresh menu must establish a new root launcher.
|
||||
let fresh = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Menu { instance: id("log-0") }));
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(fresh.id));
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(vec![host_key(egui::Key::Enter, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::Escape, false)], false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(fresh.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_editor_keeps_automatic_keys_and_explicit_panel_intents_are_observational() {
|
||||
for scale in [1.0, 2.0] {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let editor = trace.borrow().editors.last().unwrap().clone();
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.host.set_keyboard_shortcuts(vec![
|
||||
(ContextualPanelAction::Toggle, egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::T)),
|
||||
(ContextualPanelAction::FocusNext, egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::ArrowLeft)),
|
||||
]);
|
||||
let before = raw.content.runtime.layout().inspect();
|
||||
let (invalidated, _) = raw.frame_late(vec![host_key(egui::Key::T, true), egui::Event::Text("typed text".into())], false, |_, _| {});
|
||||
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(vec![host_key(egui::Key::T, false), host_key(egui::Key::ArrowLeft, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::ArrowLeft, false)], false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
|
||||
assert!(trace.borrow().editors.last().unwrap().3.contains("typed text"));
|
||||
assert_eq!(raw.content.runtime.layout().inspect(), before);
|
||||
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
|
||||
let explicit = raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::Toggle).unwrap();
|
||||
assert!(matches!(explicit, LayoutAction::Apply { .. }));
|
||||
assert_eq!(raw.content.runtime.layout().inspect(), before, "resolving a command must not dispatch it");
|
||||
assert!(raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::ResizeLeft).is_err());
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(raw.content.commands.calls, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_focus_bookmark_restores_caret_after_explicit_collapse_and_focus() {
|
||||
for scale in [1.0, 2.0] {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let editor = trace.borrow().editors.last().unwrap().clone();
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
|
||||
raw.frame(Vec::new(), false);
|
||||
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text(" retained selection".into())], false, |_, _| {});
|
||||
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
|
||||
raw.frame(Vec::new(), false);
|
||||
let before = trace.borrow().editors.last().unwrap().3.clone();
|
||||
let expected: String = before.chars().take(1).chain(std::iter::once('!')).chain(before.chars().skip(4)).collect();
|
||||
let mut state = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
|
||||
state.store(&raw.ctx, editor.0);
|
||||
raw.frame(Vec::new(), false);
|
||||
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
|
||||
assert!(!caret.unwrap().is_empty(), "exercise an actual nonempty selection before hiding");
|
||||
raw.content.dispatch(vec![Mutation::Collapse { section: section("wrapping"), collapsed: true }]).unwrap();
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let reply = raw
|
||||
.content
|
||||
.dispatch(vec![
|
||||
Mutation::Collapse { section: section("wrapping"), collapsed: false },
|
||||
Mutation::Focus { instance: id("wrapping-editor") },
|
||||
])
|
||||
.unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
|
||||
assert!(trace.borrow().editors.last().unwrap().3.contains("retained selection"));
|
||||
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text("!".into())], false, |_, _| {});
|
||||
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, expected, "typing must replace the restored selection, not insert beside it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_header_action_reports_enabled_and_pressed_state_from_retained_presentation() {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.log", "log", "log-section", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, 1.0);
|
||||
raw.frame(Vec::new(), false);
|
||||
let (frame, output) = raw.frame(Vec::new(), false);
|
||||
let target = HostControlTarget::Pointer(HostPointerTarget::HeaderAction { instance: id("log"), action: ControlId::new("pulse").unwrap() });
|
||||
let action = frame.controls.iter().find(|control| control.target == target).unwrap();
|
||||
let node = &output.platform_output.accesskit_update.as_ref().unwrap().nodes.iter().find(|(id, _)| *id == action.id.accesskit_id()).unwrap().1;
|
||||
assert_eq!(node.role(), egui::accesskit::Role::Button);
|
||||
assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True));
|
||||
assert!(!node.is_disabled());
|
||||
trace.borrow_mut().disabled = true;
|
||||
let (frame, output) = raw.frame(Vec::new(), false);
|
||||
let action = frame.controls.iter().find(|control| control.target == target).unwrap();
|
||||
assert!(!action.enabled);
|
||||
let node = &output.platform_output.accesskit_update.as_ref().unwrap().nodes.iter().find(|(id, _)| *id == action.id.accesskit_id()).unwrap().1;
|
||||
assert!(node.is_disabled());
|
||||
assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::False));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_content_response_cannot_restore_old_focus_but_live_response_keeps_its_caret() {
|
||||
for scale in [1.0, 2.0] {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
let editor = trace.borrow().editors.last().unwrap().clone();
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
|
||||
raw.frame(Vec::new(), false);
|
||||
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text(" keep caret".into())], false, |_, _| {});
|
||||
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
|
||||
raw.frame(Vec::new(), false);
|
||||
let mut state = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
|
||||
state.store(&raw.ctx, editor.0);
|
||||
raw.frame(Vec::new(), false);
|
||||
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
|
||||
trace.borrow_mut().omit_editor = true;
|
||||
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.frame_late(Vec::new(), false, |_, ctx| {
|
||||
assert!(ctx.read_response(editor.0).is_some(), "egui still exposes the omitted previous-pass widget");
|
||||
});
|
||||
let header = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Header { instance: id("wrapping-editor") }));
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(header.id));
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
|
||||
trace.borrow_mut().omit_editor = false;
|
||||
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_dismissal_keys_preserve_passive_content_restore_but_foreign_focus_wins() {
|
||||
for scale in [1.0, 2.0] {
|
||||
for foreign in [false, true] {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.prime_modal();
|
||||
let editor = trace.borrow().editors.last().unwrap().clone();
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
|
||||
raw.frame(Vec::new(), false);
|
||||
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text(" before modal".into())], false, |_, _| {});
|
||||
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
|
||||
raw.frame(Vec::new(), false);
|
||||
let before = trace.borrow().editors.last().unwrap().3.clone();
|
||||
let expected: String = before.chars().take(1).chain(std::iter::once('!')).chain(before.chars().skip(4)).collect();
|
||||
let mut state = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
|
||||
state.store(&raw.ctx, editor.0);
|
||||
raw.frame(Vec::new(), false);
|
||||
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
|
||||
raw.late_modal = true;
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
|
||||
raw.late_modal = false;
|
||||
if foreign {
|
||||
raw.foreign_cover = Some(egui::Rect::from_min_size(egui::pos2(900.0, 600.0), egui::vec2(200.0, 100.0)));
|
||||
raw.foreign_takes_focus = true;
|
||||
}
|
||||
raw.frame(vec![host_key(egui::Key::Escape, false), host_key(egui::Key::Enter, true)], false);
|
||||
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
|
||||
raw.frame(Vec::new(), false);
|
||||
if foreign {
|
||||
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
|
||||
raw.foreign_takes_focus = false;
|
||||
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.frame(Vec::new(), false);
|
||||
}
|
||||
assert_eq!(
|
||||
raw.ctx.memory(|memory| memory.focused()),
|
||||
Some(editor.0),
|
||||
"an explicit panel focus request can intentionally leave foreign focus"
|
||||
);
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
|
||||
assert!(!caret.unwrap().is_empty());
|
||||
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text("!".into())], false, |_, _| {});
|
||||
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, expected, "modal return must retain replacement selection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_modal_reentry_surrenders_content_focused_after_mount() {
|
||||
for scale in [1.0, 2.0] {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.prime_modal();
|
||||
let editor = trace.borrow().editors.last().unwrap().clone();
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
|
||||
raw.frame(Vec::new(), false);
|
||||
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
|
||||
raw.late_modal = true;
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
|
||||
raw.late_modal = false;
|
||||
raw.frame(Vec::new(), false);
|
||||
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.late_modal = true;
|
||||
raw.frame_late(Vec::new(), false, |_, ctx| {
|
||||
assert_eq!(
|
||||
ctx.memory(|memory| memory.focused()),
|
||||
Some(editor.0),
|
||||
"the actual content target receives focus after mount before the late modal is finalized"
|
||||
);
|
||||
});
|
||||
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0), "the late modal must surrender newly restored content focus too");
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(raw.content.commands.calls, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn production_content_harness(combined_history: bool, assistant: bool) -> crate::scenario::Harness {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.ui.show_browser = true;
|
||||
h.app.ui.show_timeline = true;
|
||||
h.app.preferences.timeline_left = false;
|
||||
h.app.preferences.history_in_browser = combined_history;
|
||||
h.app.preferences.ai_features = assistant;
|
||||
h.app.agent_chat.open = assistant;
|
||||
for name in ["First", "Second"] {
|
||||
h.app.run("sketch.create", serde_json::json!({"plane":"XY","name":name})).unwrap();
|
||||
h.app.run("sketch.finish", serde_json::json!({})).unwrap();
|
||||
}
|
||||
h.app.preferences.shared_panel_layout = true;
|
||||
super::production::enable_production(&mut h.app).unwrap();
|
||||
h.frames(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn production_controller_keys(app: &crate::SolveApp) -> Vec<ControllerKey> {
|
||||
let runtime = app.panel_runtime().unwrap();
|
||||
["browser", "history", "assistant", "render-browser"]
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let instance = id(name);
|
||||
let owner = runtime.instance_owner(&instance).unwrap();
|
||||
let key = ControllerKey { instance, owner };
|
||||
runtime.state(&key).map(|_| key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_browser_and_history_mount_existing_content_once_and_preserve_combined_history() {
|
||||
for combined in [false, true] {
|
||||
let h = production_content_harness(combined, false);
|
||||
let frame = h.app.universal_panel_frame().expect("real content uses the shared host");
|
||||
assert!(frame.layout.sections.iter().any(|card| card.instance == id("browser")));
|
||||
assert_eq!(frame.layout.sections.iter().any(|card| card.instance == id("history")), !combined);
|
||||
let rows = crate::browser::drawn_rows();
|
||||
for name in ["First", "Second"] {
|
||||
assert_eq!(rows.iter().filter(|row| row.0 == name).count(), 1, "{name} has one canonical Browser presentation");
|
||||
assert_eq!(crate::scenario::handle_at(&format!("timeline:{name}")).is_some(), !combined);
|
||||
}
|
||||
if combined {
|
||||
assert!(rows.iter().any(|row| row.0 == crate::i18n::tr("ui.history_title")), "combined History keeps its real Browser folder");
|
||||
} else {
|
||||
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
|
||||
assert!(history.inline, "Bottom History uses the generic one-row composition");
|
||||
let body = egui_rect(history.inline_content.unwrap());
|
||||
let first = crate::scenario::handle_at("timeline:First").unwrap();
|
||||
assert!(body.contains(first), "the real feature chips are inside the shared inline slot");
|
||||
}
|
||||
assert!(h.app.panel_runtime().unwrap().update_errors().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_tab_collapse_and_move_keep_real_selection_document_camera_and_retained_owners() {
|
||||
let mut h = production_content_harness(false, false);
|
||||
let feature = h.app.session.doc.features[0].id;
|
||||
h.app.run("select.set", serde_json::json!({"items":[solidcraft_engine::Sel::Feature { id: feature }]})).unwrap();
|
||||
h.frames(2);
|
||||
let session = h.app.session.uid;
|
||||
let document = h.app.session.doc.clone();
|
||||
let model = h.app.session.model.state();
|
||||
let camera = h.app.cam;
|
||||
let selection = h.app.session.selection.clone();
|
||||
let revision = h.app.session.revision;
|
||||
let undo = h.app.session.undo.len();
|
||||
let dirty = h.app.session.is_dirty();
|
||||
let keys = production_controller_keys(&h.app);
|
||||
let controllers = h.app.panel_runtime().unwrap().controller_count();
|
||||
let browser_section = h.app.panel_runtime().unwrap().layout().layout().containing(&id("browser")).unwrap().id.clone();
|
||||
h.app
|
||||
.dispatch_panel_layout(transaction(vec![
|
||||
Mutation::Move { instance: id("history"), destination: Destination::JoinTab { section: browser_section.clone(), index: 1 } },
|
||||
Mutation::Activate { instance: id("history") },
|
||||
]))
|
||||
.unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::browser::drawn_rows().is_empty(), "an inactive Browser does not render a hidden body");
|
||||
assert!(crate::scenario::handle_at("timeline:First").is_some(), "History switches to its existing side rows");
|
||||
h.app.dispatch_panel_layout(transaction(vec![Mutation::Activate { instance: id("browser") }])).unwrap();
|
||||
h.frames(2);
|
||||
assert!(crate::browser::drawn_rows().iter().any(|row| row.0 == "First"));
|
||||
for collapsed in [true, false] {
|
||||
h.app.dispatch_panel_layout(transaction(vec![Mutation::Collapse { section: browser_section.clone(), collapsed }])).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(crate::browser::drawn_rows().is_empty(), collapsed, "collapse controls real body work");
|
||||
}
|
||||
h.app
|
||||
.dispatch_panel_layout(transaction(vec![Mutation::Move {
|
||||
instance: id("browser"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("moved-browser"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Right },
|
||||
size: Size { width: 300.0, height: 320.0 },
|
||||
},
|
||||
}]))
|
||||
.unwrap();
|
||||
h.frames(3);
|
||||
let card = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.instance == id("browser")).unwrap();
|
||||
assert_eq!(card.zone, Zone::Right);
|
||||
assert!(crate::browser::drawn_rows().iter().any(|row| row.0 == "First"));
|
||||
assert_eq!(production_controller_keys(&h.app), keys);
|
||||
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
|
||||
assert_eq!(h.app.session.uid, session);
|
||||
assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &document));
|
||||
assert!(std::sync::Arc::ptr_eq(&h.app.session.model.state(), &model));
|
||||
assert_eq!(h.app.cam, camera);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.is_dirty(), dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_browser_and_assistant_refuse_bottom_without_changing_live_content() {
|
||||
let mut h = production_content_harness(false, true);
|
||||
h.app.agent_chat.input = "keep this unsent draft".into();
|
||||
let document = h.app.session.doc.clone();
|
||||
let camera = h.app.cam;
|
||||
let controllers = h.app.panel_runtime().unwrap().controller_count();
|
||||
for instance in ["browser", "assistant"] {
|
||||
let before = h.app.panel_runtime().unwrap().layout().inspect();
|
||||
let result = h.app.dispatch_panel_layout(transaction(vec![Mutation::Move {
|
||||
instance: id(instance),
|
||||
destination: Destination::NewSection {
|
||||
section: section("forbidden-bottom"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Bottom },
|
||||
size: Size { width: 400.0, height: 120.0 },
|
||||
},
|
||||
}]));
|
||||
assert_eq!(result.unwrap_err().rule, Rule::Placement, "only product History permits Bottom");
|
||||
assert_eq!(h.app.panel_runtime().unwrap().layout().inspect(), before);
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.agent_chat.input, "keep this unsent draft");
|
||||
assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &document));
|
||||
assert_eq!(h.app.cam, camera);
|
||||
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_setting_cancel_preserves_classic_and_apply_enables_the_real_host() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
assert!(!h.app.preferences.shared_panel_layout);
|
||||
assert!(h.app.panel_runtime().is_none());
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"open":"Layout"})).unwrap();
|
||||
h.app.preferences.shared_panel_layout = true;
|
||||
h.frames(2);
|
||||
assert!(h.app.panel_runtime().is_none(), "an unapplied checkbox cannot replace Classic during the Preferences preview");
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"cancel"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!h.app.preferences.shared_panel_layout);
|
||||
assert!(h.app.panel_runtime().is_none());
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"open":"Layout"})).unwrap();
|
||||
h.app.preferences.shared_panel_layout = true;
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"apply"})).unwrap();
|
||||
assert!(h.app.prefs_window.open, "Apply keeps Preferences open");
|
||||
assert!(h.app.panel_runtime().is_some(), "Apply accepts the production-host preference");
|
||||
h.app.preferences.shared_panel_layout = false;
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"cancel"})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.app.preferences.shared_panel_layout, "Cancel restores the last applied setting");
|
||||
assert!(h.app.universal_panel_frame().is_some());
|
||||
let saved = h.app.prefs();
|
||||
let mut restored = crate::SolveApp::new(Default::default(), Default::default());
|
||||
restored.load_prefs(&saved);
|
||||
assert!(restored.preferences.shared_panel_layout, "the opt-in setting survives the existing preference envelope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_assistant_actions_use_the_existing_confirmation_and_close_retains_its_draft_and_queue() {
|
||||
let mut h = production_content_harness(false, true);
|
||||
h.app.agent_chat.input = "unsent composer draft".into();
|
||||
h.app.agent_chat.items.push(crate::agent_chat::Item::Agent { thought: false, text: "retained transcript".into() });
|
||||
h.app.agent_chat.pending.push(crate::agent_chat::Pending {
|
||||
text: "queued instruction".into(),
|
||||
image: false,
|
||||
context: Default::default(),
|
||||
files: Vec::new(),
|
||||
});
|
||||
let pending = h.app.agent_chat.pending.clone();
|
||||
let state = h.app.agent_chat.state();
|
||||
let keys = production_controller_keys(&h.app);
|
||||
let controllers = h.app.panel_runtime().unwrap().controller_count();
|
||||
h.app.panel_content_action(&id("assistant"), &ControlId::new("new-chat").unwrap()).unwrap();
|
||||
super::production::drain_production_actions(&mut h.app).unwrap();
|
||||
assert!(h.app.agent_chat.confirm_new, "the real New Chat confirmation protects queued instructions");
|
||||
assert_eq!(h.app.agent_chat.items.len(), 1);
|
||||
assert_eq!(h.app.agent_chat.pending, pending);
|
||||
h.app.agent_chat.confirm_new = false;
|
||||
h.frames(2);
|
||||
h.app.dispatch_panel_layout(transaction(vec![Mutation::Close { instance: id("assistant") }])).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!h.app.universal_panel_frame().unwrap().layout.sections.iter().any(|card| card.instance == id("assistant")));
|
||||
assert_eq!(h.app.agent_chat.input, "unsent composer draft");
|
||||
assert_eq!(h.app.agent_chat.pending, pending);
|
||||
assert_eq!(h.app.agent_chat.items.len(), 1);
|
||||
assert_eq!(h.app.agent_chat.state(), state, "closing presentation does not restart or cancel the retained chat service");
|
||||
h.app.start("ui.assistant");
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.panel_rect.is_some(), "the existing Assistant button reopens the shared body after quick close");
|
||||
let section = h.app.panel_runtime().unwrap().layout().layout().containing(&id("assistant")).unwrap().id.clone();
|
||||
h.app.dispatch_panel_layout(transaction(vec![Mutation::Collapse { section: section.clone(), collapsed: true }])).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.panel_rect.is_none());
|
||||
crate::agent_chat::toggle(&mut h.app);
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.panel_rect.is_some(), "the keyboard toggle expands a shared collapsed Assistant");
|
||||
h.app.dispatch_panel_layout(transaction(vec![Mutation::Collapse { section, collapsed: true }])).unwrap();
|
||||
h.app.dispatch_panel_layout(transaction(vec![Mutation::Close { instance: id("assistant") }])).unwrap();
|
||||
h.frames(2);
|
||||
h.app.start("ui.assistant");
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.panel_rect.is_some(), "opening a panel remembered as collapsed expands it atomically");
|
||||
h.app.start("ui.assistant");
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.panel_rect.is_none(), "the existing Assistant button closes an expanded shared Assistant");
|
||||
crate::agent_chat::toggle(&mut h.app);
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.panel_rect.is_some());
|
||||
assert_eq!(h.app.agent_chat.input, "unsent composer draft");
|
||||
assert_eq!(h.app.agent_chat.pending, pending);
|
||||
assert_eq!(production_controller_keys(&h.app), keys);
|
||||
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
|
||||
h.app.agent_chat.pending.clear();
|
||||
h.app.panel_content_action(&id("assistant"), &ControlId::new("new-chat").unwrap()).unwrap();
|
||||
super::production::drain_production_actions(&mut h.app).unwrap();
|
||||
assert!(!h.app.agent_chat.confirm_new);
|
||||
assert!(h.app.agent_chat.items.is_empty(), "the same New Chat action clears the real transcript when no confirmation is needed");
|
||||
assert_eq!(h.app.agent_chat.input, "unsent composer draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_render_browser_reuses_real_lens_search_and_retains_explicit_layout_workspace() {
|
||||
let mut h = production_content_harness(false, false);
|
||||
let workspace = h.app.panel_runtime().unwrap().layout().layout().workspace.clone();
|
||||
let document = h.app.session.doc.clone();
|
||||
let camera = h.app.cam;
|
||||
h.app.ui.tab = "RENDER".into();
|
||||
h.frames(3);
|
||||
let frame = h.app.universal_panel_frame().unwrap();
|
||||
assert!(frame.layout.sections.iter().any(|card| card.instance == id("render-browser")));
|
||||
assert!(!frame.layout.sections.iter().any(|card| card.instance == id("browser")));
|
||||
h.app.panel_content_action(&id("render-browser"), &ControlId::new("by-look").unwrap()).unwrap();
|
||||
super::production::drain_production_actions(&mut h.app).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.render_workspace.view.by_look);
|
||||
h.app.panel_content_action(&id("render-browser"), &ControlId::new("search").unwrap()).unwrap();
|
||||
super::production::drain_production_actions(&mut h.app).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.render_workspace.view.search_objects);
|
||||
let search = h.app.panel_runtime().unwrap().instance_presentation(&id("render-browser")).unwrap();
|
||||
assert_eq!(search.actions.iter().find(|action| action.id.0 == "search").unwrap().pressed, Some(true));
|
||||
assert_eq!(h.app.panel_runtime().unwrap().layout().layout().workspace, workspace, "changing ribbon tabs never selects a layout workspace");
|
||||
assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &document));
|
||||
assert_eq!(h.app.cam, camera);
|
||||
h.app.ui.tab = "SOLID".into();
|
||||
h.frames(2);
|
||||
assert!(h.app.universal_panel_frame().unwrap().layout.sections.iter().any(|card| card.instance == id("browser")));
|
||||
h.app.ui.tab = "RENDER".into();
|
||||
h.frames(2);
|
||||
assert!(h.app.render_workspace.view.by_look && h.app.render_workspace.view.search_objects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_short_render_assistant_keeps_conversation_above_the_pinned_composer() {
|
||||
let mut h = production_content_harness(false, true);
|
||||
h.call("ui.resize", serde_json::json!({"width":1600,"height":720}));
|
||||
h.app.ui.tab = "RENDER".into();
|
||||
for draft in [String::new(), "Retained panel draft\n".repeat(40)] {
|
||||
h.app.agent_chat.input = draft.clone();
|
||||
h.frames(8);
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
assert!(conversation.height() < 64.0, "exercise the short viewport that formerly expanded over the composer");
|
||||
assert!(
|
||||
!conversation.is_positive() || conversation.bottom() <= composer.top() + 1.0,
|
||||
"a short transcript viewport must not paint or scroll over the composer: draft bytes={}, conversation={conversation:?}, composer={composer:?}",
|
||||
draft.len()
|
||||
);
|
||||
let body = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.instance == id("assistant")).unwrap().body.unwrap();
|
||||
assert!(composer.bottom() <= body.origin.y + body.size.height + 1.0, "the full footer remains inside the shared content slot");
|
||||
assert!(crate::scenario::rect_of("agent_chat:input").is_some());
|
||||
assert_eq!(h.app.agent_chat.input, draft);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sole_content_tracks_real_tree_folds_and_stops_at_column_capacity() {
|
||||
let mut h = production_content_harness(false, true);
|
||||
h.frames(6);
|
||||
let frame = h.app.universal_panel_frame().unwrap();
|
||||
let browser = frame.layout.sections.iter().find(|card| card.instance == id("browser")).unwrap();
|
||||
let original_height = browser.outer.size.height;
|
||||
assert!(original_height < frame.layout.capacity.size.height - 20.0);
|
||||
let assistant = frame.layout.sections.iter().find(|card| card.instance == id("assistant")).unwrap();
|
||||
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
|
||||
let available = history.outer.origin.y - frame.layout.metrics.gap - assistant.outer.origin.y;
|
||||
assert!((assistant.outer.size.height - available).abs() < 1.0, "Assistant fills its available column height even with an empty conversation");
|
||||
let root = crate::browser::drawn_rows().first().unwrap().0.clone();
|
||||
let point = h.call("ui.at", serde_json::json!({"chevron":root}));
|
||||
h.call("ui.click", serde_json::json!({"x":point["result"][0],"y":point["result"][1]}));
|
||||
h.frames(6);
|
||||
let folded = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.instance == id("browser")).unwrap().outer.size.height;
|
||||
assert!(folded < original_height - 20.0, "folding real content releases the unused space");
|
||||
let point = h.call("ui.at", serde_json::json!({"chevron":root}));
|
||||
h.call("ui.click", serde_json::json!({"x":point["result"][0],"y":point["result"][1]}));
|
||||
h.call("ui.resize", serde_json::json!({"width":1600,"height":350}));
|
||||
h.frames(6);
|
||||
let frame = h.app.universal_panel_frame().unwrap();
|
||||
let browser = frame.layout.sections.iter().find(|card| card.instance == id("browser")).unwrap();
|
||||
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
|
||||
let available = history.outer.origin.y - frame.layout.metrics.gap - browser.outer.origin.y;
|
||||
assert!((browser.outer.size.height - available).abs() < 1.0, "long content is capped above Bottom History and its gap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_bottom_history_has_fixed_height_without_collapse_or_height_changes() {
|
||||
let mut h = production_content_harness(false, false);
|
||||
h.frames(6);
|
||||
let frame = h.app.universal_panel_frame().unwrap();
|
||||
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
|
||||
let spec = h.app.panel_runtime().unwrap().layout().registry().get(&PanelKind::new("solidcraft.history").unwrap()).unwrap();
|
||||
assert_eq!(history.outer.size.height, spec.fixed_bottom_height.unwrap());
|
||||
assert!(history.collapse.is_none());
|
||||
let before = h.app.panel_runtime().unwrap().layout().inspect();
|
||||
assert!(h.app.run_panel_command("panel.collapse", &serde_json::json!({"target":{"type":"instance","id":"history"},"collapsed":true})).is_err());
|
||||
assert!(
|
||||
h.app
|
||||
.run_panel_command(
|
||||
"panel.resize",
|
||||
&serde_json::json!({"target":{"type":"instance","id":"history"},"size":{"width":760,"height":100},"thickness_only":true})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(h.app.panel_runtime().unwrap().layout().inspect(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_fixed_bottom_history_ignores_a_legacy_collapsed_preference() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.ui.show_timeline = true;
|
||||
h.app.preferences.timeline_left = false;
|
||||
h.app.preferences.timeline_collapsed = true;
|
||||
h.app.preferences.shared_panel_layout = true;
|
||||
super::production::enable_production(&mut h.app).unwrap();
|
||||
h.frames(6);
|
||||
let section = h.app.panel_runtime().unwrap().layout().layout().containing(&id("history")).unwrap();
|
||||
assert_eq!(section.location.zone(), Zone::Bottom);
|
||||
assert!(!section.collapsed);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1298,8 +1298,6 @@ preference_settings! {
|
||||
language: String = "system".into(), 0;
|
||||
/// Shared workspace composition; system windows and popups retain their purpose.
|
||||
layout: crate::shell::LayoutMode = Default::default(), PAGE_LAYOUT;
|
||||
/// Mount existing docked content in the shared layout; Classic stays available.
|
||||
shared_panel_layout: bool = false, PAGE_LAYOUT;
|
||||
/// Ordered view controls and their appearance.
|
||||
#[serde(default)]
|
||||
navigation_bar: crate::navigation_bar::NavigationBar = Default::default(), PAGE_LAYOUT;
|
||||
@@ -1915,7 +1913,6 @@ struct Snapshot {
|
||||
agent: crate::agent_cursor::Settings,
|
||||
autosave: f64,
|
||||
auto_project: bool,
|
||||
project_construction: bool,
|
||||
keys: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// What a settings profile also switches: the toolbar, the S box favourites and which profile is active.
|
||||
toolbar: serde_json::Value,
|
||||
@@ -1944,7 +1941,6 @@ impl Snapshot {
|
||||
agent: app.ui.agent_cursor,
|
||||
autosave: app.autosave_minutes,
|
||||
auto_project: app.session.auto_project,
|
||||
project_construction: app.session.project_construction,
|
||||
keys: app.keymap.custom.clone(),
|
||||
toolbar: app.toolbar_custom.prefs(),
|
||||
favourites: app.sbox.pinned.clone(),
|
||||
@@ -1976,7 +1972,6 @@ impl Snapshot {
|
||||
app.autosave_minutes = self.autosave;
|
||||
set_autosave(app);
|
||||
app.session.auto_project = self.auto_project;
|
||||
app.session.project_construction = self.project_construction;
|
||||
app.keymap.custom = self.keys;
|
||||
app.toolbar_custom.load(&self.toolbar);
|
||||
app.sbox.pinned = self.favourites;
|
||||
@@ -2006,7 +2001,6 @@ impl Snapshot {
|
||||
agent,
|
||||
autosave,
|
||||
auto_project,
|
||||
project_construction,
|
||||
keys,
|
||||
toolbar,
|
||||
favourites,
|
||||
@@ -2027,7 +2021,6 @@ impl Snapshot {
|
||||
&& *agent == app.ui.agent_cursor
|
||||
&& *autosave == app.autosave_minutes
|
||||
&& *auto_project == app.session.auto_project
|
||||
&& *project_construction == app.session.project_construction
|
||||
&& *keys == app.keymap.custom
|
||||
&& *toolbar == app.toolbar_custom.prefs()
|
||||
&& *favourites == app.sbox.pinned
|
||||
@@ -2079,7 +2072,6 @@ pub(crate) fn held_overlay(app: &SolveApp) -> Option<serde_json::Map<String, ser
|
||||
("perspective", serde_json::json!(s.perspective)),
|
||||
("pick_bodies", serde_json::json!(s.pick_bodies)),
|
||||
("auto_project", serde_json::json!(s.auto_project)),
|
||||
("project_construction", serde_json::json!(s.project_construction)),
|
||||
("visual_style", serde_json::json!(s.style)),
|
||||
("shortcut_box", shortcut_box),
|
||||
("shortcuts", serde_json::Value::Object(shortcuts)),
|
||||
@@ -2273,7 +2265,6 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
}
|
||||
PAGE_SKETCH => {
|
||||
app.session.auto_project = true;
|
||||
app.session.project_construction = false;
|
||||
app.ui.pick_bodies = false;
|
||||
}
|
||||
_ => {}
|
||||
@@ -2812,10 +2803,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
match button {
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some("apply") if app.prefs_window.panel_restore_error.is_none() => match crate::panel_runtime::sync_production_setting(app) {
|
||||
Ok(()) => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Err(reason) => app.prefs_window.panel_restore_error = Some(solidcraft_engine::panels::commands::error_text(reason)),
|
||||
},
|
||||
Some("apply") if app.prefs_window.panel_restore_error.is_none() => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
_ if closed => close(app, true),
|
||||
@@ -3329,10 +3317,7 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::
|
||||
match button.as_ref().and_then(|b| b.as_str()) {
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
Some("apply") if app.prefs_window.panel_restore_error.is_none() => {
|
||||
crate::panel_runtime::sync_production_setting(app).map_err(solidcraft_engine::panels::commands::error_text)?;
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
Some("apply") if app.prefs_window.panel_restore_error.is_none() => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("apply") => {}
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")),
|
||||
@@ -4305,33 +4290,6 @@ pub(crate) fn reveal_preference(h: &mut crate::scenario::Harness, handle: &str)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn invalid_legacy_panel_preferences_recover_before_shared_host_setup() {
|
||||
let mut app = SolveApp::new(Default::default(), Default::default());
|
||||
app.load_prefs(
|
||||
&json!({"preferences":{"browser_width":0.0,"timeline_left_width":-1.0,
|
||||
"agent_chat_width":1e9,"render_browser_width":0.0,"browser_share":2.0}})
|
||||
.to_string(),
|
||||
);
|
||||
let defaults = Prefs::default();
|
||||
assert_eq!(app.preferences.browser_width, 0.0);
|
||||
assert_eq!(crate::browser::browser_width(app.preferences.browser_width), 170.0, "Classic keeps its existing saved-width behavior");
|
||||
app.preferences.shared_panel_layout = true;
|
||||
crate::panel_runtime::sync_production_setting(&mut app).unwrap();
|
||||
assert!(app.panel_runtime().is_some(), "malformed legacy sizes must not leave setup retrying each frame");
|
||||
let imported = crate::panel_runtime::preferences_value(&app).unwrap();
|
||||
for (name, expected) in [
|
||||
("browser_width", defaults.browser_width),
|
||||
("history_width", defaults.timeline_left_width),
|
||||
("assistant_width", defaults.agent_chat_width),
|
||||
("render_width", defaults.render_browser_width),
|
||||
("browser_share", defaults.browser_share),
|
||||
] {
|
||||
assert_eq!(imported["legacy"][name], json!(expected));
|
||||
}
|
||||
assert_eq!((app.preferences.browser_width, app.preferences.browser_share), (0.0, 2.0), "shared import never rewrites Classic preferences");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_traits_use_resolved_background_and_maximal_contrast() {
|
||||
use theme_picker::{Classification, theme_traits};
|
||||
@@ -6518,19 +6476,16 @@ mod tests {
|
||||
app.ui.agent_cursor.show = false;
|
||||
app.ui.pick_bodies = true;
|
||||
app.session.auto_project = false;
|
||||
app.session.project_construction = true;
|
||||
defaults(&mut app, PAGE_GENERAL);
|
||||
assert!(!app.ui.agent_cursor.show);
|
||||
assert!(app.ui.pick_bodies);
|
||||
assert!(!app.session.auto_project);
|
||||
assert!(app.session.project_construction);
|
||||
defaults(&mut app, PAGE_ASSISTANT);
|
||||
let expected = SolveApp::new(Default::default(), Default::default()).ui.agent_cursor.show;
|
||||
assert_eq!(app.ui.agent_cursor.show, expected);
|
||||
defaults(&mut app, PAGE_SKETCH);
|
||||
assert!(!app.ui.pick_bodies);
|
||||
assert!(app.session.auto_project);
|
||||
assert!(!app.session.project_construction, "off by default (#13142)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -66,7 +66,6 @@ pub(super) fn closed(ctx: &egui::Context) {
|
||||
|
||||
pub(super) fn show(app: &mut SolveApp, ui: &mut Ui) {
|
||||
if !crate::panel_runtime::preferences_enabled(app) {
|
||||
ui_kit::settings_section(ui, "panel_layout", app.tr("ui.panel_layout.panel_layout"), |ui| panel_system(app, ui));
|
||||
return;
|
||||
}
|
||||
let key = Id::unique(STATE);
|
||||
@@ -89,7 +88,6 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut Ui) {
|
||||
}
|
||||
ui_kit::settings_section(ui, "panel_layout", app.tr("ui.panel_layout.panel_layout"), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = Tokens::get().space_16;
|
||||
panel_system(app, ui);
|
||||
workspace_selector(app, ui, &view);
|
||||
tiles(app, ui, &view, &mut state);
|
||||
});
|
||||
@@ -99,11 +97,6 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_system(app: &mut SolveApp, ui: &mut Ui) {
|
||||
ui_kit::checkbox(ui, crate::i18n::tr("prefs.shared_panel_layout"), &mut app.preferences.shared_panel_layout)
|
||||
.backing_on_hover_text(crate::i18n::tr("prefs.shared_panel_layout_help"));
|
||||
}
|
||||
|
||||
fn workspace_selector(app: &mut SolveApp, ui: &mut Ui, view: &LayoutPreferencesView) {
|
||||
let t = Tokens::get();
|
||||
let mut workspaces: Vec<_> = view.workspaces.iter().collect();
|
||||
|
||||
@@ -96,13 +96,8 @@ fn drawing(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
"prefs.sketch.auto_locate",
|
||||
"ui.first_dimension_scales",
|
||||
];
|
||||
let right_labels = [
|
||||
"prefs.sketch.shortcut_cycles_modes",
|
||||
"prefs.ribbon_click_ends_tool",
|
||||
"ui.project_face_edges_sketch",
|
||||
"ui.project_references_construction",
|
||||
"prefs.sketch.project_options",
|
||||
];
|
||||
let right_labels =
|
||||
["prefs.sketch.shortcut_cycles_modes", "prefs.ribbon_click_ends_tool", "ui.project_face_edges_sketch", "prefs.sketch.project_options"];
|
||||
let natural = [left_labels.as_slice(), right_labels.as_slice()]
|
||||
.map(|labels| labels.iter().map(|id| check_width(ui, app.tr(id), true)).fold(0.0, f32::max));
|
||||
let available = ui.available_width();
|
||||
@@ -180,13 +175,6 @@ fn drawing(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if check(ui, "auto_project", "ui.project_face_edges_sketch", None, &mut auto).changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({"value": auto}));
|
||||
}
|
||||
check(
|
||||
ui,
|
||||
"project_construction",
|
||||
"ui.project_references_construction",
|
||||
Some("ui.project_references_construction_note"),
|
||||
&mut app.session.project_construction,
|
||||
);
|
||||
check(
|
||||
ui,
|
||||
"project_options_dialog",
|
||||
|
||||
@@ -932,58 +932,6 @@ fn press_pull_then_curved_face_offsets_it() {
|
||||
assert!(matches!(app.dialog.as_ref().unwrap().kind, Kind::Extrude { .. }));
|
||||
}
|
||||
|
||||
/// Whether an accessibility node labeled `text` is drawn inside the published rect
|
||||
/// `rect_handle` ("dialog:header" is the dialog's heading band), so a same-named widget
|
||||
/// elsewhere on screen cannot satisfy the check.
|
||||
fn label_in(h: &crate::scenario::Harness, rect_handle: &str, text: &str) -> bool {
|
||||
let Some(rect) = crate::scenario::rect_of(rect_handle) else { return false };
|
||||
let Some(tree) = h.output.accesskit_update.as_ref() else { return false };
|
||||
tree.nodes.iter().any(|(_, n)| {
|
||||
(n.label() == Some(text) || n.value() == Some(text))
|
||||
&& n.bounds().is_some_and(|b| rect.contains(egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The dialog the Q shortcut opens says Press Pull, not Extrude (#11705), with a planar face
|
||||
/// picked first or nothing at all; a plain Extrude still says Extrude.
|
||||
#[test]
|
||||
fn press_pull_dialog_is_titled_press_pull() {
|
||||
let mut app = boxed();
|
||||
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
|
||||
let d = start(&mut app, vec![top], "solid.press_pull");
|
||||
assert!(matches!(d.kind, Kind::Extrude { .. }));
|
||||
assert!(d.press_pull);
|
||||
assert_eq!(crate::dialogs::report_title(&d), "PRESS PULL");
|
||||
|
||||
let mut app = boxed();
|
||||
app.start("solid.press_pull");
|
||||
let d = app.dialog.clone().unwrap();
|
||||
assert!(matches!(d.kind, Kind::Extrude { .. }));
|
||||
assert!(d.press_pull);
|
||||
assert_eq!(crate::dialogs::report_title(&d), "PRESS PULL");
|
||||
|
||||
let mut app = boxed();
|
||||
app.start("solid.extrude");
|
||||
let d = app.dialog.clone().unwrap();
|
||||
assert!(matches!(d.kind, Kind::Extrude { .. }));
|
||||
assert!(!d.press_pull);
|
||||
assert_eq!(crate::dialogs::report_title(&d), "EXTRUDE");
|
||||
|
||||
// The heading drawn in the panel says it too, found by role and place in its band.
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
h.step(&json!({"start": "solid.press_pull"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(label_in(&h, "dialog:header", "Press Pull"), "the drawn header is Press Pull");
|
||||
assert!(!label_in(&h, "dialog:header", "Extrude"), "the drawn header is not Extrude");
|
||||
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
h.step(&json!({"start": "solid.extrude"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(label_in(&h, "dialog:header", "Extrude"), "a plain Extrude header is Extrude");
|
||||
}
|
||||
|
||||
/// Press Pull on a fillet's round face reopens the fillet to change its radius, as the
|
||||
/// command does, rather than an Offset Face that refuses it (#1932): picked first, or picked
|
||||
/// with Press Pull already open.
|
||||
|
||||
@@ -3483,11 +3483,6 @@ pub(crate) fn environment_overlay(app: &mut SolveApp, ui: &mut egui::Ui, viewpor
|
||||
|
||||
pub(crate) fn interactive_command_info(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> solidcraft_engine::CommandInfo {
|
||||
let mut info = ribbon::command_info(app, command);
|
||||
if let Some(action) = solidcraft_engine::panels::ContextualPanelAction::from_command_id(command.id) {
|
||||
info.disabled_reason = app.panel_contextual_disabled_reason(action);
|
||||
info.enabled = info.disabled_reason.is_none();
|
||||
return info;
|
||||
}
|
||||
// A drawing tool on the SKETCH tab kept shown outside a sketch starts one (#7904).
|
||||
if crate::workspace::starts_a_sketch(app, command.id) {
|
||||
info.enabled = true;
|
||||
|
||||
@@ -1356,7 +1356,6 @@ fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
|
||||
[eye[0], eye[1], eye[2], 0.0],
|
||||
SIZE,
|
||||
srgb,
|
||||
None,
|
||||
) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
@@ -1388,65 +1387,6 @@ fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn product_preview_lights_decode_simple_colours_as_the_cpu_image_does() {
|
||||
use solidcraft_engine::render::StandardView;
|
||||
// The CPU image's transfer (`job::image` decode/encode, `studio::tone_map`).
|
||||
let decode = |s: f64| if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) };
|
||||
let encode = |l: f64| if l <= 0.0031308 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 };
|
||||
// One uniform light of radiance π: every lit pixel is the albedo itself, whatever its normal.
|
||||
let mut light = [0.0_f32; 16];
|
||||
light[3] = 4.0;
|
||||
light[4..7].fill(std::f32::consts::PI);
|
||||
const SIZE: u32 = 64;
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.sphere", &json!({"radius":10,"body_name":"Sphere"})).unwrap();
|
||||
// Black, both sides of the decoder's 0.04045 knee (#0a = 0.0392, #0b = 0.0431), and mid-tones.
|
||||
for hex in ["000000", "050505", "0a0a0a", "0b0b0b", "202020", "808080", "b47846"] {
|
||||
s.execute("appearance.assign", &json!({"bodies":["Sphere"],"color":format!("#{hex}")})).unwrap();
|
||||
let scene = solidcraft_engine::product_scene::extract(&s).unwrap();
|
||||
let (_, gpu) = gpu_scene(&scene).unwrap();
|
||||
let mut camera = Camera { fov: 0.0, ..Default::default() };
|
||||
camera.set_view(StandardView::Iso);
|
||||
camera.fit(&scene.bounds);
|
||||
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
|
||||
let eye = camera.eye().to_f32();
|
||||
let expected: Vec<u8> = (0..3)
|
||||
.map(|c| {
|
||||
let l = decode(f64::from(u8::from_str_radix(&hex[2 * c..2 * c + 2], 16).unwrap()) / 255.0);
|
||||
(encode(l / (1.0 + l)) * 255.0).round() as u8
|
||||
})
|
||||
.collect();
|
||||
for srgb in [false, true] {
|
||||
let Some(pixels) = crate::gpu::render_product_offscreen(
|
||||
gpu.clone(),
|
||||
None,
|
||||
camera.view_proj(1.0, radius).to_f32(),
|
||||
camera.back().to_f32(),
|
||||
[eye[0], eye[1], eye[2], 0.0],
|
||||
SIZE,
|
||||
srgb,
|
||||
Some(vec![light]),
|
||||
) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
// The middle of the sphere, well inside its silhouette.
|
||||
for y in SIZE / 2 - 4..SIZE / 2 + 4 {
|
||||
for x in SIZE / 2 - 4..SIZE / 2 + 4 {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
let got = &pixels[i..i + 3];
|
||||
assert!(
|
||||
got.iter().zip(&expected).all(|(a, b)| a.abs_diff(*b) <= 1),
|
||||
"#{hex} srgb={srgb}: preview {got:?}, CPU image {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_render_export_respects_upstream_file_scope() {
|
||||
let mut h = Harness::new();
|
||||
@@ -5847,7 +5787,6 @@ fn render_workspace_product_pass_draws_only_requested_face_or_body_overlay() {
|
||||
[eye[0], eye[1], eye[2], 1.0],
|
||||
256,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let Some(clean) = draw(None) else {
|
||||
|
||||
@@ -324,7 +324,7 @@ pub fn sanitize(v: &Value) -> Result<Value, String> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(id, _)| id.chars().count() <= MAX_ID)
|
||||
.filter_map(|(id, keys)| Some((id.clone(), shortcut_keys(id, keys)?)))
|
||||
.filter_map(|(id, keys)| Some((id.clone(), shortcut_keys(keys)?)))
|
||||
.take(MAX_SHORTCUTS)
|
||||
.collect();
|
||||
let favourites: Vec<String> = o.get("favourites").and_then(Value::as_array).into_iter().flatten().filter_map(text).take(MAX_FAVOURITES).collect();
|
||||
@@ -382,8 +382,8 @@ pub fn sanitize(v: &Value) -> Result<Value, String> {
|
||||
|
||||
/// A command's keys in a profile, as the keymap saves them (`""`, one key or a list of them), or
|
||||
/// `None` when they can't be read or a key name is too long.
|
||||
fn shortcut_keys(id: &str, v: &Value) -> Option<Value> {
|
||||
let keys = crate::keymap::saved_keys(id, v)?;
|
||||
fn shortcut_keys(v: &Value) -> Option<Value> {
|
||||
let keys = crate::keymap::saved_keys(v)?;
|
||||
keys.iter().all(|k| k.chars().count() <= MAX_ID).then(|| crate::keymap::keys_value(&keys))
|
||||
}
|
||||
|
||||
|
||||
@@ -14,22 +14,6 @@ fn error(h: &mut Harness, id: &str, params: Value) -> String {
|
||||
out["error"].as_str().map(str::to_string).unwrap_or_else(|| panic!("{id} should fail: {out}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_arrow_bindings_and_cleared_defaults_survive_profile_export_import_and_apply() {
|
||||
let mut h = Harness::new();
|
||||
crate::keymap::bind(&mut h.app, "panel.sash.left", "Ctrl+Left", false).unwrap();
|
||||
crate::keymap::bind(&mut h.app, "panel.sash.right", "", false).unwrap();
|
||||
run(&mut h, "settings.profile.save", json!({"name":"Panels"}));
|
||||
let profile = run(&mut h, "settings.profile.export", json!({"name":"Panels"}))["profile"].clone();
|
||||
assert_eq!(profile["shortcuts"]["panel.sash.left"], "Ctrl+Left");
|
||||
assert_eq!(profile["shortcuts"]["panel.sash.right"], "");
|
||||
crate::keymap::reset(&mut h.app, None);
|
||||
run(&mut h, "settings.profile.import", json!({"profile":profile,"name":"Imported panels"}));
|
||||
run(&mut h, "settings.profile.apply", json!({"name":"Imported panels"}));
|
||||
assert_eq!(crate::keymap::keys(&h.app, "panel.sash.left"), ["Ctrl+Left"]);
|
||||
assert!(crate::keymap::keys(&h.app, "panel.sash.right").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_holds_input_and_interface_settings_but_not_the_machine() {
|
||||
let keys: Vec<&str> = keys().collect();
|
||||
|
||||
@@ -534,11 +534,14 @@ fn actual_tabs_preset_raw_widget_drag_retains_the_inactive_editor_identity_and_d
|
||||
let target = HostControlTarget::Pointer(HostPointerTarget::Tab { instance: editor.clone() });
|
||||
let control = h.app.universal_panel_frame().unwrap().controls.iter().find(|control| control.target == target).unwrap();
|
||||
assert!(
|
||||
h.output.accesskit_update.as_ref().unwrap().nodes.iter().any(|(id, node)| *id == control.id.accesskit_id()
|
||||
&& node.role() == egui::accesskit::Role::Tab
|
||||
&& node.label() == Some(control.accessible_name.as_str())
|
||||
&& node.is_selected() == Some(false)),
|
||||
"use the actual inactive accessible Tab widget, not a reducer-only target"
|
||||
h.output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|(_, node)| node.role() == egui::accesskit::Role::Button && node.label() == Some(control.accessible_name.as_str())),
|
||||
"use an actual accessible Tab widget, not a reducer-only target"
|
||||
);
|
||||
let bounds = control.bounds;
|
||||
let card = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.section == source_id).unwrap();
|
||||
|
||||
@@ -11,17 +11,6 @@ mod palettes;
|
||||
use egui::{Color32, Stroke};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Shared paint-only panel timing. State, geometry and focus commit immediately.
|
||||
pub(crate) fn panel_motion_duration(ctx: &egui::Context, reduced: bool) -> f32 {
|
||||
let requested = ctx.global_style().animation_time;
|
||||
if reduced || !requested.is_finite() || requested <= 0.0 { 0.0 } else { requested.clamp(0.12, 0.16) }
|
||||
}
|
||||
|
||||
pub(crate) fn panel_motion_ease(progress: f32) -> f32 {
|
||||
let progress = if progress.is_finite() { progress.clamp(0.0, 1.0) } else { 1.0 };
|
||||
1.0 - (1.0 - progress).powi(3)
|
||||
}
|
||||
|
||||
/// Expanded Wide hosts compose controls and content in one total row below
|
||||
/// this logical height. Independent of pixel density and user UI scaling.
|
||||
pub const PANEL_INLINE_MAX_HEIGHT: f32 = 88.0;
|
||||
@@ -151,7 +140,7 @@ impl Surfaces {
|
||||
if progress >= 1.0 {
|
||||
return to;
|
||||
}
|
||||
let eased = panel_motion_ease(progress);
|
||||
let eased = 1.0 - (1.0 - progress).powi(3);
|
||||
Self {
|
||||
panel: oklab_blend(self.panel, to.panel, eased),
|
||||
top: oklab_blend(self.top, to.top, eased),
|
||||
@@ -1785,22 +1774,6 @@ pub fn apply(ctx: &egui::Context) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn panel_motion_is_bounded_and_reduced_or_invalid_inputs_finish_immediately() {
|
||||
let ctx = egui::Context::default();
|
||||
for (requested, expected) in [(0.01, 0.12), (0.14, 0.14), (0.8, 0.16), (0.0, 0.0), (-1.0, 0.0), (f32::NAN, 0.0), (f32::INFINITY, 0.0)] {
|
||||
ctx.global_style_mut(|style| style.animation_time = requested);
|
||||
assert_eq!(super::panel_motion_duration(&ctx, false), expected);
|
||||
assert_eq!(super::panel_motion_duration(&ctx, true), 0.0);
|
||||
}
|
||||
assert_eq!(super::panel_motion_ease(0.0), 0.0);
|
||||
assert_eq!(super::panel_motion_ease(0.5), 0.875);
|
||||
for progress in [1.0, 2.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(super::panel_motion_ease(progress), 1.0);
|
||||
}
|
||||
assert_eq!(super::panel_motion_ease(-1.0), 0.0);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tabs_state(base: &str, overrides: BTreeMap<String, crate::theme_files::OptionValue>) -> TabThemeState {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user