mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb0b795e7e | |||
| 7bd66b7c06 | |||
| 86db05e538 | |||
| d912f3533b | |||
| 9d7bbb7fa9 | |||
| 6312c2760a | |||
| 3a23d21795 | |||
| e199f9aeff | |||
| e06d6e36d8 | |||
| 9d1ad9f0b9 | |||
| d39a2d28db | |||
| 1e649653ef | |||
| 6988eb0043 | |||
| 23c2244307 | |||
| a52ee850aa | |||
| 65d23a78a5 |
@@ -0,0 +1,20 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
path_instructions:
|
||||
- path: "**"
|
||||
instructions: |
|
||||
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
|
||||
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
|
||||
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
|
||||
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
|
||||
pre_merge_checks:
|
||||
custom_checks:
|
||||
- name: "Existing defaults unchanged"
|
||||
mode: "warning"
|
||||
instructions: |
|
||||
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
|
||||
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
|
||||
+44
-44
@@ -744,22 +744,22 @@
|
||||
"ai.prefs.availability": "التوفر",
|
||||
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
|
||||
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
|
||||
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجدداً.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجددًا.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_at_start": "توصيل آخر وكيل عند البدء",
|
||||
"ai.prefs.connect_at_start_note": "يبدأ في الخلفية الوكيل الذي اتصلت به آخر مرة (أو الوكيل الافتراضي) في كل مرة يبدأ فيها SolidCraft، ليكون جاهزًا للمحادثة. لا يحدث ذلك عند بدء SolidCraft مع --control. إذا تعذّر الاتصال، يعرض المساعد «فشل الاتصال» وزر «إعادة المحاولة».",
|
||||
"ai.prefs.follow_camera": "اتباع الكاميرا",
|
||||
"ai.prefs.follow_camera": "تتبّع الكاميرا",
|
||||
"ai.prefs.follow_camera_off": "تتبّع الكاميرا التلقائي متوقف.",
|
||||
"ai.prefs.follow_camera_on": "تتجه الكاميرا نحو الرسومات التي ينشئها الوكيل.",
|
||||
"ai.prefs.intro": "وكلاء الذكاء الاصطناعي في SolidCraft.",
|
||||
"ai.prefs.make_default": "تعيين كافتراضي",
|
||||
"ai.prefs.make_default": "تعيين افتراضيًا",
|
||||
"ai.prefs.no_agents": "لا يوجد وكلاء بعد. أضف وكيلًا أو حدّث القائمة للعثور على الوكلاء المثبّتين.",
|
||||
"ai.prefs.no_saved_approvals": "لا توجد موافقات محفوظة.",
|
||||
"ai.prefs.open_at_start": "فتح المحادثة عند البدء",
|
||||
"ai.prefs.open_at_start_note": "يعرض لوحة المساعد (أو نافذتها المستقلة) في كل مرة يبدأ فيها SolidCraft. لا يحدث ذلك عند بدء SolidCraft مع --control.",
|
||||
"ai.prefs.override_all_warning": "قد يحفظ هذا الوكيل التصميم أو يكتب فوقه أو يصدّره أو يستبدله دون أن يسأل.",
|
||||
"ai.prefs.permission_all": "فعل كل شيء",
|
||||
"ai.prefs.permission_all_summary": "لا يسأل أبداً",
|
||||
"ai.prefs.permission_ask": "السؤال أولاً",
|
||||
"ai.prefs.permission_all": "تنفيذ كل شيء",
|
||||
"ai.prefs.permission_all_summary": "لا يسأل أبدًا",
|
||||
"ai.prefs.permission_ask": "السؤال أولًا",
|
||||
"ai.prefs.permission_ask_summary": "يسأل عن كل تغيير",
|
||||
"ai.prefs.permission_edits": "إجراء تعديلات",
|
||||
"ai.prefs.permission_edits_summary": "يعدّل بحرية ويسأل عن الباقي",
|
||||
@@ -1918,7 +1918,7 @@
|
||||
"joint.limit_loop": "{side} {value}: لا يمكن لحلقة مغلقة أن تنغلق بعده",
|
||||
"joint.limit_range": "{side}: لا تلامس ضمن {range}، تُرك كما كان",
|
||||
"joint.limit_turn": "{side} {value}: لا تلامس ضمن دورة واحدة",
|
||||
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا تداخلت أعمق.",
|
||||
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا ازداد تداخلها.",
|
||||
"joint.one_body": "كلا الاختيارين على {body}. اختر الثاني على قطعة أخرى.",
|
||||
"joint.one_component": "هذه القطع أجسام لمكوّن واحد: {bodies}. المفصل يحرّك مكوّنًا مقابل آخر، لذا تحتاج كل قطعة إلى مكوّن خاص بها.",
|
||||
"joint.rigid_group_one_component": "هذه الأجسام وعددها {count} كلها في مكوّن واحد (أو في التصميم نفسه): استخدم {command} أولًا.",
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"joint_move.fine": "الخطوة بعيدًا عن المحور",
|
||||
"joint_move.fixed": "لا يملك هذا الجزء محور مفصل قابلًا للحركة.",
|
||||
"joint_move.help": "تحريك — اسحب سهمًا أو قوسًا. يلغى السحب الحالي بمفتاح Esc.",
|
||||
"joint_move.legacy_hint": "إيقاف: لوحة التحريك السابقة.",
|
||||
"joint_move.legacy_hint": "عند الإيقاف: لوحة التحريك السابقة.",
|
||||
"joint_move.precision": "اسحب خارج الدليل المتقطع للخطوة الدقيقة.",
|
||||
"joint_move.rotation": "الدوران من نقطة البداية",
|
||||
"joint_move.select": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
|
||||
@@ -2240,7 +2240,7 @@
|
||||
"prefs.anti_aliasing_amd_dx12": "يستخدم تنعيم الحواف 1× لأن فحص بدء التشغيل لم يرسم شيئًا مع أخذ العينات المتعددة ونجح عند 1×. يمكنك اختيار إعداد آخر أعلاه؛ أعد التشغيل لتطبيقه إن نجح الفحص.",
|
||||
"prefs.anti_aliasing_note": "ينعّم الحواف المسننة.",
|
||||
"prefs.appearance.colour_vision_help": "يغيّر الألوان ذات الدلالة: حالات الرسم والمحاور والثنيات والتحليلات والأيقونات.",
|
||||
"prefs.appearance.desktop_only": "لسطح المكتب فقط",
|
||||
"prefs.appearance.desktop_only": "لتطبيق سطح المكتب فقط",
|
||||
"prefs.appearance.follow_help": "يعيد التحميل عندما تعيد أداة أخرى كتابة الملف.",
|
||||
"prefs.appearance.interface_scale": "مقياس الواجهة",
|
||||
"prefs.appearance.next_themes": "السمات التالية",
|
||||
@@ -2250,11 +2250,11 @@
|
||||
"prefs.appearance.theme_count": "{count} سمات",
|
||||
"prefs.appearance.theme_own": "ألوان السمة",
|
||||
"prefs.appearance.viewport_colours": "ألوان العرض ثلاثي الأبعاد",
|
||||
"prefs.appearance.viewport_logo": "منفذ العرض والشعار",
|
||||
"prefs.appearance.viewport_logo": "منطقة العرض والشعار",
|
||||
"prefs.applies_on_restart": "يُطبَّق عند إعادة التشغيل",
|
||||
"prefs.assistant": "المساعد",
|
||||
"prefs.assistant_connecting": "الاتصال",
|
||||
"prefs.assistant_follow_help": "تحرّك عروض Look At للرسم المنظور مع المؤشر.",
|
||||
"prefs.assistant_follow_help": "النظر إلى الرسومات يحرّك العرض مع المؤشر.",
|
||||
"prefs.assistant_limit": "الحد",
|
||||
"prefs.assistant_limited": "محدود",
|
||||
"prefs.assistant_pointer_speed": "سرعة المؤشر",
|
||||
@@ -2262,21 +2262,21 @@
|
||||
"prefs.assistant_seconds_aria": "الثواني",
|
||||
"prefs.assistant_use_care": "استخدم بحذر",
|
||||
"prefs.auto_operation": "اختيار العملية تلقائيًا",
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قطع، حسب ما تلامسه المادة.",
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قص، حسب ما تلامسه المادة.",
|
||||
"prefs.axes": "المحاور",
|
||||
"prefs.axis": "المحور",
|
||||
"prefs.blur_cost": "تكلفة العرض النسبية. زيادة الأشرطة تعني عادةً مزيدًا من العمل.",
|
||||
"prefs.blur_device": "غير متاح على جهاز الرسومات أو مسار العرض هذا",
|
||||
"prefs.blur_device": "غير متاح على جهاز الرسوميات أو مسار العرض هذا",
|
||||
"prefs.blur_guard": "خُفّضت جودة التمويه للحفاظ على استجابة الإطارات. لم يتغير نصف القطر.",
|
||||
"prefs.blur_off": "إيقاف",
|
||||
"prefs.blur_reduce_transparency": "معطّل بسبب إعداد تقليل الشفافية في النظام",
|
||||
"prefs.blur_software": "غير متاح مع الرسومات البرمجية (gpu-software)",
|
||||
"prefs.blur_software": "غير متاح مع الرسوميات البرمجية (gpu-software)",
|
||||
"prefs.blur_source": "غير متاح في عرض مساحة العمل هذا",
|
||||
"prefs.blur_web": "غير متاح في مسار عرض الويب هذا",
|
||||
"prefs.bodies_folders": "مجلدات الأجسام",
|
||||
"prefs.bodies_folders_always": "دائمًا",
|
||||
"prefs.bodies_folders_never": "أبدًا",
|
||||
"prefs.bodies_folders_several": "عدة",
|
||||
"prefs.bodies_folders_several": "عند التعدد",
|
||||
"prefs.bottom": "أسفل",
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
@@ -2368,7 +2368,7 @@
|
||||
"prefs.graphics_note": "وازن بين السرعة والنعومة. كل خيار يعرض أثره على عيّنة.",
|
||||
"prefs.graphics_page.current_view": "العرض الحالي",
|
||||
"prefs.graphics_page.fastest": "الأسرع",
|
||||
"prefs.graphics_page.fold_items": "طي العناصر",
|
||||
"prefs.graphics_page.fold_items": "تلخيص العناصر",
|
||||
"prefs.graphics_page.in_view": "داخل العرض",
|
||||
"prefs.graphics_page.live_details": "تفاصيل مباشرة",
|
||||
"prefs.graphics_page.own_mix": "مزيجك الخاص",
|
||||
@@ -2377,7 +2377,7 @@
|
||||
"prefs.graphics_page.restart": "تلزم إعادة التشغيل",
|
||||
"prefs.graphics_page.sharpest": "الأكثر وضوحًا",
|
||||
"prefs.graphics_page.single_surface_analysis": "تحليل سطح واحد",
|
||||
"prefs.graphics_page.status_chips": "شرائح الحالة",
|
||||
"prefs.graphics_page.status_chips": "شارات الحالة",
|
||||
"prefs.graphics_page.subtitle": "وازن بين السرعة والنعومة.",
|
||||
"prefs.graphics_page.supersampling": "التنعيم الفائق",
|
||||
"prefs.graphics_page.supersampling_adaptive": "فقط أثناء سكون العرض",
|
||||
@@ -2424,7 +2424,7 @@
|
||||
"prefs.layout_page.icons_labels": "الأيقونات + التسميات",
|
||||
"prefs.layout_page.ok_cancel": "موافق وإلغاء",
|
||||
"prefs.layout_page.on_hover": "عند مرور المؤشر",
|
||||
"prefs.layout_page.open": "فتح",
|
||||
"prefs.layout_page.open": "تُفتح",
|
||||
"prefs.layout_page.origin_bodies": "الأصل والأجسام في الأعلى",
|
||||
"prefs.layout_page.panels": "اللوحات",
|
||||
"prefs.layout_page.pinned_menus": "استبعاد الأوامر المثبتة من القوائم",
|
||||
@@ -2464,8 +2464,8 @@
|
||||
"prefs.navigation.cube_compact": "المكعب",
|
||||
"prefs.navigation.device": "الجهاز",
|
||||
"prefs.navigation.free": "حر",
|
||||
"prefs.navigation.orbit_style": "نمط الدوران",
|
||||
"prefs.navigation.radial_help": "اضغط مطولاً أو انقر سريعاً بالزر الأيمن لعرض حلقة من الأدوات.",
|
||||
"prefs.navigation.orbit_style": "نمط التدوير",
|
||||
"prefs.navigation.radial_help": "اضغط مطولًا أو انقر سريعًا بالزر الأيمن لعرض حلقة من الأدوات.",
|
||||
"prefs.navigation.turntable": "طاولة دوارة",
|
||||
"prefs.navigation_note": "كيف تحرك الفأرة ولوحة اللمس والفأرة ثلاثية الأبعاد العرض.",
|
||||
"prefs.navigator_fit": "ملاءمة النموذج بعد تغيير العرض من متصفح العرض",
|
||||
@@ -2483,7 +2483,7 @@
|
||||
"prefs.origin_plane_names_note": "الأسماء المعروضة في المستعرض والحوارات والقوائم. {top} هو XY و{front} هو XZ و{right} هو YZ، والمحور Z للأعلى. تحتفظ الأوامر والسكربتات والتصاميم المحفوظة بـ XY وXZ وYZ.",
|
||||
"prefs.overall_speed": "السرعة العامة",
|
||||
"prefs.parameter_list_rows": "صفوف قائمة المعاملات",
|
||||
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعلمات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
|
||||
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعاملات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
|
||||
"prefs.persistent_ribbon_pressed": "تمييز أوامر الشريط النشطة",
|
||||
"prefs.persistent_ribbon_pressed_help": "تمييز أوامر الشريط أثناء تفعيل تأثيراتها. أوقف هذا الخيار للاحتفاظ بالمظهر المحايد السابق ومساعدة الأوامر.",
|
||||
"prefs.perspective_fov": "مجال الرؤية",
|
||||
@@ -2552,16 +2552,16 @@
|
||||
"prefs.shortcuts_import": "استيراد",
|
||||
"prefs.shortcuts_intro": "مفاتيح كل أمر.",
|
||||
"prefs.shortcuts_not_assigned": "غير معيّن",
|
||||
"prefs.shortcuts_press": "اضغط مفتاحاً...",
|
||||
"prefs.shortcuts_press": "اضغط مفتاحًا...",
|
||||
"prefs.single_surface_analysis": "استخدام تحليل سطح واحد",
|
||||
"prefs.single_surface_analysis_help": "عند إيقاف هذا الخيار، يمكن إبقاء تظليل السطح وتحليل الاستمرارية مفعّلين معًا. عند تشغيله، يبقى آخر تحليل تم بدؤه أو تغييره فقط.",
|
||||
"prefs.sketch.align_points": "المحاذاة مع نقاط الرسم الأخرى",
|
||||
"prefs.sketch.align_points_note": "تتحاذى الخطوط أثناء الرسم مع النقاط على المحورين معًا.",
|
||||
"prefs.sketch.auto_dimensions": "إضافة كل الأبعاد للأشكال الجديدة",
|
||||
"prefs.sketch.auto_dimensions_note": "القيم المدخلة وكل بُعد يثبّت الشكل.",
|
||||
"prefs.sketch.auto_locate": "التحديد انطلاقًا من أصل الرسم",
|
||||
"prefs.sketch.auto_locate": "تحديد الموضع من أصل الرسم",
|
||||
"prefs.sketch.auto_locate_note": "مع إضافة كل الأبعاد.",
|
||||
"prefs.sketch.center_arc": "قوس المركز يتبع المؤشر",
|
||||
"prefs.sketch.center_arc": "القوس بنقطة مركز يتبع المؤشر",
|
||||
"prefs.sketch.click_selects": "النقر يحدد",
|
||||
"prefs.sketch.conflict_colour": "لون التعارض",
|
||||
"prefs.sketch.drawing": "الرسم",
|
||||
@@ -2576,7 +2576,7 @@
|
||||
"prefs.sketch.prefer_free_points": "تفضيل النقاط القابلة للتحريك عند تداخلها",
|
||||
"prefs.sketch.project_options": "السؤال عن خيارات الإسقاط أولًا",
|
||||
"prefs.sketch.recent_snap": "الالتقاط عند تقاطع دليلين",
|
||||
"prefs.sketch.select_other_hidden": "اختيار آخر يشمل الهندسة المخفية",
|
||||
"prefs.sketch.select_other_hidden": "تحديد آخر يشمل الهندسة المخفية",
|
||||
"prefs.sketch.shortcut_cycles_modes": "الاختصار مجددًا يبدّل الوضع",
|
||||
"prefs.sketch.shortcut_cycles_modes_tip": "عند الإيقاف: تبدأ الأداة مجددًا.",
|
||||
"prefs.sketch.snapping_guides": "الالتقاط والأدلة",
|
||||
@@ -2699,7 +2699,7 @@
|
||||
"prefs.validation.each_step": "بعد كل خطوة",
|
||||
"prefs.validation.label": "فحص النواة الكامل",
|
||||
"prefs.validation.off": "متوقف (تجريبي)",
|
||||
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية سحب ودمج وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
|
||||
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية كنس وعملية منطقية وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
|
||||
"prefs.value_boxes_in_view": "إظهار القيم بجانب مقابضها",
|
||||
"prefs.value_boxes_in_view_tip": "مسافة البثق ونصف قطر التدوير... في مربع داخل العرض.",
|
||||
"prefs.view": "سلوك العرض",
|
||||
@@ -4214,13 +4214,13 @@
|
||||
"thread.automatic_size": "حجم تلقائي",
|
||||
"thread.chamfer_depth_needed": "مطلوب لهذا الشكل",
|
||||
"thread.designation": "التسمية",
|
||||
"thread.drill_range": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_custom": "المثقاب هو القطر الأكبر ناقص الخطوة، {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_taper": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_taper": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.end_square": "قائمة",
|
||||
"thread.fade_short": "1.25 P (ISO 3508)",
|
||||
"thread.fade_standard": "2.5 P (ISO 3508)",
|
||||
"thread.fits_the_face": "يناسب الوجه، مثلاً {example}",
|
||||
"thread.fits_the_face": "يناسب الوجه، مثلًا {example}",
|
||||
"thread.form_bspp": "سن أنابيب G (ISO 228-1)",
|
||||
"thread.form_bspt": "سن أنابيب مخروطي R، Rc (ISO 7-1)",
|
||||
"thread.form_npt": "سن أنابيب مخروطي NPT (ASME B1.20.1)",
|
||||
@@ -4477,7 +4477,7 @@
|
||||
"ui.agent_chat_no_images": "هذا الوكيل لا يقبل الصور",
|
||||
"ui.agent_chat_no_picture": "لا توجد صورة في الحافظة",
|
||||
"ui.agent_chat_no_tool_call": "لم يستخدم النموذج أدوات SolidCraft، لذا لم يتغيّر التصميم. اختر نموذجًا يدعم استدعاء الأدوات؛ راجع «النماذج المحلية» في دليل محادثة الوكيل.",
|
||||
"ui.agent_chat_node_too_old": "إصدار Node.js {version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط على تحديث.",
|
||||
"ui.agent_chat_node_too_old": "إصدار Node.js {version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط تحديث.",
|
||||
"ui.agent_chat_none_found": "لم يُعثر على وكيل برمجي. ثبّت أحد هذه ثم اضغط تحديث:",
|
||||
"ui.agent_chat_none_found_short": "لم يُعثر على وكيل",
|
||||
"ui.agent_chat_not_installed": "غير مثبت",
|
||||
@@ -4518,7 +4518,7 @@
|
||||
"ui.agent_chat_send_now_tip": "إيقاف خطوة الوكيل الحالية وإرسال هذه بعدها (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "يتواصل SolidCraft مع وكيل برمجة مثبّت على هذا الحاسوب. اختر واحدًا من التفضيلات › الوكيل.",
|
||||
"ui.agent_chat_setup_title": "إعداد مساعد",
|
||||
"ui.agent_chat_side": "إرساء المحادثة",
|
||||
"ui.agent_chat_side": "تثبيت المحادثة",
|
||||
"ui.agent_chat_signed_in": "سجّل الدخول",
|
||||
"ui.agent_chat_state_auth": "يلزم تسجيل الدخول",
|
||||
"ui.agent_chat_state_busy": "جارٍ العمل…",
|
||||
@@ -4583,10 +4583,10 @@
|
||||
"ui.ai_key_note": "يُخزّن في سلسلة مفاتيح النظام، وليس في إعدادات SolidCraft أبدًا. اتركه فارغًا لخادم محلي.",
|
||||
"ui.ai_keychain_error": "رفضت سلسلة مفاتيح النظام: {error}",
|
||||
"ui.ai_kind": "النوع",
|
||||
"ui.ai_mode_all": "فعل كل شيء",
|
||||
"ui.ai_mode_all": "تنفيذ كل شيء",
|
||||
"ui.ai_mode_all_note": "لا شيء يسأل أولًا. تبقى تقارير الأعطال محظورة وتظل حدود المجلدات سارية.",
|
||||
"ui.ai_mode_all_warning": "يمكن للوكيل حفظ تصميمك والكتابة فوقه وتصديره واستبداله دون سؤال. استخدم هذا فقط مع وكيل تثق به، وعلى تصاميم حفظتها.",
|
||||
"ui.ai_mode_ask": "السؤال أولاً",
|
||||
"ui.ai_mode_ask": "السؤال أولًا",
|
||||
"ui.ai_mode_ask_note": "كل تغيير في التصميم وكل إجراء على الملفات ينتظر سماحك.",
|
||||
"ui.ai_mode_edits": "إجراء تعديلات",
|
||||
"ui.ai_mode_edits_note": "أوامر النمذجة تعمل دون سؤال؛ أما الحفظ والفتح والتصدير وإجراءات الملفات أو الإجراءات الهدّامة الأخرى فتسأل.",
|
||||
@@ -4757,21 +4757,21 @@
|
||||
"ui.construction.ribbon_label": "خطوط مساعدة",
|
||||
"ui.construction.side_hint": "خط إنشائي: انقر الجهة التي يمتد نحوها الخط؛ انقر بزر الفأرة الأيمن للبدء من جديد",
|
||||
"ui.context_menu": "القائمة السياقية",
|
||||
"ui.continuity_crease": "تجعّد",
|
||||
"ui.continuity_crease": "ثنية",
|
||||
"ui.continuity_curvature": "الانحناء",
|
||||
"ui.continuity_curvature_change": "تغيّر الانحناء",
|
||||
"ui.continuity_curvature_change_tip": "تغيّر الانحناء (G3): يتغير الانحناء أيضًا بسلاسة عبر خط الالتقاء.",
|
||||
"ui.continuity_curvature_tip": "متصل الانحناء (G2): تمر الانعكاسات دون انكسار.",
|
||||
"ui.continuity_g0": "G0 تجعّد",
|
||||
"ui.continuity_g0": "G0 ثنية",
|
||||
"ui.continuity_g1": "G1 مماس",
|
||||
"ui.continuity_g2": "G2 انحناء",
|
||||
"ui.continuity_gap": "فجوة",
|
||||
"ui.continuity_position": "الموضع",
|
||||
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع تجعّد عند التقائها.",
|
||||
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع ثنية عند التقائها.",
|
||||
"ui.continuity_smooth_curvature_change": "تغيّر انحناء سلس",
|
||||
"ui.continuity_smooth_curvature_change_tip": "تغيّر انحناء سلس (G4): حتى طريقة تغيّر الانحناء تستمر بسلاسة عبر خط الالتقاء.",
|
||||
"ui.continuity_tangent": "مماس",
|
||||
"ui.continuity_tangent_tip": "مماس (G1): لا تجعّد، لكن الانحناء يقفز عبر خط الالتقاء.",
|
||||
"ui.continuity_tangent_tip": "مماس (G1): لا ثنية، لكن الانحناء يقفز عبر خط الالتقاء.",
|
||||
"ui.copy": "نسخ",
|
||||
"ui.copy_details": "نسخ التفاصيل",
|
||||
"ui.copy_details_tip": "انسخ الرسالة كاملة للبحث عنها أو للإبلاغ عنها.",
|
||||
@@ -4922,7 +4922,7 @@
|
||||
"ui.extract_done": "أصبح المكوّن تصميمًا مستقلًا، مرتبطًا هنا.",
|
||||
"ui.extrude": "بثق",
|
||||
"ui.extrude_auto_to_object": "البثق إلى هدف مختار",
|
||||
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر المدى إلى «إلى كائن».",
|
||||
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر الامتداد إلى «إلى كائن».",
|
||||
"ui.factor": "المعامل",
|
||||
"ui.fade": "تلاشٍ",
|
||||
"ui.fade_length": "طول التلاشي",
|
||||
@@ -4952,13 +4952,13 @@
|
||||
"ui.fillet_setback": "مسافة الإرجاع",
|
||||
"ui.fillet_setback_hint": "{factor} × نصف القطر",
|
||||
"ui.fillet_setback_least": "أكثر من {least}",
|
||||
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، حيث تلتقي الاستدارات بدونها: نصف القطر حيث تكون الأوجه متعامدة.",
|
||||
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، وهي التي تلتقي عندها التقويسات دون إرجاع: نصف القطر حين تكون الأوجه متعامدة.",
|
||||
"ui.fillet_smooth_radius": "تغيّر ناعم لنصف القطر",
|
||||
"ui.fillet_turn": "المنعطفات",
|
||||
"ui.fillet_turn": "الالتفاف",
|
||||
"ui.fillet_turn_mitre": "وصلة مائلة",
|
||||
"ui.fillet_turn_swept": "مُدار",
|
||||
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة بروز عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nمُدار: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
|
||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
||||
"ui.fillet_turn_swept": "ملتف",
|
||||
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة نتوء عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nملتف: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
|
||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى شريحي: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
||||
"ui.fillet_type_curvature": "متصل الانحناء",
|
||||
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار.",
|
||||
"ui.filter_commands_or_keys": "تصفية الأوامر أو المفاتيح",
|
||||
@@ -5070,7 +5070,7 @@
|
||||
"ui.hidePlanes": "إخفاء المستويات",
|
||||
"ui.hideProfile": "إخفاء المقطع",
|
||||
"ui.hide_component_history": "إخفاء سجله",
|
||||
"ui.hide_origin_by_default": "إخفاء مجلد الأصل في التصاميم الجديدة",
|
||||
"ui.hide_origin_by_default": "إخفاء مجلد نقطة الأصل في التصاميم الجديدة",
|
||||
"ui.hide_origin_note": "تبدأ التصاميم الجديدة ومجلد نقطة الأصل مخفي. يحتفظ كل تصميم بإعداد نقطة الأصل الخاص به بمجرد إظهارها أو إخفائها.",
|
||||
"ui.history_back": "الرجوع خطوة",
|
||||
"ui.history_below_browser": "أسفل المستعرض",
|
||||
|
||||
@@ -1600,7 +1600,7 @@
|
||||
"help.parts.resize": "Changes the size or length of an inserted standard part and rebuilds it; its joints keep it seated. Use it when a screw turns out too long or a hole needs another size.",
|
||||
"help.plastic.assign_rule": "Assigns a plastic rule to bodies, so their plastic features take its draft and clearance as defaults. Use it to set up a moulded part once for all its features.",
|
||||
"help.plastic.boss": "Adds a screw boss on a face: a post with an optional hole, draft, root fillet and supporting ribs. Use it for screw points in moulded or printed housings.",
|
||||
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.",
|
||||
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body (Shell a face open first, then pick the rim around that opening). Use it so that two housing halves locate on each other and close tightly.",
|
||||
"help.plastic.manage_rules": "Creates and edits plastic rules: material, wall thickness, radii, clearance and draft. Use them to keep the features of a moulded part consistent.",
|
||||
"help.plastic.rest": "Adds a rest on a face: a round or rectangular pad, solid or hollow, with draft. Use it as a support or standoff in a moulded housing.",
|
||||
"help.plastic.snap_fit": "Adds a snap-fit hook on a face, with its length, thickness and catch. Use it to clip parts together without screws.",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"en": {
|
||||
"dialog.dimension_step_down": "Down by {step}",
|
||||
"dialog.dimension_step_expression": "Only a number steps; an expression stays as typed",
|
||||
"dialog.dimension_step_up": "Up by {step}",
|
||||
"prefs.dimension_step_angle": "What the window's arrows, Up/Down and the wheel add to an angle",
|
||||
"prefs.dimension_step_length": "What the window's arrows, Up/Down and the wheel add to a length",
|
||||
"prefs.dimension_steps": "Window steps",
|
||||
"prefs.dimension_values": "Dimension values",
|
||||
"prefs.dimension_values_note": "Where you type a dimension's value. A window has arrows that step it; the Dimension tool places the text first.",
|
||||
"prefs.dimension_values_view": "In the view",
|
||||
"prefs.dimension_values_window": "In a window"
|
||||
},
|
||||
"ja": {
|
||||
"dialog.dimension_step_down": "{step} 減らす",
|
||||
"dialog.dimension_step_expression": "増減できるのは数値だけです。式はそのまま残ります",
|
||||
"dialog.dimension_step_up": "{step} 増やす",
|
||||
"prefs.dimension_step_angle": "ウィンドウの矢印、上下キー、ホイールで角度に加える量",
|
||||
"prefs.dimension_step_length": "ウィンドウの矢印、上下キー、ホイールで長さに加える量",
|
||||
"prefs.dimension_steps": "ウィンドウの増減量",
|
||||
"prefs.dimension_values": "寸法値の入力",
|
||||
"prefs.dimension_values_note": "寸法値を入力する場所です。ウィンドウでは矢印で値を増減でき、寸法ツールは先に文字を配置します。",
|
||||
"prefs.dimension_values_view": "ビュー内",
|
||||
"prefs.dimension_values_window": "ウィンドウ"
|
||||
},
|
||||
"zh": {
|
||||
"dialog.dimension_step_down": "减少 {step}",
|
||||
"dialog.dimension_step_expression": "只有数字可以增减;表达式保持不变",
|
||||
"dialog.dimension_step_up": "增加 {step}",
|
||||
"prefs.dimension_step_angle": "窗口的箭头、上下方向键和滚轮给角度增减的量",
|
||||
"prefs.dimension_step_length": "窗口的箭头、上下方向键和滚轮给长度增减的量",
|
||||
"prefs.dimension_steps": "窗口步长",
|
||||
"prefs.dimension_values": "尺寸值",
|
||||
"prefs.dimension_values_note": "输入尺寸值的位置。窗口带有可增减数值的箭头;尺寸工具会先放置文字。",
|
||||
"prefs.dimension_values_view": "在视图中",
|
||||
"prefs.dimension_values_window": "在窗口中"
|
||||
},
|
||||
"de": {
|
||||
"dialog.dimension_step_down": "Um {step} verringern",
|
||||
"dialog.dimension_step_expression": "Nur eine Zahl lässt sich schrittweise ändern; ein Ausdruck bleibt, wie er eingegeben ist",
|
||||
"dialog.dimension_step_up": "Um {step} erhöhen",
|
||||
"prefs.dimension_step_angle": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einem Winkel addieren",
|
||||
"prefs.dimension_step_length": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einer Länge addieren",
|
||||
"prefs.dimension_steps": "Schritte im Fenster",
|
||||
"prefs.dimension_values": "Bemaßungswerte",
|
||||
"prefs.dimension_values_note": "Wo der Wert einer Bemaßung eingegeben wird. Ein Fenster hat Pfeile, die ihn schrittweise ändern; das Bemaßungswerkzeug platziert zuerst den Text.",
|
||||
"prefs.dimension_values_view": "In der Ansicht",
|
||||
"prefs.dimension_values_window": "In einem Fenster"
|
||||
},
|
||||
"ko": {
|
||||
"dialog.dimension_step_down": "{step} 줄이기",
|
||||
"dialog.dimension_step_expression": "숫자만 증감할 수 있으며 수식은 입력한 그대로 유지됩니다",
|
||||
"dialog.dimension_step_up": "{step} 늘리기",
|
||||
"prefs.dimension_step_angle": "창의 화살표, 위/아래 키와 휠이 각도에 더하는 값",
|
||||
"prefs.dimension_step_length": "창의 화살표, 위/아래 키와 휠이 길이에 더하는 값",
|
||||
"prefs.dimension_steps": "창의 증감 단위",
|
||||
"prefs.dimension_values": "치수 값",
|
||||
"prefs.dimension_values_note": "치수 값을 입력하는 위치입니다. 창에는 값을 증감하는 화살표가 있으며, 치수 도구는 텍스트를 먼저 배치합니다.",
|
||||
"prefs.dimension_values_view": "뷰에서",
|
||||
"prefs.dimension_values_window": "창에서"
|
||||
},
|
||||
"nl": {
|
||||
"dialog.dimension_step_down": "{step} omlaag",
|
||||
"dialog.dimension_step_expression": "Alleen een getal verspringt; een uitdrukking blijft zoals ingevoerd",
|
||||
"dialog.dimension_step_up": "{step} omhoog",
|
||||
"prefs.dimension_step_angle": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een hoek optellen",
|
||||
"prefs.dimension_step_length": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een lengte optellen",
|
||||
"prefs.dimension_steps": "Stappen in het venster",
|
||||
"prefs.dimension_values": "Maatwaarden",
|
||||
"prefs.dimension_values_note": "Waar de waarde van een maat wordt ingevoerd. Een venster heeft pijlen die de waarde laten verspringen; het maatgereedschap plaatst eerst de tekst.",
|
||||
"prefs.dimension_values_view": "In de weergave",
|
||||
"prefs.dimension_values_window": "In een venster"
|
||||
},
|
||||
"ar": {
|
||||
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
|
||||
"dialog.dimension_step_expression": "يتغير الرقم فقط بالخطوات؛ يبقى التعبير كما كُتب",
|
||||
"dialog.dimension_step_up": "زيادة بمقدار {step}",
|
||||
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الزاوية",
|
||||
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الطول",
|
||||
"prefs.dimension_steps": "خطوات النافذة",
|
||||
"prefs.dimension_values": "قيم الأبعاد",
|
||||
"prefs.dimension_values_note": "مكان كتابة قيمة البعد. للنافذة أسهم تغيّرها بخطوات؛ تضع أداة البعد النص أولًا.",
|
||||
"prefs.dimension_values_view": "في العرض",
|
||||
"prefs.dimension_values_window": "في نافذة"
|
||||
},
|
||||
"he": {
|
||||
"dialog.dimension_step_down": "הפחתה ב־{step}",
|
||||
"dialog.dimension_step_expression": "רק מספר משתנה בצעדים; ביטוי נשאר כפי שנכתב",
|
||||
"dialog.dimension_step_up": "הוספה של {step}",
|
||||
"prefs.dimension_step_angle": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לזווית",
|
||||
"prefs.dimension_step_length": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לאורך",
|
||||
"prefs.dimension_steps": "צעדי החלון",
|
||||
"prefs.dimension_values": "ערכי מידות",
|
||||
"prefs.dimension_values_note": "היכן מקלידים את ערך המידה. לחלון יש חצים שמשנים אותו בצעדים; כלי המידה מציב קודם את הטקסט.",
|
||||
"prefs.dimension_values_view": "בתצוגה",
|
||||
"prefs.dimension_values_window": "בחלון"
|
||||
},
|
||||
"vi": {
|
||||
"dialog.dimension_step_down": "Giảm {step}",
|
||||
"dialog.dimension_step_expression": "Chỉ số mới thay đổi theo bước; biểu thức giữ nguyên như đã nhập",
|
||||
"dialog.dimension_step_up": "Tăng {step}",
|
||||
"prefs.dimension_step_angle": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một góc",
|
||||
"prefs.dimension_step_length": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một độ dài",
|
||||
"prefs.dimension_steps": "Bước của cửa sổ",
|
||||
"prefs.dimension_values": "Giá trị kích thước",
|
||||
"prefs.dimension_values_note": "Nơi bạn nhập giá trị kích thước. Cửa sổ có mũi tên để thay đổi theo bước; công cụ Kích thước đặt văn bản trước.",
|
||||
"prefs.dimension_values_view": "Trong khung nhìn",
|
||||
"prefs.dimension_values_window": "Trong một cửa sổ"
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -182,6 +182,8 @@
|
||||
"Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.",
|
||||
"Diameter": "Диаметр",
|
||||
"Did this assembly open the wrong way up?": "Эта сборка открылась не той стороной вверх?",
|
||||
"Did this part open the wrong way up?": "Эта деталь открылась не той стороной вверх?",
|
||||
"Dimension new shapes": "Проставлять размеры новых фигур",
|
||||
"Direction": "Направление",
|
||||
"Discard": "Отменить",
|
||||
@@ -606,6 +608,7 @@
|
||||
"Square": "Квадрат",
|
||||
"Stand it up": "Поставить вертикально",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Поставить вертикально: четверть оборота вокруг X одной операцией «Переместить», которую можно отменить.",
|
||||
"Stand this assembly up?": "Поставить эту сборку вертикально?",
|
||||
"Stand this part up?": "Поставить эту деталь вертикально?",
|
||||
"Start angle": "Начальный угол",
|
||||
"Start bulge": "Выпуклость в начале",
|
||||
@@ -1356,6 +1359,7 @@
|
||||
"dialog.dimension_insert_hint": "Дважды щёлкните другой управляющий размер, чтобы вставить его имя",
|
||||
"dialog.dimension_name": "Размер: {name}",
|
||||
"dialog.edit_title": "ИЗМЕНИТЬ: {title}",
|
||||
"dialog.no_shown_body": "Нет отображаемого тела для изменения: покажите тело или выберите «Новое тело»",
|
||||
"dialog.pattern_sketch_not_object": "Выберите тела или операции: эскиз — ни то, ни другое. Повторите его кривые собственным инструментом «Массив» или «Зеркало» в эскизе либо сначала выдавите его.",
|
||||
"dialog.select": "↖ Выбрать",
|
||||
"dialog.selected_count": "↖ Выбрано: {count}",
|
||||
@@ -1717,6 +1721,8 @@
|
||||
"help.view.spin": "Непрерывно вращает вид вокруг модели до следующего щелчка или нажатия клавиши. Используйте, чтобы показать модель со всех сторон.",
|
||||
"help.view.tool": "Закрепляет инструмент камеры (орбиту, панорамирование или масштаб), так что перетаскивание левой кнопкой мыши двигает вид, пока вы его не отключите. Используйте на трекпаде или без средней кнопки мыши.",
|
||||
"help.view.visual_style": "Переключает способ отрисовки модели: тонированная с рёбрами или без них, каркас или со скрытыми рёбрами. Используйте каркас, чтобы заглянуть внутрь, а тонированный вид — для чистого вида.",
|
||||
"history.sketch_under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"history.sketch_under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"hole_presets.confirm_delete": "Подтвердить удаление",
|
||||
"hole_presets.delete": "Удалить",
|
||||
"hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.",
|
||||
@@ -2128,6 +2134,8 @@
|
||||
"prefs.anti_aliasing_note": "Сглаживает неровные края.",
|
||||
"prefs.applies_on_restart": "Применяется после перезапуска",
|
||||
"prefs.assistant": "Ассистент",
|
||||
"prefs.auto_operation": "Выбирать операцию автоматически",
|
||||
"prefs.auto_operation_note": "Выбор «Объединить», «Новое тело» или «Вырезать» по тому, чего касается материал.",
|
||||
"prefs.axes": "Оси",
|
||||
"prefs.axis": "Ось",
|
||||
"prefs.bodies_folders": "Папки тел",
|
||||
@@ -2349,6 +2357,8 @@
|
||||
"prefs.sketch_selection": "Эскиз и выделение",
|
||||
"prefs.sketch_selection_heading": "Эскиз и выделение",
|
||||
"prefs.sketch_selection_note": "Как работает привязка в эскизах и как щелчки выделяют объекты.",
|
||||
"prefs.sketch_state_icons": "Значки эскизов в цвете их состояния",
|
||||
"prefs.sketch_state_icons_note": "В истории и браузере значок эскиза принимает цвет его состояния (недоопределён, полностью определён или с конфликтом), а подсказка называет это состояние. Выкл.: у всех эскизов одинаковый значок.",
|
||||
"prefs.sketch_tab_always": "Всегда показывать вкладку «Эскиз»",
|
||||
"prefs.sketch_tab_always_note": "Вкладка «Эскиз» остаётся на ленте сразу после «Модель», пока не открыт ни один эскиз. Её инструменты рисования создают эскиз на выбранной плоскости или грани либо на плоскости, которую вы выберете следом, и сразу включают нужный инструмент.",
|
||||
"prefs.sketch_tracking_guides": "Направляющие выравнивания по центру и середине",
|
||||
@@ -2360,6 +2370,11 @@
|
||||
"prefs.source": "Источник",
|
||||
"prefs.speed": "Скорость",
|
||||
"prefs.stand_up_note": "После открытия файла STEP или IGES с осью Z вверх предлагать повернуть его на четверть оборота вокруг X.",
|
||||
"prefs.stand_up_unknown": "Для файлов из неизвестных программ",
|
||||
"prefs.stand_up_unknown.ask": "Спрашивать в диалоговом окне",
|
||||
"prefs.stand_up_unknown.note": "Показывать уведомление",
|
||||
"prefs.stand_up_unknown.off": "Не предлагать",
|
||||
"prefs.stand_up_unknown_note": "Как делается предложение, когда в файле не указана известная программа.",
|
||||
"prefs.status_bar": "Строка состояния",
|
||||
"prefs.status_bar_note": "Тонкая полоса вдоль нижнего края окна показывает, что изменяется, состояние открытого эскиза его цветом и единицы дизайна; щелчок по единицам изменяет их. Состояние эскиза при этом больше не показывается в углу вида.",
|
||||
"prefs.status_done": "Готово",
|
||||
@@ -3863,7 +3878,7 @@
|
||||
"status.navigation": "Назначение кнопок мыши: {mapping}",
|
||||
"status.navigation_tip": "Как мышь двигает вид. Щёлкните, чтобы выбрать другое назначение",
|
||||
"status.under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"status.units_tip": "Единицы дизайна. Щёлкните, чтобы изменить",
|
||||
"the angle must be a number": "угол должен быть числом",
|
||||
"the curves must be in one sketch": "кривые должны быть в одном эскизе",
|
||||
@@ -3902,8 +3917,10 @@
|
||||
"timeline.move_to_group": "Переместить операции в папку",
|
||||
"timeline.rename_group": "Переименовать папку операций",
|
||||
"timeline.reorder_many": "Изменить порядок операций",
|
||||
"timeline.suppress_features": "Подавить операции",
|
||||
"timeline.suppress_folders_tip": "Подавить или снять подавление со всех операций в папках и вложенных папках",
|
||||
"timeline.ungroup": "Убрать папки операций",
|
||||
"timeline.unsuppress_features": "Снять подавление с операций",
|
||||
"to the face": "до грани",
|
||||
"to the next face": "до следующей грани",
|
||||
"toolbar.customize_note": "Вкладка {tab}. Отметьте команду, чтобы добавить её кнопку на панель инструментов. Можно также перетащить строку из выпадающего меню панели на панель инструментов, перетащить кнопку за пределы панели для удаления или использовать значок закрепления в строке выпадающего меню.",
|
||||
@@ -4349,6 +4366,8 @@
|
||||
"ui.cancel": "Отмена",
|
||||
"ui.canvases": "Подложки",
|
||||
"ui.center": "Центр",
|
||||
"ui.center_arc_follows_cursor": "Дуга из центра следует направлению курсора",
|
||||
"ui.center_arc_follows_cursor_tip": "Дуга из центра идёт по часовой стрелке или против неё — в ту сторону, в которую курсор обходит её центр. Выкл.: всегда против часовой стрелки от начальной точки.",
|
||||
"ui.centerline": "Осевая линия",
|
||||
"ui.chain": "Цепочка",
|
||||
"ui.chamfer": "Фаска",
|
||||
@@ -5160,11 +5179,13 @@
|
||||
"update.installed": "SolidCraft {version} установлен. Перезапустите, чтобы использовать его.",
|
||||
"update.installer_started": "Установщик запустится после закрытия SolidCraft.",
|
||||
"update.later": "Напомнить позже",
|
||||
"update.managed": "Доступно обновление: обновите SolidCraft в {manager}",
|
||||
"update.no_notes": "У этого выпуска нет примечаний.",
|
||||
"update.package_hint": "Эта копия установлена из {kind}. Скачайте новый пакет, затем установите его командой:",
|
||||
"update.prefs_dev": "Это сборка для разработки ({version}): она не проверяет обновления.",
|
||||
"update.prefs_flatpak": "Эту копию ({version}) обновляет Flatpak: выполните {command} или воспользуйтесь центром приложений. SolidCraft не проверяет обновления.",
|
||||
"update.prefs_label": "Обновления",
|
||||
"update.prefs_managed": "Это SolidCraft {version}, которым управляет {manager}. Проверка запрашивает у GitHub последний релиз и больше ничего не отправляет; обновляйте SolidCraft в {manager}.",
|
||||
"update.prefs_note": "Это SolidCraft {version} ({kind}). При проверке у GitHub запрашивается последний выпуск, больше ничего не отправляется.",
|
||||
"update.prefs_web": "Перезагрузите страницу, чтобы получить последнюю версию веб-приложения. Если сеть недоступна или обновление не успевает загрузиться, открывается сохранённая версия; незавершённая загрузка продолжится до следующей перезагрузки.",
|
||||
"update.refresh_checking": "Проверка наличия новейшего выпуска…",
|
||||
|
||||
@@ -2318,11 +2318,16 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
_ => false,
|
||||
};
|
||||
let (Some(_), Some(_), false) = (outer, openings.first(), alike) else {
|
||||
// A solid box top has no opening: Shell that face first, then pick the rim
|
||||
// around the cavity (#10437).
|
||||
let what = match (loops.len(), openings.len()) {
|
||||
(0 | 1, _) => "pick the rim face of a shelled body (a face with an inner and an outer edge)".to_string(),
|
||||
(_, 0) => "pick the rim face of a shelled body: this face's inner edges are where other features stand on it, not an opening"
|
||||
(0 | 1, _) => "Lip needs the rim of a shelled body: Shell a face open first, then pick the rim around that opening (a face with an outer edge and an inner hole)"
|
||||
.to_string(),
|
||||
(_, k) => format!("pick the rim face of a shelled body (round one opening): this face has {k} openings alike in size"),
|
||||
(_, 0) => "Lip needs a rim around an opening: this face's inner edges are where other features stand on it, not a shell cavity. Shell a face open, then pick that rim"
|
||||
.to_string(),
|
||||
(_, k) => format!(
|
||||
"Lip needs one clear opening as the rim: this face has {k} openings alike in size (for example a plate of holes). Shell one cavity, or pick a rim that rings a single opening"
|
||||
),
|
||||
};
|
||||
return Err(DocError::Invalid(what));
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_geom::mesh_distance::box_gap;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
@@ -17,9 +17,20 @@ use solidcraft_kernel::{BodyDistance, DistanceShape};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_, string_list};
|
||||
use crate::schema::clearance::{
|
||||
Clearance as ClearanceReply, ClearanceParams, MAX_CLEARANCE, MAX_NEAREST, NearPair, Neighbour, Overlap, OverlapBounds, Unchecked,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).icon("interference").noundo().params(
|
||||
const CONTRACT: TypedContract<ClearanceParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", crate::checked!("inspect.clearance", clearance))
|
||||
.icon("interference")
|
||||
.noundo()
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ClearanceReply>)
|
||||
.params(
|
||||
"bodies?: [names] (default: every solid body, in world placement, but those in `against`; mesh bodies are not measured); against?: [names] (only pairs with one body in `bodies` and the other here); \
|
||||
min_volume?: mm³ (default 0.01: smaller overlaps count as contact); clearance?: mm (default 0: report pairs closer than this); \
|
||||
nearest?: n (each body of `bodies` gets its n nearest others); skip_within?: \"occurrence\" (pairs of bodies of one occurrence) | \
|
||||
@@ -34,10 +45,6 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "C
|
||||
const MAX_BODIES: usize = 20_000;
|
||||
/// Most pairs whose grown boxes meet that one call measures.
|
||||
const MAX_PAIRS: usize = 200_000;
|
||||
/// Most neighbours a body's `nearest` list holds.
|
||||
const MAX_NEAREST: usize = 1000;
|
||||
/// Largest clearance (mm).
|
||||
const MAX_CLEARANCE: f64 = 1e6;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum SkipWithin {
|
||||
@@ -261,48 +268,56 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in ["bodies", "against"] {
|
||||
asked.extend(string_list(cmd, p, k)?);
|
||||
}
|
||||
let mut unchecked: Vec<Value> = mesh_bodies
|
||||
let mut unchecked: Vec<Unchecked> = mesh_bodies
|
||||
.iter()
|
||||
.filter(|b| p.get("bodies").is_none_or(Value::is_null) || asked.contains(&b.name))
|
||||
.map(|b| json!({"a": b.name, "b": null, "reason": super::interference::MESH_NOT_CHECKED}))
|
||||
.map(|b| Unchecked { a: b.name.clone(), b: None, reason: super::interference::MESH_NOT_CHECKED.to_string() })
|
||||
.collect();
|
||||
let name = |i: usize| solid.get(i).map_or("", |b| b.name.as_str());
|
||||
let pair = |i: usize, j: usize, reason: String| Unchecked { a: name(i).to_string(), b: Some(name(j).to_string()), reason };
|
||||
for (&(i, j), r) in close.iter().zip(results) {
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()}));
|
||||
unchecked.push(pair(i, j, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = r.unchecked {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason}));
|
||||
unchecked.push(pair(i, j, reason));
|
||||
continue;
|
||||
}
|
||||
if let Some((volume, area, bbox, center)) = r.overlap
|
||||
&& volume >= min_volume
|
||||
{
|
||||
overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area,
|
||||
"bbox": {"min": bbox.min, "max": bbox.max}, "center": center}));
|
||||
overlaps.push(Overlap {
|
||||
a: name(i).to_string(),
|
||||
b: name(j).to_string(),
|
||||
volume_mm3: volume,
|
||||
area_mm2: area,
|
||||
bbox: OverlapBounds { min: bbox.min.into(), max: bbox.max.into() },
|
||||
center: center.into(),
|
||||
});
|
||||
known.insert((i, j), 0.0);
|
||||
continue;
|
||||
}
|
||||
let Some(d) = r.distance else { continue };
|
||||
known.insert((i, j), d.distance);
|
||||
if d.distance < gap {
|
||||
let mut e = json!({"a": name(i), "b": name(j), "distance_mm": d.distance, "from": d.from, "to": d.to});
|
||||
if let Some((v, ..)) = r.overlap.filter(|o| o.0 > 0.0) {
|
||||
e["overlap_mm3"] = json!(v);
|
||||
}
|
||||
if !d.exact {
|
||||
e["exact"] = json!(false);
|
||||
}
|
||||
near.push(e);
|
||||
near.push(NearPair {
|
||||
a: name(i).to_string(),
|
||||
b: name(j).to_string(),
|
||||
distance_mm: d.distance,
|
||||
from: d.from.into(),
|
||||
to: d.to.into(),
|
||||
overlap_mm3: r.overlap.filter(|o| o.0 > 0.0).map(|o| o.0),
|
||||
exact: (!d.exact).then_some(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
near.sort_by(|x, y| x["distance_mm"].as_f64().unwrap_or(0.0).total_cmp(&y["distance_mm"].as_f64().unwrap_or(0.0)));
|
||||
near.sort_by(|x, y| x.distance_mm.total_cmp(&y.distance_mm));
|
||||
|
||||
let mut nearest_out = serde_json::Map::new();
|
||||
let mut nearest_out = BTreeMap::new();
|
||||
if nearest > 0 {
|
||||
let lists = nearest_lists(&subjects, n, nearest, &boxes, &allowed, &mut known, |pairs| {
|
||||
solidcraft_kernel::map_with_local_pools(pairs, usize::MAX, |&(i, j)| {
|
||||
@@ -311,23 +326,25 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
});
|
||||
for (i, list) in lists {
|
||||
let rows: Vec<Value> = list.iter().map(|&(j, d)| json!({"body": name(j), "distance_mm": d})).collect();
|
||||
nearest_out.insert(name(i).to_string(), Value::Array(rows));
|
||||
let rows = list.iter().map(|&(j, d)| Neighbour { body: name(j).to_string(), distance_mm: d }).collect();
|
||||
nearest_out.insert(name(i).to_string(), rows);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"overlaps": overlaps,
|
||||
"near": near,
|
||||
"nearest": nearest_out,
|
||||
"unchecked": unchecked,
|
||||
"bodies": subjects.len(),
|
||||
"pairs_checked": close.len(),
|
||||
"pairs_reused": reused,
|
||||
"pairs_pruned": total.saturating_sub(close.len()),
|
||||
"truncated": truncated > 0,
|
||||
"ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
}))
|
||||
let reply = ClearanceReply {
|
||||
overlaps,
|
||||
near,
|
||||
nearest: nearest_out,
|
||||
unchecked,
|
||||
bodies: subjects.len(),
|
||||
pairs_checked: close.len(),
|
||||
pairs_reused: reused,
|
||||
pairs_pruned: total.saturating_sub(close.len()),
|
||||
truncated: truncated > 0,
|
||||
ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance
|
||||
|
||||
@@ -408,3 +408,24 @@ fn a_motion_link_takes_its_driven_joints_freedom_with_nothing_grounded() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling the marker back works a gear's ratio out from the faces as they were then, so the
|
||||
/// editor's pinned model and its source differ in the link's ratio, and the edit still goes
|
||||
/// through.
|
||||
#[test]
|
||||
fn an_editor_accepts_the_ratios_rolling_the_marker_works_out() {
|
||||
let mut s = gear_train();
|
||||
let before = s.doc.features.len();
|
||||
// G1's wall moves out to a radius of 12, so the link's ratio goes from -0.5 to -0.6.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 2.5]], "distance": 2}));
|
||||
let ratio = |s: &Session| s.doc.assembly.links.first().map(|l| l.ratio).unwrap_or(f64::NAN);
|
||||
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
|
||||
let original = s.scratch();
|
||||
run(&mut s, "timeline.roll_to", json!({"position": before}));
|
||||
let source = s.scratch();
|
||||
assert!(close_to(ratio(&source), -0.5), "before the offset, the pitch radius is 10: {}", ratio(&source));
|
||||
let prepared = s.scratch().compute_editor_commands(&[], &source, original).unwrap();
|
||||
s.accept_commands(prepared).unwrap();
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
//! Read-only validation of candidate engineering data; no assignment or simulation.
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::material_properties::{CardModulus, CardValidated, DirectionalModulusParams, ValidateCardParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", validate)
|
||||
const VALIDATE_CONTRACT: TypedContract<ValidateCardParams> = TypedContract::new();
|
||||
const DIRECTIONAL_CONTRACT: TypedContract<DirectionalModulusParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", crate::checked!("material.properties.validate", validate))
|
||||
.icon("material")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&VALIDATE_CONTRACT)
|
||||
.output(output_schema_for::<CardValidated>)
|
||||
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md"),
|
||||
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", directional).icon("material").noundo().doc_only()
|
||||
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", crate::checked!("material.properties.directional", directional)).icon("material").noundo().doc_only()
|
||||
.contract(&DIRECTIONAL_CONTRACT)
|
||||
.output(output_schema_for::<CardModulus>)
|
||||
.params("card: schema_version 1 engineering material card; direction: three finite nonzero-vector components in the card's material-local axes. Returns normalized direction, Young modulus in Pa and source/condition review. Read-only constitutive query; no structural solver, persistence or body-axis assignment. See docs/physical-material-cards.md")];
|
||||
|
||||
fn directional(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
@@ -28,7 +37,7 @@ fn directional(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
let direction = [component(0)?, component(1)?, component(2)?];
|
||||
let card = solidcraft_doc::physical_material::admit_card(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let response = card.directional_modulus(direction).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
serde_json::to_value(response).map_err(|e| bad(cmd, e.to_string()))
|
||||
serde_json::to_value(CardModulus::from(response)).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
@@ -38,13 +47,15 @@ fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad("material.properties.validate", "expected {card: ...}"))?;
|
||||
let card = object.get("card").ok_or_else(|| bad("material.properties.validate", "missing card"))?;
|
||||
let result = solidcraft_doc::physical_material::validate_card(card).map_err(|e| bad("material.properties.validate", e.to_string()))?;
|
||||
Ok(json!({"valid":true,"validation":result}))
|
||||
let reply = CardValidated { valid: true, validation: result.into(), param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| bad("material.properties.validate", e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::doc::Document;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn card() -> Value {
|
||||
|
||||
@@ -404,7 +404,7 @@ mod tests {
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let lip = json!({"body": "Plate", "face": [51.89, 45.2, 8.0], "width": "1.5 mm", "height": "3.5 mm", "side": "inside", "type": "lip"});
|
||||
let e = s.execute("plastic.lip", &lip).unwrap_err().to_string();
|
||||
assert!(e.contains("rim face") && e.contains("2 openings alike"), "{e}");
|
||||
assert!(e.contains("one clear opening") && e.contains("2 openings alike"), "{e}");
|
||||
// The boss alone, no holes: its foot is not an opening either.
|
||||
let mut b = Session::default();
|
||||
run(&mut b, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
|
||||
|
||||
@@ -10,12 +10,19 @@ use solidcraft_kernel::{Construction, Continuity};
|
||||
use super::CommandSpec;
|
||||
use super::features::add_feature;
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::surface_match::MatchSurfaceParams;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CONTRACT: TypedContract<MatchSurfaceParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.match", "Match Surface", match_surface)
|
||||
CommandSpec::new("surface.match", "Match Surface", crate::checked!("surface.match", match_surface))
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("continuity")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"body: surface name (the body whose face is rebuilt); faces?: [[x,y,z]] (points on that face, default: the body's first); target: body name (the body the seam must meet); edges: [[x,y,z]] (points on one edge chain of the target: the rebuilt boundary takes its place); order?: g1|g2|g3 (default g2: position and tangent plane, the curvature too, or how that curvature changes along the seam). The far boundary stays where it was and the body keeps its name",
|
||||
),
|
||||
|
||||
@@ -280,3 +280,41 @@ fn cancelling_an_editor_with_visibility_changes_restores_an_existing_history_mar
|
||||
assert_eq!(session.doc.marker, Some(1));
|
||||
assert!(session.visibility.hidden_origin.is_empty());
|
||||
}
|
||||
|
||||
/// Rolling the marker back before a move of the body a joint seats on solves the joints again,
|
||||
/// which puts the seated occurrence back where the body was: the editor's pinned model differs
|
||||
/// from its source there, and only there, and the edit still goes through.
|
||||
#[test]
|
||||
fn an_editor_accepts_the_placements_rolling_the_marker_solves() {
|
||||
let mut session = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
session.execute("component.activate", &json!({"component": "root"})).unwrap();
|
||||
session.execute("component.create", &json!({"name": name})).unwrap();
|
||||
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap();
|
||||
}
|
||||
let component = |s: &Session, name: &str| s.doc.components.iter().find(|c| c.name == name).map(|c| c.id).unwrap();
|
||||
let occurrence = |s: &Session, name: &str| {
|
||||
let component = component(s, name);
|
||||
s.doc.occurrences.iter().find(|o| o.component == component).cloned().unwrap()
|
||||
};
|
||||
session.execute("occurrence.ground", &json!({"occurrence": occurrence(&session, "A").id, "grounded": true})).unwrap();
|
||||
session
|
||||
.execute("joint.create", &json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": 5, "name": "Seat"}))
|
||||
.unwrap();
|
||||
let seated = occurrence(&session, "B").transform;
|
||||
// A's box moves along, and B, seated on it, with it.
|
||||
session.execute("component.activate", &json!({"component": "A"})).unwrap();
|
||||
let a = component(&session, "A");
|
||||
let body = session.model.state().bodies.iter().find(|b| b.component == a).map(|b| b.name.clone()).unwrap();
|
||||
session.execute("solid.move", &json!({"bodies": [body], "translate": [30, 0, 0]})).unwrap();
|
||||
let moved = occurrence(&session, "B").transform;
|
||||
assert_ne!(moved, seated, "B follows the box it is seated on");
|
||||
let original = session.scratch();
|
||||
session.execute("timeline.roll_to", &json!({"position": 2})).unwrap();
|
||||
let source = session.scratch();
|
||||
assert_eq!(occurrence(&session, "B").transform, seated, "before the move, B sits where the box was");
|
||||
let prepared = session.scratch().compute_editor_commands(&[], &source, original).unwrap();
|
||||
session.accept_commands(prepared).unwrap();
|
||||
assert_eq!(session.doc.marker, None);
|
||||
assert_eq!(occurrence(&session, "B").transform, moved);
|
||||
}
|
||||
|
||||
@@ -1961,6 +1961,23 @@ impl Session {
|
||||
}
|
||||
let mut expected = (*original.doc).clone();
|
||||
expected.marker = source.doc.marker;
|
||||
// Rolling the marker solves the joints again and stores where that puts each occurrence.
|
||||
if expected.occurrences.len() == source.doc.occurrences.len() {
|
||||
for (e, s) in expected.occurrences.iter_mut().zip(&source.doc.occurrences) {
|
||||
if e.id == s.id {
|
||||
e.transform = s.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ...and works out each coupled joint's ratio and lift from the faces at that marker.
|
||||
if expected.assembly.links.len() == source.doc.assembly.links.len() {
|
||||
for (e, s) in expected.assembly.links.iter_mut().zip(&source.doc.assembly.links) {
|
||||
if (e.a, e.ia, e.b, e.ib) == (s.a, s.ia, s.b, s.ib) {
|
||||
e.ratio = s.ratio;
|
||||
e.lift = s.lift.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if expected != *source.doc {
|
||||
return Err(EngineError::Other("the original editor model does not match its source design".into()));
|
||||
}
|
||||
|
||||
@@ -517,6 +517,7 @@ fn open_surface_measurements_have_no_volume_properties() {
|
||||
assert_eq!(props["total"]["open_bodies_skipped"], 1);
|
||||
assert!(props["bodies"][0]["mass_g"].is_null());
|
||||
assert!(props["total"]["center_of_mass"].is_null());
|
||||
assert!(s.execute("inspect.center_of_mass", &json!({"bodies": ["OpenTube"]})).is_err(), "an open surface has no centre of mass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -545,6 +546,43 @@ fn volume_totals_skip_open_surfaces_and_keep_closed_box_properties() {
|
||||
for key in ["volume_mm3", "mass_g", "center_of_mass"] {
|
||||
assert_eq!(props_before["total"][key], props_after["total"][key]);
|
||||
}
|
||||
let com = run(&mut s, "inspect.center_of_mass", json!({}));
|
||||
assert_eq!(com["skipped"].as_array().map(Vec::len), Some(1), "{com}");
|
||||
assert_eq!(com["bodies"].as_array().map(Vec::len), Some(1), "{com}");
|
||||
assert!(close(&com["volume"], 6000.0, 1e-9), "{com}");
|
||||
for (axis, expected) in [5.0, 10.0, 15.0].into_iter().enumerate() {
|
||||
assert!(close(&com["center"][axis], expected, 1e-9), "{com}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #6173: Center of Mass reports the volume and centre Measure does. It measured the display
|
||||
/// mesh, whose flat facets cut curved faces short: a sphere of radius 10 came out 0.13 % small.
|
||||
#[test]
|
||||
fn center_of_mass_reports_the_same_volume_and_centre_as_measure() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.sphere", json!({"radius": 10, "center": [0, 0, 0], "body_name": "Ball"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [50, 0, 0], "radius": 10, "height": 20, "body_name": "Cup"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 0, 20]], "thickness": 2}));
|
||||
let measured = run(&mut s, "inspect.measure", json!({}));
|
||||
let com = run(&mut s, "inspect.center_of_mass", json!({}));
|
||||
let entry = |list: &Value, key: &str, name: &str| -> Value {
|
||||
list.as_array().and_then(|l| l.iter().find(|b| b[key] == name)).cloned().unwrap_or_else(|| panic!("{name} in {list}"))
|
||||
};
|
||||
for name in ["Ball", "Cup"] {
|
||||
let (m, c) = (entry(&measured["bodies"], "name", name), entry(&com["bodies"], "body", name));
|
||||
assert!(close(&c["volume"], m["volume_mm3"].as_f64().unwrap(), 1e-9), "{name}: {c} vs {m}");
|
||||
for axis in 0..3 {
|
||||
assert!(close(&c["center"][axis], m["center_of_mass"][axis].as_f64().unwrap(), 1e-9), "{name}: {c} vs {m}");
|
||||
}
|
||||
}
|
||||
assert!(close(&com["volume"], measured["total"]["volume_mm3"].as_f64().unwrap(), 1e-6), "{com} vs {measured}");
|
||||
assert_eq!(com["skipped"].as_array().map(Vec::len), Some(0), "{com}");
|
||||
// Worked out by hand: the sphere is 4/3 pi r^3, the cup a cylinder less its 8 x 18 bore.
|
||||
let ball = 4.0 / 3.0 * PI * 1000.0;
|
||||
let cup = PI * (100.0 * 20.0 - 64.0 * 18.0);
|
||||
let (b, c) = (entry(&com["bodies"], "body", "Ball"), entry(&com["bodies"], "body", "Cup"));
|
||||
assert!(close(&b["volume"], ball, ball * 2e-4), "{b}: want {ball}");
|
||||
assert!(close(&c["volume"], cup, cup * 2e-4), "{c}: want {cup}");
|
||||
}
|
||||
|
||||
/// An ASCII STL of the tetrahedron (0,0,0), (10,0,0), (0,10,0), (0,0,10), outward facing; `open`
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Contract and result types for `inspect.clearance` in `cmd/clearance.rs` (#11051).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::StringList;
|
||||
use crate::params;
|
||||
|
||||
/// Most neighbours a body's `nearest` list holds.
|
||||
pub const MAX_NEAREST: usize = 1000;
|
||||
/// Largest clearance (mm).
|
||||
pub const MAX_CLEARANCE: f64 = 1e6;
|
||||
|
||||
/// Find the solid bodies that overlap, and those closer than `clearance`, in world placement.
|
||||
/// Distances are measured on the exact faces and edges; the meshes only rule pairs out. Mesh
|
||||
/// bodies aren't measured: each is listed in `unchecked`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ClearanceParams {
|
||||
/// The bodies to check (default: every solid body but those in `against`).
|
||||
#[serde(default)]
|
||||
pub bodies: Option<StringList>,
|
||||
/// Only pairs with one body in `bodies` and the other here.
|
||||
#[serde(default)]
|
||||
pub against: Option<StringList>,
|
||||
/// Smaller overlaps count as contact, in mm³ (default 0.01).
|
||||
#[serde(default, deserialize_with = "min_volume")]
|
||||
#[schemars(schema_with = "min_volume_schema")]
|
||||
pub min_volume: Option<f64>,
|
||||
/// Report pairs closer than this, in mm: 0 to 1 000 000 (default 0).
|
||||
#[serde(default, deserialize_with = "clearance")]
|
||||
#[schemars(schema_with = "clearance_schema")]
|
||||
pub clearance: Option<f64>,
|
||||
/// A gap below this is contact, 0, in mm: 0 to 1 (default 1e-6).
|
||||
#[serde(default, deserialize_with = "tolerance")]
|
||||
#[schemars(schema_with = "tolerance_schema")]
|
||||
pub tolerance: Option<f64>,
|
||||
/// Each body of `bodies` gets its this many nearest others, 0 to 1000 (default 0); a
|
||||
/// fraction is rounded down.
|
||||
#[serde(default, deserialize_with = "nearest")]
|
||||
#[schemars(schema_with = "nearest_schema")]
|
||||
pub nearest: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub skip_within: Option<SkipWithin>,
|
||||
}
|
||||
|
||||
/// A number from 0 to `max`, read as the handler reads it (`params::num`: finite, under 1e9 in
|
||||
/// size), with its message; `null` is the default.
|
||||
macro_rules! up_to {
|
||||
($read:ident, $schema:ident, $key:literal, $max:expr) => {
|
||||
fn $read<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.and_then(params::finite)
|
||||
.filter(|x| (0.0..=$max).contains(x))
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(whole(format!("`{}` must be a number from 0 to {}", $key, $max)))),
|
||||
}
|
||||
}
|
||||
|
||||
fn $schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["number", "null"], "minimum": 0, "maximum": f64::min($max, 1e9), "exclusiveMaximum": 1e9})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
up_to!(min_volume, min_volume_schema, "min_volume", f64::MAX);
|
||||
up_to!(clearance, clearance_schema, "clearance", MAX_CLEARANCE);
|
||||
up_to!(tolerance, tolerance_schema, "tolerance", 1.0);
|
||||
up_to!(nearest, nearest_schema, "nearest", MAX_NEAREST as f64);
|
||||
|
||||
/// Pairs not to check: of bodies of one occurrence (`occurrence`), or inside one placed
|
||||
/// component, its subcomponents included (`component`). `null` checks every pair.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SkipWithin {
|
||||
Occurrence,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SkipWithin {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)?.as_str() {
|
||||
Some("occurrence") => Ok(SkipWithin::Occurrence),
|
||||
Some("component") => Ok(SkipWithin::Component),
|
||||
_ => Err(D::Error::custom(whole("`skip_within` must be \"occurrence\", \"component\" or null"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SkipWithin {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SkipWithin".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Pairs not to check: of bodies of one occurrence (`occurrence`), or inside one placed component, its subcomponents included (`component`). null checks every pair.",
|
||||
"enum": ["occurrence", "component", null]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `inspect.clearance` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Clearance {
|
||||
/// Pairs that share at least `min_volume`.
|
||||
pub overlaps: Vec<Overlap>,
|
||||
/// Pairs closer than `clearance`, nearest first; a pair in contact is at 0.
|
||||
pub near: Vec<NearPair>,
|
||||
/// With `nearest`: each body of `bodies`, with its nearest others.
|
||||
pub nearest: BTreeMap<String, Vec<Neighbour>>,
|
||||
/// Pairs whose overlap couldn't be worked out, and each mesh body, which isn't measured
|
||||
/// (`b` is then `null`).
|
||||
pub unchecked: Vec<Unchecked>,
|
||||
/// How many solid bodies were checked.
|
||||
pub bodies: usize,
|
||||
pub pairs_checked: usize,
|
||||
/// Of those, the pairs measured by an earlier call: neither body changed or moved since.
|
||||
pub pairs_reused: usize,
|
||||
/// Pairs ruled out by their boxes.
|
||||
pub pairs_pruned: usize,
|
||||
/// Whether there were more pairs than one call measures.
|
||||
pub truncated: bool,
|
||||
/// How long it took (ms).
|
||||
pub ms: f64,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Two bodies that overlap.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Overlap {
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
/// The volume they share (mm³).
|
||||
pub volume_mm3: f64,
|
||||
/// The area of the shared volume's surface (mm²).
|
||||
pub area_mm2: f64,
|
||||
pub bbox: OverlapBounds,
|
||||
pub center: [f64; 3],
|
||||
}
|
||||
|
||||
/// A box, by its corners.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct OverlapBounds {
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
}
|
||||
|
||||
/// Two bodies closer than `clearance`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct NearPair {
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
pub distance_mm: f64,
|
||||
/// The nearest point on `a`.
|
||||
pub from: [f64; 3],
|
||||
/// The nearest point on `b`.
|
||||
pub to: [f64; 3],
|
||||
/// For a contact: the volume they share, below `min_volume` (mm³).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overlap_mm3: Option<f64>,
|
||||
/// `false` when measured on the meshes; absent when measured on the exact faces and edges.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exact: Option<bool>,
|
||||
}
|
||||
|
||||
/// One of a body's nearest others.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Neighbour {
|
||||
pub body: String,
|
||||
pub distance_mm: f64,
|
||||
}
|
||||
|
||||
/// A pair, or a mesh body, that wasn't measured.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Unchecked {
|
||||
pub a: String,
|
||||
/// The other body; `null` for a mesh body.
|
||||
pub b: Option<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clearance_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Clearance, ClearanceParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ClearanceParams>(), &["against", "bodies", "clearance", "min_volume", "nearest", "skip_within", "tolerance"]);
|
||||
}
|
||||
|
||||
/// The handler reads the numbers and `skip_within` before the bodies, so each of these fails
|
||||
/// the same way on a design with no bodies.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("inspect.clearance", json!({"min_volume": -1})),
|
||||
("inspect.clearance", json!({"min_volume": "x"})),
|
||||
("inspect.clearance", json!({"min_volume": 2e9})),
|
||||
("inspect.clearance", json!({"clearance": 2e6})),
|
||||
("inspect.clearance", json!({"tolerance": 2})),
|
||||
("inspect.clearance", json!({"nearest": 1001})),
|
||||
("inspect.clearance", json!({"skip_within": "x"})),
|
||||
("inspect.clearance", json!({"skip_within": 5})),
|
||||
("inspect.clearance", json!({"bodies": ["Nope"]})),
|
||||
("inspect.clearance", json!({"against": "Nope"})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #11051: a clearance check replies as its output schema says, in Warn without warnings for
|
||||
/// good calls and with them for a misspelt key: two overlapping boxes and a third 2 mm away,
|
||||
/// checked with every key, and with `against` alone.
|
||||
#[test]
|
||||
fn a_clearance_check_replies_with_its_schema() {
|
||||
let schema = output_schema_for::<Clearance>();
|
||||
let matches = |r: &Value, warned: bool| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
|
||||
};
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 5, 5], "body_name": "B", "operation": "new"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [12, -20, 0], "body_name": "C", "operation": "new"}));
|
||||
let r = run(&mut s, "inspect.clearance", json!({}));
|
||||
matches(&r, false);
|
||||
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let r = run(
|
||||
&mut s,
|
||||
"inspect.clearance",
|
||||
json!({"bodies": ["A", "B"], "against": "C", "min_volume": 0.01, "clearance": 30, "tolerance": 1e-6, "nearest": 2, "skip_within": "occurrence"}),
|
||||
);
|
||||
matches(&r, false);
|
||||
assert!(r["near"].as_array().is_some_and(|n| !n.is_empty()) && r["nearest"]["A"].is_array(), "{r}");
|
||||
matches(&run(&mut s, "inspect.clearance", json!({"against": ["A"], "skip_within": null})), false);
|
||||
matches(&run(&mut s, "inspect.clearance", json!({"clearence": 3})), true);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Contracts and result types for the commands in `cmd/material_properties.rs`: checking an
|
||||
//! engineering material card, and its elastic modulus in a direction (#11036).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::physical_material::{DirectionalModulus, IsotropicModuli, MaterialAxes, Validation};
|
||||
|
||||
use super::check::whole;
|
||||
|
||||
/// Check an engineering material card: its numbers' consistency, the isotropic moduli they
|
||||
/// imply, and the conditions and sources still to review. Read-only: nothing is assigned or
|
||||
/// saved, and there is no analysis solver.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ValidateCardParams {
|
||||
#[schemars(schema_with = "card_schema")]
|
||||
pub card: Value,
|
||||
}
|
||||
|
||||
/// The Young modulus of an engineering material card in one direction, in the card's own
|
||||
/// material axes. Read-only: no load case or structural solver.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DirectionalModulusParams {
|
||||
#[schemars(schema_with = "card_schema")]
|
||||
pub card: Value,
|
||||
pub direction: CardDirection,
|
||||
}
|
||||
|
||||
/// The card, checked by the handler (`admit_card`), with its messages.
|
||||
fn card_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A schema_version 1 engineering material card in SI units (see docs/physical-material-cards.md).",
|
||||
"type": "object"
|
||||
})
|
||||
}
|
||||
|
||||
/// A direction in the card's material axes: three numbers, not all zero, with the handler's
|
||||
/// messages.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CardDirection(pub [f64; 3]);
|
||||
|
||||
impl<'de> Deserialize<'de> for CardDirection {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let a =
|
||||
v.as_array().filter(|a| a.len() == 3).ok_or_else(|| D::Error::custom(whole("direction: expected exactly three numeric components")))?;
|
||||
let c = |i: usize| a.get(i).and_then(Value::as_f64).ok_or_else(|| D::Error::custom(whole("direction: expected numeric components")));
|
||||
Ok(CardDirection([c(0)?, c(1)?, c(2)?]))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CardDirection {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CardDirection".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A direction in the card's material axes: three numbers, not all zero.",
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `material.properties.validate` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardValidated {
|
||||
/// Always true: a card that isn't valid is refused.
|
||||
pub valid: bool,
|
||||
pub validation: CardValidation,
|
||||
/// 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 `material.properties.directional` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardModulus {
|
||||
/// The direction, made a unit vector.
|
||||
pub direction: [f64; 3],
|
||||
/// The card's material axes, when it has directional properties.
|
||||
pub axes: Option<CardAxes>,
|
||||
/// The Young modulus in that direction (Pa).
|
||||
pub young_modulus_pa: f64,
|
||||
pub validation: CardValidation,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl From<DirectionalModulus> for CardModulus {
|
||||
fn from(m: DirectionalModulus) -> Self {
|
||||
let DirectionalModulus { direction, axes, young_modulus_pa, validation } = m;
|
||||
CardModulus { direction, axes: axes.map(CardAxes::from), young_modulus_pa, validation: validation.into(), param_warnings: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A card's material axes.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CardAxes {
|
||||
/// The material's own axes 1, 2 and 3.
|
||||
Material123,
|
||||
/// Wood's longitudinal, radial and tangential axes.
|
||||
WoodLrt,
|
||||
}
|
||||
|
||||
impl From<MaterialAxes> for CardAxes {
|
||||
fn from(a: MaterialAxes) -> Self {
|
||||
match a {
|
||||
MaterialAxes::Material123 => CardAxes::Material123,
|
||||
MaterialAxes::WoodLrt => CardAxes::WoodLrt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What checking a card found.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardValidation {
|
||||
pub schema_version: u32,
|
||||
/// The kinds of property the card gives.
|
||||
pub property_kinds: Vec<String>,
|
||||
/// The shear and bulk moduli an isotropic card's numbers imply.
|
||||
pub isotropic_moduli: Option<CardModuli>,
|
||||
/// The property kinds given without every condition they hold at (temperature, state, and
|
||||
/// moisture for wood or strain rate for polymers).
|
||||
pub unspecified_conditions: Vec<String>,
|
||||
/// Per property kind: the condition fields it lacks.
|
||||
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
|
||||
/// The sources the card's properties cite, which still need review.
|
||||
pub source_review_required: Vec<String>,
|
||||
/// Always false: there is no analysis solver.
|
||||
pub analysis_solver_available: bool,
|
||||
/// Always false: a card holds no rendering properties.
|
||||
pub rendering_properties_available: bool,
|
||||
}
|
||||
|
||||
impl From<Validation> for CardValidation {
|
||||
fn from(v: Validation) -> Self {
|
||||
// In full, so a field added to `Validation` fails here rather than going missing.
|
||||
let Validation {
|
||||
schema_version,
|
||||
property_kinds,
|
||||
isotropic_moduli,
|
||||
unspecified_conditions,
|
||||
missing_condition_fields,
|
||||
source_review_required,
|
||||
analysis_solver_available,
|
||||
rendering_properties_available,
|
||||
} = v;
|
||||
CardValidation {
|
||||
schema_version,
|
||||
property_kinds,
|
||||
isotropic_moduli: isotropic_moduli.map(|IsotropicModuli { shear_pa, bulk_pa }| CardModuli { shear_pa, bulk_pa }),
|
||||
unspecified_conditions,
|
||||
missing_condition_fields,
|
||||
source_review_required,
|
||||
analysis_solver_available,
|
||||
rendering_properties_available,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The isotropic moduli a card's numbers imply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardModuli {
|
||||
pub shear_pa: f64,
|
||||
pub bulk_pa: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "material_properties_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CardModulus, CardValidated, DirectionalModulusParams, ValidateCardParams};
|
||||
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};
|
||||
|
||||
/// An isotropic card, as the command's own tests write one.
|
||||
fn card() -> Value {
|
||||
json!({"schema_version":1,"designation":"Original synthetic card, not design data","family":"other",
|
||||
"sources":[{"id":"fixture","title":"Original analytic constants","kind":"illustrative","license":"MIT OR Apache-2.0"}],
|
||||
"properties":[{"source_id":"fixture","conditions":{"temperature_k":293.15,"state":"synthetic"},
|
||||
"data":{"kind":"density","kg_m3":700.0}},
|
||||
{"source_id":"fixture","data":{"kind":"elasticity","model":{
|
||||
"symmetry":"isotropic","young_modulus_pa":12.25e9,"poisson_ratio":0.25}}}]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ValidateCardParams>(), &["card"]);
|
||||
assert_keys(input_schema_for::<DirectionalModulusParams>(), &["card", "direction"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("material.properties.validate", json!({"card": {}})),
|
||||
("material.properties.validate", json!({"card": null})),
|
||||
("material.properties.directional", json!({"card": {}, "direction": [1, 0, 0]})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": [1, 0]})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": "x"})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": [1, "a", 0]})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": [0, 0, 0]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #11036: both commands reply as their output schemas say, in Warn without warnings. Like the
|
||||
/// handlers, warn and strict refuse a key they don't take, so no reply carries a warning.
|
||||
#[test]
|
||||
fn card_commands_reply_with_their_schemas() {
|
||||
let (validated, modulus) = (output_schema_for::<CardValidated>(), output_schema_for::<CardModulus>());
|
||||
let matches = |schema: &schemars::Schema, r: &Value| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
};
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let r = s.execute("material.properties.validate", &json!({"card": card()})).unwrap_or_else(|e| panic!("{e}"));
|
||||
matches(&validated, &r);
|
||||
assert!(r["valid"] == true && r["validation"]["isotropic_moduli"]["shear_pa"].is_number(), "{r}");
|
||||
let r = s.execute("material.properties.directional", &json!({"card": card(), "direction": [1, 1, 0]})).unwrap_or_else(|e| panic!("{e}"));
|
||||
matches(&modulus, &r);
|
||||
assert!(r["axes"].is_null() && (r["young_modulus_pa"].as_f64().unwrap_or(0.0) - 12.25e9).abs() < 1.0, "{r}");
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
assert!(s.execute("material.properties.validate", &json!({"card": card(), "crd": 1})).is_err(), "{level:?}");
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ mod tests;
|
||||
pub mod appearance;
|
||||
pub mod bom;
|
||||
pub mod browser;
|
||||
pub mod clearance;
|
||||
pub mod component;
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
@@ -68,6 +69,7 @@ pub mod inspect;
|
||||
pub mod joints;
|
||||
pub mod libraries;
|
||||
pub mod mass_properties;
|
||||
pub mod material_properties;
|
||||
pub mod motion;
|
||||
pub mod panels;
|
||||
pub mod print_face;
|
||||
@@ -84,6 +86,7 @@ pub mod sketch_equation;
|
||||
pub mod sketch_inspect;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
pub mod surface_match;
|
||||
pub mod ui_prefs;
|
||||
pub mod view_navigation;
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Contract for `surface.match` in `cmd/surface_match.rs` (#10981). It returns `add_feature`'s
|
||||
//! reply, a [`super::features::FeatureResult`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_kernel::{Construction, Continuity};
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::{Point3, StringList, present};
|
||||
use crate::params;
|
||||
|
||||
/// Rebuild one face of a surface body so that its boundary nearest a picked edge chain of another
|
||||
/// body lies on that chain and repeats that body's shape there up to `order`, while the opposite
|
||||
/// boundary keeps its place. The body keeps its name: a match changes it rather than making one.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MatchSurfaceParams {
|
||||
/// The surface body whose face is rebuilt, by name.
|
||||
#[serde(deserialize_with = "body")]
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub faces: Option<MatchFaces>,
|
||||
/// The body the seam must meet, by name.
|
||||
#[serde(deserialize_with = "target")]
|
||||
pub target: String,
|
||||
pub edges: MatchEdges,
|
||||
/// How closely the seam follows the target: `g1` (tangent), `g2` (curvature, the default) or
|
||||
/// `g3`, in any case.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "MatchOrder")]
|
||||
pub order: Option<MatchOrder>,
|
||||
// Not `FeatureCommon`: a match makes no body, so `body_name` and `body_names` would do
|
||||
// nothing. `participants` stays because `timeline.redefine` passes a feature's own back.
|
||||
/// The feature's name in the timeline.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// The bodies taking part, which every feature records; each an existing body or `*`. A match
|
||||
/// changes only `body`, whatever this lists.
|
||||
#[serde(default)]
|
||||
pub participants: Option<StringList>,
|
||||
}
|
||||
|
||||
/// A body name, with the handler's message for anything that isn't text.
|
||||
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(D::Error::custom(whole("`body` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The target's name, with the handler's message for anything that isn't text.
|
||||
fn target<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(D::Error::custom(whole("`target` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Points `params::vec3` reads, or none if any isn't one.
|
||||
fn points(v: &[Value]) -> Option<Vec<Vec3>> {
|
||||
v.iter().map(params::vec3).collect()
|
||||
}
|
||||
|
||||
/// Points on the face to rebuild, at most 64 (default: the body's first face).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MatchFaces(pub Vec<Vec3>);
|
||||
|
||||
impl<'de> Deserialize<'de> for MatchFaces {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let faces =
|
||||
Value::deserialize(d)?.as_array().and_then(|a| points(a)).ok_or_else(|| D::Error::custom("must be a list of points [x, y, z]"))?;
|
||||
if faces.len() > 64 {
|
||||
return Err(D::Error::custom(whole("too many points on the face to rebuild")));
|
||||
}
|
||||
Ok(MatchFaces(faces))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MatchFaces {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MatchFaces".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "Points on the face to rebuild, at most 64 (default: the body's first face).",
|
||||
"type": "array",
|
||||
"items": point,
|
||||
"maxItems": 64
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Points on one edge chain of the target: the rebuilt boundary takes its place. 1 to 10 000,
|
||||
/// with the handler's message for anything else.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MatchEdges(pub Vec<Vec3>);
|
||||
|
||||
impl<'de> Deserialize<'de> for MatchEdges {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_array()
|
||||
.and_then(|a| points(a))
|
||||
.filter(|edges| !edges.is_empty() && edges.len() <= 10_000)
|
||||
.map(MatchEdges)
|
||||
.ok_or_else(|| D::Error::custom(whole("`edges` must list 1 to 10000 points on the target's edge chain")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MatchEdges {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MatchEdges".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "Points on one edge chain of the target: the rebuilt boundary takes its place.",
|
||||
"type": "array",
|
||||
"items": point,
|
||||
"minItems": 1,
|
||||
"maxItems": 10000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A match's continuity, as `params::continuity` reads it for a match (g1 to g3), with its messages.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MatchOrder(pub Continuity);
|
||||
|
||||
impl<'de> Deserialize<'de> for MatchOrder {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
params::continuity("surface.match", Some(&v), "`order`", Construction::Match).map(MatchOrder).map_err(|e| match e {
|
||||
crate::EngineError::BadParams { msg, .. } => D::Error::custom(whole(msg)),
|
||||
other => D::Error::custom(whole(other.to_string())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MatchOrder {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MatchOrder".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "pattern": "^[gG][123]$"})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "surface_match_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,121 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
use super::MatchSurfaceParams;
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// Two points on the cylinder's straight vertical wall seam, as the command's own tests pick it.
|
||||
fn picks(s: &Session) -> Vec<Vec3> {
|
||||
let boss = s.world_state().ok().and_then(|st| st.body("Boss").map(|b| b.body.clone())).unwrap_or_else(|| panic!("no Boss"));
|
||||
let e = boss
|
||||
.edges(boss.size() * 1e-2)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|e| e.length > 25.0 && (e.mid.x.hypot(e.mid.y) - 10.0).abs() < 0.05 && e.mid.z > 5.0 && e.mid.z < 25.0)
|
||||
.unwrap_or_else(|| panic!("the cylinder has a straight vertical wall seam"));
|
||||
let (a, b) = (e.points.first().copied().unwrap_or(e.mid), e.points.last().copied().unwrap_or(e.mid));
|
||||
vec![a + (b - a) * 0.25, a + (b - a) * 0.75]
|
||||
}
|
||||
|
||||
/// A cylinder `Boss` of radius 10, 30 high, and a cylindrical surface `Skirt` of radius 11 from
|
||||
/// the cylinder's wall seam round 60 degrees, as in the command's own tests: a point on the skirt.
|
||||
fn boss_and_skirt(s: &mut Session) -> [f64; 3] {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Round"}));
|
||||
run(s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Round", "distance": 30, "body_name": "Boss"}));
|
||||
let seam = picks(s)[0];
|
||||
let a = seam.y.atan2(seam.x);
|
||||
let at = |t: f64| json!([11.0 * t.cos(), 11.0 * t.sin()]);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Skirt sketch"}));
|
||||
let arc = run(s, "sketch.arc.three_point", json!({"start": at(a), "end": at(a + 60f64.to_radians()), "through": at(a + 30f64.to_radians())}))
|
||||
["created"]["curves"][0]
|
||||
.clone();
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "surface.extrude", json!({"sketch": "Skirt sketch", "curves": [arc], "distance": 30, "body_name": "Skirt"}));
|
||||
let mid = a + 30f64.to_radians();
|
||||
[11.0 * mid.cos(), 11.0 * mid.sin(), 15.0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MatchSurfaceParams>(), &["body", "edges", "faces", "name", "order", "participants", "target"]);
|
||||
}
|
||||
|
||||
/// A call that fails today gets the same message in strict. The handler looks both bodies up
|
||||
/// before it reads the picks, so each call runs on a design with two (a missing `edges` is left
|
||||
/// out: strict says it is required, the handler what it must list).
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let many = vec![[0, 0, 0]; 10_001];
|
||||
let faces = vec![[0, 0, 0]; 65];
|
||||
let e = json!([[0, 0, 0]]);
|
||||
for p in [
|
||||
json!({"target": "T", "edges": e}),
|
||||
json!({"body": 5, "target": "T", "edges": e}),
|
||||
json!({"body": "Nope", "target": "T", "edges": e}),
|
||||
json!({"body": "B", "edges": e}),
|
||||
json!({"body": "B", "target": [], "edges": e}),
|
||||
json!({"body": "B", "target": "Nope", "edges": e}),
|
||||
json!({"body": "B", "target": "T", "faces": faces, "edges": e}),
|
||||
json!({"body": "B", "target": "T", "edges": []}),
|
||||
json!({"body": "B", "target": "T", "edges": null}),
|
||||
json!({"body": "B", "target": "T", "edges": 5}),
|
||||
json!({"body": "B", "target": "T", "edges": [[1, 2]]}),
|
||||
json!({"body": "B", "target": "T", "edges": many}),
|
||||
json!({"body": "B", "target": "T", "edges": e, "order": "g5"}),
|
||||
json!({"body": "B", "target": "T", "edges": e, "order": "g0"}),
|
||||
json!({"body": "B", "target": "T", "edges": e, "order": null}),
|
||||
json!({"body": "B", "target": "T", "edges": e, "order": 2}),
|
||||
json!({"body": "B", "target": "T", "edges": e}),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "body_name": "B"}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "body_name": "T"}));
|
||||
s.param_check = level;
|
||||
s.execute("surface.match", &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #10981: a match replies with `FeatureResult`, in Warn without warnings for good calls (the
|
||||
/// body's first face, and a picked face with every other key) and with them for a misspelt key;
|
||||
/// strict refuses a face point the handler would drop.
|
||||
#[test]
|
||||
fn a_match_replies_with_the_feature_result() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let matches = |r: &Value, warned: bool| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
|
||||
};
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let on_skirt = boss_and_skirt(&mut s);
|
||||
let chain = picks(&s);
|
||||
let r = run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "order": "G2"}));
|
||||
matches(&r, false);
|
||||
assert_eq!(r["changed_bodies"], json!(["Skirt"]), "{r}");
|
||||
let r = run(
|
||||
&mut s,
|
||||
"surface.match",
|
||||
json!({"body": "Skirt", "faces": [on_skirt], "target": "Boss", "edges": chain, "order": "g1", "name": "Seam", "participants": ["*"]}),
|
||||
);
|
||||
matches(&r, false);
|
||||
assert_eq!(r["name"], "Seam", "{r}");
|
||||
matches(&run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "ordr": "g3"})), true);
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("surface.match", &json!({"body": "Skirt", "faces": [on_skirt, [1, 2]], "target": "Boss", "edges": chain}));
|
||||
let error = error.err().map(|e| e.to_string());
|
||||
assert!(error.as_deref().is_some_and(|e| e.contains("`faces` must be a list of points")), "{error:?}");
|
||||
}
|
||||
@@ -78,7 +78,6 @@ frame.member
|
||||
frame.profile.insert
|
||||
frame.profile.list
|
||||
frame.profile.properties
|
||||
inspect.clearance
|
||||
inspect.compare
|
||||
inspect.interference
|
||||
inspect.measure
|
||||
@@ -107,8 +106,6 @@ material.delete
|
||||
material.edit
|
||||
material.inspect
|
||||
material.list
|
||||
material.properties.directional
|
||||
material.properties.validate
|
||||
mesh.convert
|
||||
motion.delete
|
||||
motion.export
|
||||
@@ -332,7 +329,6 @@ surface.extend
|
||||
surface.extrude
|
||||
surface.fair
|
||||
surface.loft
|
||||
surface.match
|
||||
surface.offset
|
||||
surface.patch
|
||||
surface.reverse_normal
|
||||
|
||||
@@ -3291,6 +3291,16 @@ fn unequal_chamfers_on_circular_rims() {
|
||||
assert!(rel(full - volume(&mut s), flipped) < 5e-3, "{} vs {flipped}", full - volume(&mut s));
|
||||
}
|
||||
|
||||
/// A solid box top is not a rim: Lip says to Shell first (#10437).
|
||||
#[test]
|
||||
fn lip_on_a_solid_box_says_to_shell_first() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
|
||||
let err = s.execute("plastic.lip", &json!({"face": [20, 15, 10], "width": 1, "height": 2})).unwrap_err().to_string();
|
||||
assert!(err.contains("Shell"), "{err}");
|
||||
assert!(err.contains("rim") || err.contains("opening"), "{err}");
|
||||
}
|
||||
|
||||
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
|
||||
#[test]
|
||||
fn lip_after_boss() {
|
||||
|
||||
@@ -38,7 +38,7 @@ ids, plane axes, worked examples and the known traps of this version; `file: \"S
|
||||
Plan: work the numbers out first (the parameters, the expected volume and bounding box after each stage, the world point you \
|
||||
will use for each edge or face pick). Then build in a few `batch` calls (all-or-nothing; the reply names the failing step; \
|
||||
`save`, `measure` and `set_parameter` can be steps). Use `verbose: false`. Name every feature. `list_commands {\"filter\": word}` \
|
||||
gives ids and one-line parameter docs (never unfiltered); `describe_command` gives one command's docs and schemas.\n\n\
|
||||
gives ids and one-line parameter docs (never unfiltered: it also takes prefix, panel, compact: true and limit); `describe_command` gives one command's docs and schemas.\n\n\
|
||||
Routes: a plate, block, boss, standoff or hole is cheapest as a primitive with expression fields (`solid.box`, `solid.cylinder`, \
|
||||
`solid.hole`, `solid.fillet`, `solid.shell`, patterns and mirror), no sketch. Sketch when the profile is not a box or a circle. \
|
||||
Fillet outside edges before shelling, build inside features after the shell, then the holes and cuts through walls. Give a blind \
|
||||
|
||||
@@ -1741,6 +1741,75 @@ fn list_commands_filter_by_a_command_id_matches_ids() {
|
||||
assert!(ids.iter().all(|id| id.contains("sketch.delete")), "{} commands matched: {ids:?}", ids.len());
|
||||
}
|
||||
|
||||
/// #11726: `list_commands` narrows by id prefix and by panel, shrinks each entry to id and label
|
||||
/// or to chosen keys, and pages; with none of these the reply is the whole catalog as before. A
|
||||
/// batch step narrows the same way, and a bad argument is a tool error that names it.
|
||||
#[test]
|
||||
fn list_commands_narrows_by_prefix_panel_fields_and_page() {
|
||||
let mut s = server();
|
||||
let all = tool(&mut s, "list_commands", json!({}));
|
||||
let all = all.as_array().unwrap();
|
||||
assert!(all.len() > 100 && all[0].get("params").is_some(), "the whole catalog, every key");
|
||||
let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
let sketch = tool(&mut s, "list_commands", json!({"prefix": "SKETCH."}));
|
||||
assert!(!ids(&sketch).is_empty() && ids(&sketch).iter().all(|id| id.starts_with("sketch.")), "{:?}", ids(&sketch));
|
||||
let want = all.iter().filter(|c| c["id"].as_str().unwrap().starts_with("sketch.")).count();
|
||||
assert_eq!(ids(&sketch).len(), want, "every sketch command, not only those on a toolbar");
|
||||
let modify = tool(&mut s, "list_commands", json!({"panel": "modify"}));
|
||||
assert!(!ids(&modify).is_empty(), "{modify}");
|
||||
assert!(
|
||||
modify
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|c| c["panel"].as_str().unwrap().eq_ignore_ascii_case("modify") || c["tab"].as_str().unwrap().eq_ignore_ascii_case("modify")),
|
||||
"only the whole panel name matches"
|
||||
);
|
||||
let compact = tool(&mut s, "list_commands", json!({"compact": true}));
|
||||
assert_eq!(ids(&compact), ids(&Value::Array(all.clone())), "compact keeps every command");
|
||||
assert!(compact.as_array().unwrap().iter().all(|c| c.as_object().unwrap().keys().eq(["id", "label"].iter())), "{}", compact[0]);
|
||||
assert!(compact.to_string().len() * 5 < Value::Array(all.clone()).to_string().len(), "compact is far smaller");
|
||||
let picked = tool(&mut s, "list_commands", json!({"prefix": "solid.fillet", "fields": ["id", "enabled"], "compact": true}));
|
||||
assert_eq!(picked[0], json!({"id": "solid.fillet", "enabled": picked[0]["enabled"]}), "fields wins over compact");
|
||||
// Pages: together they are the whole match list, in order.
|
||||
let mut paged = Vec::new();
|
||||
let mut offset = json!(0);
|
||||
while let Some(at) = offset.as_u64() {
|
||||
let page = tool(&mut s, "list_commands", json!({"prefix": "sketch.", "compact": true, "limit": 7, "offset": at}));
|
||||
assert_eq!(page["total"], want, "{page}");
|
||||
assert_eq!(page["offset"], at);
|
||||
assert!(page["commands"].as_array().unwrap().len() <= 7);
|
||||
paged.extend(ids(&page["commands"]));
|
||||
offset = page["next_offset"].clone();
|
||||
}
|
||||
assert_eq!(paged, ids(&sketch));
|
||||
let past = tool(&mut s, "list_commands", json!({"limit": 5, "offset": 1_000_000}));
|
||||
assert_eq!((past["commands"].as_array().unwrap().len(), &past["next_offset"]), (0, &Value::Null), "{past}");
|
||||
// A batch step narrows as the tool does.
|
||||
let r = tool(&mut s, "batch", json!({"commands": [{"command": "list_commands", "params": {"prefix": "sketch.", "compact": true, "limit": 3}}]}));
|
||||
let step = r["results"][0].clone();
|
||||
assert_eq!(step["commands"].as_array().map(Vec::len), Some(3), "{r}");
|
||||
assert_eq!(step["total"], want, "{r}");
|
||||
for bad in [
|
||||
json!({"limit": 0}),
|
||||
json!({"limit": -1}),
|
||||
json!({"offset": "x"}),
|
||||
json!({"fields": ["nope"]}),
|
||||
json!({"fields": "id"}),
|
||||
json!({"compact": 1}),
|
||||
json!({"prefix": 3}),
|
||||
] {
|
||||
// The argument schema refuses a wrong type before the tool runs; the tool refuses the rest.
|
||||
let r = raw_tool(&mut s, "list_commands", bad.clone());
|
||||
let text = r["result"]["content"][0]["text"].as_str().or(r["error"]["message"].as_str()).unwrap_or_default().to_string();
|
||||
assert!(r["result"]["isError"] == json!(true) || r["error"]["code"].is_i64(), "{bad}: {r}");
|
||||
let key = bad.as_object().unwrap().keys().next().unwrap().clone();
|
||||
assert!(text.contains(&format!("`{key}`")), "{bad}: the error names the argument: {text}");
|
||||
let r = raw_tool(&mut s, "batch", json!({"commands": [{"command": "list_commands", "params": bad.clone()}]}));
|
||||
assert!(r["result"]["isError"] == json!(true) || r.to_string().contains(&format!("`{key}`")), "{bad} as a step: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility shims (docs/compatibility.md): a design saved before the rename (legacy
|
||||
/// extension and format tag) opens through MCP, and the legacy resource URIs still read.
|
||||
#[test]
|
||||
|
||||
+124
-13
@@ -102,8 +102,26 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
enabled right now. Ids are dotted and lower-case (solid.extrude, sketch.create, \
|
||||
solid.fillet, sketch.inspect, timeline.rollback). Optional case-insensitive \
|
||||
substring `filter` over id, label, tab and panel (not the parameter docs, so a command id \
|
||||
finds that command rather than every command whose docs mention it).",
|
||||
obj(json!({"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\"")}), &[]),
|
||||
finds that command rather than every command whose docs mention it). Narrow it further with `prefix` \
|
||||
(start of the id) and `panel` (a whole panel or tab name); shrink each entry with `compact` (id and label) \
|
||||
or `fields`; page it with `limit` and `offset`, which make the reply {commands, total, offset, next_offset}. \
|
||||
With none of these the reply is the whole catalog, about 350 KB.",
|
||||
obj(
|
||||
json!({
|
||||
"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\""),
|
||||
"prefix": string("Case-insensitive start of the command id, e.g. \"sketch.\""),
|
||||
"panel": string("A whole toolbar panel or tab name, case-insensitive, e.g. \"Modify\" or \"SKETCH\""),
|
||||
"compact": {"type": "boolean", "description": "Each entry is only {id, label} (default false)"},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": COMMAND_FIELDS},
|
||||
"description": "Keep only these keys of each entry; overrides `compact`",
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "description": "At most this many commands; the reply becomes {commands, total, offset, next_offset}"},
|
||||
"offset": {"type": "integer", "minimum": 0, "description": "Skip this many matches first (default 0); with it the reply is paged as with `limit`"},
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
@@ -146,7 +164,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"Run commands in order, stopping at the first error. Each step is {command, params}: an engine command id \
|
||||
(as list_commands lists them). A step may also name one of these tools with that tool's arguments as params, \
|
||||
and runs the engine command it stands for: set_parameter (parameters.change; `value` or `expression`), \
|
||||
list_commands (engine.commands, with `filter`), describe_command (engine.describe), execute ({command, params}), \
|
||||
list_commands (engine.commands, narrowed by its arguments), describe_command (engine.describe), execute ({command, params}), \
|
||||
measure (inspect.measure), compare_picture (inspect.compare_picture, numbers only: no overlay PNG), \
|
||||
inspect_design (document.inspect), undo, redo, export, save, open, new_design, crash_reports; screenshot, \
|
||||
body_topology, batch and pending can't be steps. On an error the steps already run are undone (unless \
|
||||
@@ -867,8 +885,9 @@ fn measure_summary_wanted(a: &Map<String, Value>) -> bool {
|
||||
fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<Value, String> {
|
||||
match name {
|
||||
"list_commands" => {
|
||||
let query = CommandQuery::parse(a)?;
|
||||
let all = b.call("engine.commands", json!({}))?;
|
||||
Ok(filter_commands(all, str_arg(a, "filter")))
|
||||
Ok(query.apply(all))
|
||||
}
|
||||
"describe_command" => {
|
||||
let id = str_arg(a, "id").ok_or("`id` is required")?;
|
||||
@@ -941,11 +960,100 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
|
||||
/// The command catalog filtered by a case-insensitive substring of id, label, panel or tab
|
||||
/// (`list_commands`; not the parameter docs, which name other commands, #269).
|
||||
fn filter_commands(all: Value, filter: Option<&str>) -> Value {
|
||||
let Some(f) = filter.map(str::to_lowercase).filter(|f| !f.is_empty()) else { return all };
|
||||
let hit =
|
||||
|c: &Value| ["id", "label", "panel", "tab"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f)));
|
||||
Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default())
|
||||
/// The keys of a `list_commands` entry, which `fields` may keep.
|
||||
const COMMAND_FIELDS: &[&str] = &["id", "label", "tab", "panel", "icon", "shortcut", "params", "enabled", "disabled_reason", "planned"];
|
||||
|
||||
/// How `list_commands` narrows the catalog (#11726): which commands, which keys of each, and
|
||||
/// which page. The default keeps the whole catalog as it is.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct CommandQuery {
|
||||
filter: Option<String>,
|
||||
prefix: Option<String>,
|
||||
panel: Option<String>,
|
||||
fields: Option<Vec<String>>,
|
||||
limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
}
|
||||
|
||||
impl CommandQuery {
|
||||
fn parse(a: &Map<String, Value>) -> Result<Self, String> {
|
||||
let text = |k: &str| -> Result<Option<String>, String> {
|
||||
match a.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.to_lowercase()).filter(|s| !s.is_empty())),
|
||||
Some(_) => Err(format!("`{k}` must be a string")),
|
||||
}
|
||||
};
|
||||
let count = |k: &str, least: u64| -> Result<Option<usize>, String> {
|
||||
match a.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| *n >= least)
|
||||
.map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX)))
|
||||
.ok_or_else(|| format!("`{k}` must be a whole number of at least {least}")),
|
||||
}
|
||||
};
|
||||
let compact = match a.get("compact") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or("`compact` must be true or false")?,
|
||||
};
|
||||
let fields = match a.get("fields") {
|
||||
None | Some(Value::Null) => compact.then(|| vec!["id".to_string(), "label".to_string()]),
|
||||
Some(Value::Array(v)) => {
|
||||
let mut keep = Vec::new();
|
||||
for f in v {
|
||||
let name = f.as_str().filter(|n| COMMAND_FIELDS.contains(n)).ok_or_else(|| {
|
||||
let shown: String = f.to_string().chars().take(64).collect();
|
||||
format!("`fields` takes the entry keys {}; not {shown}", COMMAND_FIELDS.join(", "))
|
||||
})?;
|
||||
if !keep.iter().any(|k| k == name) {
|
||||
keep.push(name.to_string());
|
||||
}
|
||||
}
|
||||
Some(keep)
|
||||
}
|
||||
Some(_) => return Err("`fields` must be a list of entry keys".into()),
|
||||
};
|
||||
Ok(Self {
|
||||
filter: text("filter")?,
|
||||
prefix: text("prefix")?,
|
||||
panel: text("panel")?,
|
||||
fields,
|
||||
limit: count("limit", 1)?,
|
||||
offset: count("offset", 0)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn matches(&self, c: &Value) -> bool {
|
||||
let field = |k: &str| c.get(k).and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
|
||||
self.filter.as_ref().is_none_or(|f| ["id", "label", "panel", "tab"].iter().any(|k| field(k).contains(f.as_str())))
|
||||
&& self.prefix.as_ref().is_none_or(|p| field("id").starts_with(p.as_str()))
|
||||
&& self.panel.as_ref().is_none_or(|p| field("panel") == *p || field("tab") == *p)
|
||||
}
|
||||
|
||||
/// The catalog narrowed: a list, or `{commands, total, offset, next_offset}` when paged.
|
||||
fn apply(&self, all: Value) -> Value {
|
||||
let Value::Array(all) = all else { return all };
|
||||
let hits: Vec<Value> = all.into_iter().filter(|c| self.matches(c)).collect();
|
||||
let total = hits.len();
|
||||
let offset = self.offset.unwrap_or(0).min(total);
|
||||
let take = self.limit.unwrap_or(usize::MAX);
|
||||
let page: Vec<Value> = hits
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(take)
|
||||
.map(|c| match (&self.fields, c) {
|
||||
(Some(keep), Value::Object(m)) => Value::Object(m.into_iter().filter(|(k, _)| keep.contains(k)).collect()),
|
||||
(_, c) => c,
|
||||
})
|
||||
.collect();
|
||||
if self.limit.is_none() && self.offset.is_none() {
|
||||
return Value::Array(page);
|
||||
}
|
||||
let end = offset + page.len();
|
||||
json!({"commands": page, "total": total, "offset": offset, "next_offset": (end < total).then_some(end)})
|
||||
}
|
||||
}
|
||||
|
||||
/// `pending`: the end of a request still running when its call returned (bridged only; a
|
||||
@@ -975,8 +1083,8 @@ fn pending(b: &mut dyn Backend) -> Result<Value, String> {
|
||||
|
||||
/// How a tool step's result is cut down to what the tool itself would reply.
|
||||
enum Narrow {
|
||||
/// A `list_commands` step's catalog, filtered as the tool filters it.
|
||||
Commands(String),
|
||||
/// A `list_commands` step's catalog, narrowed as the tool narrows it.
|
||||
Commands(CommandQuery),
|
||||
/// A `measure {summary: true}` step's reply, as `measure_summary` shapes it.
|
||||
MeasureSummary,
|
||||
}
|
||||
@@ -984,7 +1092,7 @@ enum Narrow {
|
||||
impl Narrow {
|
||||
fn apply(&self, v: Value) -> Value {
|
||||
match self {
|
||||
Narrow::Commands(f) => filter_commands(v, Some(f)),
|
||||
Narrow::Commands(q) => q.apply(v),
|
||||
Narrow::MeasureSummary => measure_summary(v),
|
||||
}
|
||||
}
|
||||
@@ -1010,7 +1118,10 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
|
||||
let a = params.as_object().unwrap_or(&empty);
|
||||
let mapped = |c: &str, p: Value| Ok((c.to_string(), p, None));
|
||||
match command {
|
||||
"list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(|f| Narrow::Commands(f.to_string())))),
|
||||
"list_commands" => {
|
||||
let query = CommandQuery::parse(a)?;
|
||||
Ok(("engine.commands".into(), json!({}), (query != CommandQuery::default()).then_some(Narrow::Commands(query))))
|
||||
}
|
||||
"set_parameter" => {
|
||||
let mut p = pick(a, &["name", "unit", "comment"]);
|
||||
p["expression"] = parameter_value(a)?;
|
||||
|
||||
@@ -133,8 +133,20 @@ pub fn open_object(app: &mut SolveApp, command: &str, params: &Value, fallback:
|
||||
// copy beside it, the card shows when done. Headless and in tests (no wait set), here.
|
||||
// The worker reports how many bodies it has measured, and stops when the card closes (#8706).
|
||||
let progress = Arc::new(Progress::default());
|
||||
// Tests hold the reply until let go (`apply_gate`), so a window thread held up under load
|
||||
// cannot find the measurement already in where the card should still be measuring.
|
||||
#[cfg(test)]
|
||||
let gate = app.apply_gate.clone();
|
||||
let hold = move || {
|
||||
#[cfg(test)]
|
||||
if let Some(gate) = gate
|
||||
&& let Ok(gate) = gate.lock()
|
||||
{
|
||||
let _ = gate.recv();
|
||||
}
|
||||
};
|
||||
if let Some(wait) = app.apply_off_thread
|
||||
&& let Some(rx) = measure_beside(app.session.scratch(), command, params, progress.clone())
|
||||
&& let Some(rx) = measure_beside(app.session.scratch(), command, params, progress.clone(), hold)
|
||||
{
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(reply) => opened(app, reply, fallback),
|
||||
@@ -190,13 +202,14 @@ fn opened(app: &mut SolveApp, reply: Result<Value, String>, fallback: String) {
|
||||
}
|
||||
|
||||
/// Run `command` on `copy` on a worker, watched by `progress`; the reply comes on the returned
|
||||
/// channel.
|
||||
/// channel once `hold` returns (which only tests make wait).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn measure_beside(
|
||||
mut copy: solidcraft_engine::Session,
|
||||
command: &str,
|
||||
params: &Value,
|
||||
progress: Arc<Progress>,
|
||||
hold: impl FnOnce() + Send + 'static,
|
||||
) -> Option<std::sync::mpsc::Receiver<Result<Value, String>>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (command, params) = (command.to_string(), params.clone());
|
||||
@@ -204,6 +217,7 @@ fn measure_beside(
|
||||
.name("properties".into())
|
||||
.spawn(move || {
|
||||
let reply = solidcraft_engine::doc::progress::watch(progress, || copy.execute(&command, ¶ms));
|
||||
hold();
|
||||
let _ = tx.send(reply.map_err(|e| e.to_string()));
|
||||
})
|
||||
.ok()?;
|
||||
@@ -217,6 +231,7 @@ fn measure_beside(
|
||||
_command: &str,
|
||||
_params: &Value,
|
||||
_progress: Arc<Progress>,
|
||||
_hold: impl FnOnce() + Send + 'static,
|
||||
) -> Option<std::sync::mpsc::Receiver<Result<Value, String>>> {
|
||||
None
|
||||
}
|
||||
@@ -631,9 +646,14 @@ mod tests {
|
||||
h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"})).unwrap();
|
||||
let here = h.app.session.execute("component.properties", &json!({})).unwrap();
|
||||
h.app.apply_off_thread = Some(std::time::Duration::ZERO);
|
||||
// The worker holds its reply until let go, so a window thread held up under load cannot
|
||||
// find it already in.
|
||||
let (finish, gate) = std::sync::mpsc::channel();
|
||||
h.app.apply_gate = Some(std::sync::Arc::new(std::sync::Mutex::new(gate)));
|
||||
super::open_object(&mut h.app, "component.properties", &json!({}), String::new());
|
||||
assert!(h.app.menu.props.is_none(), "nothing is waited for on the window thread");
|
||||
let m = h.app.menu.props_measuring.take().expect("measuring beside the window");
|
||||
finish.send(()).unwrap();
|
||||
assert_eq!(m.rx.recv().unwrap().unwrap(), here, "the copy measures what the design here does");
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
@@ -674,11 +694,15 @@ mod tests {
|
||||
h.app.run("occurrence.copy", json!({"component": block, "translate": [20 * i, 0, 0]})).unwrap();
|
||||
}
|
||||
h.app.apply_off_thread = Some(std::time::Duration::ZERO);
|
||||
// Held until checked: under load the worker could otherwise finish before the menu returns.
|
||||
let (finish, gate) = std::sync::mpsc::channel();
|
||||
h.app.apply_gate = Some(std::sync::Arc::new(std::sync::Mutex::new(gate)));
|
||||
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Component { id: 0 });
|
||||
let item = items.iter().find(|i| i.label == "Properties").expect("Properties item");
|
||||
crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0));
|
||||
assert!(h.app.menu.props_measuring.is_some(), "the menu returns at once, the card measuring");
|
||||
assert!(h.app.menu.props.is_none());
|
||||
finish.send(()).unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
|
||||
while h.app.menu.props.is_none() {
|
||||
assert!(std::time::Instant::now() < deadline, "the measurements never came in");
|
||||
|
||||
@@ -31,6 +31,8 @@ use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Proj;
|
||||
|
||||
pub(crate) mod window;
|
||||
|
||||
/// A dimension as drawn this frame (screen space), for hit testing.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Drawn {
|
||||
@@ -153,6 +155,7 @@ fn shown_error(id: &str, e: &str) -> String {
|
||||
pub fn cancel() {
|
||||
EDIT.with(|edit| *edit.borrow_mut() = None);
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
window::forget();
|
||||
}
|
||||
|
||||
/// Infer the measured value through the same command as scripts, without creating a real
|
||||
@@ -192,6 +195,11 @@ pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> R
|
||||
/// it. Midpoint pairs and dimensions that would only be driven keep the place-first flow:
|
||||
/// the pointer chooses the orientation, and a click places the text before entering a value.
|
||||
pub fn begin_live(app: &mut SolveApp, entities: Vec<String>, pointer: Option<Pos2>) -> Result<bool, String> {
|
||||
// The value window opens on placed text (`window`): a window following the pointer could
|
||||
// not be reached.
|
||||
if window::on(app) {
|
||||
return Ok(false);
|
||||
}
|
||||
// A midpoint pair chooses its orientation and text position before entering a value.
|
||||
// This also permits switching freely between driving and reference spans.
|
||||
let midpoint_pair = entities.len() == 2 && entities.iter().all(|id| id.starts_with("mid:"));
|
||||
@@ -844,6 +852,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
}
|
||||
}
|
||||
DRAWN.with(|d| *d.borrow_mut() = drawn);
|
||||
window::paint(app, painter, proj);
|
||||
edit_box(app, ui.ctx());
|
||||
}
|
||||
|
||||
@@ -1005,6 +1014,10 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
return;
|
||||
};
|
||||
if window::on(app) {
|
||||
let (done, escaped) = window::show(app, ctx, &mut e, at);
|
||||
return settle(app, e, done, escaped);
|
||||
}
|
||||
let id = egui::Id::new("sc_dim_edit");
|
||||
let mut done: Option<bool> = None;
|
||||
let mut escaped = false;
|
||||
@@ -1074,8 +1087,14 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
});
|
||||
e.fresh = e.fresh.saturating_sub(1);
|
||||
e.shown = Some(anchor);
|
||||
settle(app, e, done, escaped);
|
||||
}
|
||||
|
||||
/// Apply or drop the edit as the box or the window answered (`done`: Some(true) applies, Some(false)
|
||||
/// drops it, and `escaped` the Dimension tool's picks with it), or keep it open.
|
||||
fn settle(app: &mut SolveApp, mut e: Edit, done: Option<bool>, escaped: bool) {
|
||||
e.fresh = e.fresh.saturating_sub(1);
|
||||
match done {
|
||||
Some(true) => {
|
||||
let result = if let Some(draft) = &e.draft {
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
//! The dimension value window (Preferences › Sketch › Dimension values: In a window, #7246).
|
||||
//! Placing a dimension with the Dimension tool, or double-clicking one, opens a small window at
|
||||
//! its text, kept inside the view, instead of the box on the dimension:
|
||||
//! - the value field, which takes what the box takes (numbers, units, expressions, parameter
|
||||
//! names, `w = 20`; a click on another dimension inserts its name);
|
||||
//! - arrows that step a plain number by the step preferences: a length by `dimension_step_mm`,
|
||||
//! written in the unit the value is written in (none: the design's), an angle by
|
||||
//! `dimension_step_deg`. Up/Down and the mouse wheel over the field step it too (Shift: ten
|
||||
//! steps, Alt: a tenth, as in the other value fields). An expression is left as typed;
|
||||
//! - a check (OK, also Enter) and a cross (Cancel, also Esc).
|
||||
//!
|
||||
//! While it is open the sketch shows the value: what it moves is drawn in the preview colour, and
|
||||
//! why OK would refuse it shows under the field. Nothing reaches the design until OK, which
|
||||
//! applies through the box's own path (`edit_box`), so undo, driven dimensions and refusals are
|
||||
//! the same as there. Cancel drops this edit only: a new dimension is not added, and the
|
||||
//! Dimension tool stays on for the next one. A click elsewhere drops it as well, as it does the
|
||||
//! box.
|
||||
//!
|
||||
//! With the window the Dimension tool places a new dimension's text first (`begin_live` keeps the
|
||||
//! place-first flow), and the window opens on the placed text: a window following the pointer
|
||||
//! could never be reached.
|
||||
|
||||
use super::*;
|
||||
use crate::prefs::{DIMENSION_STEP_DEG, DIMENSION_STEP_MM, DimensionInput};
|
||||
use solidcraft_engine::doc::expr::{Kind, unit_info};
|
||||
|
||||
/// The window's area.
|
||||
const AREA: &str = "sc_dim_window";
|
||||
|
||||
/// The window is on (Preferences › Sketch).
|
||||
pub(super) fn on(app: &SolveApp) -> bool {
|
||||
app.preferences.dimension_input == DimensionInput::Window
|
||||
}
|
||||
|
||||
/// What a step adds to a length (millimetres) and to an angle (degrees). A saved value outside
|
||||
/// what Preferences accepts (a hand edit) steps by the default.
|
||||
fn steps(app: &SolveApp) -> (f64, f64) {
|
||||
let or_default = |value: f64, accepted: std::ops::RangeInclusive<f64>| if accepted.contains(&value) { value } else { 1.0 };
|
||||
(or_default(app.preferences.dimension_step_mm, DIMENSION_STEP_MM), or_default(app.preferences.dimension_step_deg, DIMENSION_STEP_DEG))
|
||||
}
|
||||
|
||||
/// Whether a dimension's value is a length or an angle, from its parameter's unit.
|
||||
fn kind(doc: &solidcraft_engine::doc::Document, param: &str) -> Kind {
|
||||
doc.param(param).and_then(|p| unit_info(&p.unit)).map_or(Kind::Length, |(kind, _)| kind)
|
||||
}
|
||||
|
||||
/// `text` moved by `steps` steps (negative: down) of `step_mm` (a length) or `step_deg` (an
|
||||
/// angle). A plain number keeps the unit it is written in (none: `design_unit` for a length,
|
||||
/// degrees for an angle) and its decimal mark, gaining the decimal places the step needs in that
|
||||
/// unit. `None` for anything else: an expression is left as typed.
|
||||
pub(crate) fn stepped(text: &str, steps: f64, kind: Kind, design_unit: &str, step_mm: f64, step_deg: f64) -> Option<String> {
|
||||
crate::value_step::shifted(text, |written| {
|
||||
let unit = match written {
|
||||
"" if kind == Kind::Angle => "deg",
|
||||
"" => design_unit,
|
||||
"\"" | "″" => "in",
|
||||
"'" | "′" => "ft",
|
||||
unit => unit,
|
||||
};
|
||||
let (unit_kind, per_unit) = unit_info(unit)?;
|
||||
let step = match (kind, unit_kind) {
|
||||
(Kind::Length, Kind::Length) => step_mm,
|
||||
(Kind::Angle, Kind::Angle) => step_deg.to_radians(),
|
||||
_ => return None,
|
||||
};
|
||||
let amount = step / per_unit * steps;
|
||||
(amount.is_finite() && per_unit > 0.0).then(|| (amount, places(amount)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The fewest decimal places, at most six, that write `amount` as it is.
|
||||
fn places(amount: f64) -> usize {
|
||||
let a = amount.abs();
|
||||
(0..6)
|
||||
.find(|p| {
|
||||
let scale = 10f64.powi(*p);
|
||||
((a * scale).round() / scale - a).abs() <= a * 1e-9
|
||||
})
|
||||
.map_or(6, |p| p as usize)
|
||||
}
|
||||
|
||||
/// What OK would make of an existing dimension's typed value, worked out once per text: the
|
||||
/// sketch it gives, or the refusal OK would show. It runs OK's own command on a scratch copy of
|
||||
/// the session, so nothing reaches the design.
|
||||
struct Existing {
|
||||
doc: Arc<solidcraft_engine::doc::Document>,
|
||||
param: String,
|
||||
expr: String,
|
||||
sketch: Option<Arc<solidcraft_engine::doc::SolvedSketch>>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static EXISTING: RefCell<Option<Existing>> = const { RefCell::new(None) };
|
||||
/// Where the open window sits, for which parameter, and the frame it was last drawn in. It
|
||||
/// stays where it opened while it is open, so its arrows stay under the pointer while the
|
||||
/// stepped value moves the dimension's text.
|
||||
static ANCHOR: RefCell<Option<(String, u64, Pos2)>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Whether the open window is under `p`: the wheel there steps its value, so the camera under it
|
||||
/// does not zoom (navigation asks).
|
||||
pub(crate) fn covers(ctx: &egui::Context, p: Pos2) -> bool {
|
||||
super::editing() && ctx.memory(|m| m.area_rect(egui::Id::new(AREA))).is_some_and(|r| r.contains(p))
|
||||
}
|
||||
|
||||
/// Forget the worked-out value (the edit closed).
|
||||
pub(super) fn forget() {
|
||||
EXISTING.with(|e| *e.borrow_mut() = None);
|
||||
}
|
||||
|
||||
fn existing(app: &SolveApp, param: &str, expr: &str) -> (Option<Arc<solidcraft_engine::doc::SolvedSketch>>, Option<String>) {
|
||||
EXISTING.with(|cell| {
|
||||
let mut cell = cell.borrow_mut();
|
||||
let current = cell.as_ref().is_some_and(|x| Arc::ptr_eq(&x.doc, &app.session.doc) && x.param == param && x.expr == expr);
|
||||
if !current {
|
||||
let expression = crate::length_input::expression(&app.session.doc, expr, kind(&app.session.doc, param));
|
||||
let mut scratch = app.session.scratch();
|
||||
let (sketch, error) = match scratch.execute("parameters.change", &json!({"name": param, "expression": expression})) {
|
||||
Ok(_) => {
|
||||
(app.session.active_sketch.and_then(|id| scratch.world_state().ok()?.sketches.iter().find(|s| s.feature == id).cloned()), None)
|
||||
}
|
||||
Err(e) => (None, Some(shown_error("parameters.change", &e.to_string()))),
|
||||
};
|
||||
*cell = Some(Existing { doc: app.session.doc.clone(), param: param.to_string(), expr: expr.to_string(), sketch, error });
|
||||
}
|
||||
cell.as_ref().map(|x| (x.sketch.clone(), x.error.clone())).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw what the open window's value moves in the sketch, in the preview colour, over the sketch
|
||||
/// as it is. A value OK would refuse shows nothing.
|
||||
pub(super) fn paint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if !on(app) {
|
||||
return;
|
||||
}
|
||||
let Some((param, expr, draft)) = EDIT.with(|edit| {
|
||||
let edit = edit.borrow();
|
||||
let e = edit.as_ref()?;
|
||||
let draft = e.draft.as_ref().map(|d| (d.sketch, d.preview.clone(), d.error.is_some()));
|
||||
Some((e.param.clone(), e.expr.clone(), draft))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let Some(sketch) = app.session.active_sketch else { return };
|
||||
let after = match draft {
|
||||
Some((id, preview, false)) => preview.sketches.iter().find(|s| s.feature == id).cloned(),
|
||||
Some((_, _, true)) => None,
|
||||
None => existing(app, ¶m, &expr).0,
|
||||
};
|
||||
let state = app.session.world_state().ok();
|
||||
let changes = after.and_then(|after| crate::preview::sketch_changes(state.as_ref().and_then(|state| state.sketch(sketch)), &after));
|
||||
let mut budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536);
|
||||
let curves = changes.map_or(0, |changes| crate::preview::paint_sketch(&changes, painter, proj, &mut budget));
|
||||
crate::scenario::publish_count("dimwin:preview_curves", curves as f64);
|
||||
}
|
||||
|
||||
/// Where the window goes: where it already is while it stays open, else under the dimension's
|
||||
/// text `at` (over it where the view has no room below); always inside the view's free area.
|
||||
/// `size` is the window's as drawn last frame.
|
||||
fn place(app: &SolveApp, ctx: &egui::Context, param: &str, at: Pos2, size: egui::Vec2) -> Pos2 {
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
let view = app.viewport.layout.as_ref().map(|layout| layout.drawable).filter(|r| r.is_finite() && r.is_positive());
|
||||
ANCHOR.with(|anchor| {
|
||||
let mut anchor = anchor.borrow_mut();
|
||||
let open = anchor.as_ref().filter(|(open, drawn, _)| open == param && drawn.saturating_add(1) >= frame).map(|x| x.2);
|
||||
let mut pos = open.unwrap_or_else(|| {
|
||||
let gap = Tokens::get().space_12;
|
||||
let below = Pos2::new(at.x - size.x * 0.5, at.y + gap);
|
||||
if view.is_some_and(|rect| below.y + size.y > rect.bottom()) { Pos2::new(below.x, at.y - gap - size.y) } else { below }
|
||||
});
|
||||
if let Some(rect) = view {
|
||||
pos.x = pos.x.clamp(rect.left(), (rect.right() - size.x).max(rect.left()));
|
||||
pos.y = pos.y.clamp(rect.top(), (rect.bottom() - size.y).max(rect.top()));
|
||||
}
|
||||
*anchor = Some((param.to_string(), frame, pos));
|
||||
pos
|
||||
})
|
||||
}
|
||||
|
||||
/// The check or the cross in `rect`, its icon in colour as the view's own OK and Cancel draw it
|
||||
/// (`view_confirm`): hover, the pointing hand, its tooltip, and its handle `key` for scenarios and
|
||||
/// the control channel. True when clicked.
|
||||
fn button(ui: &mut egui::Ui, rect: Rect, key: &str, tip: &str, icon: &str) -> bool {
|
||||
let t = Tokens::get();
|
||||
let response = ui.interact(rect, ui.id().with(key), egui::Sense::click());
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, t.control_radius, t.hover);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
crate::icons::paint_icon(ui.painter(), rect.shrink(t.space_4), icon, 1.0);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
crate::scenario::publish_target(key, rect);
|
||||
response.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
/// Draw the window for the open edit `e` at its dimension's text `at` (screen) and answer it:
|
||||
/// `Some(true)` applies it (the check, Enter), `Some(false)` drops it; the second value says
|
||||
/// whether a new dimension's picks go too (the cross, Esc), as Esc in the box does.
|
||||
pub(super) fn show(app: &mut SolveApp, ctx: &egui::Context, e: &mut Edit, at: Pos2) -> (Option<bool>, bool) {
|
||||
let t = Tokens::get();
|
||||
let id = egui::Id::new("sc_dim_edit");
|
||||
let area = egui::Id::new(AREA);
|
||||
let (kind, error) = match &e.draft {
|
||||
Some(draft) => (kind(&draft.preview_doc, &e.param), draft.error.clone()),
|
||||
None => (kind(&app.session.doc, &e.param), existing(app, &e.param, &e.expr).1),
|
||||
};
|
||||
let (step_mm, step_deg) = steps(app);
|
||||
let unit = crate::length_input::unit(&app.session.doc).to_string();
|
||||
let step = |text: &str, steps: f64| stepped(text, steps, kind, &unit, step_mm, step_deg);
|
||||
let step_text = if kind == Kind::Angle {
|
||||
format!("{:.*}°", usize::from(app.preferences.angle_decimals), step_deg)
|
||||
} else {
|
||||
crate::prefs::format_length(step_mm, &app.session.doc.units, app.preferences.length_decimals)
|
||||
};
|
||||
let last = ctx.memory(|m| m.area_rect(area));
|
||||
let width = t.space_24 * 8.0;
|
||||
let guess = egui::vec2(width + t.space_6 * 2.0, t.control_height * 2.0 + t.space_6 * 3.0);
|
||||
let anchor = place(app, ctx, &e.param, at, last.map_or(guess, |r| r.size()));
|
||||
// A click on the window (an arrow) takes the keyboard from its field, as any click away does;
|
||||
// the field takes it back instead of closing.
|
||||
let on_window = ctx.input(|i| i.pointer.any_click() && i.pointer.interact_pos().is_some_and(|p| last.is_some_and(|r| r.contains(p))));
|
||||
let mut answer: Option<bool> = None;
|
||||
let mut escaped = false;
|
||||
let mut by = 0.0;
|
||||
Container::new(ctx, Surface::Canvas).area(area).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
Container::popup_frame(ui.style()).fill(t.dialog_body).inner_margin(t.space_6).show(ui, |ui| {
|
||||
crate::ui_kit::scope(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
// The parameter's name, then the check and the cross.
|
||||
let (title, _) = ui.allocate_exact_size(egui::vec2(width, t.control_height), egui::Sense::hover());
|
||||
let name = crate::i18n::format("dialog.dimension_name", &[("name", &e.param)]);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(title).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
|
||||
crate::ui_kit::text(ui, &name, crate::ui_kit::Text::Inline).on_hover_text(crate::i18n::tr("dialog.dimension_insert_hint"));
|
||||
});
|
||||
let side = t.control_height;
|
||||
let cancel = Rect::from_min_size(egui::pos2(title.right() - side, title.top()), egui::vec2(side, side));
|
||||
let ok = cancel.translate(egui::vec2(-(side + t.space_2), 0.0));
|
||||
if button(ui, ok, "dimwin:ok", crate::i18n::tr("ui.heading_ok"), "finish") {
|
||||
answer = Some(true);
|
||||
}
|
||||
if button(ui, cancel, "dimwin:cancel", crate::i18n::tr("ui.cancel"), "close") {
|
||||
answer = Some(false);
|
||||
escaped = true;
|
||||
}
|
||||
// The value, with its arrows at its right (the kit's own up and down arrows).
|
||||
let (row, _) = ui.allocate_exact_size(egui::vec2(width, t.control_height), egui::Sense::hover());
|
||||
let arrows = 2.0 * (t.form_close_size - t.space_4) + t.space_2;
|
||||
let (r, up, down) = ui
|
||||
.scope_builder(egui::UiBuilder::new().max_rect(row).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_2;
|
||||
let field = egui::vec2((row.width() - arrows - t.space_2).max(t.space_24), t.control_height);
|
||||
let r = ui.allocate_ui(field, |ui| crate::ui_kit::cell_editor(ui, id, &mut e.expr, "", false)).inner;
|
||||
crate::scenario::publish_target("dimwin:value", r.rect);
|
||||
if r.has_focus() {
|
||||
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
|
||||
}
|
||||
if crate::expression_complete::complete(ui, &r, &mut e.expr) {
|
||||
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
|
||||
}
|
||||
let can_step = step(&e.expr, 1.0).is_some();
|
||||
let tip = |id: &str| {
|
||||
if can_step {
|
||||
crate::i18n::format(id, &[("step", &step_text)])
|
||||
} else {
|
||||
crate::i18n::tr("dialog.dimension_step_expression").to_string()
|
||||
}
|
||||
};
|
||||
let up = crate::ui_kit::compact_button(ui, crate::ui_kit::MOVE_UP, &tip("dialog.dimension_step_up"), can_step, false);
|
||||
crate::scenario::publish_target("dimwin:up", up.rect);
|
||||
let down = crate::ui_kit::compact_button(ui, crate::ui_kit::MOVE_DOWN, &tip("dialog.dimension_step_down"), can_step, false);
|
||||
crate::scenario::publish_target("dimwin:down", down.rect);
|
||||
(r, up.clicked(), down.clicked())
|
||||
})
|
||||
.inner;
|
||||
if up {
|
||||
by += 1.0;
|
||||
}
|
||||
if down {
|
||||
by -= 1.0;
|
||||
}
|
||||
// Up/Down in the field and the wheel over it or its arrows step too.
|
||||
if r.has_focus() {
|
||||
by += ui.input(crate::value_step::key_tenths) as f64 / 10.0;
|
||||
}
|
||||
let wheel = id.with("dimwin_wheel");
|
||||
if ui.rect_contains_pointer(row) {
|
||||
by += crate::value_step::wheel_tenths(ui, wheel) as f64 / 10.0;
|
||||
crate::navigation::claim_wheel(ui.ctx());
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<f32>(wheel));
|
||||
}
|
||||
if let Some(error) = &error {
|
||||
crate::ui_kit::text(ui, error, crate::ui_kit::Text::Report("dimension_error", true));
|
||||
}
|
||||
if e.fresh == FRESH {
|
||||
select_all(ctx, id, e);
|
||||
}
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let field = if r.lost_focus() && enter {
|
||||
Some(true)
|
||||
} else if e.fresh > 0 || on_window || by != 0.0 {
|
||||
if !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
None
|
||||
} else if r.lost_focus() {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
answer = answer.or(field);
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
answer = Some(false);
|
||||
escaped = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(rect) = ctx.memory(|m| m.area_rect(area)) {
|
||||
crate::scenario::publish_rect("dimwin", rect);
|
||||
}
|
||||
if by != 0.0
|
||||
&& answer.is_none()
|
||||
&& let Some(stepped) = step(&e.expr, by)
|
||||
{
|
||||
e.expr = stepped;
|
||||
// The whole new value is selected, so typing replaces it.
|
||||
select_all(ctx, id, e);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if answer.is_some() {
|
||||
forget();
|
||||
}
|
||||
(answer, escaped)
|
||||
}
|
||||
|
||||
/// Select the whole value in the field, so typing replaces it.
|
||||
fn select_all(ctx: &egui::Context, id: egui::Id, e: &mut Edit) {
|
||||
let n = e.expr.chars().count();
|
||||
let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
|
||||
e.selection = state.cursor.char_range();
|
||||
state.store(ctx, id);
|
||||
}
|
||||
@@ -192,6 +192,215 @@ fn dimension_tool_click_respects_the_selection_filter() {
|
||||
crate::dim_view::cancel();
|
||||
}
|
||||
|
||||
/// A line from the origin to `end` with Preferences › Sketch › Dimension values set to In a
|
||||
/// window (#7246).
|
||||
fn windowed(end: [f64; 2]) -> Harness {
|
||||
let mut h = line(end);
|
||||
h.call("ui.prefs", json!({"dimension_input": "window"}));
|
||||
h
|
||||
}
|
||||
|
||||
/// The value the open new dimension's window holds.
|
||||
fn draft_value() -> String {
|
||||
crate::dim_view::draft_preview().map(|(expr, _, _)| expr).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// In a window, the Dimension tool places the text first and the window opens on it. Its arrows,
|
||||
/// Up/Down and the wheel over the field step the value while the sketch previews it, nothing
|
||||
/// reaches the design before the check, the check adds the dimension in one undo step, and the
|
||||
/// tool stays on for the next dimension.
|
||||
#[test]
|
||||
fn a_new_dimension_in_the_window_steps_previews_and_applies_once() {
|
||||
let mut h = windowed([20., 0.]);
|
||||
let original = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
|
||||
assert_eq!(crate::dim_view::live(), None, "no value follows the pointer before the text is placed");
|
||||
assert!(!crate::dim_view::editing());
|
||||
h.step(&json!({"click":{"world":[10,6,0]}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::dim_view::editing(), "placing the text opens the window");
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")), "its field takes the keyboard");
|
||||
let window = crate::scenario::rect_of("dimwin").expect("the window is shown");
|
||||
let drawable = h.app.viewport.layout.as_ref().unwrap().drawable;
|
||||
assert!(drawable.contains_rect(window), "the window {window:?} stays in the view {drawable:?}");
|
||||
assert_eq!(draft_value(), "20 mm");
|
||||
let arrow = crate::scenario::handle_at("dimwin:up");
|
||||
for _ in 0..3 {
|
||||
h.step(&json!({"click":{"handle":"dimwin:up"}})).unwrap();
|
||||
}
|
||||
h.frames(2);
|
||||
assert_eq!(crate::scenario::handle_at("dimwin:up"), arrow, "the window stays put while the value moves the text");
|
||||
let (value, dimension, _) = crate::dim_view::draft_preview().unwrap();
|
||||
assert_eq!(value, "23 mm");
|
||||
assert!((dimension.kind.value().unwrap() - 23.).abs() < 1e-6, "the sketch previews the stepped value");
|
||||
assert!(crate::scenario::count_of("dimwin:preview_curves").unwrap_or(0.0) > 0.0, "what the value moves is drawn");
|
||||
assert_eq!(h.app.session.doc.to_json(), original, "nothing reaches the design before OK");
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")), "the arrows leave the keyboard with the field");
|
||||
// The wheel over the field steps it instead of zooming the view.
|
||||
let cam = h.app.cam;
|
||||
let field = crate::scenario::rect_of("dimwin:value").unwrap();
|
||||
h.call("ui.scroll", json!({"x": field.center().x, "y": field.center().y, "delta": -40.0}));
|
||||
h.frames(2);
|
||||
assert_eq!(draft_value(), "22 mm");
|
||||
assert_eq!(h.app.cam, cam, "the view did not zoom");
|
||||
h.step(&json!({"key":"ArrowUp"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(draft_value(), "23 mm");
|
||||
h.step(&json!({"click":{"handle":"dimwin:ok"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!crate::dim_view::editing(), "{}", h.call("ui.inspect", json!({})));
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
|
||||
let state = h.app.session.world_state().unwrap();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
let dimension = sk.constraints.iter().find(|c| c.param.is_some()).expect("the dimension was added");
|
||||
assert!((dimension.kind.value().unwrap() - 23.).abs() < 1e-6);
|
||||
assert_eq!(h.app.tool.as_ref().map(|t| (t.cmd, t.picks.len())), Some(("sketch.dimension", 0)), "the tool stays on for the next one");
|
||||
crate::dim_view::cancel();
|
||||
}
|
||||
|
||||
/// The cross and Esc drop only the dimension in the window: nothing is added and the Dimension
|
||||
/// tool stays on. Esc with no window open then puts the tool down, as before.
|
||||
#[test]
|
||||
fn the_window_cross_and_escape_drop_only_this_dimension() {
|
||||
let mut h = windowed([20., 0.]);
|
||||
let original = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
place(&mut h, &[[10., 0.]], [10., 6.]);
|
||||
h.step(&json!({"click":{"handle":"dimwin:up"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dimwin:cancel"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.tool.as_ref().map(|t| (t.cmd, t.picks.len())), Some(("sketch.dimension", 0)));
|
||||
place(&mut h, &[[10., 0.]], [10., 6.]);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.dimension"), "Esc in the window leaves the tool on");
|
||||
h.frames(40);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.tool.is_none(), "Esc with no window open ends the tool");
|
||||
crate::dim_view::cancel();
|
||||
}
|
||||
|
||||
/// Double-clicking an existing dimension opens the window too. Typing and stepping preview the
|
||||
/// sketch without touching the design, a value OK would refuse says why under the field, an
|
||||
/// expression is left as typed by the arrows, and Enter applies the value in one undo step.
|
||||
#[test]
|
||||
fn double_clicking_a_dimension_opens_the_window_with_a_live_preview() {
|
||||
let mut h = windowed([20., 0.]);
|
||||
h.app.run("parameters.add", json!({"name":"width","expression":"30 mm"})).unwrap();
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap();
|
||||
h.settle();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.frames(40);
|
||||
h.step(&json!({"click":{"dimension":"d1"},"double":true})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::dim_view::editing() && crate::scenario::rect_of("dimwin").is_some(), "the window opens on the dimension");
|
||||
type_value(&mut h, "26");
|
||||
h.frames(2);
|
||||
assert!(crate::scenario::count_of("dimwin:preview_curves").unwrap_or(0.0) > 0.0, "the sketch previews 26");
|
||||
h.step(&json!({"key":"ArrowUp"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.widget("27").is_some(), "Up stepped the value: {}", h.call("ui.inspect", json!({})));
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "nothing reaches the design before OK");
|
||||
type_value(&mut h, "no_such_name");
|
||||
h.frames(2);
|
||||
assert!(crate::scenario::rect_of("dialog:report:dimension_error").is_some(), "the refusal shows under the field");
|
||||
assert_eq!(crate::scenario::count_of("dimwin:preview_curves"), Some(0.0), "a refused value previews nothing");
|
||||
type_value(&mut h, "width - 4");
|
||||
h.step(&json!({"click":{"handle":"dimwin:up"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.widget("width - 4").is_some(), "an expression is left as typed");
|
||||
type_value(&mut h, "27");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert!((h.app.session.doc.eval("d1", solidcraft_engine::doc::expr::Kind::Length).unwrap() - 27.).abs() < 1e-6);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
|
||||
crate::dim_view::cancel();
|
||||
}
|
||||
|
||||
/// The arrows keep the unit and decimal mark a value is written in: a length steps by the length
|
||||
/// step converted to that unit (a bare number is in the design's unit), an angle by the angle
|
||||
/// step, and anything but a plain number is left alone.
|
||||
#[test]
|
||||
fn window_steps_keep_the_written_unit_and_decimal_mark() {
|
||||
use crate::dim_view::window::stepped;
|
||||
use solidcraft_engine::doc::expr::Kind::{Angle, Length};
|
||||
assert_eq!(stepped("20 mm", 1.0, Length, "mm", 1.0, 1.0).as_deref(), Some("21 mm"));
|
||||
assert_eq!(stepped("20", -1.0, Length, "mm", 0.5, 1.0).as_deref(), Some("19.5"));
|
||||
assert_eq!(stepped("1 in", 1.0, Length, "mm", 2.54, 1.0).as_deref(), Some("1.1 in"));
|
||||
assert_eq!(stepped("1", 1.0, Length, "in", 2.54, 1.0).as_deref(), Some("1.1"), "a bare length is in the design's unit");
|
||||
assert_eq!(stepped("3\"", 10.0, Length, "mm", 2.54, 1.0).as_deref(), Some("4\""));
|
||||
assert_eq!(stepped("2,5 cm", 1.0, Length, "mm", 1.0, 1.0).as_deref(), Some("2,6 cm"));
|
||||
assert_eq!(stepped("45 deg", -1.0, Angle, "mm", 1.0, 5.0).as_deref(), Some("40 deg"));
|
||||
assert_eq!(stepped("45", 0.1, Angle, "in", 1.0, 1.0).as_deref(), Some("45.1"), "a bare angle is in degrees");
|
||||
assert_eq!(stepped("width / 2", 1.0, Length, "mm", 1.0, 1.0), None);
|
||||
assert_eq!(stepped("20 deg", 1.0, Length, "mm", 1.0, 1.0), None, "a length does not step an angle");
|
||||
}
|
||||
|
||||
/// Dimension values default to In the view with steps of 1 mm and 1°; the choice and the steps
|
||||
/// save, out-of-range steps and unknown choices are refused, and the Sketch page's Restore
|
||||
/// Defaults and Cancel treat them as the page's own.
|
||||
#[test]
|
||||
fn the_dimension_window_preferences_default_save_and_reset() {
|
||||
use crate::prefs::{DimensionInput, Prefs, control};
|
||||
let d = Prefs::default();
|
||||
assert_eq!((d.dimension_input, d.dimension_step_mm, d.dimension_step_deg), (DimensionInput::View, 1.0, 1.0));
|
||||
let old: Prefs = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(old.dimension_input, DimensionInput::View, "saved preferences without it keep the box");
|
||||
let read = crate::prefs::lenient(&json!({"dimension_input": "sideways", "dimension_step_mm": 2.0}));
|
||||
assert_eq!((read.dimension_input, read.dimension_step_mm), (DimensionInput::View, 2.0), "an unknown choice costs only itself");
|
||||
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
for refused in [
|
||||
json!({"dimension_input": "sideways"}),
|
||||
json!({"dimension_step_mm": 0.0}),
|
||||
json!({"dimension_step_mm": 5000.0}),
|
||||
json!({"dimension_step_deg": 0.0}),
|
||||
json!({"dimension_step_deg": 200.0}),
|
||||
] {
|
||||
assert!(control(&mut app, &refused).is_err(), "{refused}");
|
||||
}
|
||||
assert_eq!(app.preferences, Prefs::default(), "a refusal changes nothing");
|
||||
control(&mut app, &json!({"open": "Sketch", "dimension_input": "window", "dimension_step_mm": 0.5, "dimension_step_deg": 15.0})).unwrap();
|
||||
control(&mut app, &json!({"button": "apply"})).unwrap();
|
||||
let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert_eq!(
|
||||
(other.preferences.dimension_input, other.preferences.dimension_step_mm, other.preferences.dimension_step_deg),
|
||||
(DimensionInput::Window, 0.5, 15.0)
|
||||
);
|
||||
control(&mut app, &json!({"button": "defaults"})).unwrap();
|
||||
assert_eq!(
|
||||
(app.preferences.dimension_input, app.preferences.dimension_step_mm, app.preferences.dimension_step_deg),
|
||||
(DimensionInput::View, 1.0, 1.0)
|
||||
);
|
||||
control(&mut app, &json!({"button": "cancel"})).unwrap();
|
||||
assert_eq!(
|
||||
(app.preferences.dimension_input, app.preferences.dimension_step_mm, app.preferences.dimension_step_deg),
|
||||
(DimensionInput::Window, 0.5, 15.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// The Sketch page's Dimension values switch is a real control.
|
||||
#[test]
|
||||
fn the_sketch_page_switches_dimension_values_to_a_window() {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"open": "Sketch"}));
|
||||
h.frames(4);
|
||||
let handle = format!("dialog:{}:{}", crate::i18n::tr("prefs.dimension_values"), crate::i18n::tr("prefs.dimension_values_window"));
|
||||
crate::prefs::reveal_preference(&mut h, &handle);
|
||||
h.step(&json!({"click":{"handle": handle}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.preferences.dimension_input, crate::prefs::DimensionInput::Window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_parameters_use_typing_and_ctrl_space_completion() {
|
||||
for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] {
|
||||
|
||||
@@ -619,7 +619,8 @@ pub struct SolveApp {
|
||||
/// How long OK waits for a feature before computing it off the window thread, saying so;
|
||||
/// `None`: it waits as long as it takes (headless use, tests). The desktop app sets it.
|
||||
pub apply_off_thread: Option<std::time::Duration>,
|
||||
/// Tests: the off-thread evaluation waits for this before it reports done.
|
||||
/// Tests: work started off the window thread (a feature's evaluation, a Properties
|
||||
/// measurement) waits for this before it reports done.
|
||||
#[cfg(test)]
|
||||
pub(crate) apply_gate: Option<std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<()>>>>,
|
||||
/// The last command started interactively (id, label), for Repeat.
|
||||
|
||||
@@ -582,6 +582,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
|
||||
let wheel_inside = inside
|
||||
|| (ui.is_enabled()
|
||||
&& !crate::value_step::enabled(ui.ctx())
|
||||
&& !hover.is_some_and(|p| crate::dim_view::window::covers(ui.ctx(), p))
|
||||
&& hover.is_some_and(|p| {
|
||||
rect.contains(p)
|
||||
&& !axes.contains(p)
|
||||
|
||||
@@ -676,6 +676,21 @@ impl OriginPlaneNames {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a sketch dimension's value is typed (Preferences › Sketch, "Dimension values", #7246).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DimensionInput {
|
||||
/// In a box on the dimension (also what a value this version doesn't know reads as).
|
||||
#[default]
|
||||
View,
|
||||
/// In a small window at the dimension, with arrows that step the value (`dim_view::window`).
|
||||
Window,
|
||||
}
|
||||
|
||||
/// The lengths (millimetres) and angles (degrees) the dimension window's arrows may step by.
|
||||
pub const DIMENSION_STEP_MM: std::ops::RangeInclusive<f64> = 0.001..=1000.0;
|
||||
pub const DIMENSION_STEP_DEG: std::ops::RangeInclusive<f64> = 0.001..=180.0;
|
||||
|
||||
/// What an orbit turns around (Preferences: "Orbit around").
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1567,6 +1582,13 @@ preference_settings! {
|
||||
line_hover_tangent_arc: bool = false, PAGE_SKETCH;
|
||||
/// Include hidden geometry and a small pointer neighbourhood in Select Other.
|
||||
select_other_hidden: bool = true, PAGE_SKETCH;
|
||||
/// Where a dimension's value is typed: in a box on the dimension (the default), or in a small
|
||||
/// window at it whose arrows step the value (#7246, `dim_view::window`).
|
||||
dimension_input: DimensionInput = DimensionInput::View, PAGE_SKETCH;
|
||||
/// What the dimension window's arrows, Up/Down and the wheel add to a length (millimetres,
|
||||
/// converted to the unit the value is written in) and to an angle (degrees).
|
||||
dimension_step_mm: f64 = 1.0, PAGE_SKETCH, accept(DIMENSION_STEP_MM);
|
||||
dimension_step_deg: f64 = 1.0, PAGE_SKETCH, accept(DIMENSION_STEP_DEG);
|
||||
/// When true: Shift+click adds without removing; Primary toggles.
|
||||
/// When false (default): Shift and Primary both toggle.
|
||||
selection_shift_add_only: bool = false, PAGE_SKETCH;
|
||||
@@ -3983,6 +4005,11 @@ fn prepare_preferences(
|
||||
{
|
||||
return Err("`stand_up_unknown` must be ask, note or off".into());
|
||||
}
|
||||
if let Some(v) = p.get("dimension_input")
|
||||
&& !matches!(v.as_str(), Some("view" | "window"))
|
||||
{
|
||||
return Err("`dimension_input` must be view or window".into());
|
||||
}
|
||||
let mut new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?;
|
||||
migrate_gruvbox(&mut new);
|
||||
if let Some(update) = p.get("theme_options") {
|
||||
@@ -4076,6 +4103,9 @@ fn prepare_preferences(
|
||||
if !accepted::perspective_fov(&new) {
|
||||
return Err("`perspective_fov` must be 15°…90° in radians (0.2618…1.5708)".into());
|
||||
}
|
||||
if !accepted::dimension_step_mm(&new) || !accepted::dimension_step_deg(&new) {
|
||||
return Err("`dimension_step_mm` must be 0.001…1000 and `dimension_step_deg` 0.001…180".into());
|
||||
}
|
||||
if solidcraft_engine::doc::display_quality::DisplayQuality::parse(&new.display_quality).is_none() {
|
||||
return Err("`display_quality` must be coarse, normal, fine or ultra".into());
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ fn device(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui,
|
||||
&mut app.preferences.space_mouse_speed,
|
||||
0.25..=5.0,
|
||||
ui_kit::Slider { step: Some(0.05), format: |value| format!("{value:.2}×"), ..Default::default() },
|
||||
ui_kit::Slider { step: Some(0.05), format: |value| format!("{value:.2}×"), ..Default::default() },
|
||||
);
|
||||
crate::scenario::publish_target("prefs:space_mouse_speed", response.rect);
|
||||
});
|
||||
|
||||
@@ -172,6 +172,7 @@ fn drawing(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
});
|
||||
});
|
||||
dimension_values(app, ui, t.space_24 * 8.0 + t.space_8);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -480,3 +481,35 @@ fn targets(key: &str, response: &egui::Response) {
|
||||
};
|
||||
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
|
||||
}
|
||||
|
||||
/// Where a dimension's value is typed, and what the window's arrows step lengths and angles by
|
||||
/// (#7246). The steps only matter in the window, so they wait for it.
|
||||
fn dimension_values(app: &mut SolveApp, ui: &mut egui::Ui, label_width: f32) {
|
||||
let label = app.tr("prefs.dimension_values").to_string();
|
||||
ui_kit::settings_block_hint(ui, &label, app.tr("prefs.dimension_values_note"), label_width, |ui| {
|
||||
let options = [app.tr("prefs.dimension_values_view"), app.tr("prefs.dimension_values_window")];
|
||||
let mut selected = usize::from(app.preferences.dimension_input == DimensionInput::Window);
|
||||
if ui_kit::settings_segments(ui, &label, &mut selected, &options) {
|
||||
app.preferences.dimension_input = if selected == 1 { DimensionInput::Window } else { DimensionInput::View };
|
||||
}
|
||||
});
|
||||
let window = app.preferences.dimension_input == DimensionInput::Window;
|
||||
let units = app.session.doc.units.clone();
|
||||
let decimals = app.preferences.length_decimals;
|
||||
ui_kit::settings_block(ui, app.tr("prefs.dimension_steps"), label_width, |ui| {
|
||||
ui.add_enabled_ui(window, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let t = Tokens::get();
|
||||
ui_kit::settings_control_width(ui, t.form_label_width, |ui| {
|
||||
let p = &mut app.preferences;
|
||||
ui_kit::bounded_length_field(ui, &mut p.dimension_step_mm, &units, decimals, DIMENSION_STEP_MM, 0.1)
|
||||
.on_hover_text(crate::i18n::tr("prefs.dimension_step_length"));
|
||||
});
|
||||
ui_kit::settings_control_width(ui, t.form_label_width, |ui| {
|
||||
ui_kit::number_field(ui, &mut app.preferences.dimension_step_deg, DIMENSION_STEP_DEG, 0.1, "°")
|
||||
.on_hover_text(crate::i18n::tr("prefs.dimension_step_angle"));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -774,38 +774,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
|
||||
replaced.push(b.name.clone());
|
||||
}
|
||||
}
|
||||
let sketches = after
|
||||
.sketches
|
||||
.iter()
|
||||
.filter_map(|sketch| {
|
||||
let old = before.sketch(sketch.feature);
|
||||
let old_curves =
|
||||
old.into_iter().flat_map(|s| s.sketch.curves.iter().map(|c| (c.id.as_str(), c))).collect::<std::collections::HashMap<_, _>>();
|
||||
let old_points =
|
||||
old.into_iter().flat_map(|s| s.sketch.points.iter().map(|p| (p.id.as_str(), p.pos))).collect::<std::collections::HashMap<_, _>>();
|
||||
let indices = sketch
|
||||
.sketch
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, curve)| {
|
||||
let unchanged = old_curves.get(curve.id.as_str()).is_some_and(|previous| {
|
||||
previous.kind == curve.kind
|
||||
&& curve.kind.point_ids().iter().all(|i| old.and_then(|s| s.sketch.point(*i)) == sketch.sketch.point(*i))
|
||||
});
|
||||
if unchanged { None } else { Some(index) }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let points = sketch
|
||||
.sketch
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, p)| if old_points.get(p.id.as_str()) == Some(&p.pos) { None } else { Some(i) })
|
||||
.collect::<Vec<_>>();
|
||||
if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) }
|
||||
})
|
||||
.collect();
|
||||
let sketches = after.sketches.iter().filter_map(|sketch| sketch_changes(before.sketch(sketch.feature), sketch)).collect();
|
||||
Built { scene: sc, sketches, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new(), stopped: None }
|
||||
}
|
||||
|
||||
@@ -821,6 +790,37 @@ fn on_surface(index: &SurfaceIndex, [a, b, c]: [Vec3; 3]) -> bool {
|
||||
/// bounded, so a modeled thread's thousands of faces still cost little.
|
||||
const FACE_SAMPLES: usize = 24;
|
||||
|
||||
/// What of `sketch` differs from `old`, the same sketch before a change: the curves whose shape
|
||||
/// or points moved and the points that moved (`None`: nothing did).
|
||||
pub(crate) fn sketch_changes(
|
||||
old: Option<&solidcraft_engine::doc::SolvedSketch>,
|
||||
sketch: &std::sync::Arc<solidcraft_engine::doc::SolvedSketch>,
|
||||
) -> Option<SketchPreview> {
|
||||
let old_curves = old.into_iter().flat_map(|s| s.sketch.curves.iter().map(|c| (c.id.as_str(), c))).collect::<std::collections::HashMap<_, _>>();
|
||||
let old_points =
|
||||
old.into_iter().flat_map(|s| s.sketch.points.iter().map(|p| (p.id.as_str(), p.pos))).collect::<std::collections::HashMap<_, _>>();
|
||||
let indices = sketch
|
||||
.sketch
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, curve)| {
|
||||
let unchanged = old_curves.get(curve.id.as_str()).is_some_and(|previous| {
|
||||
previous.kind == curve.kind && curve.kind.point_ids().iter().all(|i| old.and_then(|s| s.sketch.point(*i)) == sketch.sketch.point(*i))
|
||||
});
|
||||
if unchanged { None } else { Some(index) }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let points = sketch
|
||||
.sketch
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, p)| if old_points.get(p.id.as_str()) == Some(&p.pos) { None } else { Some(i) })
|
||||
.collect::<Vec<_>>();
|
||||
if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) }
|
||||
}
|
||||
|
||||
/// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by
|
||||
/// a vote of points on it (triangle centroids spread over the face). One pass over `m`'s
|
||||
/// triangles gathers the centroids, and the old mesh is indexed once, so the cost stays
|
||||
@@ -962,22 +962,30 @@ pub fn paint_sketches(app: &SolveApp, painter: &egui::Painter, proj: &crate::vie
|
||||
{
|
||||
return;
|
||||
}
|
||||
let stroke = egui::Stroke::new(2.5, Tokens::get().preview_sketch);
|
||||
let mut budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536);
|
||||
let mut curves = 0;
|
||||
for preview in &app.preview.sketches {
|
||||
let sketch = &preview.sketch;
|
||||
for index in &preview.curves {
|
||||
crate::tools::paint_curve(&sketch.sketch, *index, &sketch.plane, painter, proj, stroke, &mut budget);
|
||||
curves += 1;
|
||||
}
|
||||
for index in &preview.points {
|
||||
if let Some(point) = sketch.sketch.point(*index).and_then(|p| proj.to_screen(sketch.plane.to_world(p))) {
|
||||
painter.circle_filled(point, 3.0, stroke.color);
|
||||
}
|
||||
let curves: usize = app.preview.sketches.iter().map(|preview| paint_sketch(preview, painter, proj, &mut budget)).sum();
|
||||
crate::scenario::publish_count("sketch_preview_curves", curves as f64);
|
||||
}
|
||||
|
||||
/// Draw what a change moves in a sketch, its curves and points, in the preview colour; returns
|
||||
/// how many curves it drew.
|
||||
pub(crate) fn paint_sketch(
|
||||
preview: &SketchPreview,
|
||||
painter: &egui::Painter,
|
||||
proj: &crate::viewport::Proj,
|
||||
budget: &mut solidcraft_engine::render::SamplingBudget,
|
||||
) -> usize {
|
||||
let stroke = egui::Stroke::new(2.5, Tokens::get().preview_sketch);
|
||||
let sketch = &preview.sketch;
|
||||
for index in &preview.curves {
|
||||
crate::tools::paint_curve(&sketch.sketch, *index, &sketch.plane, painter, proj, stroke, budget);
|
||||
}
|
||||
for index in &preview.points {
|
||||
if let Some(point) = sketch.sketch.point(*index).and_then(|p| proj.to_screen(sketch.plane.to_world(p))) {
|
||||
painter.circle_filled(point, 3.0, stroke.color);
|
||||
}
|
||||
}
|
||||
crate::scenario::publish_count("sketch_preview_curves", curves as f64);
|
||||
preview.curves.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -550,6 +550,64 @@ fn a_line_starts_at_a_curve_crossing_and_keeps_it_after_undo() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Line tool's first click on an existing shape starts the line there and ties it to the shape,
|
||||
/// as its last click does: a line's midpoint and a circle's centre (#11171, #11186), a circle's
|
||||
/// edge (#11272), a rectangle's edge (#11291) and a point part-way along a line (#11223).
|
||||
#[test]
|
||||
fn a_line_starts_on_a_snap_point_of_an_existing_shape() {
|
||||
use solidcraft_engine::sketch::{ConstraintKind, CurveKind};
|
||||
let edge = [25.0 + 10.0 * std::f64::consts::FRAC_1_SQRT_2, -15.0 + 10.0 * std::f64::consts::FRAC_1_SQRT_2];
|
||||
for (case, shape, start, end) in [
|
||||
("line midpoint", "guide", [-10.0, 10.0], [-5.0, 30.0]),
|
||||
("circle centre", "round", [25.0, -15.0], [45.0, 5.0]),
|
||||
("circle edge", "round", edge, [45.0, 15.0]),
|
||||
("rectangle edge", "box", [-35.0, -28.0], [-50.0, -5.0]),
|
||||
("part-way along a line", "guide", [-22.0, 10.0], [-17.0, 30.0]),
|
||||
] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[-30,10],[10,10]],"ids":["guide"],"infer":false})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[25,-15],"radius":10})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-35,-40],"p1":[-15,-25]})).unwrap();
|
||||
h.call("ui.camera", json!({"target":[0,-10,0],"distance":200}));
|
||||
h.settle();
|
||||
let before = lines(&h).len();
|
||||
h.step(&json!({"start":"sketch.line"})).unwrap();
|
||||
h.step(&json!({"click":{"sketch":start}})).unwrap();
|
||||
h.step(&json!({"click":{"sketch":end}})).unwrap();
|
||||
h.settle();
|
||||
let drawn = lines(&h);
|
||||
assert_eq!(drawn.len(), before + 1, "{case}: no line from the first click; {:?}", h.app.status);
|
||||
let near = |a: [f64; 2], b: [f64; 2], tol: f64| (a[0] - b[0]).abs() < tol && (a[1] - b[1]).abs() < tol;
|
||||
let line = drawn[before];
|
||||
assert!(near(line.0, start, 1e-3) && near(line.1, end, 1.0), "{case}: {line:?}, expected {start:?} to {end:?}");
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let state = h.app.session.world_state().unwrap();
|
||||
let sketch = &state.sketch(sid).unwrap().sketch;
|
||||
let Some(CurveKind::Line { a, .. }) = sketch.curves.last().map(|c| c.kind.clone()) else { panic!("{case}: no new line") };
|
||||
// The rectangle's left edge is the one at x = -35.
|
||||
let target = match shape {
|
||||
"box" => (0..sketch.curves.len())
|
||||
.find(|&c| matches!(sketch.shape(c), Some(solidcraft_engine::sketch::Shape::Line { a, b }) if a.x == -35.0 && b.x == -35.0))
|
||||
.unwrap(),
|
||||
"round" => sketch.curves.iter().position(|c| matches!(c.kind, CurveKind::Circle { .. })).unwrap(),
|
||||
id => sketch.curve_index(id).unwrap(),
|
||||
};
|
||||
let centre = match sketch.curves[target].kind {
|
||||
CurveKind::Circle { c, .. } if case == "circle centre" => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
let tied = centre == Some(a)
|
||||
|| sketch.constraints.iter().any(|c| match c.kind {
|
||||
ConstraintKind::Coincident { p, q } => centre.is_some_and(|m| (p, q) == (a, m) || (p, q) == (m, a)),
|
||||
ConstraintKind::PointOnCurve { p, c } => p == a && c == target,
|
||||
ConstraintKind::Midpoint { p, l } => p == a && l == target,
|
||||
_ => false,
|
||||
});
|
||||
assert!(tied, "{case}: the line's start is not tied to the {shape}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_snap_whose_source_curve_is_undone_ends_without_an_error() {
|
||||
for start in [[-20.0, 10.0], [-2.5, 10.0]] {
|
||||
|
||||
@@ -80,7 +80,7 @@ fn tenths_for(m: Modifiers) -> Option<i64> {
|
||||
}
|
||||
|
||||
/// This frame's Up/Down presses, key repeats included, in signed tenths.
|
||||
fn key_tenths(i: &egui::InputState) -> i64 {
|
||||
pub(crate) fn key_tenths(i: &egui::InputState) -> i64 {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
@@ -94,7 +94,7 @@ fn key_tenths(i: &egui::InputState) -> i64 {
|
||||
/// This frame's wheel notches over the field in signed tenths. Scrolling up steps up. A mouse
|
||||
/// wheel reports lines (a notch each); a trackpad reports points, which add up across frames in
|
||||
/// `key` until they make a notch (a line's worth, `line_scroll_speed`).
|
||||
fn wheel_tenths(ui: &egui::Ui, key: egui::Id) -> i64 {
|
||||
pub(crate) fn wheel_tenths(ui: &egui::Ui, key: egui::Id) -> i64 {
|
||||
let speed = ui.ctx().options(|o| o.input_options.line_scroll_speed).max(1.0);
|
||||
let mut pending = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or(0.0);
|
||||
let mut out = 0i64;
|
||||
@@ -167,7 +167,22 @@ fn plain(text: &str) -> Option<Plain> {
|
||||
fn stepped(text: &str, tenths: i64) -> Option<String> {
|
||||
let p = plain(text)?;
|
||||
let places = if tenths % 10 == 0 { p.places } else { p.places.max(1) };
|
||||
let v = p.value + tenths as f64 / 10.0;
|
||||
rewritten(text, &p, p.value + tenths as f64 / 10.0, places)
|
||||
}
|
||||
|
||||
/// `text` with its plain number moved by what `by` answers for the unit it is written in (the
|
||||
/// text after the number, empty for none): an amount and the decimal places it needs, written
|
||||
/// with at least the places the number had (`dim_view::window` steps by a length or an angle
|
||||
/// this way). `None` when `text` is not a plain number or `by` has no answer.
|
||||
pub(crate) fn shifted(text: &str, by: impl FnOnce(&str) -> Option<(f64, usize)>) -> Option<String> {
|
||||
let p = plain(text)?;
|
||||
let (amount, places) = by(text.get(p.end..)?.trim())?;
|
||||
rewritten(text, &p, p.value + amount, places.max(p.places))
|
||||
}
|
||||
|
||||
/// `text` with its plain number `p` replaced by `v` written with `places` decimals, in the decimal
|
||||
/// mark it had.
|
||||
fn rewritten(text: &str, p: &Plain, v: f64, places: usize) -> Option<String> {
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[
|
||||
{"note": "#7246: with Preferences › Sketch › Dimension values set to In a window, the Dimension tool places the text first and a small window opens on it. Its arrows step the value, the check applies it in one step, and the tool stays on for the next dimension."},
|
||||
{"call": "ui.prefs", "params": {"dimension_input": "window"}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 0], [30, 0]], "ids": ["l1"]}}},
|
||||
{"call": "ui.view", "params": {"view": "top"}},
|
||||
{"call": "ui.view", "params": {"view": "fit"}},
|
||||
{"start": "sketch.dimension"},
|
||||
{"click": {"sketch": [15, 0]}},
|
||||
{"click": {"sketch": [15, 6]}},
|
||||
{"frames": 5},
|
||||
{"expect": {"shown_handles": ["dimwin:value", "dimwin:up", "dimwin:down", "dimwin:ok", "dimwin:cancel"]}},
|
||||
{"click": {"handle": "dimwin:up"}},
|
||||
{"click": {"handle": "dimwin:up"}},
|
||||
{"click": {"handle": "dimwin:ok"}},
|
||||
{"frames": 3},
|
||||
{"expect": {"errors": 0}},
|
||||
{"expect": {"hidden_handles": ["dimwin:value"]}},
|
||||
{"expect": {"tool": "sketch.dimension"}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 0, "end_at", 0], "value": 32}}}
|
||||
]
|
||||
@@ -368,6 +368,16 @@ nothing picked yet, click it once: its value box opens with the value selected,
|
||||
and the tool stays on for the next dimension. The dimension's text always takes the click; its
|
||||
lines take it only away from sketch points and curves, which still start a new dimension.
|
||||
|
||||
To type values in a small window instead, set **Preferences › Sketch › Dimension values** to
|
||||
**In a window**. The Dimension tool then places the text first: click what you want to
|
||||
measure, then where the text goes, and the window opens there (double-clicking a dimension
|
||||
opens it too). Its field takes the same numbers, units, expressions and parameter names as the
|
||||
box, and the sketch shows the value as you type. The arrows beside the field, Up and Down, and
|
||||
the mouse wheel over the field step a number by **Window steps** (1 mm and 1° unless you change
|
||||
them; Shift steps ten times as far, Alt a tenth), keeping the unit it is written in. The check
|
||||
(or Enter) applies the value as one undo step and the tool stays on for the next dimension; the
|
||||
cross (or Esc) drops this dimension only, and Esc with no window open puts the tool down.
|
||||
|
||||
To have shapes dimensioned as you draw them, turn on **Dimension new shapes** in Preferences ›
|
||||
Sketch (it is off by default). Each line, rectangle, circle, arc, slot, polygon or ellipse you draw
|
||||
then gets all its dimensions at the sizes you drew: a line its length and angle, a rectangle its
|
||||
|
||||
@@ -702,6 +702,9 @@ One setting per line, sorted by name. A change that adds a setting adds its own
|
||||
- `default_units`
|
||||
- `dialog_buttons` (`bottom` (default), `top`: a feature dialog's OK, Cancel and Apply sit in its title row instead of under its inputs, #6708; the buttons keep their handles, and `dialog:close` is then Cancel's; the Sketch panel's Finish Sketch row moves above its options, #9860)
|
||||
- `dialog_place` (`edge` (default) or `left_column`: command dialogs open in the left column in place of the browser and the History, #6707)
|
||||
- `dimension_input` (`view` (default): a sketch dimension's value is typed in a box on the dimension; `window`: placing a dimension with the Dimension tool, or double-clicking one, opens a small window at it with the value field (handle `dimwin:value`), arrows that step the value (`dimwin:up`, `dimwin:down`), a check (`dimwin:ok`, also Enter) and a cross (`dimwin:cancel`, also Esc), #7246)
|
||||
- `dimension_step_deg` (default 1, 0.001…180: what the dimension window's arrows, Up/Down and the wheel over its field add to an angle, in degrees)
|
||||
- `dimension_step_mm` (default 1, 0.001…1000: what the window's arrows, Up/Down and the wheel over its field add to a length, in millimetres, written in the unit the value is written in)
|
||||
- `extrude_auto_to_object` (default true: in the Extrude dialog, with a profile picked, a face, vertex or parallel plane picked off its plane switches the extent to To Object, unless the Profiles box was clicked into; it applies to Extrude dialogs opened afterwards)
|
||||
- `frame_rate_details` (default false: with the frame rate, a frame-time graph of the frames drawn with the triangles over it, a frame-time histogram and what the GPU drew; clicking the readout switches it)
|
||||
- `graphics_adapter` (exact card name, or empty for Automatic; applies after restart; `WGPU_ADAPTER_NAME` takes precedence)
|
||||
|
||||
+2
-2
@@ -289,10 +289,10 @@ The `skill_reference` tool returns these documents from the copies built into So
|
||||
|
||||
| Tool | Arguments | What it does |
|
||||
|---|---|---|
|
||||
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
|
||||
| `list_commands` | `filter?`, `prefix?`, `panel?`, `compact?`, `fields?`, `limit?`, `offset?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. `prefix` keeps the ids that start with it (`"sketch."`) and `panel` the commands of one whole toolbar panel or tab (`"Modify"`), both ignoring case. `compact: true` keeps only `id` and `label` of each entry, about an eighth of the size; `fields` keeps the keys it lists instead. `limit` and `offset` page the matches, and the reply becomes `{commands, total, offset, next_offset}` (`next_offset` is null on the last page). With none of these the reply is the whole catalog, about 350 KB. |
|
||||
| `describe_command` | `id` | One command's parameter docs (`params`) and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); `null` for the others. The same as `execute` `engine.describe {command}`. |
|
||||
| `execute` | `command`, `params?`, `verbose?` (default true), `expect?` | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. `expect` checks the design after the command, as it does for `batch` (see below). |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false), `expect?` | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure` (`summary: true` summarised as the tool summarises it), `compare_picture` (`inspect.compare_picture`, numbers only: no overlay PNG), `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `check_parameters`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false), `expect?` | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, narrowed by its arguments as the tool narrows it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure` (`summary: true` summarised as the tool summarises it), `compare_picture` (`inspect.compare_picture`, numbers only: no overlay PNG), `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `check_parameters`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
|
||||
|
||||
**`expect`** on `batch` and `execute` turns the verify step into part of the build. An agent
|
||||
computes the volume, body count or bounding box a stage should produce before building it;
|
||||
|
||||
Reference in New Issue
Block a user