mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66bc1b7de2 | |||
| e8e4861933 | |||
| c56cb30444 | |||
| 8706d623fb | |||
| ae1d85e5d4 | |||
| 0d350e7b38 | |||
| e69dfe126c | |||
| 3c4b336080 | |||
| 1ec5eda955 | |||
| 7bb016e5e4 | |||
| 98fdb6ef3c | |||
| 95fece4c7b | |||
| f959c0168d | |||
| a2d42fdaec | |||
| eaf8f27a7f | |||
| 8f1143b940 | |||
| 778e7465e9 | |||
| fa127b3e29 | |||
| 970eb715f3 | |||
| a74e78fba8 | |||
| 2171393816 | |||
| 3eacb3722e | |||
| 2c82e702f5 | |||
| f256bef34b | |||
| 83cb509f81 | |||
| c18aaf7cc3 | |||
| 1e6b1ec20f | |||
| 4d96c780e1 | |||
| 52afe212a8 | |||
| 0bb09d0b13 | |||
| 11abdba1ba | |||
| 7c9c1699de | |||
| cf9ae4d25e | |||
| 1f29f432ee | |||
| dc679cae34 | |||
| dbbfa437a5 | |||
| ba43d2798f | |||
| 6a1fe74683 | |||
| a9ff55ef27 | |||
| 3081630f92 | |||
| 323defa525 | |||
| 5eaf8c4181 | |||
| b464c48506 | |||
| 7a2f2949a7 | |||
| fcd4ccd09a | |||
| beaa43cb40 | |||
| 6085423d0d | |||
| 4425b87d2b | |||
| 59d032659f | |||
| 395679015f | |||
| 30be21a224 |
@@ -1,6 +1,9 @@
|
||||
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
|
||||
.gitattributes text eol=lf
|
||||
/assets/icons/ui/design/requested_icons.txt text eol=lf
|
||||
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
|
||||
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
|
||||
*.wgsl text eol=lf
|
||||
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
|
||||
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
|
||||
*.sc -linguist-detectable
|
||||
|
||||
@@ -842,6 +842,8 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
|
||||
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
Generated
+57
@@ -2508,6 +2508,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -2773,6 +2782,16 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -4288,12 +4307,14 @@ dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
"sysinfo",
|
||||
"web-time",
|
||||
"weezl",
|
||||
]
|
||||
@@ -4485,6 +4506,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5337,6 +5372,22 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -5346,6 +5397,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
||||
@@ -62,6 +62,8 @@ semver = "1"
|
||||
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0).
|
||||
spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
# Native render-memory admission; RAM-only queries through a safe MIT-licensed API.
|
||||
sysinfo = { version = "0.40.1", default-features = false, features = ["system"] }
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
|
||||
let mut unscoped = p.clone();
|
||||
unscoped.as_object_mut().unwrap().remove("body");
|
||||
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
|
||||
assert!(err.contains("edges meet"), "{err}");
|
||||
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
|
||||
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
|
||||
tried += 1;
|
||||
}
|
||||
|
||||
+26
-5
@@ -977,7 +977,7 @@
|
||||
"browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_title": "معايرة: {name}",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً.",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولًا.",
|
||||
"browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.",
|
||||
"browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.",
|
||||
"browser.capture_one": "تم تحريك مكوّن دون تثبيت موضعه.",
|
||||
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "معامل المرونة الاتجاهي",
|
||||
"cmd.material_properties_validate": "التحقق من بيانات المادة الفيزيائية",
|
||||
"cmd.mesh_convert": "تحويل الشبكة",
|
||||
"cmd.model_body_features": "سرد ميزات الجسم",
|
||||
"cmd.model_edges": "قائمة الحواف",
|
||||
"cmd.model_faces": "قائمة الأوجه",
|
||||
"cmd.model_threads": "قائمة الأسنان اللولبية",
|
||||
@@ -1235,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
|
||||
"cmd.panel.float": "تعويم اللوحة",
|
||||
"cmd.panel.focus": "تركيز اللوحة",
|
||||
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
|
||||
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
|
||||
"cmd.panel.list": "سرد اللوحات",
|
||||
"cmd.panel.move": "نقل اللوحة",
|
||||
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
|
||||
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
|
||||
"cmd.panel.open": "فتح اللوحة",
|
||||
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
|
||||
@@ -1244,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
|
||||
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
|
||||
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
|
||||
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
|
||||
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
|
||||
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
|
||||
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
|
||||
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
|
||||
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
|
||||
"cmd.parameters_add": "إضافة معامل مستخدم",
|
||||
"cmd.parameters_change": "تغيير المعاملات",
|
||||
"cmd.parameters_comment": "التعليق على المعامل",
|
||||
@@ -1864,7 +1874,7 @@
|
||||
"help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.",
|
||||
"help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.",
|
||||
"help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم التخطيطي، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.",
|
||||
"help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.",
|
||||
"help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.",
|
||||
@@ -2518,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
"prefs.browser": "المستعرض",
|
||||
"prefs.browser_body_features": "الميزات تحت الأجسام",
|
||||
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات الخط الزمني التي بنته، بترتيب الخط الزمني.",
|
||||
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
|
||||
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
|
||||
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
|
||||
@@ -2558,6 +2570,8 @@
|
||||
"prefs.design_tools.degrees": "درجات",
|
||||
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
|
||||
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
|
||||
"prefs.design_tools.hole_group": "تجميع الثقوب المُنشأة معًا",
|
||||
"prefs.design_tools.hole_group_help": "الثقوب المُنشأة دفعة واحدة تشترك في مجلد واحد في السجل يبدأ مطويًا؛ افتحه لتحرير كل ثقب على حدة.",
|
||||
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
|
||||
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
|
||||
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
|
||||
@@ -2642,6 +2656,8 @@
|
||||
"prefs.history": "السجل",
|
||||
"prefs.history_bar_groups": "طي شريط السجل في مجموعات",
|
||||
"prefs.history_bar_groups_note": "في سجل طويل، تُطوى سلسلة ميزات المكوّن الواحد في شريحة واحدة، وتبدأ مجلدات الميزات مطوية. تدخل مجموعة STEP المُدرجة في مجلد Group. عند الإيقاف تظهر كل ميزة، وتبدأ المجلدات مفتوحة، ولا يُنشأ أي مجلد.",
|
||||
"prefs.history_folder_kind_icon": "أيقونة المجلد من ميزاته",
|
||||
"prefs.history_folder_kind_icon_note": "مجلد السجل الذي تكون ميزاته كلها من نوع واحد يعرض أيقونتها.",
|
||||
"prefs.history_group_joints": "تجميع المفاصل في السجل",
|
||||
"prefs.history_group_joints_note": "يظهر مفصلان أو أكثر كزر واحد مع عددها، والنقر عليه يُظهر كل مفصل. عند الإيقاف يظهر كل مفصل على حدة.",
|
||||
"prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى",
|
||||
@@ -2835,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.selecting": "التحديد",
|
||||
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
|
||||
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
@@ -2857,8 +2875,8 @@
|
||||
"prefs.sketch.drawing": "الرسم",
|
||||
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
|
||||
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط على Esc مرتين ينهي الرسم",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم.",
|
||||
"prefs.sketch.exit_keep": "إبقاء",
|
||||
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
|
||||
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
|
||||
@@ -3379,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
|
||||
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
|
||||
"render.cpu_path_depth": "عمق المسار",
|
||||
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–64). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_point": "ضوء نقطي",
|
||||
@@ -5525,6 +5543,7 @@
|
||||
"ui.heading_plastic": "البلاستيك",
|
||||
"ui.heading_position": "الموضع",
|
||||
"ui.heading_prepare": "تحضير",
|
||||
"ui.heading_press_pull": "ضغط وسحب",
|
||||
"ui.heading_rectangular_pattern": "نمط مستطيلي",
|
||||
"ui.heading_revolve": "دوران",
|
||||
"ui.heading_right": "يمين",
|
||||
@@ -5868,6 +5887,8 @@
|
||||
"ui.profiles": "المقاطع",
|
||||
"ui.project": "إسقاط",
|
||||
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
|
||||
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
|
||||
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.properties": "الخصائص",
|
||||
"ui.properties.copy_matrix": "نسخ المصفوفة",
|
||||
"ui.properties.first_moments": "العزوم الأولى",
|
||||
|
||||
+751
-3
File diff suppressed because it is too large
Load Diff
+22
-1
@@ -1018,6 +1018,7 @@
|
||||
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
|
||||
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
|
||||
"cmd.mesh_convert": "Netz konvertieren",
|
||||
"cmd.model_body_features": "Körper-Features auflisten",
|
||||
"cmd.model_edges": "Kanten auflisten",
|
||||
"cmd.model_faces": "Flächen auflisten",
|
||||
"cmd.model_threads": "Gewinde auflisten",
|
||||
@@ -1046,8 +1047,11 @@
|
||||
"cmd.panel.dismiss": "Panel-Vorschau schließen",
|
||||
"cmd.panel.float": "Panel frei platzieren",
|
||||
"cmd.panel.focus": "Panel fokussieren",
|
||||
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
|
||||
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
|
||||
"cmd.panel.list": "Panels auflisten",
|
||||
"cmd.panel.move": "Panel verschieben",
|
||||
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
|
||||
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
|
||||
"cmd.panel.open": "Panel öffnen",
|
||||
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
|
||||
@@ -1055,6 +1059,12 @@
|
||||
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
|
||||
"cmd.panel.resize": "Panel-Bereich skalieren",
|
||||
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
|
||||
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
|
||||
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
|
||||
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
|
||||
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
|
||||
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
|
||||
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen",
|
||||
"cmd.parameters_add": "Benutzerparameter hinzufügen",
|
||||
"cmd.parameters_change": "Parameter ändern",
|
||||
"cmd.parameters_comment": "Parameter kommentieren",
|
||||
@@ -2305,6 +2315,8 @@
|
||||
"prefs.bottom_centre": "Unten mittig",
|
||||
"prefs.bottom_right": "Unten rechts",
|
||||
"prefs.browser": "Browser",
|
||||
"prefs.browser_body_features": "Features unter Körpern",
|
||||
"prefs.browser_body_features_note": "Jeder Körper im Browser lässt sich aufklappen und zeigt die Features der Zeitleiste, die ihn erzeugt haben, in deren Reihenfolge.",
|
||||
"prefs.browser_components_open": "Komponenten zu Beginn aufgeklappt",
|
||||
"prefs.browser_components_open_note": "Jede Komponente im Browser ist wie bisher von Anfang an aufgeklappt, statt zugeklappt zu bleiben, bis du sie aufklappst.",
|
||||
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
|
||||
@@ -2345,6 +2357,8 @@
|
||||
"prefs.design_tools.degrees": "Grad",
|
||||
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
|
||||
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
|
||||
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
|
||||
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
|
||||
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
|
||||
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
|
||||
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
|
||||
@@ -2429,6 +2443,8 @@
|
||||
"prefs.history": "Verlauf",
|
||||
"prefs.history_bar_groups": "Verlaufsleiste in Gruppen falten",
|
||||
"prefs.history_bar_groups_note": "In einem langen Verlauf wird eine Folge von Features einer Komponente zu einem Chip gefaltet, und Merkmalsordner beginnen gefaltet. Eine eingefügte STEP-Baugruppe kommt in einen Group-Ordner. Aus zeigt jedes Feature, öffnet Ordner und legt keinen Ordner an.",
|
||||
"prefs.history_folder_kind_icon": "Ordnersymbol aus seinen Features",
|
||||
"prefs.history_folder_kind_icon_note": "Ein Ordner im Verlauf, dessen Features alle von einer Art sind, zeigt deren Symbol.",
|
||||
"prefs.history_group_joints": "Gelenke im Verlauf gruppieren",
|
||||
"prefs.history_group_joints_note": "Zwei oder mehr Gelenke erscheinen als eine Schaltfläche mit ihrer Anzahl; ein Klick zeigt jedes einzeln. Aus zeigt jedes Gelenk einzeln.",
|
||||
"prefs.history_origin_bodies": "Ursprung und Körper oben zeigen",
|
||||
@@ -2595,6 +2611,8 @@
|
||||
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
|
||||
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
|
||||
"prefs.selecting": "Auswahl",
|
||||
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
|
||||
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam.",
|
||||
"prefs.shift": "Umschalt",
|
||||
"prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um",
|
||||
"prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.",
|
||||
@@ -3133,7 +3151,7 @@
|
||||
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
|
||||
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
|
||||
"render.cpu_path_depth": "Pfadtiefe",
|
||||
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–64). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_path_lighting": "Indirekte Beleuchtung",
|
||||
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_point": "Punktlicht",
|
||||
@@ -5245,6 +5263,7 @@
|
||||
"ui.heading_plastic": "PLASTIK",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "VORBEREITEN",
|
||||
"ui.heading_press_pull": "DRÜCKEN/ZIEHEN",
|
||||
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
|
||||
"ui.heading_revolve": "DREHEN",
|
||||
"ui.heading_right": "RECHTS",
|
||||
@@ -5584,6 +5603,8 @@
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
|
||||
"ui.project_references_construction": "Referenzen als Konstruktionsgeometrie projizieren",
|
||||
"ui.project_references_construction_note": "Projizieren, Schneiden, 3D-Geometrie einbeziehen und die Flächenkanten einer neuen Skizze auf einer Fläche erzeugen Konstruktionsgeometrie: Bezüge zum Zeichnen, die nicht zu Profilen gehören. Sie bleiben mit dem Modell verknüpft.",
|
||||
"ui.properties": "Eigenschaften",
|
||||
"ui.properties.copy_matrix": "Matrix kopieren",
|
||||
"ui.properties.first_moments": "Statische Momente",
|
||||
|
||||
+22
-1
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "Directional Elastic Modulus",
|
||||
"cmd.material_properties_validate": "Validate Physical Material Data",
|
||||
"cmd.mesh_convert": "Convert Mesh",
|
||||
"cmd.model_body_features": "List Body Features",
|
||||
"cmd.model_edges": "List Edges",
|
||||
"cmd.model_faces": "List Faces",
|
||||
"cmd.model_threads": "List Threads",
|
||||
@@ -1235,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "Dismiss Panel Peek",
|
||||
"cmd.panel.float": "Float Panel",
|
||||
"cmd.panel.focus": "Focus Panel",
|
||||
"cmd.panel.focus.next": "Focus Next Panel",
|
||||
"cmd.panel.focus.previous": "Focus Previous Panel",
|
||||
"cmd.panel.list": "List Panels",
|
||||
"cmd.panel.move": "Move Panel",
|
||||
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
|
||||
"cmd.panel.native_close": "Redock Native Panel Section",
|
||||
"cmd.panel.open": "Open Panel",
|
||||
"cmd.panel.peek": "Peek Collapsed Panel",
|
||||
@@ -1244,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "Reset Panel Sash",
|
||||
"cmd.panel.resize": "Resize Panel Section",
|
||||
"cmd.panel.resize_pair": "Resize Panel Sash",
|
||||
"cmd.panel.sash.down": "Resize Panel Sash Down",
|
||||
"cmd.panel.sash.left": "Resize Panel Sash Left",
|
||||
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
|
||||
"cmd.panel.sash.right": "Resize Panel Sash Right",
|
||||
"cmd.panel.sash.up": "Resize Panel Sash Up",
|
||||
"cmd.panel.toggle": "Toggle Focused Panel",
|
||||
"cmd.parameters_add": "Add User Parameter",
|
||||
"cmd.parameters_change": "Change Parameters",
|
||||
"cmd.parameters_comment": "Comment Parameter",
|
||||
@@ -2518,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "Bottom center",
|
||||
"prefs.bottom_right": "Bottom right",
|
||||
"prefs.browser": "Browser",
|
||||
"prefs.browser_body_features": "Features under bodies",
|
||||
"prefs.browser_body_features_note": "Each body in the browser folds open on the timeline features that built it, in timeline order.",
|
||||
"prefs.browser_components_open": "Components start open",
|
||||
"prefs.browser_components_open_note": "Every component in the browser starts open, as before, instead of closed until you open it.",
|
||||
"prefs.browser_eye_left": "Eye beside the fold arrow",
|
||||
@@ -2558,6 +2570,8 @@
|
||||
"prefs.design_tools.degrees": "Degrees",
|
||||
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
|
||||
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
|
||||
"prefs.design_tools.hole_group": "Group holes made together",
|
||||
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
|
||||
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
|
||||
"prefs.design_tools.hole_several": "Several holes on one face",
|
||||
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
|
||||
@@ -2642,6 +2656,8 @@
|
||||
"prefs.history": "History",
|
||||
"prefs.history_bar_groups": "Fold the History bar into groups",
|
||||
"prefs.history_bar_groups_note": "In a long history, a run of one component's features folds into one chip, and feature folders start folded. An inserted STEP assembly goes into a Group folder. Off shows each feature, starts folders open and makes no folder.",
|
||||
"prefs.history_folder_kind_icon": "Folder icon from its features",
|
||||
"prefs.history_folder_kind_icon_note": "A History folder whose features are all of one kind shows their icon.",
|
||||
"prefs.history_group_joints": "Group joints in the History",
|
||||
"prefs.history_group_joints_note": "Two or more joints show as one button with their count, and a click shows each one. Off shows each joint.",
|
||||
"prefs.history_origin_bodies": "Show origin and bodies at the top",
|
||||
@@ -2835,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
|
||||
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
|
||||
"prefs.selecting": "Selecting",
|
||||
"prefs.shared_panel_layout": "Use shared panel layout",
|
||||
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
|
||||
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
|
||||
@@ -3379,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "CPU ray tracing needs an opaque background. Clear Transparent background to continue.",
|
||||
"render.cpu_options_pending": "This CPU panel cannot edit emission, surface effects, glass or anisotropy yet. Clear those API options before using it.",
|
||||
"render.cpu_path_depth": "Path depth",
|
||||
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–16). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–64). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_path_lighting": "Indirect lighting",
|
||||
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_point": "Point light",
|
||||
@@ -5525,6 +5543,7 @@
|
||||
"ui.heading_plastic": "PLASTIC",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "PREPARE",
|
||||
"ui.heading_press_pull": "PRESS PULL",
|
||||
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
|
||||
"ui.heading_revolve": "REVOLVE",
|
||||
"ui.heading_right": "RIGHT",
|
||||
@@ -5868,6 +5887,8 @@
|
||||
"ui.profiles": "Profiles",
|
||||
"ui.project": "Project",
|
||||
"ui.project_face_edges_sketch": "Project face edges into new sketches",
|
||||
"ui.project_references_construction": "Project references as construction geometry",
|
||||
"ui.project_references_construction_note": "Project, Intersect, Include 3D Geometry and the face edges of a new sketch on a face make construction geometry: references to draw against, left out of profiles. They stay linked to the model.",
|
||||
"ui.properties": "Properties",
|
||||
"ui.properties.copy_matrix": "Copy matrix",
|
||||
"ui.properties.first_moments": "First moments",
|
||||
|
||||
@@ -1000,6 +1000,7 @@
|
||||
"cmd.material_properties_directional": "Module d'élasticité directionnel",
|
||||
"cmd.material_properties_validate": "Valider les données du matériau physique",
|
||||
"cmd.mesh_convert": "Convertir le maillage",
|
||||
"cmd.model_body_features": "Lister les fonctions du corps",
|
||||
"cmd.model_edges": "Lister les arêtes",
|
||||
"cmd.model_faces": "Lister les faces",
|
||||
"cmd.model_threads": "Lister les filetages",
|
||||
@@ -2164,6 +2165,8 @@
|
||||
"prefs.bottom_centre": "Bas centre",
|
||||
"prefs.bottom_right": "Bas droite",
|
||||
"prefs.browser": "Navigateur",
|
||||
"prefs.browser_body_features": "Fonctions sous les corps",
|
||||
"prefs.browser_body_features_note": "Chaque corps du navigateur se déplie sur les fonctions de l'historique qui l'ont construit, dans l'ordre de l'historique.",
|
||||
"prefs.browser_components_open": "Composants dépliés au départ",
|
||||
"prefs.browser_components_open_note": "Chaque composant du navigateur commence déplié, comme avant, au lieu de rester replié jusqu'à ce que vous le dépliiez.",
|
||||
"prefs.browser_eye_left": "Œil à côté de la flèche de dépliage",
|
||||
|
||||
+22
-1
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "מודול אלסטיות כיווני",
|
||||
"cmd.material_properties_validate": "אימות נתוני חומר פיזיקלי",
|
||||
"cmd.mesh_convert": "המרת רשת משולשים",
|
||||
"cmd.model_body_features": "הצגת פיצ'רים של גוף",
|
||||
"cmd.model_edges": "רשימת מקצועות",
|
||||
"cmd.model_faces": "רשימת פאות",
|
||||
"cmd.model_threads": "רשימת הברגות",
|
||||
@@ -1235,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
|
||||
"cmd.panel.float": "הצפת חלונית",
|
||||
"cmd.panel.focus": "מיקוד חלונית",
|
||||
"cmd.panel.focus.next": "מיקוד בלוח הבא",
|
||||
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
|
||||
"cmd.panel.list": "הצגת רשימת חלוניות",
|
||||
"cmd.panel.move": "העברת חלונית",
|
||||
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
|
||||
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
|
||||
"cmd.panel.open": "פתיחת חלונית",
|
||||
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
|
||||
@@ -1244,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
|
||||
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
|
||||
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
|
||||
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
|
||||
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
|
||||
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
|
||||
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
|
||||
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
|
||||
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד",
|
||||
"cmd.parameters_add": "הוספת פרמטר משתמש",
|
||||
"cmd.parameters_change": "שינוי פרמטרים",
|
||||
"cmd.parameters_comment": "הערה לפרמטר",
|
||||
@@ -2518,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "למטה במרכז",
|
||||
"prefs.bottom_right": "למטה מימין",
|
||||
"prefs.browser": "עץ התכן",
|
||||
"prefs.browser_body_features": "פיצ'רים מתחת לגופים",
|
||||
"prefs.browser_body_features_note": "כל גוף בדפדפן נפתח ומציג את הפיצ'רים מציר הזמן שבנו אותו, לפי סדר ציר הזמן.",
|
||||
"prefs.browser_components_open": "רכיבים פתוחים מההתחלה",
|
||||
"prefs.browser_components_open_note": "כל רכיב בעץ התכן מתחיל פתוח, כמו קודם, במקום סגור עד שתפתח אותו.",
|
||||
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
|
||||
@@ -2558,6 +2570,8 @@
|
||||
"prefs.design_tools.degrees": "מעלות",
|
||||
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
|
||||
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
|
||||
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
|
||||
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
|
||||
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
|
||||
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
|
||||
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
|
||||
@@ -2642,6 +2656,8 @@
|
||||
"prefs.history": "ציר הזמן",
|
||||
"prefs.history_bar_groups": "קיפול סרגל ציר הזמן לקבוצות",
|
||||
"prefs.history_bar_groups_note": "בציר זמן ארוך, רצף פיצ'רים של רכיב אחד מתקפל לשבב אחד, ותיקיות פיצ'רים מתחילות מקופלות. מכלול STEP שהוכנס נכנס לתיקיית Group. כשהאפשרות כבויה, כל פיצ'ר מוצג, התיקיות מתחילות פתוחות ולא נוצרת תיקייה.",
|
||||
"prefs.history_folder_kind_icon": "סמל התיקייה לפי התכונות שבה",
|
||||
"prefs.history_folder_kind_icon_note": "תיקייה בהיסטוריה שכל התכונות בה מאותו סוג מציגה את הסמל שלהן.",
|
||||
"prefs.history_group_joints": "קיבוץ מפרקים בציר הזמן",
|
||||
"prefs.history_group_joints_note": "שני מפרקים או יותר מוצגים ככפתור אחד עם מספרם, ולחיצה מציגה כל אחד מהם. כשהאפשרות כבויה, כל מפרק מוצג בנפרד.",
|
||||
"prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה",
|
||||
@@ -2835,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
|
||||
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
|
||||
"prefs.selecting": "בחירה",
|
||||
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
|
||||
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
|
||||
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
|
||||
@@ -3379,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "מעקב קרניים ב־CPU דורש רקע אטום. יש לבטל את „רקע שקוף” כדי להמשיך.",
|
||||
"render.cpu_options_pending": "לוח CPU זה עדיין אינו יכול לערוך פליטה, אפקטי משטח, זכוכית או אנאיזוטרופיה. יש לנקות את אפשרויות ה־API האלה לפני השימוש בו.",
|
||||
"render.cpu_path_depth": "עומק מסלול",
|
||||
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–16). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–64). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_path_lighting": "תאורה עקיפה",
|
||||
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_point": "אור נקודתי",
|
||||
@@ -5525,6 +5543,7 @@
|
||||
"ui.heading_plastic": "פלסטיק",
|
||||
"ui.heading_position": "מיקום",
|
||||
"ui.heading_prepare": "הכנה",
|
||||
"ui.heading_press_pull": "דחיפה/משיכה",
|
||||
"ui.heading_rectangular_pattern": "מערך מלבני",
|
||||
"ui.heading_revolve": "סיבוב",
|
||||
"ui.heading_right": "ימין",
|
||||
@@ -5868,6 +5887,8 @@
|
||||
"ui.profiles": "פרופילים",
|
||||
"ui.project": "הטלה",
|
||||
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
|
||||
"ui.project_references_construction": "הטלת הפניות כגאומטריית עזר",
|
||||
"ui.project_references_construction_note": "הטלה, חיתוך, הכללת גאומטריה תלת־ממדית ומקצועות הפאה של סקיצה חדשה על פאה יוצרים גאומטריית עזר: הפניות לשרטוט שאינן נכללות בפרופילים. הן נשארות מקושרות למודל.",
|
||||
"ui.properties": "מאפיינים",
|
||||
"ui.properties.copy_matrix": "העתקת המטריצה",
|
||||
"ui.properties.first_moments": "מומנטים ראשונים",
|
||||
|
||||
+22
-1
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "方向別弾性率",
|
||||
"cmd.material_properties_validate": "物理材料データを検証",
|
||||
"cmd.mesh_convert": "メッシュを変換",
|
||||
"cmd.model_body_features": "ボディのフィーチャーを一覧表示",
|
||||
"cmd.model_edges": "エッジを一覧表示",
|
||||
"cmd.model_faces": "面を一覧表示",
|
||||
"cmd.model_threads": "ねじを一覧表示",
|
||||
@@ -1235,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
|
||||
"cmd.panel.float": "パネルをフロート表示",
|
||||
"cmd.panel.focus": "パネルにフォーカス",
|
||||
"cmd.panel.focus.next": "次のパネルにフォーカス",
|
||||
"cmd.panel.focus.previous": "前のパネルにフォーカス",
|
||||
"cmd.panel.list": "パネルを一覧表示",
|
||||
"cmd.panel.move": "パネルを移動",
|
||||
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
|
||||
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
|
||||
"cmd.panel.open": "パネルを開く",
|
||||
"cmd.panel.peek": "折りたたんだパネルを一時表示",
|
||||
@@ -1244,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "パネルの区切りをリセット",
|
||||
"cmd.panel.resize": "パネルのセクションのサイズを変更",
|
||||
"cmd.panel.resize_pair": "パネルの区切りを移動",
|
||||
"cmd.panel.sash.down": "パネルの区切りを下に移動",
|
||||
"cmd.panel.sash.left": "パネルの区切りを左に移動",
|
||||
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
|
||||
"cmd.panel.sash.right": "パネルの区切りを右に移動",
|
||||
"cmd.panel.sash.up": "パネルの区切りを上に移動",
|
||||
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え",
|
||||
"cmd.parameters_add": "ユーザー パラメータを追加",
|
||||
"cmd.parameters_change": "パラメータを変更",
|
||||
"cmd.parameters_comment": "パラメータにコメントを付与",
|
||||
@@ -2518,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "下中央",
|
||||
"prefs.bottom_right": "右下",
|
||||
"prefs.browser": "ブラウザー",
|
||||
"prefs.browser_body_features": "ボディの下にフィーチャー",
|
||||
"prefs.browser_body_features_note": "ブラウザーの各ボディを展開すると、そのボディを作ったタイムラインのフィーチャーがタイムライン順に表示されます。",
|
||||
"prefs.browser_components_open": "コンポーネントを最初から展開",
|
||||
"prefs.browser_components_open_note": "ブラウザーのすべてのコンポーネントを以前と同じく最初から展開して表示します。オフの場合は展開するまで折りたたまれています。",
|
||||
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
|
||||
@@ -2558,6 +2570,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
|
||||
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
|
||||
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
|
||||
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
|
||||
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
|
||||
"prefs.design_tools.hole_several": "1つの面に複数の穴",
|
||||
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
|
||||
@@ -2642,6 +2656,8 @@
|
||||
"prefs.history": "履歴",
|
||||
"prefs.history_bar_groups": "履歴バーをグループにまとめる",
|
||||
"prefs.history_bar_groups_note": "長い履歴では、1つのコンポーネントの連続したフィーチャーを1つのチップにまとめ、フィーチャーフォルダは閉じた状態で始まります。挿入したSTEPアセンブリはGroupフォルダに入ります。オフにすると、各フィーチャーを表示し、フォルダは開いた状態で始まり、フォルダは作成されません。",
|
||||
"prefs.history_folder_kind_icon": "中のフィーチャーのアイコンをフォルダーに使う",
|
||||
"prefs.history_folder_kind_icon_note": "中のフィーチャーがすべて同じ種類の履歴フォルダーは、そのアイコンで表示されます。",
|
||||
"prefs.history_group_joints": "履歴でジョイントをまとめる",
|
||||
"prefs.history_group_joints_note": "2つ以上のジョイントを、数を添えた1つのボタンにまとめます。クリックすると各ジョイントを表示します。オフにすると各ジョイントを個別に表示します。",
|
||||
"prefs.history_origin_bodies": "原点とボディを先頭に表示",
|
||||
@@ -2835,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
|
||||
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
|
||||
"prefs.selecting": "選択",
|
||||
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
|
||||
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
|
||||
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
|
||||
@@ -3379,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "CPU レイトレーシングには不透明な背景が必要です。透明な背景を解除してください。",
|
||||
"render.cpu_options_pending": "このCPUパネルでは、発光、表面効果、ガラス、異方性はまだ編集できません。使用前にそれらのAPIオプションを解除してください。",
|
||||
"render.cpu_path_depth": "パス深度",
|
||||
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~16)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~64)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_path_lighting": "間接照明",
|
||||
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_point": "点光源",
|
||||
@@ -5525,6 +5543,7 @@
|
||||
"ui.heading_plastic": "プラスチック",
|
||||
"ui.heading_position": "配置",
|
||||
"ui.heading_prepare": "準備",
|
||||
"ui.heading_press_pull": "プレス プル",
|
||||
"ui.heading_rectangular_pattern": "矩形パターン",
|
||||
"ui.heading_revolve": "回転",
|
||||
"ui.heading_right": "右側面",
|
||||
@@ -5868,6 +5887,8 @@
|
||||
"ui.profiles": "プロファイル",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
|
||||
"ui.project_references_construction": "参照をコンストラクションジオメトリとして投影",
|
||||
"ui.project_references_construction_note": "投影、交差、3D ジオメトリを含める、および面上の新規スケッチの面のエッジは、コンストラクションジオメトリになります。描画の参照用で、プロファイルには含まれません。モデルとのリンクは保たれます。",
|
||||
"ui.properties": "プロパティ",
|
||||
"ui.properties.copy_matrix": "行列をコピー",
|
||||
"ui.properties.first_moments": "一次モーメント",
|
||||
|
||||
+19
-1
@@ -992,8 +992,11 @@
|
||||
"cmd.panel.dismiss": "패널 임시 보기 닫기",
|
||||
"cmd.panel.float": "패널 띄우기",
|
||||
"cmd.panel.focus": "패널에 초점 맞추기",
|
||||
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
|
||||
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
|
||||
"cmd.panel.list": "패널 목록",
|
||||
"cmd.panel.move": "패널 이동",
|
||||
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
|
||||
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
|
||||
"cmd.panel.open": "패널 열기",
|
||||
"cmd.panel.peek": "접힌 패널 임시 보기",
|
||||
@@ -1001,6 +1004,12 @@
|
||||
"cmd.panel.reset_pair": "패널 구분선 초기화",
|
||||
"cmd.panel.resize": "패널 구역 크기 조정",
|
||||
"cmd.panel.resize_pair": "패널 구분선 이동",
|
||||
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
|
||||
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
|
||||
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
|
||||
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
|
||||
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
|
||||
"cmd.panel.toggle": "초점이 있는 패널 접기 전환",
|
||||
"cmd.parameters_add": "사용자 매개 변수 추가",
|
||||
"cmd.parameters_change": "매개 변수 변경",
|
||||
"cmd.parameters_comment": "매개 변수에 설명 추가",
|
||||
@@ -1929,6 +1938,8 @@
|
||||
"prefs.design_tools.degrees": "도",
|
||||
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
|
||||
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
|
||||
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
|
||||
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
|
||||
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
|
||||
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
@@ -2006,6 +2017,8 @@
|
||||
"prefs.history": "이력",
|
||||
"prefs.history_bar_groups": "이력 막대를 그룹으로 접기",
|
||||
"prefs.history_bar_groups_note": "긴 이력에서는 한 컴포넌트의 연속된 특성을 하나의 칩으로 접고, 특성 폴더는 접힌 상태로 시작합니다. 삽입한 STEP 어셈블리는 Group 폴더에 들어갑니다. 끄면 각 특성을 표시하고, 폴더를 펼친 상태로 시작하며, 폴더를 만들지 않습니다.",
|
||||
"prefs.history_folder_kind_icon": "폴더 아이콘을 안의 피처에서 가져오기",
|
||||
"prefs.history_folder_kind_icon_note": "피처가 모두 같은 종류인 기록 폴더는 그 피처의 아이콘을 표시합니다.",
|
||||
"prefs.history_group_joints": "이력에서 조인트 묶기",
|
||||
"prefs.history_group_joints_note": "조인트가 두 개 이상이면 개수가 표시된 버튼 하나로 묶고, 클릭하면 각 조인트를 표시합니다. 끄면 각 조인트를 하나씩 표시합니다.",
|
||||
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
|
||||
@@ -2133,6 +2146,8 @@
|
||||
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
|
||||
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
|
||||
"prefs.selecting": "선택",
|
||||
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
|
||||
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
|
||||
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
|
||||
@@ -2654,7 +2669,7 @@
|
||||
"render.cpu_opaque_required": "CPU 레이 트레이싱에는 불투명 배경이 필요합니다. 계속하려면 투명 배경을 해제하세요.",
|
||||
"render.cpu_options_pending": "이 CPU 패널은 아직 발광, 표면 효과, 유리, 이방성을 편집할 수 없습니다. 사용하기 전에 해당 API 옵션을 해제하세요.",
|
||||
"render.cpu_path_depth": "경로 깊이",
|
||||
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–16). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–64). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_path_lighting": "간접 조명",
|
||||
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_point": "점 조명",
|
||||
@@ -4717,6 +4732,7 @@
|
||||
"ui.heading_plastic": "플라스틱",
|
||||
"ui.heading_position": "배치",
|
||||
"ui.heading_prepare": "준비",
|
||||
"ui.heading_press_pull": "밀기/당기기",
|
||||
"ui.heading_rectangular_pattern": "직사각형 패턴",
|
||||
"ui.heading_revolve": "회전",
|
||||
"ui.heading_right": "우측면",
|
||||
@@ -5038,6 +5054,8 @@
|
||||
"ui.profiles": "프로파일",
|
||||
"ui.project": "투영",
|
||||
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
|
||||
"ui.project_references_construction": "참조를 구성선으로 투영",
|
||||
"ui.project_references_construction_note": "투영, 교차, 3D 형상 포함과 면 위 새 스케치의 면 모서리가 구성선으로 만들어집니다. 프로파일에 포함되지 않는 그리기용 참조이며, 모델과의 연결은 유지됩니다.",
|
||||
"ui.properties": "속성",
|
||||
"ui.properties.copy_matrix": "행렬 복사",
|
||||
"ui.properties.first_moments": "1차 모멘트",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
|
||||
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
|
||||
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
|
||||
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
|
||||
"render.texture_filtering": "ترشيح تفاصيل الخامات",
|
||||
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً."
|
||||
},
|
||||
"de": {
|
||||
"render.caustics_area_lights": "Kaustiken von Flächenlichtern",
|
||||
"render.caustics_environment": "Kaustiken aus der Umgebung",
|
||||
"render.metal_energy_compensation": "Reflexion rauer Metalle wiederherstellen",
|
||||
"render.metal_energy_compensation_help": "Gleicht den Lichtverlust zwischen den Mikrofacetten rauer Metalle aus. Die CPU benötigt indirekte Beleuchtung.",
|
||||
"render.texture_filtering": "Texturdetails filtern",
|
||||
"render.texture_filtering_help": "Mip-Stufen und anisotrope Filterung verringern Flimmern verkleinerter Texturen. Benötigt zusätzlichen Speicher; ausgeschaltet bleibt die bisherige Filterung."
|
||||
},
|
||||
"en": {
|
||||
"render.caustics_area_lights": "Area light caustics",
|
||||
"render.caustics_environment": "Environment caustics",
|
||||
"render.metal_energy_compensation": "Recover rough metal reflectance",
|
||||
"render.metal_energy_compensation_help": "Compensate for light lost between rough metal microfacets. CPU rendering needs Indirect lighting.",
|
||||
"render.texture_filtering": "Filter texture detail",
|
||||
"render.texture_filtering_help": "Use mip levels and anisotropic filtering to reduce texture aliasing under minification. Uses more memory; off retains the previous filtering."
|
||||
},
|
||||
"he": {
|
||||
"render.caustics_area_lights": "קאוסטיקה מתאורת שטח",
|
||||
"render.caustics_environment": "קאוסטיקה מתאורת הסביבה",
|
||||
"render.metal_energy_compensation": "שחזור ההחזר של מתכת מחוספסת",
|
||||
"render.metal_energy_compensation_help": "מפצה על האור שאובד בין הפאות המיקרוסקופיות של מתכת מחוספסת. עיבוד במעבד דורש תאורה עקיפה.",
|
||||
"render.texture_filtering": "סינון פרטי מרקם",
|
||||
"render.texture_filtering_help": "שימוש ברמות פירוט ובסינון אנאיזוטרופי מפחית שינון במרקמים מוקטנים. דורש יותר זיכרון; כשהאפשרות כבויה נשמר הסינון הקודם."
|
||||
},
|
||||
"ja": {
|
||||
"render.caustics_area_lights": "面光源のコースティクス",
|
||||
"render.caustics_environment": "環境光のコースティクス",
|
||||
"render.metal_energy_compensation": "粗い金属の反射を補正",
|
||||
"render.metal_energy_compensation_help": "粗い金属の微小面間で失われる光を補います。CPU レンダリングには間接照明が必要です。",
|
||||
"render.texture_filtering": "テクスチャの細部をフィルタリング",
|
||||
"render.texture_filtering_help": "ミップレベルと異方性フィルタリングで縮小時のちらつきを抑えます。メモリ使用量が増えます。オフでは従来のフィルタリングを維持します。"
|
||||
},
|
||||
"ko": {
|
||||
"render.caustics_area_lights": "면광원 집광 효과",
|
||||
"render.caustics_environment": "환경광 집광 효과",
|
||||
"render.metal_energy_compensation": "거친 금속의 반사 복원",
|
||||
"render.metal_energy_compensation_help": "거친 금속의 미세면 사이에서 손실되는 빛을 보정합니다. CPU 렌더링에는 간접 조명이 필요합니다.",
|
||||
"render.texture_filtering": "텍스처 세부 필터링",
|
||||
"render.texture_filtering_help": "밉 레벨과 비등방성 필터링으로 축소 시 텍스처 계단 현상을 줄입니다. 메모리를 더 사용하며 끄면 기존 필터링을 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.caustics_area_lights": "Tụ quang từ đèn diện tích",
|
||||
"render.caustics_environment": "Tụ quang từ ánh sáng môi trường",
|
||||
"render.metal_energy_compensation": "Khôi phục phản xạ của kim loại nhám",
|
||||
"render.metal_energy_compensation_help": "Bù ánh sáng thất thoát giữa các vi diện của kim loại nhám. Kết xuất CPU cần chiếu sáng gián tiếp.",
|
||||
"render.texture_filtering": "Lọc chi tiết kết cấu",
|
||||
"render.texture_filtering_help": "Dùng các mức mip và lọc bất đẳng hướng để giảm răng cưa khi thu nhỏ kết cấu. Tốn thêm bộ nhớ; tắt để giữ cách lọc trước đây."
|
||||
},
|
||||
"zh": {
|
||||
"render.caustics_area_lights": "面光源焦散",
|
||||
"render.caustics_environment": "环境光焦散",
|
||||
"render.metal_energy_compensation": "恢复粗糙金属的反射",
|
||||
"render.metal_energy_compensation_help": "补偿粗糙金属微表面之间损失的光能。CPU 渲染需要间接照明。",
|
||||
"render.texture_filtering": "过滤纹理细节",
|
||||
"render.texture_filtering_help": "使用多级纹理和各向异性过滤,减少纹理缩小时的锯齿。需要更多内存;关闭时保留原有过滤方式。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"en": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Saved value: {samples} spp. New selections are limited to {maximum} spp.",
|
||||
"render.quality_preset_limit": "This preset exceeds the 16384 spp selection limit. Choose a sample count up to 16384.",
|
||||
"render.supersampling": "Supersampling",
|
||||
"render.supersampling_off": "Off",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Render at twice the width and height, then reduce in linear light. The output size stays unchanged.",
|
||||
"render.supersampling_dimensions": "Renders {width} × {height} and reduces to {output_width} × {output_height}. Uses four times the pixels.",
|
||||
"render.cpu_threads": "CPU workers",
|
||||
"render.cpu_threads_auto": "Auto",
|
||||
"render.cpu_threads_single": "Single",
|
||||
"render.cpu_threads_custom": "Custom",
|
||||
"render.cpu_threads_help": "Auto uses available CPU workers. One worker runs serially. More workers can need more memory.",
|
||||
"render.cpu_threads_web": "Browser rendering uses one cooperative worker. The saved desktop worker choice is preserved.",
|
||||
"render.denoise_passes": "Filter passes",
|
||||
"render.denoise_strength": "Denoising strength",
|
||||
"render.denoise_firefly": "Suppress isolated bright pixels",
|
||||
"render.denoise_settings_help": "More passes smooth a wider area. Strength controls how much filtering each pass applies. Unsupported optical paths remain unfiltered.",
|
||||
"render.denoise_diagnostics_enable": "Collect denoising diagnostics",
|
||||
"render.denoise_diagnostics_help": "Collects eligible and skipped pixel counts even when denoising is off. Counts use the internal render size; the mask uses the output size.",
|
||||
"render.denoise_diagnostics_title": "Denoising diagnostics",
|
||||
"render.denoise_diagnostics_grid": "Pixel counts at internal size {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Eligible: {eligible} · Skipped: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Enable denoising diagnostics and render an image to inspect its mask.",
|
||||
"render.denoise_mask_copy_help": "Export the denoising mask as PNG, or return to the rendered image to copy it.",
|
||||
"render.denoise_view_image": "Rendered image",
|
||||
"render.denoise_view_mask": "Denoising mask",
|
||||
"render.denoise_export_mask": "Export mask PNG",
|
||||
"render.denoise_format_mask": "Mask PNG",
|
||||
"render.denoise_class_unavailable": "Skipped: no guide",
|
||||
"render.denoise_class_surface": "Eligible: surface",
|
||||
"render.denoise_class_optical": "Eligible: refracted surface",
|
||||
"render.denoise_class_background": "Skipped: background",
|
||||
"render.denoise_class_emitter": "Skipped: light emitter",
|
||||
"render.denoise_class_material": "Skipped: unsupported material",
|
||||
"render.denoise_class_nested": "Skipped: nested medium",
|
||||
"render.denoise_class_lens": "Skipped: depth of field",
|
||||
"render.denoise_class_volume": "Skipped: volume scattering",
|
||||
"render.denoise_class_depth": "Skipped: path depth limit",
|
||||
"render.denoise_class_degenerate": "Skipped: degenerate path",
|
||||
"render.denoise_class_exit": "Skipped: incompatible exit",
|
||||
"render.denoise_class_tir": "Skipped: total internal reflection",
|
||||
"render.denoise_class_transmission": "Skipped: further transmission",
|
||||
"render.denoise_class_reflection": "Skipped: reflective endpoint",
|
||||
"render.denoise_class_identity": "Skipped: guide identity limit",
|
||||
"render.denoise_class_coverage": "Skipped: partial coverage"
|
||||
},
|
||||
"ar": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "القيمة المحفوظة: {samples} عينة لكل بكسل. تقتصر الاختيارات الجديدة على {maximum} عينة لكل بكسل.",
|
||||
"render.quality_preset_limit": "يتجاوز هذا الإعداد المسبق حد الاختيار البالغ 16384 عينة لكل بكسل. اختر عدد عينات لا يتجاوز 16384.",
|
||||
"render.supersampling": "أخذ عينات فائقة",
|
||||
"render.supersampling_off": "إيقاف",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "يُصيَّر المشهد بضعف العرض والارتفاع، ثم يُصغَّر في فضاء الإضاءة الخطي. يبقى حجم الإخراج دون تغيير.",
|
||||
"render.supersampling_dimensions": "يُصيَّر بحجم {width} × {height} ويُصغَّر إلى {output_width} × {output_height}. يستخدم أربعة أضعاف عدد البكسلات.",
|
||||
"render.cpu_threads": "خيوط المعالجة",
|
||||
"render.cpu_threads_auto": "تلقائي",
|
||||
"render.cpu_threads_single": "واحد",
|
||||
"render.cpu_threads_custom": "مخصص",
|
||||
"render.cpu_threads_help": "يستخدم الوضع التلقائي خيوط المعالجة المتاحة. ينفّذ خيط واحد العمل تسلسليًا. قد تحتاج الخيوط الإضافية إلى ذاكرة أكبر.",
|
||||
"render.cpu_threads_web": "يستخدم التصيير في المتصفح خيطًا تعاونيًا واحدًا. يُحفظ اختيار خيوط المعالجة الخاص بسطح المكتب.",
|
||||
"render.denoise_passes": "مرات الترشيح",
|
||||
"render.denoise_strength": "قوة إزالة الضوضاء",
|
||||
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
|
||||
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
|
||||
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
|
||||
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
|
||||
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
|
||||
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
|
||||
"render.denoise_view_image": "الصورة المصيّرة",
|
||||
"render.denoise_view_mask": "قناع إزالة الضوضاء",
|
||||
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
|
||||
"render.denoise_format_mask": "قناع PNG",
|
||||
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
|
||||
"render.denoise_class_surface": "مؤهل: سطح",
|
||||
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
|
||||
"render.denoise_class_background": "متجاوز: خلفية",
|
||||
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
|
||||
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
|
||||
"render.denoise_class_nested": "متجاوز: وسط متداخل",
|
||||
"render.denoise_class_lens": "متجاوز: عمق المجال",
|
||||
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
|
||||
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
|
||||
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
|
||||
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
|
||||
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
|
||||
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
|
||||
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
|
||||
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
|
||||
"render.denoise_class_coverage": "متجاوز: تغطية جزئية"
|
||||
},
|
||||
"he": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "ערך שמור: {samples} דגימות לפיקסל. בחירות חדשות מוגבלות ל־{maximum} דגימות לפיקסל.",
|
||||
"render.quality_preset_limit": "הגדרה קבועה זו חורגת ממגבלת הבחירה של 16384 דגימות לפיקסל. יש לבחור עד 16384 דגימות.",
|
||||
"render.supersampling": "דגימת יתר",
|
||||
"render.supersampling_off": "כבוי",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "רינדור ברוחב ובגובה כפולים, ואז הקטנה במרחב אור ליניארי. גודל הפלט נשאר ללא שינוי.",
|
||||
"render.supersampling_dimensions": "רינדור בגודל {width} × {height} והקטנה ל־{output_width} × {output_height}. נעשה שימוש בפי ארבעה פיקסלים.",
|
||||
"render.cpu_threads": "תהליכוני CPU",
|
||||
"render.cpu_threads_auto": "אוטומטי",
|
||||
"render.cpu_threads_single": "יחיד",
|
||||
"render.cpu_threads_custom": "מותאם",
|
||||
"render.cpu_threads_help": "המצב האוטומטי משתמש בתהליכוני ה־CPU הזמינים. תהליכון יחיד פועל באופן סדרתי. תהליכונים נוספים עשויים לדרוש יותר זיכרון.",
|
||||
"render.cpu_threads_web": "רינדור בדפדפן משתמש בתהליכון שיתופי אחד. בחירת התהליכונים השמורה למחשב נשמרת.",
|
||||
"render.denoise_passes": "מעברי סינון",
|
||||
"render.denoise_strength": "עוצמת הפחתת רעש",
|
||||
"render.denoise_firefly": "דיכוי פיקסלים בהירים מבודדים",
|
||||
"render.denoise_settings_help": "מעברים נוספים מחליקים אזור רחב יותר. העוצמה קובעת כמה סינון מופעל בכל מעבר. מסלולים אופטיים שאינם נתמכים נשארים ללא סינון.",
|
||||
"render.denoise_diagnostics_enable": "איסוף נתוני אבחון להפחתת רעש",
|
||||
"render.denoise_diagnostics_help": "נאספים מספרי הפיקסלים המתאימים ואלה שדולגו, גם כשהפחתת הרעש כבויה. הספירה לפי גודל הרינדור הפנימי; המסכה לפי גודל הפלט.",
|
||||
"render.denoise_diagnostics_title": "אבחון הפחתת רעש",
|
||||
"render.denoise_diagnostics_grid": "ספירת פיקסלים בגודל הפנימי {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "מתאימים: {eligible} · דולגו: {skipped}",
|
||||
"render.denoise_mask_unavailable": "יש להפעיל אבחון הפחתת רעש ולרנדר תמונה כדי לבדוק את המסכה שלה.",
|
||||
"render.denoise_mask_copy_help": "אפשר לייצא את מסכת הפחתת הרעש כ־PNG, או לחזור לתמונה המרונדרת כדי להעתיק אותה.",
|
||||
"render.denoise_view_image": "תמונה מרונדרת",
|
||||
"render.denoise_view_mask": "מסכת הפחתת רעש",
|
||||
"render.denoise_export_mask": "ייצוא המסכה כ־PNG",
|
||||
"render.denoise_format_mask": "מסכה PNG",
|
||||
"render.denoise_class_unavailable": "דולג: אין נתוני הנחיה",
|
||||
"render.denoise_class_surface": "מתאים: משטח",
|
||||
"render.denoise_class_optical": "מתאים: משטח דרך שבירה",
|
||||
"render.denoise_class_background": "דולג: רקע",
|
||||
"render.denoise_class_emitter": "דולג: מקור אור",
|
||||
"render.denoise_class_material": "דולג: חומר לא נתמך",
|
||||
"render.denoise_class_nested": "דולג: תווך מקונן",
|
||||
"render.denoise_class_lens": "דולג: עומק שדה",
|
||||
"render.denoise_class_volume": "דולג: פיזור נפחי",
|
||||
"render.denoise_class_depth": "דולג: מגבלת עומק מסלול",
|
||||
"render.denoise_class_degenerate": "דולג: מסלול מנוון",
|
||||
"render.denoise_class_exit": "דולג: יציאה לא תואמת",
|
||||
"render.denoise_class_tir": "דולג: החזרה פנימית מלאה",
|
||||
"render.denoise_class_transmission": "דולג: העברה נוספת",
|
||||
"render.denoise_class_reflection": "דולג: נקודת סיום מחזירה",
|
||||
"render.denoise_class_identity": "דולג: מגבלת מזהי הנחיה",
|
||||
"render.denoise_class_coverage": "דולג: כיסוי חלקי"
|
||||
},
|
||||
"ja": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "保存値: {samples} spp。新しい選択値の上限は {maximum} spp です。",
|
||||
"render.quality_preset_limit": "このプリセットは選択上限の 16384 spp を超えています。16384 以下のサンプル数を選択してください。",
|
||||
"render.supersampling": "スーパーサンプリング",
|
||||
"render.supersampling_off": "オフ",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "幅と高さをそれぞれ 2 倍にしてレンダリングし、リニアライトで縮小します。出力サイズは変わりません。",
|
||||
"render.supersampling_dimensions": "{width} × {height} でレンダリングし、{output_width} × {output_height} に縮小します。ピクセル数は 4 倍になります。",
|
||||
"render.cpu_threads": "CPU ワーカー",
|
||||
"render.cpu_threads_auto": "自動",
|
||||
"render.cpu_threads_single": "1 ワーカー",
|
||||
"render.cpu_threads_custom": "カスタム",
|
||||
"render.cpu_threads_help": "自動では利用可能な CPU ワーカーを使用します。1 ワーカーでは順次実行します。ワーカー数を増やすとメモリ使用量が増える場合があります。",
|
||||
"render.cpu_threads_web": "ブラウザーでのレンダリングは単一の協調ワーカーを使用します。保存されたデスクトップ用ワーカー設定は保持されます。",
|
||||
"render.denoise_passes": "フィルターパス数",
|
||||
"render.denoise_strength": "ノイズ除去の強度",
|
||||
"render.denoise_firefly": "孤立した高輝度ピクセルを抑制",
|
||||
"render.denoise_settings_help": "パス数を増やすと、より広い範囲が平滑化されます。強度は各パスでのフィルター適用量を調整します。未対応の光路にはフィルターを適用しません。",
|
||||
"render.denoise_diagnostics_enable": "ノイズ除去の診断情報を収集",
|
||||
"render.denoise_diagnostics_help": "ノイズ除去がオフでも、対象と対象外のピクセル数を収集します。集計は内部レンダリングサイズ、マスクは出力サイズに対応します。",
|
||||
"render.denoise_diagnostics_title": "ノイズ除去の診断",
|
||||
"render.denoise_diagnostics_grid": "内部サイズ {width} × {height} でのピクセル数。",
|
||||
"render.denoise_diagnostics_counts": "対象: {eligible} · 対象外: {skipped}",
|
||||
"render.denoise_mask_unavailable": "ノイズ除去の診断を有効にして画像をレンダリングすると、マスクを確認できます。",
|
||||
"render.denoise_mask_copy_help": "ノイズ除去マスクを PNG で書き出すか、レンダリング画像に戻ってコピーしてください。",
|
||||
"render.denoise_view_image": "レンダリング画像",
|
||||
"render.denoise_view_mask": "ノイズ除去マスク",
|
||||
"render.denoise_export_mask": "マスク PNG を書き出す",
|
||||
"render.denoise_format_mask": "マスク PNG",
|
||||
"render.denoise_class_unavailable": "対象外: ガイドなし",
|
||||
"render.denoise_class_surface": "対象: サーフェス",
|
||||
"render.denoise_class_optical": "対象: 屈折越しのサーフェス",
|
||||
"render.denoise_class_background": "対象外: 背景",
|
||||
"render.denoise_class_emitter": "対象外: 発光体",
|
||||
"render.denoise_class_material": "対象外: 未対応のマテリアル",
|
||||
"render.denoise_class_nested": "対象外: 入れ子の媒質",
|
||||
"render.denoise_class_lens": "対象外: 被写界深度",
|
||||
"render.denoise_class_volume": "対象外: ボリューム散乱",
|
||||
"render.denoise_class_depth": "対象外: 経路深度の上限",
|
||||
"render.denoise_class_degenerate": "対象外: 退化した経路",
|
||||
"render.denoise_class_exit": "対象外: 不整合な出射",
|
||||
"render.denoise_class_tir": "対象外: 全反射",
|
||||
"render.denoise_class_transmission": "対象外: 追加の透過",
|
||||
"render.denoise_class_reflection": "対象外: 反射する終点",
|
||||
"render.denoise_class_identity": "対象外: ガイド識別子の上限",
|
||||
"render.denoise_class_coverage": "対象外: 部分的なカバレッジ"
|
||||
},
|
||||
"vi": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Giá trị đã lưu: {samples} mẫu/pixel. Lựa chọn mới được giới hạn ở {maximum} mẫu/pixel.",
|
||||
"render.quality_preset_limit": "Thiết lập sẵn này vượt quá giới hạn 16384 mẫu/pixel. Hãy chọn số mẫu tối đa là 16384.",
|
||||
"render.supersampling": "Lấy mẫu siêu phân giải",
|
||||
"render.supersampling_off": "Tắt",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Kết xuất với chiều rộng và chiều cao gấp đôi, rồi thu nhỏ trong không gian ánh sáng tuyến tính. Kích thước đầu ra không đổi.",
|
||||
"render.supersampling_dimensions": "Kết xuất ở {width} × {height} rồi thu nhỏ về {output_width} × {output_height}. Sử dụng số pixel gấp bốn lần.",
|
||||
"render.cpu_threads": "Luồng CPU",
|
||||
"render.cpu_threads_auto": "Tự động",
|
||||
"render.cpu_threads_single": "Một luồng",
|
||||
"render.cpu_threads_custom": "Tùy chỉnh",
|
||||
"render.cpu_threads_help": "Chế độ tự động dùng các luồng CPU khả dụng. Một luồng chạy tuần tự. Nhiều luồng hơn có thể cần nhiều bộ nhớ hơn.",
|
||||
"render.cpu_threads_web": "Kết xuất trong trình duyệt dùng một luồng phối hợp. Lựa chọn luồng đã lưu cho ứng dụng máy tính được giữ nguyên.",
|
||||
"render.denoise_passes": "Số lượt lọc",
|
||||
"render.denoise_strength": "Cường độ khử nhiễu",
|
||||
"render.denoise_firefly": "Giảm các pixel sáng đơn lẻ",
|
||||
"render.denoise_settings_help": "Nhiều lượt lọc hơn làm mịn vùng rộng hơn. Cường độ điều chỉnh mức lọc áp dụng trong mỗi lượt. Các đường truyền quang học không được hỗ trợ vẫn không được lọc.",
|
||||
"render.denoise_diagnostics_enable": "Thu thập chẩn đoán khử nhiễu",
|
||||
"render.denoise_diagnostics_help": "Thu thập số pixel đủ điều kiện và bị bỏ qua ngay cả khi tắt khử nhiễu. Số lượng dựa trên kích thước kết xuất nội bộ; mặt nạ dùng kích thước đầu ra.",
|
||||
"render.denoise_diagnostics_title": "Chẩn đoán khử nhiễu",
|
||||
"render.denoise_diagnostics_grid": "Số pixel ở kích thước nội bộ {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Đủ điều kiện: {eligible} · Bỏ qua: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Bật chẩn đoán khử nhiễu và kết xuất ảnh để kiểm tra mặt nạ.",
|
||||
"render.denoise_mask_copy_help": "Xuất mặt nạ khử nhiễu dưới dạng PNG, hoặc quay lại ảnh đã kết xuất để sao chép.",
|
||||
"render.denoise_view_image": "Ảnh đã kết xuất",
|
||||
"render.denoise_view_mask": "Mặt nạ khử nhiễu",
|
||||
"render.denoise_export_mask": "Xuất mặt nạ PNG",
|
||||
"render.denoise_format_mask": "PNG mặt nạ",
|
||||
"render.denoise_class_unavailable": "Bỏ qua: không có dữ liệu hướng dẫn",
|
||||
"render.denoise_class_surface": "Đủ điều kiện: bề mặt",
|
||||
"render.denoise_class_optical": "Đủ điều kiện: bề mặt qua khúc xạ",
|
||||
"render.denoise_class_background": "Bỏ qua: nền",
|
||||
"render.denoise_class_emitter": "Bỏ qua: nguồn phát sáng",
|
||||
"render.denoise_class_material": "Bỏ qua: vật liệu không hỗ trợ",
|
||||
"render.denoise_class_nested": "Bỏ qua: môi trường lồng nhau",
|
||||
"render.denoise_class_lens": "Bỏ qua: độ sâu trường ảnh",
|
||||
"render.denoise_class_volume": "Bỏ qua: tán xạ thể tích",
|
||||
"render.denoise_class_depth": "Bỏ qua: giới hạn độ sâu đường truyền",
|
||||
"render.denoise_class_degenerate": "Bỏ qua: đường truyền suy biến",
|
||||
"render.denoise_class_exit": "Bỏ qua: điểm thoát không tương thích",
|
||||
"render.denoise_class_tir": "Bỏ qua: phản xạ toàn phần",
|
||||
"render.denoise_class_transmission": "Bỏ qua: truyền qua tiếp",
|
||||
"render.denoise_class_reflection": "Bỏ qua: điểm cuối phản xạ",
|
||||
"render.denoise_class_identity": "Bỏ qua: giới hạn định danh hướng dẫn",
|
||||
"render.denoise_class_coverage": "Bỏ qua: độ phủ một phần"
|
||||
},
|
||||
"zh": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "已保存的值:{samples} spp。新选择的上限为 {maximum} spp。",
|
||||
"render.quality_preset_limit": "此预设超过 16384 spp 的选择上限。请选择不超过 16384 的采样数。",
|
||||
"render.supersampling": "超采样",
|
||||
"render.supersampling_off": "关闭",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "以双倍宽度和高度渲染,然后在线性光空间中缩小。输出尺寸保持不变。",
|
||||
"render.supersampling_dimensions": "以 {width} × {height} 渲染,再缩小至 {output_width} × {output_height}。使用四倍像素。",
|
||||
"render.cpu_threads": "CPU 工作线程",
|
||||
"render.cpu_threads_auto": "自动",
|
||||
"render.cpu_threads_single": "单线程",
|
||||
"render.cpu_threads_custom": "自定义",
|
||||
"render.cpu_threads_help": "自动模式使用可用的 CPU 工作线程。单线程按顺序执行。更多线程可能需要更多内存。",
|
||||
"render.cpu_threads_web": "浏览器渲染使用一个协作式工作线程。已保存的桌面线程选择会保留。",
|
||||
"render.denoise_passes": "滤波轮数",
|
||||
"render.denoise_strength": "降噪强度",
|
||||
"render.denoise_firefly": "抑制孤立亮点",
|
||||
"render.denoise_settings_help": "更多轮次会平滑更大范围。强度控制每轮滤波的应用程度。不支持的光路保持未滤波状态。",
|
||||
"render.denoise_diagnostics_enable": "收集降噪诊断信息",
|
||||
"render.denoise_diagnostics_help": "即使关闭降噪,也会收集可滤波和跳过的像素数量。计数基于内部渲染尺寸;蒙版使用输出尺寸。",
|
||||
"render.denoise_diagnostics_title": "降噪诊断",
|
||||
"render.denoise_diagnostics_grid": "内部尺寸 {width} × {height} 下的像素计数。",
|
||||
"render.denoise_diagnostics_counts": "可滤波:{eligible} · 已跳过:{skipped}",
|
||||
"render.denoise_mask_unavailable": "启用降噪诊断并渲染图像后,即可查看其蒙版。",
|
||||
"render.denoise_mask_copy_help": "将降噪蒙版导出为 PNG,或返回渲染图像后复制图像。",
|
||||
"render.denoise_view_image": "渲染图像",
|
||||
"render.denoise_view_mask": "降噪蒙版",
|
||||
"render.denoise_export_mask": "导出蒙版 PNG",
|
||||
"render.denoise_format_mask": "蒙版 PNG",
|
||||
"render.denoise_class_unavailable": "已跳过:无引导数据",
|
||||
"render.denoise_class_surface": "可滤波:表面",
|
||||
"render.denoise_class_optical": "可滤波:折射后的表面",
|
||||
"render.denoise_class_background": "已跳过:背景",
|
||||
"render.denoise_class_emitter": "已跳过:发光体",
|
||||
"render.denoise_class_material": "已跳过:不支持的材质",
|
||||
"render.denoise_class_nested": "已跳过:嵌套介质",
|
||||
"render.denoise_class_lens": "已跳过:景深",
|
||||
"render.denoise_class_volume": "已跳过:体积散射",
|
||||
"render.denoise_class_depth": "已跳过:路径深度上限",
|
||||
"render.denoise_class_degenerate": "已跳过:退化路径",
|
||||
"render.denoise_class_exit": "已跳过:不兼容的出射",
|
||||
"render.denoise_class_tir": "已跳过:全内反射",
|
||||
"render.denoise_class_transmission": "已跳过:进一步透射",
|
||||
"render.denoise_class_reflection": "已跳过:反射终点",
|
||||
"render.denoise_class_identity": "已跳过:引导标识上限",
|
||||
"render.denoise_class_coverage": "已跳过:部分覆盖"
|
||||
},
|
||||
"de": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Gespeicherter Wert: {samples} spp. Neue Werte sind auf {maximum} spp begrenzt.",
|
||||
"render.quality_preset_limit": "Diese Vorgabe überschreitet die Auswahlgrenze von 16384 spp. Wählen Sie höchstens 16384 Samples.",
|
||||
"render.supersampling": "Supersampling",
|
||||
"render.supersampling_off": "Aus",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Mit doppelter Breite und Höhe rendern und anschließend im linearen Farbraum verkleinern. Die Ausgabegröße bleibt unverändert.",
|
||||
"render.supersampling_dimensions": "Rendert {width} × {height} und verkleinert auf {output_width} × {output_height}. Verwendet die vierfache Pixelzahl.",
|
||||
"render.cpu_threads": "CPU-Threads",
|
||||
"render.cpu_threads_auto": "Auto",
|
||||
"render.cpu_threads_single": "Einzeln",
|
||||
"render.cpu_threads_custom": "Eigene",
|
||||
"render.cpu_threads_help": "Auto verwendet die verfügbaren CPU-Threads. Ein Thread arbeitet seriell. Mehr Threads können mehr Speicher benötigen.",
|
||||
"render.cpu_threads_web": "Im Browser wird ein kooperativer Thread verwendet. Die gespeicherte Desktop-Auswahl bleibt erhalten.",
|
||||
"render.denoise_passes": "Filterdurchläufe",
|
||||
"render.denoise_strength": "Entrauschungsstärke",
|
||||
"render.denoise_firefly": "Einzelne helle Pixel unterdrücken",
|
||||
"render.denoise_settings_help": "Mehr Durchläufe glätten einen größeren Bereich. Die Stärke regelt die Filterwirkung jedes Durchlaufs. Nicht unterstützte Lichtwege bleiben ungefiltert.",
|
||||
"render.denoise_diagnostics_enable": "Entrauschungsdiagnose erfassen",
|
||||
"render.denoise_diagnostics_help": "Erfasst geeignete und übersprungene Pixel auch ohne Entrauschung. Die Zählung nutzt die interne Rendergröße, die Maske die Ausgabegröße.",
|
||||
"render.denoise_diagnostics_title": "Entrauschungsdiagnose",
|
||||
"render.denoise_diagnostics_grid": "Pixelzahlen bei interner Größe {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Geeignet: {eligible} · Übersprungen: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Aktivieren Sie die Entrauschungsdiagnose und rendern Sie ein Bild, um seine Maske zu prüfen.",
|
||||
"render.denoise_mask_copy_help": "Exportieren Sie die Entrauschungsmaske als PNG oder kehren Sie zum gerenderten Bild zurück, um es zu kopieren.",
|
||||
"render.denoise_view_image": "Gerendertes Bild",
|
||||
"render.denoise_view_mask": "Entrauschungsmaske",
|
||||
"render.denoise_export_mask": "Maske als PNG exportieren",
|
||||
"render.denoise_format_mask": "Maske PNG",
|
||||
"render.denoise_class_unavailable": "Übersprungen: keine Führungsdaten",
|
||||
"render.denoise_class_surface": "Geeignet: Oberfläche",
|
||||
"render.denoise_class_optical": "Geeignet: Oberfläche durch Brechung",
|
||||
"render.denoise_class_background": "Übersprungen: Hintergrund",
|
||||
"render.denoise_class_emitter": "Übersprungen: Lichtquelle",
|
||||
"render.denoise_class_material": "Übersprungen: nicht unterstütztes Material",
|
||||
"render.denoise_class_nested": "Übersprungen: verschachteltes Medium",
|
||||
"render.denoise_class_lens": "Übersprungen: Schärfentiefe",
|
||||
"render.denoise_class_volume": "Übersprungen: Volumenstreuung",
|
||||
"render.denoise_class_depth": "Übersprungen: Pfadtiefengrenze",
|
||||
"render.denoise_class_degenerate": "Übersprungen: entarteter Pfad",
|
||||
"render.denoise_class_exit": "Übersprungen: unpassender Austritt",
|
||||
"render.denoise_class_tir": "Übersprungen: Totalreflexion",
|
||||
"render.denoise_class_transmission": "Übersprungen: weitere Transmission",
|
||||
"render.denoise_class_reflection": "Übersprungen: reflektierender Endpunkt",
|
||||
"render.denoise_class_identity": "Übersprungen: Grenze der Führungskennungen",
|
||||
"render.denoise_class_coverage": "Übersprungen: teilweise Abdeckung"
|
||||
},
|
||||
"ko": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "저장된 값: {samples} spp. 새 선택값은 {maximum} spp로 제한됩니다.",
|
||||
"render.quality_preset_limit": "이 프리셋은 선택 한도인 16384 spp를 초과합니다. 샘플 수를 16384 이하로 선택하세요.",
|
||||
"render.supersampling": "슈퍼샘플링",
|
||||
"render.supersampling_off": "끄기",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "가로와 세로를 각각 두 배로 렌더링한 뒤 선형 광 공간에서 축소합니다. 출력 크기는 그대로 유지됩니다.",
|
||||
"render.supersampling_dimensions": "{width} × {height}로 렌더링한 뒤 {output_width} × {output_height}로 축소합니다. 픽셀 수가 네 배가 됩니다.",
|
||||
"render.cpu_threads": "CPU 작업 스레드",
|
||||
"render.cpu_threads_auto": "자동",
|
||||
"render.cpu_threads_single": "단일",
|
||||
"render.cpu_threads_custom": "사용자 지정",
|
||||
"render.cpu_threads_help": "자동은 사용 가능한 CPU 작업 스레드를 사용합니다. 단일 스레드는 순차적으로 실행됩니다. 스레드가 많을수록 메모리가 더 필요할 수 있습니다.",
|
||||
"render.cpu_threads_web": "브라우저 렌더링은 하나의 협력형 작업 스레드를 사용합니다. 저장된 데스크톱 스레드 설정은 유지됩니다.",
|
||||
"render.denoise_passes": "필터 반복 횟수",
|
||||
"render.denoise_strength": "노이즈 제거 강도",
|
||||
"render.denoise_firefly": "고립된 밝은 픽셀 억제",
|
||||
"render.denoise_settings_help": "반복 횟수가 많을수록 더 넓은 영역을 부드럽게 합니다. 강도는 각 단계에서 적용하는 필터의 정도를 조절합니다. 지원되지 않는 광 경로에는 필터를 적용하지 않습니다.",
|
||||
"render.denoise_diagnostics_enable": "노이즈 제거 진단 수집",
|
||||
"render.denoise_diagnostics_help": "노이즈 제거가 꺼져 있어도 적용 가능 픽셀과 건너뛴 픽셀 수를 수집합니다. 집계는 내부 렌더링 크기 기준이며 마스크는 출력 크기를 사용합니다.",
|
||||
"render.denoise_diagnostics_title": "노이즈 제거 진단",
|
||||
"render.denoise_diagnostics_grid": "내부 크기 {width} × {height} 기준 픽셀 수입니다.",
|
||||
"render.denoise_diagnostics_counts": "적용 가능: {eligible} · 건너뜀: {skipped}",
|
||||
"render.denoise_mask_unavailable": "노이즈 제거 진단을 켜고 이미지를 렌더링하면 마스크를 확인할 수 있습니다.",
|
||||
"render.denoise_mask_copy_help": "노이즈 제거 마스크를 PNG로 내보내거나 렌더링 이미지로 돌아가 이미지를 복사하세요.",
|
||||
"render.denoise_view_image": "렌더링 이미지",
|
||||
"render.denoise_view_mask": "노이즈 제거 마스크",
|
||||
"render.denoise_export_mask": "마스크 PNG 내보내기",
|
||||
"render.denoise_format_mask": "마스크 PNG",
|
||||
"render.denoise_class_unavailable": "건너뜀: 가이드 없음",
|
||||
"render.denoise_class_surface": "적용 가능: 표면",
|
||||
"render.denoise_class_optical": "적용 가능: 굴절을 통해 보이는 표면",
|
||||
"render.denoise_class_background": "건너뜀: 배경",
|
||||
"render.denoise_class_emitter": "건너뜀: 발광체",
|
||||
"render.denoise_class_material": "건너뜀: 지원되지 않는 재질",
|
||||
"render.denoise_class_nested": "건너뜀: 중첩된 매질",
|
||||
"render.denoise_class_lens": "건너뜀: 피사계 심도",
|
||||
"render.denoise_class_volume": "건너뜀: 볼륨 산란",
|
||||
"render.denoise_class_depth": "건너뜀: 경로 깊이 한도",
|
||||
"render.denoise_class_degenerate": "건너뜀: 퇴화 경로",
|
||||
"render.denoise_class_exit": "건너뜀: 호환되지 않는 출사",
|
||||
"render.denoise_class_tir": "건너뜀: 전반사",
|
||||
"render.denoise_class_transmission": "건너뜀: 추가 투과",
|
||||
"render.denoise_class_reflection": "건너뜀: 반사 종점",
|
||||
"render.denoise_class_identity": "건너뜀: 가이드 식별자 한도",
|
||||
"render.denoise_class_coverage": "건너뜀: 부분 커버리지"
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "Mô đun đàn hồi theo hướng",
|
||||
"cmd.material_properties_validate": "Xác thực dữ liệu thuộc tính vật liệu",
|
||||
"cmd.mesh_convert": "Chuyển đổi lưới",
|
||||
"cmd.model_body_features": "Liệt kê tính năng của thân",
|
||||
"cmd.model_edges": "Danh sách các cạnh",
|
||||
"cmd.model_faces": "Danh sách các bề mặt",
|
||||
"cmd.model_threads": "Danh sách các đoạn ren",
|
||||
@@ -1235,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "Đóng chế độ xem tạm của bảng",
|
||||
"cmd.panel.float": "Thả nổi bảng",
|
||||
"cmd.panel.focus": "Đặt tiêu điểm vào bảng",
|
||||
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
|
||||
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
|
||||
"cmd.panel.list": "Liệt kê các bảng",
|
||||
"cmd.panel.move": "Di chuyển bảng",
|
||||
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
|
||||
"cmd.panel.native_close": "Gắn lại vùng bảng từ cửa sổ riêng",
|
||||
"cmd.panel.open": "Mở bảng",
|
||||
"cmd.panel.peek": "Xem tạm bảng đã thu gọn",
|
||||
@@ -1244,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "Đặt lại thanh chia bảng",
|
||||
"cmd.panel.resize": "Đổi kích thước vùng bảng",
|
||||
"cmd.panel.resize_pair": "Di chuyển thanh chia bảng",
|
||||
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
|
||||
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
|
||||
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
|
||||
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
|
||||
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
|
||||
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm",
|
||||
"cmd.parameters_add": "Thêm tham số người dùng",
|
||||
"cmd.parameters_change": "Thay đổi tham số",
|
||||
"cmd.parameters_comment": "Thêm chú thích tham số",
|
||||
@@ -2518,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "Giữa phía dưới",
|
||||
"prefs.bottom_right": "Góc dưới bên phải",
|
||||
"prefs.browser": "Cây thư mục",
|
||||
"prefs.browser_body_features": "Tính năng dưới thân",
|
||||
"prefs.browser_body_features_note": "Mỗi thân trong trình duyệt có thể mở ra để hiện các tính năng trên dòng thời gian đã tạo nên nó, theo thứ tự dòng thời gian.",
|
||||
"prefs.browser_components_open": "Thành phần mở sẵn",
|
||||
"prefs.browser_components_open_note": "Mọi thành phần trong trình duyệt đều mở sẵn như trước, thay vì đóng cho đến khi bạn mở.",
|
||||
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
|
||||
@@ -2558,6 +2570,8 @@
|
||||
"prefs.design_tools.degrees": "Độ",
|
||||
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
|
||||
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
|
||||
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
|
||||
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
|
||||
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
|
||||
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
|
||||
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
|
||||
@@ -2642,6 +2656,8 @@
|
||||
"prefs.history": "Lịch sử",
|
||||
"prefs.history_bar_groups": "Thu gọn thanh Lịch sử thành nhóm",
|
||||
"prefs.history_bar_groups_note": "Trong lịch sử dài, một chuỗi tính năng của cùng một thành phần được thu gọn thành một thẻ, và thư mục tính năng bắt đầu ở dạng thu gọn. Cụm lắp ráp STEP được chèn vào sẽ nằm trong thư mục Group. Khi tắt, mỗi tính năng đều hiện, thư mục bắt đầu ở dạng mở và không tạo thư mục nào.",
|
||||
"prefs.history_folder_kind_icon": "Biểu tượng thư mục theo các tính năng bên trong",
|
||||
"prefs.history_folder_kind_icon_note": "Thư mục Lịch sử có các tính năng cùng một loại sẽ hiện biểu tượng của chúng.",
|
||||
"prefs.history_group_joints": "Nhóm các khớp nối trong Lịch sử",
|
||||
"prefs.history_group_joints_note": "Từ hai khớp nối trở lên hiện thành một nút kèm số lượng, nhấp vào để hiện từng khớp nối. Khi tắt, mỗi khớp nối hiện riêng.",
|
||||
"prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng",
|
||||
@@ -2835,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
|
||||
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
|
||||
"prefs.selecting": "Đang chọn",
|
||||
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
|
||||
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
|
||||
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
|
||||
@@ -3379,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "Dò tia trên CPU yêu cầu nền đục. Hãy bỏ chọn 'Nền trong suốt' để tiếp tục.",
|
||||
"render.cpu_options_pending": "Bảng điều khiển CPU này chưa hỗ trợ chỉnh sửa tự phát xạ, hiệu ứng bề mặt, thủy tinh hoặc bất đẳng hướng. Hãy xóa các tùy chọn API đó trước khi sử dụng.",
|
||||
"render.cpu_path_depth": "Độ sâu đường truyền",
|
||||
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–16). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–64). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_path_lighting": "Chiếu sáng gián tiếp",
|
||||
"render.cpu_path_optics_reason": "Các thiết lập được giữ nguyên. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_point": "Nguồn sáng điểm",
|
||||
@@ -5525,6 +5543,7 @@
|
||||
"ui.heading_plastic": "CHI TIẾT NHỰA",
|
||||
"ui.heading_position": "VỊ TRÍ",
|
||||
"ui.heading_prepare": "CHUẨN BỊ",
|
||||
"ui.heading_press_pull": "KÉO/ĐẨY",
|
||||
"ui.heading_rectangular_pattern": "SAO CHÉP HÌNH CHỮ NHẬT",
|
||||
"ui.heading_revolve": "XOAY TRÒN TẠO KHỐI",
|
||||
"ui.heading_right": "HÌNH CHIẾU CẠNH",
|
||||
@@ -5868,6 +5887,8 @@
|
||||
"ui.profiles": "Các biên dạng",
|
||||
"ui.project": "Chiếu hình học",
|
||||
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
|
||||
"ui.project_references_construction": "Chiếu tham chiếu thành đường dựng hình",
|
||||
"ui.project_references_construction_note": "Chiếu, Giao, Đưa vào hình học 3D và các cạnh bề mặt của phác thảo mới trên một bề mặt tạo ra đường dựng hình: tham chiếu để vẽ theo, không thuộc biên dạng. Chúng vẫn liên kết với mô hình.",
|
||||
"ui.properties": "Thuộc tính cơ lý",
|
||||
"ui.properties.copy_matrix": "Sao chép ma trận",
|
||||
"ui.properties.first_moments": "Mômen bậc nhất",
|
||||
|
||||
+22
-1
@@ -1207,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "方向弹性模量",
|
||||
"cmd.material_properties_validate": "验证物理材料数据",
|
||||
"cmd.mesh_convert": "转换网格",
|
||||
"cmd.model_body_features": "列出实体特征",
|
||||
"cmd.model_edges": "列出边",
|
||||
"cmd.model_faces": "列出面",
|
||||
"cmd.model_threads": "列出螺纹",
|
||||
@@ -1235,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "关闭面板临时预览",
|
||||
"cmd.panel.float": "浮动面板",
|
||||
"cmd.panel.focus": "聚焦面板",
|
||||
"cmd.panel.focus.next": "聚焦下一个面板",
|
||||
"cmd.panel.focus.previous": "聚焦上一个面板",
|
||||
"cmd.panel.list": "列出面板",
|
||||
"cmd.panel.move": "移动面板",
|
||||
"cmd.panel.move.next_zone": "将面板移至下一个区域",
|
||||
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
|
||||
"cmd.panel.open": "打开面板",
|
||||
"cmd.panel.peek": "临时预览折叠面板",
|
||||
@@ -1244,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "重置面板分隔条",
|
||||
"cmd.panel.resize": "调整面板分区大小",
|
||||
"cmd.panel.resize_pair": "移动面板分隔条",
|
||||
"cmd.panel.sash.down": "向下移动面板分隔条",
|
||||
"cmd.panel.sash.left": "向左移动面板分隔条",
|
||||
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
|
||||
"cmd.panel.sash.right": "向右移动面板分隔条",
|
||||
"cmd.panel.sash.up": "向上移动面板分隔条",
|
||||
"cmd.panel.toggle": "切换当前焦点面板的折叠状态",
|
||||
"cmd.parameters_add": "添加用户参数",
|
||||
"cmd.parameters_change": "更改参数",
|
||||
"cmd.parameters_comment": "注释参数",
|
||||
@@ -2518,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "底部居中",
|
||||
"prefs.bottom_right": "右下角",
|
||||
"prefs.browser": "浏览器",
|
||||
"prefs.browser_body_features": "实体下显示特征",
|
||||
"prefs.browser_body_features_note": "浏览器中的每个实体都可展开,按时间线顺序显示构建它的时间线特征。",
|
||||
"prefs.browser_components_open": "组件初始展开",
|
||||
"prefs.browser_components_open_note": "浏览器中的每个组件都像以前一样初始展开,而不是在您展开前保持折叠。",
|
||||
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
|
||||
@@ -2558,6 +2570,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
|
||||
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
|
||||
"prefs.design_tools.hole_group": "将一起创建的孔分组",
|
||||
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
|
||||
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
|
||||
"prefs.design_tools.hole_several": "一个面上多个孔",
|
||||
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
|
||||
@@ -2642,6 +2656,8 @@
|
||||
"prefs.history": "历史",
|
||||
"prefs.history_bar_groups": "将历史栏折叠为分组",
|
||||
"prefs.history_bar_groups_note": "在较长的历史中,同一组件的连续特征折叠为一个标签,特征文件夹开始时为折叠状态。插入的 STEP 装配体放入 Group 文件夹。关闭后显示每个特征,文件夹开始时为展开状态,且不创建文件夹。",
|
||||
"prefs.history_folder_kind_icon": "文件夹使用其中特征的图标",
|
||||
"prefs.history_folder_kind_icon_note": "历史中特征全部属于同一类型的文件夹显示该类型的图标。",
|
||||
"prefs.history_group_joints": "在历史中将关节分组",
|
||||
"prefs.history_group_joints_note": "两个或更多关节显示为一个带数量的按钮,单击即可显示每个关节。关闭后逐个显示每个关节。",
|
||||
"prefs.history_origin_bodies": "在顶部显示原点和实体",
|
||||
@@ -2835,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
|
||||
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
|
||||
"prefs.selecting": "选择",
|
||||
"prefs.shared_panel_layout": "使用共享面板布局",
|
||||
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
|
||||
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
|
||||
@@ -3379,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "CPU 光线追踪需要不透明背景。请关闭透明背景。",
|
||||
"render.cpu_options_pending": "此CPU面板暂不支持编辑自发光、表面效果、玻璃或各向异性。使用前请清除这些API选项。",
|
||||
"render.cpu_path_depth": "路径深度",
|
||||
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–16)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–64)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_path_lighting": "间接照明",
|
||||
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_point": "点光源",
|
||||
@@ -5525,6 +5543,7 @@
|
||||
"ui.heading_plastic": "塑料",
|
||||
"ui.heading_position": "位置",
|
||||
"ui.heading_prepare": "准备",
|
||||
"ui.heading_press_pull": "按压拖动",
|
||||
"ui.heading_rectangular_pattern": "矩形阵列",
|
||||
"ui.heading_revolve": "旋转",
|
||||
"ui.heading_right": "右视",
|
||||
@@ -5868,6 +5887,8 @@
|
||||
"ui.profiles": "轮廓",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "将面的边投影到新草图",
|
||||
"ui.project_references_construction": "将参考投影为构造几何",
|
||||
"ui.project_references_construction_note": "投影、相交、包含三维几何以及在面上新建草图时投影的面边,都会生成构造几何:用于绘图参考,不计入轮廓。它们仍与模型保持关联。",
|
||||
"ui.properties": "属性",
|
||||
"ui.properties.copy_matrix": "复制矩阵",
|
||||
"ui.properties.first_moments": "一阶矩",
|
||||
|
||||
@@ -716,14 +716,20 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
|
||||
if (dot(n, v) < 0.0) { n = -n; }
|
||||
if u.product_lights_meta.x>0.0 {
|
||||
var radiance=vec3<f32>(0.0);
|
||||
let albedo=srgb_decode(base.rgb);
|
||||
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=albedo*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
let sample=product_light_sample(k,i.wp);
|
||||
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
radiance+=albedo*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
}
|
||||
let mapped=radiance/(vec3<f32>(1.0)+radiance);
|
||||
// A linear attachment takes linear light as it is; any other is encoded once, as the CPU
|
||||
// image is (decoding the encoded value again through `out_color` would flatten the dark end).
|
||||
if (u.back.w > 0.5) {
|
||||
return vec4<f32>(mapped,base.a);
|
||||
}
|
||||
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
|
||||
return out_color(vec4<f32>(encoded,base.a));
|
||||
return vec4<f32>(encoded,base.a);
|
||||
}
|
||||
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
|
||||
let diff = max(dot(n, key), 0.0);
|
||||
|
||||
@@ -2294,6 +2294,10 @@ pub struct MeshData {
|
||||
pub positions: std::sync::Arc<[f64]>,
|
||||
/// Three vertex indices per triangle.
|
||||
pub triangles: std::sync::Arc<[u32]>,
|
||||
/// Unit shading normals, x/y/z for each triangle corner. Empty means flat shading.
|
||||
/// Independent of the welded positions and the geometric boundary certificate.
|
||||
#[serde(default, skip_serializing_if = "<[f64]>::is_empty")]
|
||||
pub corner_normals: std::sync::Arc<[f64]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<[f32; 3]>,
|
||||
}
|
||||
@@ -2309,6 +2313,36 @@ impl PartialEq for MeshData {
|
||||
&& self.color == other.color
|
||||
&& (Arc::ptr_eq(&self.triangles, &other.triangles) || self.triangles == other.triangles)
|
||||
&& (Arc::ptr_eq(&self.positions, &other.positions) || self.positions == other.positions)
|
||||
&& (Arc::ptr_eq(&self.corner_normals, &other.corner_normals) || self.corner_normals == other.corner_normals)
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshData {
|
||||
/// Explicitly generate crease-aware shading for an existing imported source.
|
||||
/// Coordinates, triangle indices, topology and body identity remain unchanged.
|
||||
pub fn generate_crease_normals(&mut self, angle_degrees: f64) -> crate::Result<()> {
|
||||
if !angle_degrees.is_finite()
|
||||
|| !(0.0..=180.0).contains(&angle_degrees)
|
||||
|| !self.positions.len().is_multiple_of(3)
|
||||
|| !self.triangles.len().is_multiple_of(3)
|
||||
{
|
||||
return Err(crate::DocError::Invalid("mesh crease angle must be between 0 and 180 degrees with complete triangles".into()));
|
||||
}
|
||||
let allocation = |_| crate::DocError::Invalid("could not reserve mesh normal generation".into());
|
||||
let mut positions = Vec::new();
|
||||
positions.try_reserve_exact(self.positions.len() / 3).map_err(allocation)?;
|
||||
positions.extend(self.positions.as_chunks::<3>().0.iter().map(|p| solidcore_record::Vec3::new(p[0], p[1], p[2])));
|
||||
let mut triangles = Vec::new();
|
||||
triangles.try_reserve_exact(self.triangles.len() / 3).map_err(allocation)?;
|
||||
triangles.extend_from_slice(self.triangles.as_chunks::<3>().0);
|
||||
let mesh = solidcraft_geom::Mesh { positions, triangles, ..Default::default() };
|
||||
let normals = mesh.crease_corner_normals(angle_degrees.to_radians(), None).map_err(crate::DocError::Invalid)?;
|
||||
let count = normals.len().checked_mul(9).ok_or_else(|| crate::DocError::Invalid("mesh normal count overflow".into()))?;
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(count).map_err(allocation)?;
|
||||
values.extend(normals.iter().flatten().flat_map(|n| [n.x, n.y, n.z]));
|
||||
self.corner_normals = values.into();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-3
@@ -1224,12 +1224,15 @@ struct ImportDigests {
|
||||
struct MeshDigest {
|
||||
positions: Arc<[f64]>,
|
||||
triangles: Arc<[u32]>,
|
||||
corner_normals: Arc<[f64]>,
|
||||
digest: u64,
|
||||
}
|
||||
|
||||
impl MeshDigest {
|
||||
fn matches(&self, mesh: &crate::MeshData) -> bool {
|
||||
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
|
||||
Arc::ptr_eq(&self.positions, &mesh.positions)
|
||||
&& Arc::ptr_eq(&self.triangles, &mesh.triangles)
|
||||
&& Arc::ptr_eq(&self.corner_normals, &mesh.corner_normals)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1248,7 +1251,12 @@ impl ImportDigests {
|
||||
.enumerate()
|
||||
.map(|(index, mesh)| {
|
||||
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
|
||||
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
|
||||
MeshDigest {
|
||||
positions: mesh.positions.clone(),
|
||||
triangles: mesh.triangles.clone(),
|
||||
corner_normals: mesh.corner_normals.clone(),
|
||||
digest,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.meshes.insert(f.id, digests);
|
||||
@@ -1277,6 +1285,9 @@ fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
|
||||
coordinate.to_bits().hash(&mut h);
|
||||
}
|
||||
mesh.triangles.hash(&mut h);
|
||||
for n in mesh.corner_normals.iter() {
|
||||
n.to_bits().hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -1368,6 +1379,7 @@ fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
|
||||
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
|
||||
mesh.positions = Arc::from([]);
|
||||
mesh.triangles = Arc::from([]);
|
||||
mesh.corner_normals = Arc::from([]);
|
||||
}
|
||||
}
|
||||
// Keep the complete definition (including mesh order, names and colors) without
|
||||
@@ -7021,6 +7033,22 @@ fn replay(
|
||||
instances.push(start..copies.len());
|
||||
}
|
||||
}
|
||||
// A cut or intersect copy with no named targets, made where the pattern's own component
|
||||
// has no body for it, works on the bodies of its source's component, as the source did
|
||||
// (#12996). A pattern whose component has bodies cuts them, as before. The same scope
|
||||
// counts misses, takes sheet cuts and applies the copies.
|
||||
let scoped;
|
||||
let f = if matches!(op, Operation::Cut | Operation::Intersect)
|
||||
&& targets.is_empty()
|
||||
&& f.participants.is_empty()
|
||||
&& src.component != f.component
|
||||
&& !st.bodies.iter().any(|b| b.component == f.component || b.feature == f.id)
|
||||
{
|
||||
scoped = Feature { component: src.component, ..f.clone() };
|
||||
&scoped
|
||||
} else {
|
||||
f
|
||||
};
|
||||
if op == Operation::Cut {
|
||||
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
|
||||
missed += m;
|
||||
@@ -8382,7 +8410,17 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
|
||||
for m in meshes {
|
||||
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
|
||||
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
|
||||
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
|
||||
if !m.corner_normals.is_empty() && m.corner_normals.len() != m.triangles.len().saturating_mul(3) {
|
||||
return Err(DocError::Invalid(format!("mesh `{}`: invalid shading normal count", m.name)));
|
||||
}
|
||||
let normals: Vec<[Vec3; 3]> = m
|
||||
.corner_normals
|
||||
.as_chunks::<9>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| [Vec3::new(c[0], c[1], c[2]), Vec3::new(c[3], c[4], c[5]), Vec3::new(c[6], c[7], c[8])])
|
||||
.collect();
|
||||
let b = kernel::mesh_body_with_normals(&positions, &triangles, &normals).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
|
||||
out.push((m.name.clone(), b.with_color(m.color)));
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -911,6 +911,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., 0.]),
|
||||
triangles: Arc::from([0, 1, 2]),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
}],
|
||||
},
|
||||
None,
|
||||
@@ -924,7 +925,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
Arc::make_mut(&mut doc).occurrences[0].transform = rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Z, 0.3);
|
||||
assert!(!model.evaluate_shared(&doc));
|
||||
assert!(Arc::ptr_eq(&original, &model.state()));
|
||||
for edit in 0..5 {
|
||||
for edit in 0..6 {
|
||||
let FeatureKind::MeshImport { meshes, .. } = &mut Arc::make_mut(&mut doc).feature_mut(feature).unwrap().kind else { panic!("mesh") };
|
||||
let mesh = &mut meshes[0];
|
||||
match edit {
|
||||
@@ -932,6 +933,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
1 => Arc::make_mut(&mut mesh.triangles).swap(1, 2),
|
||||
2 => mesh.color = Some([0.2, 0.4, 0.6]),
|
||||
3 => mesh.name = "Renamed".into(),
|
||||
4 => mesh.corner_normals = Arc::from([0., 1., 1., 0., 1., 1., 0., 1., 1.]),
|
||||
_ => mesh.positions = Arc::from(mesh.positions.to_vec()),
|
||||
}
|
||||
assert!(model.evaluate_shared(&doc), "mesh edit {edit} must invalidate");
|
||||
@@ -1044,6 +1046,7 @@ fn comparing_a_copied_mesh_does_not_read_its_shared_arrays() {
|
||||
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., f64::NAN]),
|
||||
triangles: Arc::from([0, 1, 2]),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
};
|
||||
let copy = mesh.clone();
|
||||
assert!(copy == mesh, "a copy shares the arrays and is the same mesh without reading them");
|
||||
@@ -1771,6 +1774,7 @@ fn imported_meshes() -> Document {
|
||||
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
|
||||
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
|
||||
color: Some([0.2, 0.4, 0.6]),
|
||||
corner_normals: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
|
||||
@@ -1802,6 +1806,16 @@ fn same_as_fresh(doc: &Document, model: &Model) {
|
||||
#[test]
|
||||
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
|
||||
let mut doc = imported_meshes();
|
||||
for mesh in meshes_mut(&mut doc) {
|
||||
mesh.generate_crease_normals(10.0).unwrap();
|
||||
assert!(!mesh.corner_normals.is_empty());
|
||||
}
|
||||
let compact: serde_json::Value = serde_json::from_str(&super::feature_json(&doc.features[0], &super::ImportDigests::default())).unwrap();
|
||||
for mesh in compact[0]["meshes"].as_array().unwrap() {
|
||||
assert_eq!(mesh["positions"], serde_json::json!([]));
|
||||
assert_eq!(mesh["triangles"], serde_json::json!([]));
|
||||
assert!(mesh.get("corner_normals").is_none(), "the fingerprint must hash the retained digest without serializing shading payloads");
|
||||
}
|
||||
let mut model = Model::new();
|
||||
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
|
||||
for k in 1..5 {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Imported mesh sources may save optional `corner_normals`: nine f64 values per
|
||||
//! triangle, independent of positions, topology and optical boundary certification.
|
||||
//! Missing/empty normals retain the previous flat shading. OBJ `vn` and `s` import
|
||||
//! preserve authored corner normals and smoothing groups. Explicit crease generation
|
||||
//! stores only shading metadata and preserves original coordinates and triangle indices.
|
||||
//! Older released readers warn about this format and retain fields they cannot read.
|
||||
//!
|
||||
//! Render jobs may save opt-in `texture_filtering`; omission/false preserves LOD0
|
||||
//! texture sampling. Supported consumers use the captured texture's mip chain and
|
||||
//! ray footprint without changing source image identity or source color semantics.
|
||||
//!
|
||||
//! Optional `metal_energy_compensation` restores a bounded share of rough metal
|
||||
//! reflectance in both path tracers. Omission or false keeps the previous BRDF.
|
||||
//!
|
||||
//! Caustics options may save `area_lights: true` and `environment: true` for
|
||||
//! both path tracers. Omission preserves the hard-source photon map.
|
||||
//!
|
||||
//! Caustics may also save `guided_emission: true` to concentrate photon emission
|
||||
//! around captured optical bounds with probability-corrected power. Omission or
|
||||
//! false preserves the original emission proposal. Older released readers warn
|
||||
//! about the newer format and retain fields they cannot interpret when saving.
|
||||
//!
|
||||
//! Caustics may save `refresh_per_pass: true` to build one photon map per camera
|
||||
//! pass. Omission or false retains one map per render job.
|
||||
//!
|
||||
//! Caustics may save `power_weighted_sources: true` to allocate photon launches
|
||||
//! using estimated source power with a uniform support component. Each source
|
||||
//! retains its matching probability correction. Omission or false preserves
|
||||
//! uniform source selection and the original random sequence.
|
||||
//!
|
||||
//! CPU jobs may save `environment_importance_sampling: true` to use the captured
|
||||
//! HDR radiance distribution for lighting and photon emission. Omission or false
|
||||
//! retains uniform sphere sampling. Older readers warn and retain unknown fields.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Saves CPU worker selection, linear 2x supersampling and denoising controls in render setups.
|
||||
//! Older designs retain native-size output and the existing denoising settings; omitted
|
||||
//! CPU worker selection uses the bounded automatic pool, with one worker available for serial work.
|
||||
//! The last release warns about this newer design format and preserves fields it cannot read.
|
||||
@@ -571,9 +571,12 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
|
||||
if descends(current, name) {
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
// The named piece, told apart by the faces around it: they still bound this piece.
|
||||
// The named piece, told apart by the faces around it: they still bound this piece. Or the
|
||||
// face it was a piece of, whole again since, which they still bound: the piece is all of it
|
||||
// (#11731). Only a piece told apart by faces: a numbered one (`#2`, nothing told it apart)
|
||||
// says nothing about which part of the whole face it was.
|
||||
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
|
||||
&& crate::naming::parent_piece(current) == Some(stem)
|
||||
&& (crate::naming::parent_piece(current) == Some(stem) || (current == stem && !around.is_empty() && name.ends_with(']')))
|
||||
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
|
||||
{
|
||||
return Some(Match::Piece);
|
||||
|
||||
@@ -171,3 +171,21 @@ fn a_linked_designs_face_names_keep_their_pieces_and_nothing_else() {
|
||||
let _ = role(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// A face that was split when a name was stored and is whole again (#11731): the stored piece
|
||||
/// is all of it, when the face still touches the faces that told the piece apart.
|
||||
#[test]
|
||||
fn a_piece_name_finds_its_face_once_it_is_whole_again() {
|
||||
let faces = named(&["top", "left", "right", "start"]);
|
||||
let names = qualify(faces, &[(0, 1), (0, 3)]);
|
||||
assert_eq!(names.faces[0], "top");
|
||||
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
|
||||
assert_eq!(matches(&names, 0, "top#[left,start]"), Some(Match::Piece));
|
||||
// It no longer touches a face the qualifier names: some other piece, gone since.
|
||||
assert_eq!(matches(&names, 0, "top#[right]"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]@F9.1"), None, "a copy's piece");
|
||||
// A numbered piece (nothing told it apart, or a tie) says nothing about which part it was.
|
||||
assert_eq!(matches(&names, 0, "top#1"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]#2"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[]"), Some(Match::Stem));
|
||||
}
|
||||
|
||||
@@ -243,3 +243,57 @@ fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
|
||||
assert_eq!(pick(5.).map(|(index, _)| index), Some(top));
|
||||
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
|
||||
}
|
||||
|
||||
/// An edge picked when its side face was split in two keeps the piece in its name
|
||||
/// (`F2:side:l1#[F2:start]`); once the side is one face again the edge is still found (#11731).
|
||||
#[test]
|
||||
fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
|
||||
let doc = Document {
|
||||
features: serde_json::from_value(serde_json::json!([
|
||||
{
|
||||
"id": 1, "name": "Sketch1", "type": "sketch", "plane": {"name": "XY", "type": "origin"},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{"fixed": true, "id": "origin", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p1", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p2", "pos": [10., 0.]},
|
||||
{"fixed": false, "id": "p3", "pos": [10., 16.1]},
|
||||
{"fixed": false, "id": "p4", "pos": [0., 16.1]}
|
||||
],
|
||||
"curves": [
|
||||
{"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"},
|
||||
{"a": 2, "b": 3, "construction": false, "id": "l2", "type": "line"},
|
||||
{"a": 3, "b": 4, "construction": false, "id": "l3", "type": "line"},
|
||||
{"a": 4, "b": 1, "construction": false, "id": "l4", "type": "line"}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {"k": 0, "l": 4, "p": 4}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2, "name": "Extrude1", "type": "extrude", "body_names": ["Body1"],
|
||||
"extent": {"convention": "total_distance", "direction": "positive", "distance": "5"},
|
||||
"operation": "new_body", "profiles": {"loops": [["l1", "l2", "l3", "l4"]], "type": "curves"}, "sketch": 1
|
||||
}
|
||||
]))
|
||||
.unwrap(),
|
||||
..Document::default()
|
||||
};
|
||||
let doc = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Body1").unwrap();
|
||||
// The side along x = 0 meets the start cap in the edge under the pick.
|
||||
let near = Vec3::new(0., 8.05, 0.);
|
||||
let names = crate::naming::edge_names(body);
|
||||
let whole = names.iter().find(|n| n.contains("F2:start") && n.contains("F2:side:l4")).expect("the side's edge on the start cap");
|
||||
let piece = whole.replace("F2:side:l4", "F2:side:l4#[F2:start]");
|
||||
let hit = crate::name_lookup::locate_edge(body, &piece, near).unwrap_or_else(|e| panic!("{piece}: {e}"));
|
||||
assert!(hit.on_point, "the edge under the saved pick");
|
||||
assert_eq!(hit.point, near);
|
||||
assert_eq!(names.get(hit.edge), Some(whole));
|
||||
// A piece told apart by a face the side never touches (the opposite side) is still refused.
|
||||
let gone = whole.replace("F2:side:l4", "F2:side:l4#[F2:side:l2]");
|
||||
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
|
||||
}
|
||||
|
||||
@@ -240,8 +240,12 @@ fn same_feature(a: &Feature, b: &Feature) -> bool {
|
||||
a == b
|
||||
&& meshes.len() == other.len()
|
||||
&& meshes.iter().zip(other).all(|(a, b)| {
|
||||
let crate::MeshData { name, positions, triangles, color } = a;
|
||||
*name == b.name && *color == b.color && Arc::ptr_eq(positions, &b.positions) && Arc::ptr_eq(triangles, &b.triangles)
|
||||
let crate::MeshData { name, positions, triangles, corner_normals, color } = a;
|
||||
*name == b.name
|
||||
&& *color == b.color
|
||||
&& Arc::ptr_eq(positions, &b.positions)
|
||||
&& Arc::ptr_eq(triangles, &b.triangles)
|
||||
&& Arc::ptr_eq(corner_normals, &b.corner_normals)
|
||||
})
|
||||
}
|
||||
(
|
||||
|
||||
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
||||
use solidcraft_render::{
|
||||
Camera,
|
||||
job::{
|
||||
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
CpuBudget, CpuProgressiveOptions, DenoiseSettings, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
|
||||
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
},
|
||||
lighting::environment::SkyGradient,
|
||||
};
|
||||
@@ -82,6 +82,18 @@ impl RenderJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CpuJob {
|
||||
/// Zero selects a bounded native worker count; one retains serial transport.
|
||||
#[serde(skip_serializing_if = "zero_threads")]
|
||||
pub threads: u16,
|
||||
/// Spend HDR lighting samples according to captured environment radiance.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub environment_importance_sampling: bool,
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -98,6 +110,13 @@ pub struct CpuJob {
|
||||
pub path: Option<solidcraft_render::job::CpuPathOptions>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Internal scale; saved width and height remain the exported image size.
|
||||
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
|
||||
pub supersampling: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -129,6 +148,10 @@ pub struct CpuJob {
|
||||
impl Default for CpuJob {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threads: 0,
|
||||
environment_importance_sampling: false,
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
volume: None,
|
||||
sun: None,
|
||||
@@ -136,6 +159,9 @@ impl Default for CpuJob {
|
||||
aging: Default::default(),
|
||||
adaptive: None,
|
||||
denoise: false,
|
||||
denoise_settings: DenoiseSettings::default(),
|
||||
denoise_diagnostics: false,
|
||||
supersampling: 1,
|
||||
path: None,
|
||||
lens: None,
|
||||
output: None,
|
||||
@@ -200,6 +226,10 @@ impl CpuJob {
|
||||
})
|
||||
.transpose()?;
|
||||
let options = RaytraceOptions {
|
||||
threads: self.threads,
|
||||
environment_importance_sampling: self.environment_importance_sampling,
|
||||
texture_filtering: self.texture_filtering,
|
||||
metal_energy_compensation: self.metal_energy_compensation,
|
||||
caustics: self.caustics,
|
||||
volume: self.volume,
|
||||
sun: self.sun.map(Box::new),
|
||||
@@ -207,6 +237,9 @@ impl CpuJob {
|
||||
aging: (!aging.is_empty()).then(|| Box::new(aging)),
|
||||
adaptive: self.adaptive.map(Box::new),
|
||||
denoise: self.denoise,
|
||||
denoise_settings: self.denoise_settings,
|
||||
denoise_diagnostics: self.denoise_diagnostics,
|
||||
supersampling: self.supersampling,
|
||||
path: self.path,
|
||||
lens: self.lens,
|
||||
output: self.output.clone(),
|
||||
@@ -233,6 +266,12 @@ impl CpuJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct GpuJob {
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
|
||||
@@ -255,6 +294,13 @@ pub struct GpuJob {
|
||||
pub seed: u32,
|
||||
pub tone_map: ToneMap,
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Internal scale; saved width and height remain the exported image size.
|
||||
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
|
||||
pub supersampling: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -269,6 +315,8 @@ impl Default for GpuJob {
|
||||
fn default() -> Self {
|
||||
let options = JobOptions::default();
|
||||
Self {
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
aging: Default::default(),
|
||||
nested_media: options.nested_media,
|
||||
@@ -283,6 +331,9 @@ impl Default for GpuJob {
|
||||
seed: options.seed,
|
||||
tone_map: options.tone_map,
|
||||
denoise: options.denoise,
|
||||
denoise_settings: options.denoise_settings,
|
||||
denoise_diagnostics: options.denoise_diagnostics,
|
||||
supersampling: options.supersampling,
|
||||
lens: options.lens,
|
||||
lighting: options.lighting,
|
||||
environment: None,
|
||||
@@ -317,6 +368,8 @@ impl GpuJob {
|
||||
environment.validate()?;
|
||||
}
|
||||
let options = JobOptions {
|
||||
texture_filtering: self.texture_filtering,
|
||||
metal_energy_compensation: self.metal_energy_compensation,
|
||||
caustics: self.caustics,
|
||||
aging: self.aging.parameters_only()?,
|
||||
nested_media: self.nested_media,
|
||||
@@ -331,6 +384,9 @@ impl GpuJob {
|
||||
seed: self.seed,
|
||||
tone_map: self.tone_map,
|
||||
denoise: self.denoise,
|
||||
denoise_settings: self.denoise_settings,
|
||||
denoise_diagnostics: self.denoise_diagnostics,
|
||||
supersampling: self.supersampling,
|
||||
lens: self.lens,
|
||||
lighting: self.lighting,
|
||||
exposure_ev,
|
||||
@@ -365,6 +421,10 @@ impl SkyEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_threads(threads: &u16) -> bool {
|
||||
*threads == 0
|
||||
}
|
||||
|
||||
fn unit() -> f64 {
|
||||
1.0
|
||||
}
|
||||
@@ -478,3 +538,33 @@ mod cpu_volume_portable_tests {
|
||||
assert_eq!(serde_json::to_value(job).unwrap(), old);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn portable_quality_controls_preserve_explicit_values_and_legacy_defaults() {
|
||||
let old_cpu: CpuJob = serde_json::from_str("{}").unwrap();
|
||||
let old_gpu: GpuJob = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old_cpu.threads, 0);
|
||||
assert_eq!((old_cpu.supersampling, old_gpu.supersampling), (1, 1));
|
||||
assert_eq!(old_cpu.denoise_settings, DenoiseSettings::default());
|
||||
assert_eq!(old_gpu.denoise_settings, DenoiseSettings::default());
|
||||
assert!(!old_cpu.denoise_diagnostics && !old_gpu.denoise_diagnostics);
|
||||
let filter = DenoiseSettings { passes: 2, strength: 0.35, firefly_suppression: false };
|
||||
let cpu = CpuJob { threads: 4, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
|
||||
let gpu = GpuJob { samples: 128, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
|
||||
let cpu_reopened: CpuJob = serde_json::from_value(serde_json::to_value(&cpu).unwrap()).unwrap();
|
||||
let gpu_reopened: GpuJob = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
|
||||
assert_eq!(cpu_reopened, cpu);
|
||||
assert_eq!(gpu_reopened, gpu);
|
||||
let cpu_runtime = cpu_reopened.to_runtime(-1.0).unwrap();
|
||||
let gpu_runtime = gpu_reopened.to_runtime(-1.0).unwrap();
|
||||
assert_eq!((cpu_runtime.threads, cpu_runtime.supersampling), (4, 2));
|
||||
assert_eq!((gpu_runtime.samples, gpu_runtime.supersampling), (128, 2));
|
||||
assert_eq!(cpu_runtime.denoise_settings, filter);
|
||||
assert_eq!(gpu_runtime.denoise_settings, filter);
|
||||
assert!(cpu_runtime.denoise_diagnostics && gpu_runtime.denoise_diagnostics);
|
||||
assert!(CpuJob { threads: 65, ..cpu.clone() }.validate(0.0).is_err());
|
||||
assert!(GpuJob { supersampling: 3, ..gpu.clone() }.validate(0.0).is_err());
|
||||
assert!(CpuJob { denoise_settings: DenoiseSettings { strength: f64::NAN, ..filter }, ..cpu }.validate(0.0).is_err());
|
||||
assert!(GpuJob { denoise_settings: DenoiseSettings { passes: 4, ..filter }, ..gpu }.validate(0.0).is_err());
|
||||
}
|
||||
|
||||
@@ -147,8 +147,8 @@ impl OpticalBindings {
|
||||
self.0.iter()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.0.len() > 32 || self.0.capacity() > 32 {
|
||||
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
|
||||
if self.0.len() > solidcraft_render::job::MAX_OPTICAL_TARGETS || self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for binding in &self.0 {
|
||||
@@ -164,7 +164,7 @@ impl OpticalBindings {
|
||||
}
|
||||
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
|
||||
self.validate()?;
|
||||
if self.len() > 8 {
|
||||
if !path && self.len() > 8 {
|
||||
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
|
||||
}
|
||||
for binding in self.iter() {
|
||||
@@ -183,11 +183,11 @@ impl OpticalBindings {
|
||||
}
|
||||
*old = binding;
|
||||
} else {
|
||||
if self.0.len() >= 32 {
|
||||
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
|
||||
if self.0.len() >= solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
|
||||
}
|
||||
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("optical binding reservation failed".into()))?;
|
||||
if self.0.capacity() > 32 {
|
||||
if self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("optical capacity exceeds its bound".into()));
|
||||
}
|
||||
self.0.push(binding);
|
||||
@@ -202,7 +202,7 @@ impl OpticalBindings {
|
||||
}
|
||||
impl<'de> Deserialize<'de> for OpticalBindings {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let result = Self(bounded_sequence::<D, SavedOptics, 32>(d)?);
|
||||
let result = Self(bounded_sequence::<D, SavedOptics, { solidcraft_render::job::MAX_OPTICAL_TARGETS }>(d)?);
|
||||
result.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -212,6 +212,25 @@ impl<'de> Deserialize<'de> for OpticalBindings {
|
||||
mod advanced_model_contract_tests {
|
||||
use super::*;
|
||||
use solidcraft_render::glass_model::{Dispersion, GlassModel, ThinFilm};
|
||||
#[test]
|
||||
fn portable_optics_keep_128_targets_and_legacy_cpu_keeps_its_explicit_limit() {
|
||||
let mut bindings = OpticalBindings::default();
|
||||
for feature in 1..=solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
let binding = SavedOptics {
|
||||
target: RenderBodyTarget { definition: "OPUS".into(), feature: feature as u64, component: 0, occurrence_path: Vec::new() },
|
||||
optics: OpticalMaterial::default(),
|
||||
};
|
||||
bindings.set(binding, false).unwrap();
|
||||
}
|
||||
bindings.validate_for_cpu_path(true).unwrap();
|
||||
assert!(bindings.validate_for_cpu().is_err());
|
||||
let raw = serde_json::to_value(&bindings).unwrap();
|
||||
assert_eq!(serde_json::from_value::<OpticalBindings>(raw.clone()).unwrap(), bindings);
|
||||
let mut too_many = raw.as_array().unwrap().clone();
|
||||
too_many.push(too_many[0].clone());
|
||||
assert!(serde_json::from_value::<OpticalBindings>(serde_json::Value::Array(too_many)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_optics_keep_every_authored_number_through_saved_and_cpu_lowering() {
|
||||
let model = GlassModel {
|
||||
@@ -238,14 +257,32 @@ mod advanced_model_contract_tests {
|
||||
#[test]
|
||||
fn caustic_quality_is_portable_between_both_saved_jobs_and_runtime_requests() {
|
||||
use crate::render_job::{CpuJob, GpuJob};
|
||||
let caustics = Some(solidcraft_render::job::CausticOptions { photons: 8192, radius_mm: 0.4, max_depth: 12, seed: 381 });
|
||||
let cpu = CpuJob { caustics, path: Some(Default::default()), progressive: Some(Default::default()), ..Default::default() };
|
||||
let caustics = Some(solidcraft_render::job::CausticOptions {
|
||||
photons: 8192,
|
||||
radius_mm: 0.4,
|
||||
max_depth: 12,
|
||||
seed: 381,
|
||||
area_lights: false,
|
||||
environment: false,
|
||||
guided_emission: true,
|
||||
refresh_per_pass: true,
|
||||
power_weighted_sources: true,
|
||||
});
|
||||
let cpu = CpuJob {
|
||||
caustics,
|
||||
environment_importance_sampling: true,
|
||||
path: Some(Default::default()),
|
||||
progressive: Some(Default::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let gpu = GpuJob { caustics, ..Default::default() };
|
||||
assert_eq!(cpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert!(cpu.to_runtime(0.0).unwrap().environment_importance_sampling);
|
||||
assert_eq!(gpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert_eq!(serde_json::from_value::<CpuJob>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
|
||||
assert_eq!(serde_json::from_value::<GpuJob>(serde_json::to_value(&gpu).unwrap()).unwrap(), gpu);
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("caustics").is_none());
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("environment_importance_sampling").is_none());
|
||||
assert!(serde_json::to_value(GpuJob::default()).unwrap().get("caustics").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,15 +212,27 @@ impl RenderSetup {
|
||||
));
|
||||
}
|
||||
job.validate(&self.camera, self.exposure_ev)?;
|
||||
if let RenderJob::GpuPath { options, .. } = job {
|
||||
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
if let RenderJob::CpuRaytrace { options, .. } = job {
|
||||
if options.denoise {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
// Existing native-size Workstation intent remains readable. X2
|
||||
// uses the bounded 8K output envelope and pays for its internal grid.
|
||||
let (trace_width, trace_height) = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
|
||||
(self.width, self.height)
|
||||
} else {
|
||||
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?
|
||||
};
|
||||
if options.denoise || options.denoise_diagnostics {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(trace_width, trace_height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
options
|
||||
.budget
|
||||
.validate_image(
|
||||
self.width,
|
||||
self.height,
|
||||
trace_width,
|
||||
trace_height,
|
||||
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?,
|
||||
)
|
||||
@@ -670,7 +682,7 @@ mod tests {
|
||||
gpu(3840, 2160).validate().unwrap();
|
||||
gpu(3840, 2160).validate_for_gpu().unwrap();
|
||||
let message = gpu(8193, 8193).validate().unwrap_err().to_string();
|
||||
assert!(message.contains("(8192 pixels per side, 16777216 pixels in all)"), "{message}");
|
||||
assert!(message.contains("(8192 pixels per side, 33554432 pixels in all)"), "{message}");
|
||||
assert!(!message.contains("CPU resource profile"), "{message}");
|
||||
}
|
||||
|
||||
@@ -883,7 +895,7 @@ mod tests {
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-portable.hdr"}}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"exposure_ev":0}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"samples":0}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"max_depth":17}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"max_depth":65}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"sky","intensity":101}}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"lighting":{"environment":[-1,0,0]}}}}),
|
||||
json!({"camera":Camera { fov: 0.0, ..Default::default() },"job":{"method":"gpu_path","options":{"lens":{"aperture_radius_mm":1,"focus_distance_mm":10}}}}),
|
||||
@@ -1021,7 +1033,7 @@ mod cpu_budget_tests {
|
||||
let old = CpuJob::default();
|
||||
assert!(old.path.is_none());
|
||||
assert!(serde_json::to_value(&old).unwrap().get("path").is_none());
|
||||
for depth in [1, 8, 16] {
|
||||
for depth in [1, 8, 16, 64] {
|
||||
let setup:RenderSetup=serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"path":{"max_depth":depth},"progressive":{"samples_per_pixel":7,"seed":9}}}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let mut document = crate::Document::default();
|
||||
@@ -1032,7 +1044,7 @@ mod cpu_budget_tests {
|
||||
}
|
||||
for raw in [
|
||||
json!({"path":{"max_depth":0},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":17},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":65},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":8}}),
|
||||
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
|
||||
] {
|
||||
@@ -1073,7 +1085,7 @@ fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
|
||||
setup.validate().unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
options.denoise = true;
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("CPU denoising"));
|
||||
assert_eq!(setup.validate().unwrap_err(), DocError::Invalid("LimitExceeded: output requires 1..8192 sides and at most 33554432 pixels".into()),);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1220,7 +1232,9 @@ mod cpu_adaptive_saved_tests {
|
||||
serde_json::from_value::<RenderSetup>(cap).unwrap().validate().is_err(),
|
||||
"16 pilots cannot be hidden outside Compact's 64-sample cap"
|
||||
);
|
||||
for (budget, maximum) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 4096)] {
|
||||
for (budget, maximum) in
|
||||
[(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, solidcraft_render::job::MAX_CPU_SAMPLES_PER_PIXEL)]
|
||||
{
|
||||
let mut runtime = runtime.clone();
|
||||
runtime.budget = budget;
|
||||
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
|
||||
@@ -1385,3 +1399,39 @@ mod cpu_celestial_saved_tests {
|
||||
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_quality_saved_intent_preserves_output_size_and_validates_the_internal_grid() {
|
||||
let gpu: RenderSetup = serde_json::from_value(serde_json::json!({
|
||||
"width":7680,"height":4320,"job":{"method":"gpu_path","options":{
|
||||
"samples":128,"supersampling":2,"denoise":true,"denoise_diagnostics":true,
|
||||
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false}
|
||||
}}
|
||||
}))
|
||||
.unwrap();
|
||||
gpu.validate().unwrap();
|
||||
let reopened: RenderSetup = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
|
||||
assert_eq!(reopened, gpu);
|
||||
assert_eq!((reopened.width, reopened.height), (7680, 4320));
|
||||
let mut cpu: RenderSetup = serde_json::from_value(serde_json::json!({
|
||||
"width":7680,"height":4320,"job":{"method":"cpu_raytrace","options":{
|
||||
"threads":4,"budget":"workstation","progressive":{"samples_per_pixel":128},"supersampling":2
|
||||
}}
|
||||
}))
|
||||
.unwrap();
|
||||
cpu.validate().unwrap();
|
||||
assert_eq!((cpu.width, cpu.height), (7680, 4320));
|
||||
cpu.width = 1024;
|
||||
cpu.height = 1024;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
|
||||
options.budget = solidcraft_render::job::CpuBudget::Compact;
|
||||
options.progressive.as_mut().unwrap().samples_per_pixel = 8;
|
||||
assert!(cpu.validate().is_err(), "X2 must charge four million internal pixels to Compact's progressive limit");
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
|
||||
options.supersampling = 1;
|
||||
cpu.validate().unwrap();
|
||||
let mut invalid = gpu;
|
||||
invalid.width = 8193;
|
||||
invalid.height = 1;
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Prepare an explicitly shaded OPUS copy and physical CPU/GPU reference setups.
|
||||
//! No render is started. Geometry and the original OPUS Master setup are retained.
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
doc::{Document, FeatureKind, MeshData, render_settings::RenderSetup},
|
||||
};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::{error::Error, fs, path::PathBuf, sync::Arc};
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
|
||||
if condition { Ok(()) } else { Err(message.into()) }
|
||||
}
|
||||
|
||||
const CURVED: &[&str] = &[
|
||||
"A3 - continuous sixteen facet freeform glass knot",
|
||||
"A3 - six lobe quilted aspheric witness optic",
|
||||
"Glass - smoked rounded toroid",
|
||||
"Glass - clear calibration sphere",
|
||||
"O2 - Lens 1 - Biconvex crown",
|
||||
"O2 - Lens 2 - Biconcave flint",
|
||||
"O2 - Lens 3 - Thick meniscus",
|
||||
"O2 - Lens 4 - Plano convex magnifier",
|
||||
"O2 - Lens 5 - Aspheric correction optic",
|
||||
"R2 - clear chest reactor lens",
|
||||
"R2 - near clear glass eye",
|
||||
"R2 - far clear glass eye",
|
||||
"S2 - amber solid ellipsoid",
|
||||
"S2 - cobalt fluted solid decanter",
|
||||
"R2 - thorax ivory swept breastplates",
|
||||
"S2 - glass gallery turned wood pedestal -217",
|
||||
"S2 - glass gallery turned wood pedestal 211",
|
||||
];
|
||||
|
||||
fn scene_counts(scene: &Value) -> Result<(usize, u64), Box<dyn Error>> {
|
||||
let bodies = scene["bodies"].as_array().ok_or("missing scene bodies")?;
|
||||
let triangles = bodies
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, body| sum.checked_add(body["triangles"].as_u64().ok_or("missing triangle count")?).ok_or("triangle sum overflow"))?;
|
||||
Ok((bodies.len(), triangles))
|
||||
}
|
||||
|
||||
fn physical(master: &RenderSetup, cpu: bool, pilot: bool) -> Result<Value, Box<dyn Error>> {
|
||||
let mut setup = serde_json::to_value(master)?;
|
||||
let mut optics = setup["optics"].as_array().ok_or("missing optics")?.clone();
|
||||
for binding in &mut optics {
|
||||
binding["optics"]["shadow_transmission"] = json!(false);
|
||||
}
|
||||
setup["optics"] = json!(optics);
|
||||
setup["mode"] = json!("final");
|
||||
setup["width"] = json!(if pilot { 320 } else { 3840 });
|
||||
setup["height"] = json!(if pilot { 180 } else { 2160 });
|
||||
let samples = if pilot { 8u32 } else { 4096u32 };
|
||||
let mut options = setup["job"]["options"].clone();
|
||||
options["texture_filtering"] = json!(true);
|
||||
options["linear_exr"] = json!(true);
|
||||
options["max_depth"] = json!(64);
|
||||
options["samples"] = json!(samples);
|
||||
options["nested_media"] = json!(true);
|
||||
options["denoise"] = json!(false);
|
||||
if cpu {
|
||||
let lighting = &options["lighting"];
|
||||
let area_lights = lighting["area_lights"]
|
||||
.as_array()
|
||||
.ok_or("missing area lights")?
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let radiance: [f64; 3] = serde_json::from_value(a["radiance"].clone())?;
|
||||
let intensity = radiance.into_iter().fold(0.0, f64::max);
|
||||
let color = radiance.map(|v| if intensity > 0.0 { v / intensity } else { 0.0 });
|
||||
// GPU stores half-edges; the CPU rectangle contract takes full sides.
|
||||
let u: [f64; 3] = serde_json::from_value(a["u"].clone())?;
|
||||
let v: [f64; 3] = serde_json::from_value(a["v"].clone())?;
|
||||
Ok(json!({"center_mm":a["center"],"edge_u_mm":u.map(|x|x*2.0),"edge_v_mm":v.map(|x|x*2.0),"color":color,"intensity":intensity}))
|
||||
})
|
||||
.collect::<Result<Vec<Value>, serde_json::Error>>()?;
|
||||
let cpu_options = json!({
|
||||
"budget":"workstation", "texture_filtering":true,
|
||||
"progressive":{"samples_per_pixel":samples,"seed":4619},
|
||||
"path":{"max_depth":64}, "primary_samples":1, "shadows":true,
|
||||
"lights":[], "area_lights":area_lights,"area_samples":1,
|
||||
"punctual_lights":lighting["punctual_lights"],
|
||||
"environment":{"color":lighting["environment"],"intensity":1.0},
|
||||
"aging":options["aging"], "denoise":false,
|
||||
"output":{"tone_map":options["tone_map"],"linear_exr":true}
|
||||
});
|
||||
setup["job"] = json!({"method":"cpu_raytrace","transparent_background":false,"options":cpu_options});
|
||||
} else {
|
||||
setup["job"]["options"] = options;
|
||||
}
|
||||
let checked: RenderSetup = serde_json::from_value(setup.clone())?;
|
||||
checked.validate()?;
|
||||
require(checked.camera == master.camera, "reference camera differs from Master")?;
|
||||
require(checked.surfaces == master.surfaces && checked.coats == master.coats, "reference lost surface maps or coats")?;
|
||||
Ok(setup)
|
||||
}
|
||||
|
||||
fn crease_audit(mesh: &MeshData) -> Result<Value, Box<dyn Error>> {
|
||||
let positions: Vec<Vec3> = mesh.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect();
|
||||
let triangles = mesh.triangles.as_chunks::<3>().0;
|
||||
let mut weld = std::collections::HashMap::new();
|
||||
let mut canonical = Vec::with_capacity(positions.len());
|
||||
for p in &positions {
|
||||
let next = weld.len();
|
||||
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
|
||||
}
|
||||
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
|
||||
let mut face_normals = Vec::with_capacity(triangles.len());
|
||||
for (i, t) in triangles.iter().enumerate() {
|
||||
let [a, b, c] = t.map(|k| positions.get(k as usize).copied());
|
||||
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("audit index missing".into()) };
|
||||
face_normals.push((b - a).cross(c - a).normalized().ok_or("audit triangle degenerate")?);
|
||||
for k in 0..3 {
|
||||
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
|
||||
edges
|
||||
.entry((a.min(b), a.max(b)))
|
||||
.and_modify(|e| {
|
||||
e.1 = Some(i * 3 + k);
|
||||
e.2 += 1;
|
||||
})
|
||||
.or_insert((i * 3 + k, None, 1));
|
||||
}
|
||||
}
|
||||
let normal = |corner: usize| -> Result<Vec3, Box<dyn Error>> {
|
||||
let n = mesh.corner_normals.get(corner * 3..corner * 3 + 3).ok_or("audit normal missing")?;
|
||||
Ok(Vec3::new(n[0], n[1], n[2]))
|
||||
};
|
||||
let mut bins = [0u64; 7];
|
||||
let mut sharp = 0u64;
|
||||
let mut sharp_discontinuous = 0u64;
|
||||
let mut boundary_or_nonmanifold = 0u64;
|
||||
for (_, (a, b, count)) in edges {
|
||||
let Some(b) = b.filter(|_| count == 2) else {
|
||||
boundary_or_nonmanifold += 1;
|
||||
continue;
|
||||
};
|
||||
let angle = face_normals[a / 3].dot(face_normals[b / 3]).clamp(-1.0, 1.0).acos().to_degrees();
|
||||
let bin = [1.0, 5.0, 10.0, 22.5, 45.0, 90.0].iter().position(|&max| angle <= max).unwrap_or(6);
|
||||
bins[bin] += 1;
|
||||
if angle > 10.0 + 1e-9 {
|
||||
sharp += 1;
|
||||
let (ak, bk) = (a % 3, b % 3);
|
||||
let same_a = normal(a)?.dist(normal((b / 3) * 3 + (bk + 1) % 3)?) <= 1e-8;
|
||||
let same_b = normal((a / 3) * 3 + (ak + 1) % 3)?.dist(normal(b)?) <= 1e-8;
|
||||
if !same_a || !same_b {
|
||||
sharp_discontinuous += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"manifold_edge_angle_bins_degrees":[[0,1],[1,5],[5,10],[10,22.5],[22.5,45],[45,90],[90,180]],
|
||||
"manifold_edge_counts":bins,"geometric_sharp_edges_above_10_degrees_before":sharp,
|
||||
"geometric_sharp_edges_above_10_degrees_after":sharp,"sharp_edges_with_shading_discontinuity":sharp_discontinuous,
|
||||
"boundary_or_nonmanifold_edges":boundary_or_nonmanifold}))
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let baseline = PathBuf::from(args.next().ok_or("usage: opus_quality_fixture BASELINE.sc NEW_OUTPUT_DIRECTORY")?);
|
||||
let output = PathBuf::from(args.next().ok_or("missing new output directory")?);
|
||||
require(args.next().is_none(), "unexpected argument")?;
|
||||
fs::create_dir(&output)?;
|
||||
let original = fs::read(&baseline)?;
|
||||
let mut doc: Document = solidcraft_io::read_design(&original)?;
|
||||
let master = doc.render.setups.get("OPUS Master").cloned().ok_or("missing OPUS Master")?;
|
||||
require(master.surfaces.len() == 104 && master.optics.len() == 30 && master.coats.len() == 6, "fixture bindings differ from OPUS baseline")?;
|
||||
require(doc.components.len() == 82 && doc.assembly.joints.len() == 81 && doc.assembly.links.len() == 15, "fixture assembly counts differ")?;
|
||||
let baseline_doc = doc.clone();
|
||||
let mut baseline_session = Session::new(doc.clone());
|
||||
let baseline_scene = baseline_session.execute("render.scene", &json!({}))?;
|
||||
require(scene_counts(&baseline_scene)? == (511, 729874), "baseline scene geometry counts differ")?;
|
||||
let mut edits = Vec::new();
|
||||
for feature in &mut doc.features {
|
||||
if let FeatureKind::MeshImport { meshes, .. } = &mut feature.kind {
|
||||
for mesh in meshes {
|
||||
if CURVED.contains(&mesh.name.as_str()) {
|
||||
let positions = mesh.positions.clone();
|
||||
let triangles = mesh.triangles.clone();
|
||||
mesh.generate_crease_normals(10.0)?;
|
||||
require(
|
||||
Arc::ptr_eq(&positions, &mesh.positions) && Arc::ptr_eq(&triangles, &mesh.triangles),
|
||||
"normal generation changed geometry arrays",
|
||||
)?;
|
||||
edits.push(json!({"feature":feature.id,"body":mesh.name,"crease_angle_degrees":10.0,"triangles":mesh.triangles.len()/3,"normal_values":mesh.corner_normals.len(),"crease_audit":crease_audit(mesh)?}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require(edits.len() == CURVED.len(), "a curated normal target is missing or duplicated")?;
|
||||
doc.name = format!("{} — curated 10 degree shading reference", doc.name);
|
||||
let mut session = Session::new(doc);
|
||||
let mut setup_receipts = Vec::new();
|
||||
for (cpu, pilot, name) in [
|
||||
(true, false, "OPUS Physical CPU 4K"),
|
||||
(false, false, "OPUS Physical GPU 4K"),
|
||||
(true, true, "OPUS Physical CPU Pilot"),
|
||||
(false, true, "OPUS Physical GPU Pilot"),
|
||||
] {
|
||||
let setup = physical(&master, cpu, pilot)?;
|
||||
setup_receipts.push(session.execute("render.settings.set", &json!({"name":name,"set":setup}))?);
|
||||
}
|
||||
require(session.doc.render.setups.get("OPUS Master") == Some(&master), "original Master setup changed")?;
|
||||
let scene = session.execute("render.scene", &json!({}))?;
|
||||
require(scene == baseline_scene, "shading changed captured geometry, names, bounds or stable targets")?;
|
||||
require(
|
||||
session.doc.components == baseline_doc.components
|
||||
&& session.doc.occurrences == baseline_doc.occurrences
|
||||
&& session.doc.assembly == baseline_doc.assembly,
|
||||
"assembly, pose or occurrence metadata changed",
|
||||
)?;
|
||||
let copy_path = output.join("OPUS-04-physical-reference.sc");
|
||||
let copy = solidcraft_io::write_design_container(&session.doc)?;
|
||||
fs::write(©_path, ©)?;
|
||||
let reopened = solidcraft_io::read_design(©)?;
|
||||
require(reopened == *session.doc, "copy changed on cold reopen")?;
|
||||
fs::write(output.join("baseline-scene.json"), serde_json::to_vec_pretty(&baseline_scene)?)?;
|
||||
fs::write(output.join("reference-setups.json"), serde_json::to_vec_pretty(&setup_receipts)?)?;
|
||||
let report = json!({
|
||||
"baseline":baseline,"reference":copy_path,"baseline_bytes":original.len(),"reference_bytes":copy.len(),
|
||||
"geometry":{"bodies":511,"triangles":729874,"unchanged":true},
|
||||
"assembly":{"components":82,"joints":81,"couplings":15,"unchanged":true},
|
||||
"bindings_per_reference":{"surfaces":104,"optics":30,"coats":6,"aging":7,"area_lights":4,"punctual_lights":7},
|
||||
"curated_normal_edits":edits,"master_unchanged":true,"camera_unchanged":true,
|
||||
"assembly_and_saved_pose_unchanged":true,"recorded_motion_step15_applied":false,
|
||||
"reference_shadow_transmission":false,"render_started":false,
|
||||
"qualification":"fixture preparation and public settings/scene commands only; no transport convergence claim"
|
||||
});
|
||||
fs::write(output.join("fixture-manifest.json"), serde_json::to_vec_pretty(&report)?)?;
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1552,3 +1552,57 @@ fn join_that_meets_no_body_keeps_the_piece_and_says_so() {
|
||||
let combine = s.execute("solid.combine", &json!({"target": "A", "tools": ["B"], "operation": "join"})).unwrap();
|
||||
assert!(combine["warning"].is_null(), "{combine}");
|
||||
}
|
||||
|
||||
/// #12996: a circular pattern of an untargeted cut made in a component, run with another
|
||||
/// component active, cuts the bodies of the cut's own component, as the cut did.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_in_a_component_works_with_root_active() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((one_hole - got - 9.0 * hole).abs() < 0.02 * hole, "{one_hole} -> {got}");
|
||||
}
|
||||
|
||||
/// #12996: when the component active for the pattern has a body of its own, the copies of an
|
||||
/// untargeted cut keep cutting it, as they did before: saved designs rebuild the same.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_still_cuts_its_own_components_body() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Plate2"}));
|
||||
let plate = volume(&s, "Plate2");
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let (body1, plate2) = (volume(&s, "Body1"), volume(&s, "Plate2"));
|
||||
assert!((one_hole - body1).abs() < 0.02 * hole, "Body1 untouched: {one_hole} -> {body1}");
|
||||
assert!((plate - plate2 - 9.0 * hole).abs() < 0.02 * hole, "Plate2 cut: {plate} -> {plate2}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Which timeline features built each body (#11945): the data behind `model.body_features` and
|
||||
//! the Browser's optional features-under-bodies tree.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::inspect::{BodyFeature, BodyFeatures, ModelBodyFeatures};
|
||||
use crate::{Result, Session};
|
||||
|
||||
impl Session {
|
||||
/// The features that built each body of the evaluated model, by the body's model name, in
|
||||
/// timeline order: the feature that created it, every later feature that changed its
|
||||
/// geometry, and the features of the bodies a feature consumed while it made or changed this
|
||||
/// one (a Combine's tools, a body joined in). A piece a cut split off keeps the features of
|
||||
/// the body it was cut from. Features behind the history marker, which built nothing yet,
|
||||
/// are not listed.
|
||||
///
|
||||
/// Decided on exact body identity (a body a feature passed through unchanged is the same
|
||||
/// record), never on a mesh.
|
||||
pub fn body_features(&self) -> BTreeMap<String, Vec<u64>> {
|
||||
let order: HashMap<u64, usize> = self.doc.features.iter().enumerate().map(|(i, f)| (f.id, i)).collect();
|
||||
// Each body before the feature being looked at: its geometry, its creator and its features.
|
||||
let mut before: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::new();
|
||||
for r in &self.model.results {
|
||||
let id = r.id;
|
||||
let after = &r.state.bodies;
|
||||
let names: HashSet<&str> = after.iter().map(|b| b.name.as_str()).collect();
|
||||
// What the feature consumed: bodies there before and gone after.
|
||||
let consumed: Vec<u64> =
|
||||
before.iter().filter(|(name, _)| !names.contains(name.as_str())).flat_map(|(_, (_, _, list))| list.iter().copied()).collect();
|
||||
let mut next: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::with_capacity(after.len());
|
||||
for b in after {
|
||||
let list = match before.get(&b.name) {
|
||||
// Passed through unchanged.
|
||||
Some((old, creator, list)) if old.same(&b.body) && *creator == b.feature => list.clone(),
|
||||
// Changed: it keeps what built it so far.
|
||||
Some((_, creator, list)) if *creator == b.feature => touched(list.clone(), &consumed, id, &order),
|
||||
// Made by this feature.
|
||||
_ if b.feature == id => touched(Vec::new(), &consumed, id, &order),
|
||||
// A piece of a body made earlier (a split): what built the bodies of its creator.
|
||||
_ => {
|
||||
let inherited =
|
||||
before.values().filter(|(_, creator, _)| *creator == b.feature).flat_map(|(_, _, list)| list.iter().copied()).collect();
|
||||
touched(inherited, &consumed, id, &order)
|
||||
}
|
||||
};
|
||||
next.insert(b.name.clone(), (b.body.clone(), b.feature, list));
|
||||
}
|
||||
before = next;
|
||||
}
|
||||
// The model as it stands (a sketch shown live keeps its last results).
|
||||
self.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| (b.name.clone(), before.get(&b.name).map(|(_, _, list)| list.clone()).unwrap_or_else(|| vec![b.feature])))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `list` with the features of what was consumed and the feature `id` added, once each, in
|
||||
/// timeline order.
|
||||
fn touched(mut list: Vec<u64>, consumed: &[u64], id: u64, order: &HashMap<u64, usize>) -> Vec<u64> {
|
||||
list.extend(consumed.iter().copied());
|
||||
list.push(id);
|
||||
list.sort_by_key(|f| order.get(f).copied().unwrap_or(usize::MAX));
|
||||
list.dedup();
|
||||
list
|
||||
}
|
||||
|
||||
/// `model.body_features`: the features that built each body, or one body (a placed copy's name
|
||||
/// means the body it shows).
|
||||
pub(super) fn model_body_features(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "model.body_features";
|
||||
let all = s.body_features();
|
||||
let wanted = match str_(p, "body") {
|
||||
Some(name) => {
|
||||
let st = s.world_state()?;
|
||||
let b = st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
|
||||
Some((b.name.clone(), b.definition_name().to_string()))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let feature = |id: &u64| BodyFeature { id: *id, name: s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_default() };
|
||||
let bodies = match wanted {
|
||||
Some((shown, definition)) => {
|
||||
vec![BodyFeatures { body: shown, features: all.get(&definition).map(|l| l.iter().map(feature).collect()).unwrap_or_default() }]
|
||||
}
|
||||
None => s
|
||||
.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| BodyFeatures { body: b.name.clone(), features: all.get(&b.name).map(|l| l.iter().map(feature).collect()).unwrap_or_default() })
|
||||
.collect(),
|
||||
};
|
||||
serde_json::to_value(ModelBodyFeatures { bodies, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
@@ -19,9 +19,11 @@ use crate::params::{bad, bool_, expr, str_};
|
||||
use crate::schema::browser::{
|
||||
BodyRenameParams, BodyRenamed, BrowserFoldParams, BrowserFoldResult, BrowserGroupMade, BrowserGroupParams, BrowserGroupRenamed,
|
||||
BrowserMoveParams, BrowserMoved, BrowserOrderParams, BrowserOrdered, BrowserRenameGroupParams, BrowserUngroupParams, BrowserUngrouped,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, RepairDone, RepairFace, RepairHole,
|
||||
RepairParams, RepairResult, RepairSewn, VisibilityState,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, PlaneFrame, RedefineAttachment,
|
||||
RedefineSketchParams, RedefineSketchResult, RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn, ViewApplyParams,
|
||||
ViewDeleteParams, ViewDeleted, ViewListParams, ViewListResult, ViewRenameParams, ViewRenamed, ViewSaveParams, ViewSaved, VisibilityState,
|
||||
};
|
||||
use crate::schema::view_navigation::CameraReply;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -35,6 +37,12 @@ const MOVE_CONTRACT: TypedContract<BrowserMoveParams> = TypedContract::new();
|
||||
const ORDER_CONTRACT: TypedContract<BrowserOrderParams> = TypedContract::new();
|
||||
const VISIBILITY_CONTRACT: TypedContract<BrowserVisibilityParams> = TypedContract::new();
|
||||
const FOLD_CONTRACT: TypedContract<BrowserFoldParams> = TypedContract::new();
|
||||
const REDEFINE_SKETCH_CONTRACT: TypedContract<RedefineSketchParams> = TypedContract::new();
|
||||
const VIEW_SAVE_CONTRACT: TypedContract<ViewSaveParams> = TypedContract::new();
|
||||
const VIEW_RENAME_CONTRACT: TypedContract<ViewRenameParams> = TypedContract::new();
|
||||
const VIEW_DELETE_CONTRACT: TypedContract<ViewDeleteParams> = TypedContract::new();
|
||||
const VIEW_LIST_CONTRACT: TypedContract<ViewListParams> = TypedContract::new();
|
||||
const VIEW_APPLY_CONTRACT: TypedContract<ViewApplyParams> = TypedContract::new();
|
||||
|
||||
/// A reply as JSON: the commands here build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
@@ -56,8 +64,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(
|
||||
"body: its name (a part the modelling kernel refuses, or an open part that should be a solid); gap?: expr (default 0.2 mm, at most 1 mm: free edges that pair up within it are sewn, each sewn edge carrying its own gap as its tolerance); patch_holes?: bool (default false: loops of free edges left are patched, in the plane of the faces around them where those are that plane, else tangent to them, and sewn in; otherwise the reply offers the patches); name? → the feature, and repair: what changed face by face (rebuilt and refused faces, sewn gaps, holes with their surface.patch), faces and largest edge tolerance before and after, closed, modelling, volume. Refused with why when nothing applies",
|
||||
),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", crate::checked!("sketch.redefine", redefine_sketch))
|
||||
.icon("plane")
|
||||
.contract(&REDEFINE_SKETCH_CONTRACT)
|
||||
.output(output_schema_for::<RedefineSketchResult>)
|
||||
.params("sketch: id or name; plane: as sketch.create; frame?: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
||||
CommandSpec::new("browser.group", "New Group", crate::checked!("browser.group", group))
|
||||
.icon("folder")
|
||||
@@ -84,13 +94,33 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&UNITS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<DocumentUnitsResult>)
|
||||
.params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"),
|
||||
CommandSpec::new("view.save", "New Named View", view_save)
|
||||
CommandSpec::new("view.save", "New Named View", crate::checked!("view.save", view_save))
|
||||
.icon("camera")
|
||||
.contract(&VIEW_SAVE_CONTRACT)
|
||||
.output(output_schema_for::<ViewSaved>)
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
|
||||
CommandSpec::new("view.rename", "Rename Named View", crate::checked!("view.rename", view_rename))
|
||||
.icon("rename")
|
||||
.contract(&VIEW_RENAME_CONTRACT)
|
||||
.output(output_schema_for::<ViewRenamed>)
|
||||
.params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", crate::checked!("view.delete", view_delete))
|
||||
.icon("delete")
|
||||
.contract(&VIEW_DELETE_CONTRACT)
|
||||
.output(output_schema_for::<ViewDeleted>)
|
||||
.params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", crate::checked!("view.list", view_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&VIEW_LIST_CONTRACT)
|
||||
.output(output_schema_for::<ViewListResult>)
|
||||
.doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", crate::checked!("view.apply", view_apply))
|
||||
.icon("camera")
|
||||
.noundo()
|
||||
.contract(&VIEW_APPLY_CONTRACT)
|
||||
.output(output_schema_for::<CameraReply>)
|
||||
.params(
|
||||
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?, roll?} (radians, mm, about the up axis; yaw: heading of the line of sight, 0 along +Y (Front), π/2 along +X (Left), π (Back), −π/2 (Right); pitch: the eye's elevation, within ±π/2, π/2 = Top; roll defaults to 0, turns the picture clockwise, must be finite and is clamped to ±π; the View angles readout shows these in degrees; up is refused: the up axis comes from the design settings) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
|
||||
),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", crate::checked!("browser.order", order))
|
||||
@@ -1049,14 +1079,47 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply(kept)
|
||||
}
|
||||
|
||||
/// A camera `view.save` keeps, as the handler reads it (and `ViewCamera`, its contract type): a
|
||||
/// [`solidcraft_doc::NamedView`] of finite numbers with a positive distance, or the message the
|
||||
/// handler refuses it with.
|
||||
pub(crate) fn saved_view_form(c: &Value) -> std::result::Result<solidcraft_doc::NamedView, String> {
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
|
||||
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
||||
if ok { Ok(v) } else { Err("the camera must be finite with a positive distance".into()) }
|
||||
}
|
||||
|
||||
/// The camera `view.apply` turns to for a `camera` it is given, as the handler reads it (and
|
||||
/// `ApplyCamera`, its contract type), or the message the handler refuses it with.
|
||||
pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<crate::render::Camera, String> {
|
||||
if c.get("up").is_some() {
|
||||
return Err("camera `up` is not supported: the up axis comes from the design settings".into());
|
||||
}
|
||||
let roll = match c.get("roll") {
|
||||
Some(value) => value.as_f64().filter(|r| r.is_finite()).ok_or("camera `roll` must be a finite number in radians")?,
|
||||
None => 0.0,
|
||||
};
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
|
||||
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
|
||||
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
|
||||
return Err("camera `pitch` must be within ±π/2 radians (±90°)".into());
|
||||
}
|
||||
let cam = crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) };
|
||||
if !usable_camera(&cam) {
|
||||
return Err("the camera must be finite with a positive distance".into());
|
||||
}
|
||||
Ok(cam)
|
||||
}
|
||||
|
||||
/// A camera the viewport can be put at: finite, with a positive distance and a field of view of
|
||||
/// at least 0.
|
||||
fn usable_camera(cam: &crate::render::Camera) -> bool {
|
||||
cam.is_valid() && cam.fov.is_finite() && cam.fov >= 0.0
|
||||
}
|
||||
|
||||
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.save";
|
||||
let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?;
|
||||
let mut v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
||||
if !ok {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
let mut v = saved_view_form(c).map_err(|e| bad(cmd, e))?;
|
||||
let n = s.doc.named_views.len();
|
||||
v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if n.len() <= 128 => n.to_string(),
|
||||
@@ -1070,7 +1133,7 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut();
|
||||
d.named_views.retain(|x| x.name != name);
|
||||
d.named_views.push(v);
|
||||
Ok(json!({ "view": name }))
|
||||
reply(cmd, ViewSaved { view: name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1078,23 +1141,7 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cam = match (str_(p, "view"), p.get("camera")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
|
||||
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
|
||||
(None, Some(c)) => {
|
||||
if c.get("up").is_some() {
|
||||
return Err(bad(cmd, "camera `up` is not supported: the up axis comes from the design settings"));
|
||||
}
|
||||
let roll = match c.get("roll") {
|
||||
Some(value) => {
|
||||
value.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(cmd, "camera `roll` must be a finite number in radians"))?
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
|
||||
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
|
||||
return Err(bad(cmd, "camera `pitch` must be within ±π/2 radians (±90°)"));
|
||||
}
|
||||
crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) }
|
||||
}
|
||||
(None, Some(c)) => applied_camera_form(c).map_err(|e| bad(cmd, e))?,
|
||||
// Standard view names come first, as in the browser's Named Views folder.
|
||||
(Some(name), None) => match crate::render::StandardView::parse(name) {
|
||||
Some(sv) => {
|
||||
@@ -1106,11 +1153,11 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
|
||||
},
|
||||
};
|
||||
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
|
||||
if !usable_camera(&cam) {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
s.camera = Some(cam);
|
||||
Ok(json!({ "camera": cam }))
|
||||
reply(cmd, CameraReply { camera: cam, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The orbit camera of a named view (Z up, no roll).
|
||||
@@ -1134,17 +1181,18 @@ fn view_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = s.doc_mut().named_views.get_mut(i) {
|
||||
v.name = name.to_string();
|
||||
}
|
||||
Ok(json!({ "view": name }))
|
||||
reply(cmd, ViewRenamed { view: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = view_index(s, p, "view.delete")?;
|
||||
let cmd = "view.delete";
|
||||
let i = view_index(s, p, cmd)?;
|
||||
let v = s.doc_mut().named_views.remove(i);
|
||||
Ok(json!({ "deleted": v.name }))
|
||||
reply(cmd, ViewDeleted { deleted: v.name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "views": s.doc.named_views }))
|
||||
reply("view.list", ViewListResult { views: s.doc.named_views.clone(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The keys whose strings name bodies, alone or in a list (`body`, `targets`…), at any depth of a
|
||||
@@ -1493,17 +1541,17 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
RedefineAttachment::ExactFace
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
RedefineAttachment::SheetFace
|
||||
} else {
|
||||
"fixed"
|
||||
RedefineAttachment::Fixed
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
let mut out = json!({ "sketch": id,"attachment":attachment });
|
||||
super::sketch::frame_reply(&mut out, frame, &placement);
|
||||
Ok(out)
|
||||
let note = frame.and_then(|f| super::sketch::frame_note(Some(f), &placement)).map(str::to_string);
|
||||
let frame = frame.and_then(PlaneFrame::from_name);
|
||||
reply(cmd, RedefineSketchResult { sketch: id, attachment, frame, note, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,37 +4,70 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FeatureKind, PlaneRef};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, name_or_id_values, vec3};
|
||||
use crate::schema::timeline_clipboard::{CopyParams, CopyResult, PasteError, PasteParams, PasteResult, Pasted};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
|
||||
const PASTE_CONTRACT: TypedContract<PasteParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.copy", "Copy Features", copy).icon("copy").noundo().params("features: [ids or names] (in timeline order on paste)"),
|
||||
CommandSpec::new("timeline.paste", "Paste Features", paste).icon("paste").params(
|
||||
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
|
||||
CommandSpec::new("timeline.copy", "Copy Features", crate::checked!("timeline.copy", copy))
|
||||
.icon("copy")
|
||||
.noundo()
|
||||
.contract(©_CONTRACT)
|
||||
.output(output_schema_for::<CopyResult>)
|
||||
.params("features: [ids or names] (in timeline order on paste)"),
|
||||
CommandSpec::new("timeline.paste", "Paste Features", crate::checked!("timeline.paste", paste))
|
||||
.icon("paste")
|
||||
.contract(&PASTE_CONTRACT)
|
||||
.output(output_schema_for::<PasteResult>)
|
||||
.params(
|
||||
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
|
||||
component_path?: its placement (default: the active one, else its only one); the copies go at the marker, each where \
|
||||
its original is in the world (copies into their own component keep its coordinates); body transfers are left out; \
|
||||
from_design?: true to paste sketch features from the host's cross-design clipboard snapshot",
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// A command's reply as JSON: they build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The `features` of `timeline.copy`: named or numbered, both kinds in one list, 1…1000 of them.
|
||||
/// The reader the command's contract uses too.
|
||||
pub(crate) fn copy_keys(p: &Value) -> Result<&[Value]> {
|
||||
let cmd = "timeline.copy";
|
||||
// The features are named or numbered, and both kinds can sit in one list.
|
||||
let keys = name_or_id_values(cmd, p, "features")?;
|
||||
if keys.is_empty() || keys.len() > 1000 {
|
||||
return Err(bad(cmd, "`features` must list 1…1000 features"));
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// A `component_path` of `timeline.paste`: 0 to 64 occurrence ids. The reader both pastes and the
|
||||
/// command's contract use.
|
||||
pub(crate) fn component_path_form(v: &Value) -> Option<Vec<u64>> {
|
||||
v.as_array().filter(|a| a.len() <= 64).and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.copy";
|
||||
let keys = copy_keys(p)?;
|
||||
let ids = keys.iter().map(|key| super::edit::feature_id(s, Some(key), cmd)).collect::<Result<Vec<_>>>()?;
|
||||
// Keep timeline order so sketches come before the features that use them.
|
||||
let copied: Vec<solidcraft_doc::Feature> = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect();
|
||||
let names: Vec<String> = copied.iter().map(|f| f.name.clone()).collect();
|
||||
s.clipboard = copied;
|
||||
s.foreign_copy = Some(std::sync::Arc::new(crate::ForeignCopy::of_features(s)));
|
||||
Ok(json!({"copied": names}))
|
||||
reply(cmd, CopyResult { copied: names, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Origin planes become explicit planes so a translation can move them.
|
||||
@@ -86,12 +119,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let given_path = match p.get("component_path") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.filter(|a| a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
),
|
||||
Some(v) => Some(component_path_form(v).ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?),
|
||||
};
|
||||
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
|
||||
@@ -230,15 +258,15 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
sketches.insert(f.id, id);
|
||||
names.insert(f.name.clone(), new_name.clone());
|
||||
made.push(json!({"id": id, "name": new_name, "from": f.name}));
|
||||
made.push(Pasted { id, name: new_name, from: f.name.clone() });
|
||||
}
|
||||
s.refresh();
|
||||
let errors: Vec<Value> = made
|
||||
let errors: Vec<PasteError> = made
|
||||
.iter()
|
||||
.filter_map(|m| m["id"].as_u64())
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
|
||||
.map(|m| m.id)
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|error| PasteError { feature: id, error }))
|
||||
.collect();
|
||||
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
|
||||
reply(cmd, PasteResult { pasted: made, errors, notices, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, PlaneRef};
|
||||
|
||||
use crate::params::{bad, vec3};
|
||||
use crate::schema::timeline_clipboard::{PasteResult, Pasted};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
const CMD: &str = "timeline.paste";
|
||||
@@ -186,11 +187,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let path = match p.get("component_path") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.filter(|a| a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
Some(v) => super::clipboard::component_path_form(v).ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
None if component == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(component)),
|
||||
None => super::component::placing::placement_of(s, &s.design_document(), component, None, "component_path", CMD)?.0,
|
||||
};
|
||||
@@ -249,7 +246,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ids.insert(feature.id, id);
|
||||
names.insert(feature.name.clone(), name.clone());
|
||||
made.push(json!({"id": id, "name": name, "from": feature.name}));
|
||||
made.push(Pasted { id, name: name.clone(), from: feature.name.clone() });
|
||||
}
|
||||
parameters(scratch.doc_mut(), ©.doc, ©.features, &mut mapped)?;
|
||||
let source_values = copy.doc.param_values().0;
|
||||
@@ -307,5 +304,6 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
scratch.selection = ids.values().map(|id| Sel::Feature { id: *id }).collect();
|
||||
s.accept_computed(scratch);
|
||||
Ok(json!({"pasted": made, "errors": [], "notices": []}))
|
||||
serde_json::to_value(PasteResult { pasted: made, errors: Vec::new(), notices: Vec::new(), param_warnings: Vec::new() })
|
||||
.map_err(|e| bad(CMD, e.to_string()))
|
||||
}
|
||||
|
||||
@@ -13,17 +13,40 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::schema::component::{
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams,
|
||||
MoveSketchesToPartsResult, OccurrenceAlignParams, OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
|
||||
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, ChildEntry, CompactPlacement, ComponentNode, CopyParams,
|
||||
CopyResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DragParams, DragResult, EditInPlaceParams, EditInPlaceResult,
|
||||
FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult, ListParams, ListResult, MoveBodiesParams,
|
||||
MoveBodiesResult, MoveFeaturesParams, MoveFeaturesResult, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
|
||||
MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams,
|
||||
OccurrencePropertiesResult, PasteNewParams, PasteNewResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams,
|
||||
RenameResult, ReparentItemsParams, ReparentItemsResult, RevertPositionParams, RevertPositionResult, UnlockParams, UnlockResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const ACTIVATE_CONTRACT: TypedContract<ActivateParams> = TypedContract::new();
|
||||
const CREATE_CONTRACT: TypedContract<CreateParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<DeleteParams> = TypedContract::new();
|
||||
const FROM_BODIES_CONTRACT: TypedContract<FromBodiesParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ListParams> = TypedContract::new();
|
||||
const PROPERTIES_CONTRACT: TypedContract<PropertiesParams> = TypedContract::new();
|
||||
const RENAME_CONTRACT: TypedContract<RenameParams> = TypedContract::new();
|
||||
const UNLOCK_CONTRACT: TypedContract<UnlockParams> = TypedContract::new();
|
||||
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
|
||||
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
|
||||
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
|
||||
const MOVE_SKETCHES_TO_PARTS_CONTRACT: TypedContract<MoveSketchesToPartsParams> = TypedContract::new();
|
||||
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
|
||||
const CAPTURE_POSITION_CONTRACT: TypedContract<CapturePositionParams> = TypedContract::new();
|
||||
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
|
||||
const DRAG_CONTRACT: TypedContract<DragParams> = TypedContract::new();
|
||||
const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
|
||||
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
|
||||
const MOVE_BODIES_CONTRACT: TypedContract<MoveBodiesParams> = TypedContract::new();
|
||||
const MOVE_SKETCHES_CONTRACT: TypedContract<MoveSketchesParams> = TypedContract::new();
|
||||
const OCCURRENCE_PROPERTIES_CONTRACT: TypedContract<OccurrencePropertiesParams> = TypedContract::new();
|
||||
const PASTE_NEW_CONTRACT: TypedContract<PasteNewParams> = TypedContract::new();
|
||||
const REVERT_POSITION_CONTRACT: TypedContract<RevertPositionParams> = TypedContract::new();
|
||||
|
||||
#[path = "component_align.rs"]
|
||||
mod align;
|
||||
@@ -67,14 +90,23 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.show_history", "Show Component History", show_history)
|
||||
.icon("eye").doc_only()
|
||||
.params("component: id or name; puts a component's features back in the History bar after Hide Component History (#10393)"),
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
CommandSpec::new("component.create", "New Component", crate::checked!("component.create", new_component))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<CreateResult>)
|
||||
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"),
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", crate::checked!("component.from_bodies", from_bodies))
|
||||
.icon("component")
|
||||
.contract(&FROM_BODIES_CONTRACT)
|
||||
.output(output_schema_for::<FromBodiesResult>)
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.activate", "Activate Component", crate::checked!("component.activate", activate))
|
||||
.icon("activate")
|
||||
.noundo()
|
||||
.contract(&ACTIVATE_CONTRACT)
|
||||
.output(output_schema_for::<ActivateResult>)
|
||||
.params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.edit_in_place", "Edit in Place", crate::checked!("component.edit_in_place", edit_in_place))
|
||||
.icon("edit")
|
||||
.noundo()
|
||||
@@ -99,23 +131,48 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
fails while the design has unsaved changes (run file.save first) or something is open in it; asked during the agent's \
|
||||
turn it finishes once the turn ends (queued: true); elsewhere nothing changes",
|
||||
),
|
||||
CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params(
|
||||
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
|
||||
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
|
||||
),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
CommandSpec::new("component.list", "List Components", crate::checked!("component.list", list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ListResult>)
|
||||
.params(
|
||||
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
|
||||
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
|
||||
),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", crate::checked!("component.properties", properties::component))
|
||||
.icon("measure").noundo()
|
||||
.contract(&PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<PropertiesResult>)
|
||||
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", crate::checked!("occurrence.properties", properties::occurrence))
|
||||
.icon("measure").noundo()
|
||||
.contract(&OCCURRENCE_PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<OccurrencePropertiesResult>)
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
|
||||
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
|
||||
move and take joints one by one → joints (the ids removed)",
|
||||
),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params(
|
||||
CommandSpec::new("component.rename", "Rename Component", crate::checked!("component.rename", rename))
|
||||
.icon("rename")
|
||||
.contract(&RENAME_CONTRACT)
|
||||
.output(output_schema_for::<RenameResult>)
|
||||
.params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", crate::checked!("component.delete", delete))
|
||||
.icon("delete")
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(output_schema_for::<DeleteResult>)
|
||||
.params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.unlock", "Unlock Contents", crate::checked!("component.unlock", unlock))
|
||||
.icon("rigid_group")
|
||||
.contract(&UNLOCK_CONTRACT)
|
||||
.output(output_schema_for::<UnlockResult>)
|
||||
.params(
|
||||
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
|
||||
move and take joints one by one → joints (the ids removed)",
|
||||
),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", crate::checked!("component.move_bodies", move_bodies))
|
||||
.icon("move")
|
||||
.contract(&MOVE_BODIES_CONTRACT)
|
||||
.output(output_schema_for::<MoveBodiesResult>)
|
||||
.params(
|
||||
"bodies: [names], component; component_path?: its placement to place them by (default: its only one, else the active \
|
||||
one; a component placed more than once gets them in every instance); from_path?: the placement of their component they \
|
||||
move from (default: its only one, else its first, where its bodies have their own names)",
|
||||
@@ -134,8 +191,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
|
||||
`bodies` and `feature`, or the pasted components' `paths`",
|
||||
),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", crate::checked!("component.move_sketches", move_sketches))
|
||||
.icon("move")
|
||||
.contract(&MOVE_SKETCHES_CONTRACT)
|
||||
.output(output_schema_for::<MoveSketchesResult>)
|
||||
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world), except that a sketch on an exactly attached face stays where it is on the face"),
|
||||
CommandSpec::new(
|
||||
"component.move_sketches.to_parts",
|
||||
@@ -171,12 +230,26 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
|
||||
still elsewhere (a body transfer, which leaves its features behind)",
|
||||
),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", crate::checked!("occurrence.copy", copy_occurrence))
|
||||
.icon("copy")
|
||||
.contract(©_CONTRACT)
|
||||
.output(output_schema_for::<CopyResult>)
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
|
||||
CommandSpec::new("component.paste_new", "Paste New", crate::checked!("component.paste_new", paste_new))
|
||||
.icon("paste")
|
||||
.contract(&PASTE_NEW_CONTRACT)
|
||||
.output(output_schema_for::<PasteNewResult>)
|
||||
.params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", crate::checked!("occurrence.move", move_occurrence))
|
||||
.icon("move")
|
||||
.contract(&MOVE_CONTRACT)
|
||||
.output(output_schema_for::<MoveResult>)
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared. Replies {occurrence, transform, pending}; when joints hold it to something fixed and put it back, transform is where it is and the reply adds reverted: true and a warning naming the joint"),
|
||||
CommandSpec::new("occurrence.drag", "Drag Occurrence", drag::drag).icon("move").params(
|
||||
CommandSpec::new("occurrence.drag", "Drag Occurrence", crate::checked!("occurrence.drag", drag::drag))
|
||||
.icon("move")
|
||||
.contract(&DRAG_CONTRACT)
|
||||
.output(output_schema_for::<DragResult>)
|
||||
.params(
|
||||
"occurrence or path: exact placement; at: [x,y,z] world point on it; point: [x,y,z] where it goes. Moves the placement by \
|
||||
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
|
||||
moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)",
|
||||
@@ -191,18 +264,38 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
world coordinates without changing shared component geometry; a placement inside a component placed more than once moves in \
|
||||
every placement of it, and the reply's `shared` lists those paths",
|
||||
),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", crate::checked!("occurrence.ground", ground))
|
||||
.icon("ground")
|
||||
.contract(&GROUND_CONTRACT)
|
||||
.output(output_schema_for::<GroundResult>)
|
||||
.params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("occurrence.reparent", "Move to Component", reparent::reparent).icon("move").params(
|
||||
"occurrence; path?: exact placement; parent: component id or name (0 or \"root\" for the design); parent_path?: the parent's \
|
||||
placement to place it by (default: its only one, else the active one; every instance of the parent gets it). The placement \
|
||||
keeps its world position; a move that would break a feature is refused",
|
||||
),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", crate::checked!("component.capture_position", capture))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("capture")
|
||||
.contract(&CAPTURE_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<CapturePositionResult>)
|
||||
.params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("revert")
|
||||
.noundo()
|
||||
.contract(&REVERT_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<RevertPositionResult>)
|
||||
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
|
||||
|
||||
/// A typed reply as the JSON a handler returns.
|
||||
pub(super) fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
|
||||
serde_json::to_value(value).map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
@@ -225,7 +318,7 @@ fn translation(t: Vec3) -> Mat {
|
||||
solidcraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
pub(crate) fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
|
||||
@@ -288,7 +381,14 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let occ = s.doc.occurrence_of(id);
|
||||
Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ.map(|o| o.id), "grounded": occ.is_some_and(|o| o.grounded)}))
|
||||
reply(CreateResult {
|
||||
component: id,
|
||||
name,
|
||||
parent,
|
||||
occurrence: occ.map(|o| o.id),
|
||||
grounded: occ.is_some_and(|o| o.grounded),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -334,7 +434,7 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = path;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
reply(ActivateResult { active: id, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -342,17 +442,20 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc;
|
||||
// Compact: what a large assembly needs to be found around in, without every name and matrix.
|
||||
let compact = bool_(p, "compact").unwrap_or(false);
|
||||
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
|
||||
let names = |v: Vec<&str>| if compact { NamesOrCount::Count(v.len()) } else { NamesOrCount::Names(v.into_iter().map(str::to_string).collect()) };
|
||||
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
|
||||
let placement = |o: &Occurrence| {
|
||||
if !compact {
|
||||
return json!(o);
|
||||
return PlacementEntry::Full(PlacementInfo::from(o));
|
||||
}
|
||||
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
|
||||
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
|
||||
v["transform"] = json!(o.transform);
|
||||
}
|
||||
v
|
||||
let moved = o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12);
|
||||
PlacementEntry::Compact(CompactPlacement {
|
||||
id: o.id,
|
||||
name: o.name.clone(),
|
||||
parent: o.parent,
|
||||
grounded: o.grounded,
|
||||
transform: moved.then_some(o.transform),
|
||||
})
|
||||
};
|
||||
// Each component's bodies, features, occurrences and children, grouped once rather than
|
||||
// searched for every component (thousands of each in a design with many components).
|
||||
@@ -376,17 +479,37 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sketches = named(|k| matches!(k, FeatureKind::Sketch { .. }));
|
||||
let construction = named(|k| matches!(k, FeatureKind::ConstructionPlane { .. }));
|
||||
let features = named(|k| !matches!(k, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }));
|
||||
let occurrences: Vec<Value> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
|
||||
let children: Vec<Value> = children_of.get(&id).into_iter().flatten().map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
|
||||
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
|
||||
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
|
||||
"construction": names(construction), "occurrences": occurrences, "children": children})
|
||||
let occurrences: Vec<PlacementEntry> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
|
||||
let children: Vec<ChildEntry> = children_of
|
||||
.get(&id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| if compact { ChildEntry::Id(o.id) } else { ChildEntry::Full(PlacementInfo::from(*o)) })
|
||||
.collect();
|
||||
let bodies =
|
||||
if body_names { NamesOrCount::Names(bodies.into_iter().map(str::to_string).collect()) } else { NamesOrCount::Count(bodies.len()) };
|
||||
ComponentNode {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
parent,
|
||||
bodies,
|
||||
features: names(features),
|
||||
sketches: names(sketches),
|
||||
construction: names(construction),
|
||||
occurrences,
|
||||
children,
|
||||
}
|
||||
};
|
||||
let mut out = vec![node(0, &doc.name, None)];
|
||||
for c in &doc.components {
|
||||
out.push(node(c.id, &c.name, Some(c.parent)));
|
||||
}
|
||||
Ok(json!({"active": s.active_component, "components": out, "pending_moves": s.pending_moves.keys().collect::<Vec<_>>()}))
|
||||
reply(ListResult {
|
||||
active: s.active_component,
|
||||
components: out,
|
||||
pending_moves: s.pending_moves.keys().copied().collect(),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -411,7 +534,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A sketch projecting a body of one of those placements keeps it.
|
||||
super::sketch_project::follow_placed_renames(d, &st, &placed, None);
|
||||
}
|
||||
Ok(json!({"component": id, "name": name}))
|
||||
reply(RenameResult { component: id, name: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -428,7 +551,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if gone.contains(&s.active_component) {
|
||||
s.active_component = 0;
|
||||
}
|
||||
Ok(json!({"deleted": gone, "features": removed.features}))
|
||||
reply(DeleteResult { deleted: gone, features: removed.features, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -448,7 +571,7 @@ fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
|
||||
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
|
||||
Ok(json!({"component": id, "joints": gone}))
|
||||
reply(UnlockResult { component: id, joints: gone, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -547,7 +670,7 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.component = to;
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketches": ids, "component": to}))
|
||||
reply(MoveSketchesResult { sketches: ids, component: to, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -581,7 +704,7 @@ pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
reply(CopyResult { occurrence: id, component: comp, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -711,17 +834,7 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delta
|
||||
};
|
||||
let cur = if let Some(value) = p.get("base_transform") {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solidcraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
|
||||
}
|
||||
base
|
||||
base_transform(value).map_err(|message| bad(cmd, message))?
|
||||
} else {
|
||||
s.uncaptured_moves().get(&id).copied().unwrap_or(o.transform)
|
||||
};
|
||||
@@ -735,24 +848,47 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.transform = next;
|
||||
}
|
||||
if !s.doc.assembly.joints.is_empty() {
|
||||
return Ok(after_joints(s, p, id, next, cmd));
|
||||
return reply(after_joints(s, p, id, next, cmd));
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.insert(id, next);
|
||||
s.revision += 1;
|
||||
}
|
||||
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
|
||||
reply(MoveResult {
|
||||
occurrence: id,
|
||||
transform: next,
|
||||
pending: !bool_(p, "capture").unwrap_or(true),
|
||||
reverted: false,
|
||||
warning: None,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A move's `base_transform`: a 4×4 matrix that is finite, affine and invertible, or what is
|
||||
/// wrong with it. The parameters contract reads it the same way.
|
||||
pub(crate) fn base_transform(value: &Value) -> std::result::Result<Mat, &'static str> {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| "base_transform must be a 4x4 affine matrix")?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solidcraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err("base_transform must be finite, affine and invertible");
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// The reply to a captured move once the joints are solved (#11723): a part its joints hold to
|
||||
/// something fixed is put back by that solve, so the reply gives where it is now, says
|
||||
/// `reverted`, and names the joint and what to change instead.
|
||||
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> Value {
|
||||
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> MoveResult {
|
||||
s.refresh();
|
||||
let now = s.doc.occurrences.iter().find(|o| o.id == id).map_or(moved, |o| o.transform);
|
||||
let same = now.iter().flatten().zip(moved.iter().flatten()).all(|(a, b)| (a - b).abs() <= 1e-6);
|
||||
if same {
|
||||
return json!({"occurrence": id, "transform": now, "pending": false});
|
||||
return MoveResult { occurrence: id, transform: now, pending: false, reverted: false, warning: None, param_warnings: Vec::new() };
|
||||
}
|
||||
let name = s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
// The placement the caller named, else the first one: a part in a component placed more
|
||||
@@ -781,7 +917,7 @@ fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> V
|
||||
"{why}The joints put {name} back where they hold it. To move it, change the joint (joint.edit for its offset or angle, \
|
||||
joint.drive for a hinge or slider value), move what it is held to, or use joint.reach"
|
||||
);
|
||||
json!({"occurrence": id, "transform": now, "pending": false, "reverted": true, "warning": warning})
|
||||
MoveResult { occurrence: id, transform: now, pending: false, reverted: true, warning: Some(warning), param_warnings: Vec::new() }
|
||||
}
|
||||
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -794,7 +930,7 @@ fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
let g = o.grounded;
|
||||
Ok(json!({"occurrence": id, "grounded": g}))
|
||||
reply(GroundResult { occurrence: id, grounded: g, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -808,7 +944,7 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({ "captured": n }))
|
||||
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -830,7 +966,7 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
s.revision += 1;
|
||||
Ok(json!({ "reverted": n }))
|
||||
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The active component's frame from world coordinates (for new features' geometry).
|
||||
|
||||
@@ -4,8 +4,9 @@ use solidcraft_geom::prelude::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::PasteNewResult;
|
||||
use crate::{Result, Sel, Session};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
|
||||
const CMD: &str = "component.paste_new";
|
||||
@@ -793,7 +794,16 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
|
||||
let out = super::component::reply(PasteNewResult {
|
||||
component: root,
|
||||
occurrence: root_occurrence,
|
||||
features: new_ids,
|
||||
components: components.len(),
|
||||
bodies: expected_count,
|
||||
frozen_bodies: frozen,
|
||||
notices,
|
||||
param_warnings: Vec::new(),
|
||||
})?;
|
||||
Ok((scratch, out))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
//! (`joint.reach` moves those as far as their joints allow). A chain of joints joined to nothing
|
||||
//! fixed moves as one, so its joints stay as they are.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{mat_inverse, mat_mul};
|
||||
|
||||
use super::{move_vector, occurrence, occurrence_path, translation, valid_position};
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::DragResult;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -158,5 +159,12 @@ pub(super) fn drag(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let continuous = p.get("continuous").and_then(Value::as_bool).unwrap_or(false);
|
||||
Ok(json!({"occurrence": path.last(), "path": path, "moved": moved, "translate": [t.x, t.y, t.z], "continuous": continuous}))
|
||||
super::reply(DragResult {
|
||||
occurrence: path.last().copied().unwrap_or_default(),
|
||||
path,
|
||||
moved,
|
||||
translate: [t.x, t.y, t.z],
|
||||
continuous,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::{Document, Mat};
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_geom::inertia::{self, Tensor3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::{
|
||||
BodyProperties, BoundingBox, InertiaAbout, OccurrencePropertiesResult, PhysicalProperties, PropertiesResult, TotalProperties,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
@@ -84,10 +87,8 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
out["component"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
super::reply(PropertiesResult { component: id, name, properties, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -105,10 +106,9 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
|
||||
}
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
out["occurrence"] = json!(id);
|
||||
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
|
||||
Ok(out)
|
||||
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
let name = doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone());
|
||||
super::reply(OccurrencePropertiesResult { occurrence: id, name, properties, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The linear part of `m` is a rotation (no scale, shear or mirror), to rounding.
|
||||
@@ -200,7 +200,7 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result<Pl
|
||||
})
|
||||
}
|
||||
|
||||
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
|
||||
fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<PhysicalProperties> {
|
||||
let state = s.model.state();
|
||||
let mut bodies = Vec::new();
|
||||
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
|
||||
@@ -292,13 +292,20 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
faces += measured.faces;
|
||||
edges += measured.edges;
|
||||
vertices += measured.vertices;
|
||||
bodies.push(json!({"name": body.name, "component": component, "path": path,
|
||||
"material": doc.body_material(&body.name),
|
||||
"closed": closed, "density_g_cm3": closed.then_some(density),
|
||||
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
|
||||
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
|
||||
"inertia_g_mm2": body_inertia,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
bodies.push(BodyProperties {
|
||||
name: body.name.to_string(),
|
||||
component,
|
||||
path: path.clone(),
|
||||
material: doc.body_material(&body.name),
|
||||
closed,
|
||||
density_g_cm3: closed.then_some(density),
|
||||
mass_g: closed.then_some(body_mass),
|
||||
volume_mm3: closed.then_some(measured.volume),
|
||||
area_mm2: measured.area,
|
||||
center_of_mass: closed.then_some(measured.centroid),
|
||||
inertia_g_mm2: body_inertia,
|
||||
bbox: BoundingBox { min: measured.bbox.min, max: measured.bbox.max },
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(w) = &watch {
|
||||
@@ -321,19 +328,36 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
});
|
||||
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
|
||||
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
|
||||
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
|
||||
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
|
||||
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
|
||||
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
|
||||
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
|
||||
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
|
||||
if let Some(point) = about {
|
||||
let bbox = (!bodies.is_empty()).then_some(BoundingBox { min: bounds.min, max: bounds.max });
|
||||
let inertia_about = about.map(|point| {
|
||||
let shifted =
|
||||
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
|
||||
}
|
||||
Ok(out)
|
||||
InertiaAbout { point, inertia_g_mm2: shifted }
|
||||
});
|
||||
Ok(PhysicalProperties {
|
||||
path: selected.to_vec(),
|
||||
coordinate_frame: "world".to_string(),
|
||||
descendants,
|
||||
placement_count,
|
||||
body_count: bodies.len(),
|
||||
bodies,
|
||||
total: TotalProperties {
|
||||
open_bodies_skipped: skipped,
|
||||
volume_mm3: volume,
|
||||
area_mm2: area,
|
||||
mass_g: mass,
|
||||
center_of_mass: centroid,
|
||||
first_moment_g_mm: moment,
|
||||
inertia_g_mm2: at_centre,
|
||||
principal_moments_g_mm2: principal.map(|(moments, _)| moments),
|
||||
principal_axes: principal.map(|(_, axes)| axes),
|
||||
bbox,
|
||||
faces,
|
||||
edges,
|
||||
vertices,
|
||||
inertia_about,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The tessellating work a properties command did on this thread, for tests: placed copies
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use crate::schema::component::{BodyComponent, FromBodiesResult, MoveBodiesResult};
|
||||
use solidcraft_doc::BodyName;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -128,10 +129,10 @@ pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (name, component) in bodies.into_iter().zip(targets) {
|
||||
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
|
||||
features.push(feature);
|
||||
made.push(json!({"component":component,"body":name,"feature":feature}));
|
||||
made.push(BodyComponent { component, body: name, feature });
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"components":made}))
|
||||
super::reply(FromBodiesResult { components: made, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A placement path parameter (`component_path`: the target's placement to place by,
|
||||
@@ -187,7 +188,7 @@ fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<
|
||||
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"bodies":bodies,"component":to,"features":features}))
|
||||
super::reply(MoveBodiesResult { bodies, component: to, features, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+102
-60
@@ -8,13 +8,17 @@ use solidcraft_doc::{Feature, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, name_or_id_values, num, str_};
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::edit::{
|
||||
FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams, TimelineComputeAllResult,
|
||||
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
|
||||
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
|
||||
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
|
||||
BrokenFeature, FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams,
|
||||
TimelineComputeAllResult, TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams,
|
||||
TimelineEditResult, TimelineGroupMade, TimelineGroupParams, TimelineGroupRenamed, TimelineInspectParams, TimelineInspectResult,
|
||||
TimelineMarkerResult, TimelineMoveManyParams, TimelineMoveToGroupParams, TimelineMoved, TimelineMovedToGroup, TimelineRedefineParams,
|
||||
TimelineRedefined, TimelineRenameGroupParams, TimelineRenameParams, TimelineRenameResult, TimelineReorderManyParams, TimelineReorderParams,
|
||||
TimelineReordered, TimelineReorderedMany, TimelineRollToParams, TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
|
||||
TimelineUngroupParams, TimelineUngrouped,
|
||||
};
|
||||
use crate::schema::select::{PartialPick, SelectClearParams, SelectClearResult, SelectItem, SelectSetParams, SelectSetResult, read_item};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -87,18 +91,35 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&RENAME_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineRenameResult>)
|
||||
.params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", crate::checked!("timeline.group", group))
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineGroupMade>)
|
||||
.params("name?; features?: [id|name]; joints?: [id|name]; groups?: [folder ids]; parent?: folder id (features and joints leave any other timeline folder)"),
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", crate::checked!("timeline.ungroup", ungroup))
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&UNGROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineUngrouped>)
|
||||
.params("group: id | groups: [ids] (features and joints remain in the timeline)"),
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name (renames the folder holding features and joints)"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", crate::checked!("timeline.rename_group", rename_group))
|
||||
.icon("rename")
|
||||
.doc_only()
|
||||
.contract(&RENAME_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineGroupRenamed>)
|
||||
.params("group: id, name (renames the folder holding features and joints)"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", crate::checked!("timeline.move_to_group", move_to_group))
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&MOVE_TO_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineMovedToGroup>)
|
||||
.params("features?: [id|name], joints?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", crate::checked!("timeline.move_many", move_many))
|
||||
.icon("reorder")
|
||||
.contract(&MOVE_MANY_CONTRACT)
|
||||
.output(output_schema_for::<TimelineMoved>)
|
||||
.params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", crate::checked!("timeline.rollback", rollback))
|
||||
@@ -111,12 +132,22 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&ROLL_TO_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", crate::checked!("timeline.reorder", reorder))
|
||||
.icon("reorder")
|
||||
.contract(&REORDER_CONTRACT)
|
||||
.output(output_schema_for::<TimelineReordered>)
|
||||
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke → feature, position: its index now, from: its index before, moved: bool, note?: why nothing moved, or that a position past the end meant the last slot"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", crate::checked!("timeline.reorder_many", reorder_many))
|
||||
.icon("reorder")
|
||||
.contract(&REORDER_MANY_CONTRACT)
|
||||
.output(output_schema_for::<TimelineReorderedMany>)
|
||||
.params(
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", crate::checked!("timeline.redefine", redefine))
|
||||
.icon("edit")
|
||||
.contract(&REDEFINE_CONTRACT)
|
||||
.output(output_schema_for::<TimelineRedefined>)
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", crate::checked!("timeline.dependents", dependents))
|
||||
.icon("graph")
|
||||
@@ -152,16 +183,24 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set).icon("select")
|
||||
CommandSpec::new("select.set", "Select", crate::checked!("select.set", select_set)).icon("select")
|
||||
.noundo()
|
||||
.contract(&SELECT_SET_CONTRACT)
|
||||
.output(output_schema_for::<SelectSetResult>)
|
||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool; face/edge: body plus point [x,y,z] in world mm or index (both also accepted); a point uses Measure's pick tolerance, an index supplies a point on the item; invalid partial picks leave the selection unchanged"),
|
||||
CommandSpec::new("select.filter", "Selection filter", select_filter).icon("select").noundo().doc_only()
|
||||
.params("mode: body|face|component → validated UI pick granularity; headless echo only, no document edits or undo"),
|
||||
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
|
||||
CommandSpec::new("select.clear", "Clear Selection", crate::checked!("select.clear", select_clear))
|
||||
.icon("select")
|
||||
.noundo()
|
||||
.contract(&SELECT_CLEAR_CONTRACT)
|
||||
.output(output_schema_for::<SelectClearResult>),
|
||||
CommandSpec::new("select.same_look", "Select Same Look", select_same_look).icon("select").noundo().doc_only()
|
||||
.params("body?: world body name, face?: face index; defaults to the current body/face selection → selects objects using the resolved appearance"),
|
||||
];
|
||||
|
||||
const SELECT_SET_CONTRACT: TypedContract<SelectSetParams> = TypedContract::new();
|
||||
const SELECT_CLEAR_CONTRACT: TypedContract<SelectClearParams> = TypedContract::new();
|
||||
const COMPUTE_ALL_CONTRACT: TypedContract<TimelineComputeAllParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<TimelineDeleteParams> = TypedContract::new();
|
||||
const RENAME_CONTRACT: TypedContract<TimelineRenameParams> = TypedContract::new();
|
||||
@@ -171,6 +210,14 @@ const DEPENDENTS_CONTRACT: TypedContract<TimelineDependentsParams> = TypedContra
|
||||
const SUPPRESS_CONTRACT: TypedContract<TimelineSuppressParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<TimelineEditParams> = TypedContract::new();
|
||||
const INSPECT_CONTRACT: TypedContract<TimelineInspectParams> = TypedContract::new();
|
||||
const GROUP_CONTRACT: TypedContract<TimelineGroupParams> = TypedContract::new();
|
||||
const UNGROUP_CONTRACT: TypedContract<TimelineUngroupParams> = TypedContract::new();
|
||||
const RENAME_GROUP_CONTRACT: TypedContract<TimelineRenameGroupParams> = TypedContract::new();
|
||||
const MOVE_TO_GROUP_CONTRACT: TypedContract<TimelineMoveToGroupParams> = TypedContract::new();
|
||||
const MOVE_MANY_CONTRACT: TypedContract<TimelineMoveManyParams> = TypedContract::new();
|
||||
const REORDER_CONTRACT: TypedContract<TimelineReorderParams> = TypedContract::new();
|
||||
const REORDER_MANY_CONTRACT: TypedContract<TimelineReorderManyParams> = TypedContract::new();
|
||||
const REDEFINE_CONTRACT: TypedContract<TimelineRedefineParams> = TypedContract::new();
|
||||
const PLAY_CONTRACT: crate::schema::TypedContract<crate::schema::edit::TimelinePlayParams> = crate::schema::TypedContract::new();
|
||||
|
||||
fn play(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -608,7 +655,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(not_contiguous(&folders, broken, cmd));
|
||||
}
|
||||
s.doc_mut().timeline_groups = folders;
|
||||
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
|
||||
reply(cmd, TimelineGroupMade { group: id, name, parent, groups, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -665,7 +712,7 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"groups": ids}))
|
||||
reply(cmd, TimelineUngrouped { groups: ids, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -678,7 +725,7 @@ fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
|
||||
group.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"group": id, "name": name}))
|
||||
reply(cmd, TimelineGroupRenamed { group: id, name: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -715,7 +762,7 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"features": features, "joints": joints, "groups": groups, "group": target}))
|
||||
reply(cmd, TimelineMovedToGroup { features, joints, groups, group: target, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -761,7 +808,7 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
|
||||
reply(cmd, TimelineMoved { features: ordered, groups, group: target, position: slot, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1408,20 +1455,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// an existing index-and-point pair. Unlike Measure, selection keeps the exact face/edge index:
|
||||
/// it must not merge a cylinder's patches or a circular rim's arcs into a different item.
|
||||
fn selection_item(s: &Session, value: Value) -> Result<Sel> {
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
let kind = value.get("type").and_then(Value::as_str);
|
||||
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
|
||||
return serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()));
|
||||
}
|
||||
let face = kind == Some("face");
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Pick {
|
||||
body: String,
|
||||
index: Option<usize>,
|
||||
point: Option<Vec3>,
|
||||
}
|
||||
let Pick { body, index, point } = serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
let (face, PartialPick { body, index, point }) = match read_item(value).map_err(|e| bad("select.set", e))? {
|
||||
SelectItem::Exact(sel) => return Ok(sel),
|
||||
SelectItem::Pick { face, pick } => (face, pick),
|
||||
};
|
||||
let state = s.world_state()?;
|
||||
let b = state.body(&body).ok_or_else(|| bad("select.set", format!("no body `{body}`")))?;
|
||||
let missing = || bad("select.set", format!("no {} of `{body}` at the given index or point", if face { "face" } else { "edge" }));
|
||||
@@ -1496,7 +1533,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.refresh_display();
|
||||
Ok(json!({"selected": s.selection.len()}))
|
||||
reply("select.set", SelectSetResult { selected: s.selection.len(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn select_filter(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1553,7 +1590,7 @@ fn select_same_look(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.selection.clear();
|
||||
s.refresh_display();
|
||||
Ok(json!({"selected": 0}))
|
||||
reply("select.clear", SelectClearResult { selected: 0, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Features that worked in `before` and fail in `after`: (name, error).
|
||||
@@ -1660,7 +1697,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let active = active_param(p, cmd)?;
|
||||
if from == to && active.is_none() {
|
||||
let why = capped.map_or_else(|| format!("it is already at position {to}"), |c| format!("{c}, where it already is"));
|
||||
return Ok(json!({"feature": id, "position": to, "from": from, "moved": false, "note": format!("nothing moved: {why}")}));
|
||||
let note = Some(format!("nothing moved: {why}"));
|
||||
return reply(cmd, TimelineReordered { feature: id, position: to, from, moved: false, note, broken: None, param_warnings: Vec::new() });
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
let f = next.features.remove(from);
|
||||
@@ -1705,20 +1743,22 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let mut out = json!({"feature": id, "position": now, "from": from, "moved": now != from});
|
||||
// With `active`, a feature left in place may still cross the history marker: say which.
|
||||
let stayed = (now == from).then(|| {
|
||||
let what = if s.doc.marker == marker { "nothing moved" } else { "only the history marker moved" };
|
||||
format!("{what}: {name} is already at position {now}")
|
||||
});
|
||||
let note: Vec<String> = [capped, stayed].into_iter().flatten().collect();
|
||||
if !note.is_empty() {
|
||||
out["note"] = json!(note.join("; "));
|
||||
}
|
||||
if allow {
|
||||
out["broken"] = broken_json(&broken);
|
||||
}
|
||||
Ok(out)
|
||||
let out = TimelineReordered {
|
||||
feature: id,
|
||||
position: now,
|
||||
from,
|
||||
moved: now != from,
|
||||
note: (!note.is_empty()).then(|| note.join("; ")),
|
||||
broken: allow.then(|| broken_features(&broken)),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The optional `allow_errors` of a move: make it even when it breaks features that worked.
|
||||
@@ -1731,8 +1771,8 @@ fn allow_errors(p: &Value, cmd: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
/// The features a move broke, as `[{feature, error}]`.
|
||||
fn broken_json(broken: &[(String, String)]) -> Value {
|
||||
broken.iter().map(|(feature, error)| json!({"feature": feature, "error": error})).collect()
|
||||
fn broken_features(broken: &[(String, String)]) -> Vec<BrokenFeature> {
|
||||
broken.iter().map(|(feature, error)| BrokenFeature { feature: feature.clone(), error: error.clone() }).collect()
|
||||
}
|
||||
|
||||
/// The optional `active` of a move: whether the moved features end up before the history marker.
|
||||
@@ -1778,11 +1818,8 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let mut out = json!({"features": ordered, "position": slot.min(s.doc.features.len())});
|
||||
if allow {
|
||||
out["broken"] = broken_json(&broken);
|
||||
}
|
||||
Ok(out)
|
||||
let position = slot.min(s.doc.features.len());
|
||||
reply(cmd, TimelineReorderedMany { features: ordered, position, broken: allow.then(|| broken_features(&broken)), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The reordered design, the moved features in order, and the model the move was checked on
|
||||
@@ -2093,15 +2130,20 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
|
||||
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
|
||||
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
|
||||
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
|
||||
if let Some(warning) = warning {
|
||||
reply["warning"] = json!(warning);
|
||||
}
|
||||
if rolled_back {
|
||||
let note = rolled_back.then(|| {
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
|
||||
}
|
||||
Ok(reply)
|
||||
format!("{name} is after the history marker, so it is not built: move the marker past it to build it")
|
||||
});
|
||||
let out = TimelineRedefined {
|
||||
feature: id,
|
||||
recomputed: rebuilt + s.model.last_recomputed,
|
||||
restored: reused,
|
||||
rolled_back,
|
||||
warning,
|
||||
note,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -261,9 +261,9 @@ fn an_older_two_sided_extrude_with_a_negative_distance_keeps_its_sides() {
|
||||
let mut base = fixture(json!({"direction":"positive","distance":"10 mm","distance2":"10 mm"}));
|
||||
run(&mut base, "parameters.add", json!({"name":"width","expression":"20 mm"}));
|
||||
let mut saved: Value = serde_json::from_str(&base.doc.to_json()).unwrap();
|
||||
// As the last release saved it: the format before this build's pending changes.
|
||||
let version: u32 = saved["format"].as_str().unwrap().trim_start_matches("solidcraft/").parse().unwrap();
|
||||
saved["format"] = json!(format!("solidcraft/{}", version - 1));
|
||||
// Format 50 predates the v51 second-side migration. A moving current-1 tag
|
||||
// can already include that migration after unrelated format changes.
|
||||
saved["format"] = json!("solidcraft/50");
|
||||
saved.as_object_mut().unwrap().remove("format_pending");
|
||||
for (distance, lo, hi) in [("-width", -20.0, 10.0), ("-0 mm", -10.0, 0.0), ("-10 mm", -10.0, 10.0)] {
|
||||
let mut file = saved.clone();
|
||||
|
||||
@@ -87,8 +87,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("F")
|
||||
.contract(&FILLET_CONTRACT)
|
||||
.output(output_schema_for::<FilletResult>)
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them); type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
|
||||
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("combine")
|
||||
@@ -177,7 +177,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("presspull")
|
||||
.key("Q")
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet); body?: the body the face or edges are on (for edges, as for solid.fillet)"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place); occurrences?: [occurrence paths, root first] (only with copy: each component is copied as an independent component, as Paste New copies it, and the copy moves by the same translate, axis and angle in the world; the reply lists the copies in occurrences)"),
|
||||
];
|
||||
|
||||
@@ -1554,20 +1554,131 @@ fn prim_cone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::Cone { base, axis: Dir(axis), radius, top_radius, height, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
/// Edge references → points on the edges.
|
||||
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
/// What [`edge_points`] found: a point on each edge, in its body's frame, and the body the
|
||||
/// feature keeps (`None`: each edge is looked up on its own).
|
||||
struct EdgePicks {
|
||||
points: Vec<Vec3>,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
/// A body as a pick is looked up on: the model body, the name it is shown by, and the frame it
|
||||
/// is placed by and its inverse.
|
||||
struct PickRow {
|
||||
body: usize,
|
||||
name: String,
|
||||
frame: solidcraft_doc::Mat,
|
||||
inverse: solidcraft_doc::Mat,
|
||||
}
|
||||
|
||||
/// What one body's edges near a pick are: the point in its frame, its distance from the pick on
|
||||
/// the exact curves and the edge there, whether any edge within reach is not a seam, and the
|
||||
/// edges within reach (polyline distance, seam, the direction leaving).
|
||||
struct RowHit {
|
||||
row: usize,
|
||||
q: Vec3,
|
||||
d: f64,
|
||||
edge: Option<usize>,
|
||||
edge_like: bool,
|
||||
near: Vec<(f64, bool, Option<Vec3>)>,
|
||||
}
|
||||
|
||||
/// How many bodies a refusal names before "and N more".
|
||||
const NAMED_BODIES: usize = 8;
|
||||
|
||||
/// A body's size, its edges' polylines and which of them are seams.
|
||||
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>);
|
||||
|
||||
/// How far a polyline is from `q`.
|
||||
fn polyline_dist(points: &[Vec3], q: Vec3) -> f64 {
|
||||
points.windows(2).filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// How far from an edge of a body this big a pick still means it.
|
||||
fn edge_reach(size: f64) -> f64 {
|
||||
size * 0.05 + 1e-3
|
||||
}
|
||||
|
||||
/// Edge references → points on the edges, each in the frame of the body it lies on.
|
||||
///
|
||||
/// A point is looked up where it was picked, on every placed body, shown or hidden: a part
|
||||
/// whose edge is in the same place only in its own frame is not in the way, and two bodies
|
||||
/// with an edge there make it ambiguous. The polylines short-list the edges; which body a pick
|
||||
/// means is decided on the exact curves.
|
||||
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "select 1…1000 edges"));
|
||||
}
|
||||
let verb = if cmd == "solid.chamfer" { "chamfer" } else { "round" };
|
||||
let st = s.model.state();
|
||||
// A picked point must lie on an edge (within the distance the kernel re-finds an edge from)
|
||||
// of the body the feature names, or of any body: a pick far from every edge would round
|
||||
// whichever edge is nearest (#783). Each body's edges are worked out once.
|
||||
let named = str_(p, "body").filter(|n| st.body(n).is_some());
|
||||
// Per body: its size, its edges' polylines and which of them are seams.
|
||||
let mut candidates: Option<Vec<(f64, Vec<Vec<Vec3>>, Vec<bool>)>> = None;
|
||||
// whichever edge is nearest (#783).
|
||||
let given = str_(p, "body");
|
||||
let named = given.and_then(|n| st.bodies.iter().position(|b| b.name == n));
|
||||
// A pick's own body is the feature's, which keeps one.
|
||||
let agrees = |i: usize| -> Result<usize> {
|
||||
match given {
|
||||
Some(g) if named != Some(i) => {
|
||||
let n = st.bodies.get(i).map_or("", |b| b.name.as_str());
|
||||
Err(bad(cmd, format!("an edge names `{n}`, but `body` is `{g}`: give one")))
|
||||
}
|
||||
_ => Ok(i),
|
||||
}
|
||||
};
|
||||
let identity = |i: usize, b: &solidcraft_doc::ModelBody| PickRow {
|
||||
body: i,
|
||||
name: b.name.clone(),
|
||||
frame: solidcraft_doc::IDENTITY,
|
||||
inverse: solidcraft_doc::IDENTITY,
|
||||
};
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
let local: Vec<PickRow> = st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()).map(|(i, b)| identity(i, b)).collect();
|
||||
let placed = crate::frames::placed(s);
|
||||
// Worked out for the first pick looked up where it was picked.
|
||||
let world: std::cell::OnceCell<Vec<PickRow>> = std::cell::OnceCell::new();
|
||||
let world_rows = || {
|
||||
world.get_or_init(|| {
|
||||
let index: std::collections::BTreeMap<&str, usize> = st.bodies.iter().enumerate().map(|(i, b)| (b.definition_name(), i)).collect();
|
||||
s.body_frames()
|
||||
.iter()
|
||||
.filter_map(|(name, own, frame)| {
|
||||
let body = *index.get(own.as_str())?;
|
||||
let inverse = solidcraft_doc::mat_inverse(frame)?;
|
||||
(!st.bodies.get(body)?.body.is_mesh()).then(|| PickRow { body, name: name.clone(), frame: *frame, inverse })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
// Per body, worked out once.
|
||||
let mut shapes: std::collections::BTreeMap<usize, EdgeShape> = std::collections::BTreeMap::new();
|
||||
let shape_of = |i: usize, shapes: &mut std::collections::BTreeMap<usize, EdgeShape>| {
|
||||
if shapes.contains_key(&i) {
|
||||
return;
|
||||
}
|
||||
let Some(b) = st.bodies.get(i) else { return };
|
||||
let size = b.body.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
// The display mesh flags seams (a smooth surface meeting itself, as down the side
|
||||
// of a cylinder), which nobody means by a pick where they end.
|
||||
let shape = match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
|
||||
// An edge where the faces meet tangentially (the spans of a spline wall
|
||||
// meeting) can't be blended either: nobody means it by a pick.
|
||||
Ok(m) => Some((
|
||||
size,
|
||||
m.edges,
|
||||
m.seams.iter().zip(&m.smooth_edges).map(|(s, t)| *s || *t).chain(m.seams.iter().skip(m.smooth_edges.len()).copied()).collect(),
|
||||
)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
};
|
||||
if let Some(shape) = shape {
|
||||
shapes.insert(i, shape);
|
||||
}
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// Per pick: the body it was found on, where it was picked, and how far from that body's edge.
|
||||
let mut found: Vec<(usize, String, Vec3, Vec3, f64)> = Vec::new();
|
||||
let mut seen: Vec<Vec3> = Vec::new();
|
||||
for e in a {
|
||||
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
|
||||
EdgeRefForm::Point(pt) => pt,
|
||||
@@ -1576,57 +1687,139 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
let mid = edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid;
|
||||
if let Some(i) = st.bodies.iter().position(|m| m.name == b.name) {
|
||||
found.push((agrees(i)?, b.name.clone(), mid, mid, 0.0));
|
||||
}
|
||||
out.push(mid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let bodies = candidates.get_or_insert_with(|| {
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
st.bodies
|
||||
// The nth pick seen at this point is the nth remembered there.
|
||||
let nth = seen.iter().filter(|q| **q == pt).count();
|
||||
seen.push(pt);
|
||||
let picked = crate::frames::picked_world(pt, nth).filter(|_| placed);
|
||||
let shown = picked.unwrap_or(pt);
|
||||
let own = match e.get("body").and_then(Value::as_str) {
|
||||
Some(n) => Some(agrees(st.bodies.iter().position(|b| b.name == n).ok_or_else(|| bad(cmd, format!("no body `{n}`")))?)?),
|
||||
None => named,
|
||||
};
|
||||
// Named: on that body only, in its frame. Else where it was picked, on every placed body;
|
||||
// a command run by another without the world point, or a design with no placement to look
|
||||
// through, sees the bodies in their own frames.
|
||||
let (w, rows): (Vec3, Vec<&PickRow>) = match (own, picked) {
|
||||
(Some(i), _) => (pt, local.iter().filter(|r| r.body == i).collect()),
|
||||
(None, Some(w)) if !world_rows().is_empty() => (w, world_rows().iter().collect()),
|
||||
_ => (pt, local.iter().collect()),
|
||||
};
|
||||
let q_of = |r: &PickRow| {
|
||||
let q = solidcraft_doc::apply_point(&r.inverse, w);
|
||||
(q.x.is_finite() && q.y.is_finite() && q.z.is_finite()).then_some(q)
|
||||
};
|
||||
// Only bodies whose box (grown by the reach) holds the pick: an edge lies in its body's box.
|
||||
let culled: Vec<(usize, Vec3)> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, r)| {
|
||||
let b = st.bodies.get(r.body)?;
|
||||
let q = q_of(r)?;
|
||||
b.outline_bounds().inflated(edge_reach(b.body.size())).contains(q).then_some((k, q))
|
||||
})
|
||||
.collect();
|
||||
for (k, _) in &culled {
|
||||
if let Some(r) = rows.get(*k) {
|
||||
shape_of(r.body, &mut shapes);
|
||||
}
|
||||
}
|
||||
let mut hits: Vec<RowHit> = Vec::new();
|
||||
for (k, q) in culled {
|
||||
let Some(r) = rows.get(k) else { continue };
|
||||
let (Some((size, edges, seams)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let mut near = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = polyline_dist(points, q);
|
||||
if d <= edge_reach(*size) {
|
||||
let seam = seams.get(i).copied().unwrap_or(false);
|
||||
near.push((d, seam, leaving(points, q)));
|
||||
candidates.push((i, d, seam));
|
||||
}
|
||||
}
|
||||
if near.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Seams count only where nothing else is near.
|
||||
let edge_like = candidates.iter().any(|(_, _, seam)| !seam);
|
||||
let candidates: Vec<(usize, f64)> = candidates.into_iter().filter(|(_, _, seam)| !edge_like || !seam).map(|(i, d, _)| (i, d)).collect();
|
||||
let polyline = candidates.iter().map(|(_, d)| *d).fold(f64::INFINITY, f64::min);
|
||||
let (d, edge) = candidates
|
||||
.iter()
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
// The display mesh flags seams (a smooth surface meeting itself, as down the side
|
||||
// of a cylinder), which nobody means by a pick where they end.
|
||||
match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
|
||||
// An edge where the faces meet tangentially (the spans of a spline wall
|
||||
// meeting) can't be blended either: nobody means it by a pick.
|
||||
Ok(m) => Some((
|
||||
size,
|
||||
m.edges,
|
||||
m.seams
|
||||
.iter()
|
||||
.zip(&m.smooth_edges)
|
||||
.map(|(s, t)| *s || *t)
|
||||
.chain(m.seams.iter().skip(m.smooth_edges.len()).copied())
|
||||
.collect(),
|
||||
)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
.filter(|(_, d)| *d <= polyline + 2.0 * chord)
|
||||
.map(|(i, d)| (edge_foot(&b.body, *i, q).map_or(*d, |(_, foot)| solidcraft_doc::apply_point(&r.frame, foot).dist(w)), *i))
|
||||
.fold((f64::INFINITY, None), |best, (d, i)| if d < best.0 { (d, Some(i)) } else { best });
|
||||
hits.push(RowHit { row: k, q, d, edge, edge_like, near });
|
||||
}
|
||||
if hits.is_empty() {
|
||||
if rows.is_empty() {
|
||||
out.push(pt);
|
||||
continue;
|
||||
}
|
||||
// How far the nearest edge is, on every body the pick was looked up on.
|
||||
let mut nearest = f64::INFINITY;
|
||||
for r in &rows {
|
||||
let Some(q) = q_of(r) else { continue };
|
||||
shape_of(r.body, &mut shapes);
|
||||
let Some((_, edges, _)) = shapes.get(&r.body) else { continue };
|
||||
nearest = edges.iter().map(|points| polyline_dist(points, q)).fold(nearest, f64::min);
|
||||
}
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", shown.x, shown.y, shown.z)));
|
||||
}
|
||||
// A body with only a seam there has no edge to blend, where another body has one.
|
||||
if hits.iter().any(|h| h.edge_like) {
|
||||
hits.retain(|h| h.edge_like);
|
||||
}
|
||||
// Bodies with an edge as near as the nearest one's: the pick is on both. A pick off its
|
||||
// curve by e widens the window to about 2e. The same body placed twice, at the same
|
||||
// edge or point of its frame, is one edge.
|
||||
let d_min = hits.iter().map(|h| h.d).fold(f64::INFINITY, f64::min);
|
||||
let mut groups: Vec<&RowHit> = Vec::new();
|
||||
for h in hits.iter().filter(|h| h.d <= 2.0 * d_min + 1e-3) {
|
||||
let Some(r) = rows.get(h.row) else { continue };
|
||||
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _)| *size);
|
||||
let same = |g: &&RowHit| {
|
||||
rows.get(g.row).is_some_and(|o| o.body == r.body) && ((g.edge.is_some() && g.edge == h.edge) || g.q.dist(h.q) <= 1e-6 * size.max(1.0))
|
||||
};
|
||||
if !groups.iter().any(same) {
|
||||
groups.push(h);
|
||||
}
|
||||
}
|
||||
if groups.len() > 1 {
|
||||
let names: Vec<String> = groups.iter().filter_map(|g| rows.get(g.row).map(|r| r.name.clone())).collect();
|
||||
let hidden: String = names.iter().filter(|n| !s.body_visible(n)).map(|n| format!(" ({n} is hidden)")).collect();
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"[{}, {}, {}] lies on edges of {} bodies, {}: name the one to {verb} with `body`{hidden}",
|
||||
shown.x,
|
||||
shown.y,
|
||||
shown.z,
|
||||
names.len(),
|
||||
and_list(&names, NAMED_BODIES)
|
||||
),
|
||||
));
|
||||
}
|
||||
let Some(hit) = hits.iter().filter(|h| h.d <= d_min).chain(hits.iter()).next() else { continue };
|
||||
let Some(row) = rows.get(hit.row) else { continue };
|
||||
// A point where several edges meet (a corner) is ambiguous: the kernel would round one of
|
||||
// them, picked silently (#5173). Edges that carry straight on through the point (the two
|
||||
// halves of a circle) are one edge to whoever picked it, and seams and tangent joins don't
|
||||
// count. Directions
|
||||
// come from the polylines' end chords, which lean off a curve's tangent: within about 45°
|
||||
// of opposite counts as carrying on (edges at a real corner are far from opposite).
|
||||
let mut nearest = f64::INFINITY;
|
||||
let mut near = Vec::new();
|
||||
for (size, edges, seams) in bodies.iter() {
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = points.windows(2).filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
if d <= size * 0.05 + 1e-3 {
|
||||
near.push((d, seams.get(i).copied().unwrap_or(false), leaving(points, pt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let matches = near.len();
|
||||
let mut meeting: Vec<Option<Vec3>> = near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
|
||||
// count. Directions come from the polylines' end chords, which lean off a curve's
|
||||
// tangent: within about 45° of opposite counts as carrying on (edges at a real corner are
|
||||
// far from opposite).
|
||||
let nearest = hit.near.iter().map(|(d, _, _)| *d).fold(f64::INFINITY, f64::min);
|
||||
let mut meeting: Vec<Option<Vec3>> = hit.near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
|
||||
let mut chains = 0usize;
|
||||
while let Some(dir) = meeting.pop() {
|
||||
chains += 1;
|
||||
@@ -1639,16 +1832,107 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
if chains > 1 {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", pt.x, pt.y, pt.z),
|
||||
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", shown.x, shown.y, shown.z),
|
||||
));
|
||||
}
|
||||
if matches == 0 && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
}
|
||||
out.push(pt);
|
||||
found.push((row.body, row.name.clone(), hit.q, shown, hit.d));
|
||||
out.push(hit.q);
|
||||
}
|
||||
Ok(out)
|
||||
if given.is_some() {
|
||||
return Ok(EdgePicks { points: out, body: given.map(str::to_string) });
|
||||
}
|
||||
let mut bodies: Vec<(usize, String)> = Vec::new();
|
||||
for (i, name, ..) in &found {
|
||||
if !bodies.iter().any(|(b, _)| b == i) {
|
||||
bodies.push((*i, name.clone()));
|
||||
}
|
||||
}
|
||||
let body = match bodies.as_slice() {
|
||||
// One body of several: the feature keeps it, so the model never looks the edges up on
|
||||
// another body that has one in the same place in its own frame.
|
||||
[(i, _)] => st.bodies.get(*i).filter(|_| local.len() > 1).map(|b| b.name.clone()),
|
||||
[] => None,
|
||||
_ => {
|
||||
// Several: each edge is looked up on its own when the model is built, on whichever
|
||||
// body has an edge there in its own frame, so another body's edge there is refused.
|
||||
for (i, _, q, shown, d) in &found {
|
||||
for other in local.iter().filter(|r| r.body != *i) {
|
||||
let Some(b) = st.bodies.get(other.body) else { continue };
|
||||
let reach = edge_reach(b.body.size());
|
||||
if !b.outline_bounds().inflated(reach).contains(*q) {
|
||||
continue;
|
||||
}
|
||||
shape_of(other.body, &mut shapes);
|
||||
let Some((size, edges, seams)) = shapes.get(&other.body) else { continue };
|
||||
// The exact curve is no nearer than the polyline less its chord tolerance.
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let ghost = edges.iter().enumerate().filter(|(k, _)| !seams.get(*k).copied().unwrap_or(false)).any(|(k, points)| {
|
||||
let poly = polyline_dist(points, *q);
|
||||
poly <= reach && poly - chord <= 2.0 * d + 1e-3 && edge_foot(&b.body, k, *q).map_or(poly, |(d, _)| d) <= 2.0 * d + 1e-3
|
||||
});
|
||||
if ghost {
|
||||
let names: Vec<String> = bodies.iter().map(|(_, n)| n.clone()).collect();
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"the picks lie on {} bodies, {}, and at [{}, {}, {}] {} has an edge in the same place in its own component: {verb} each body in a command of its own, naming it with `body`",
|
||||
names.len(),
|
||||
and_list(&names, NAMED_BODIES),
|
||||
shown.x,
|
||||
shown.y,
|
||||
shown.z,
|
||||
b.name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(EdgePicks { points: out, body })
|
||||
}
|
||||
|
||||
/// "A", "A and B", "A, B and C", and past `most` names "A, B and 3 more".
|
||||
fn and_list(names: &[String], most: usize) -> String {
|
||||
let mut shown: Vec<String> = names.iter().take(most).cloned().collect();
|
||||
if names.len() > most {
|
||||
shown.push(format!("{} more", names.len() - most));
|
||||
}
|
||||
match shown.split_last() {
|
||||
Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
|
||||
Some((last, _)) => last.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance from `q` to edge `index` of `body` on its exact curve, and the point there:
|
||||
/// samples, then a golden-section search between the best one's neighbours.
|
||||
fn edge_foot(body: &solidcraft_kernel::Body, index: usize, q: Vec3) -> Option<(f64, Vec3)> {
|
||||
const SAMPLES: usize = 32;
|
||||
const STEPS: usize = 40;
|
||||
let e = body.record().edges.get(index)?;
|
||||
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
|
||||
let [t0, t1] = e.interval;
|
||||
if !(t0.is_finite() && t1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let at = |t: f64| c.point(t).map(|p| p.dist(q)).filter(|d| d.is_finite());
|
||||
let step = (t1 - t0) / SAMPLES as f64;
|
||||
let (k, _) = (0..=SAMPLES).filter_map(|k| Some((k, at(t0 + step * k as f64)?))).min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let (mut lo, mut hi) = (t0 + step * k.saturating_sub(1) as f64, t0 + step * (k + 1).min(SAMPLES) as f64);
|
||||
let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
|
||||
for _ in 0..STEPS {
|
||||
let (a, b) = (hi - ratio * (hi - lo), lo + ratio * (hi - lo));
|
||||
if at(a).unwrap_or(f64::INFINITY) <= at(b).unwrap_or(f64::INFINITY) {
|
||||
hi = b;
|
||||
} else {
|
||||
lo = a;
|
||||
}
|
||||
}
|
||||
let foot = c.point((lo + hi) / 2.0)?;
|
||||
let d = foot.dist(q);
|
||||
d.is_finite().then_some((d, foot))
|
||||
}
|
||||
|
||||
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
|
||||
@@ -1687,7 +1971,11 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.press_pull";
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
if p.get("edges").is_some() {
|
||||
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
|
||||
let mut q = json!({"edges": p.get("edges"), "radius": distance});
|
||||
if let Some(body) = p.get("body").filter(|b| b.is_string()) {
|
||||
q["body"] = body.clone();
|
||||
}
|
||||
return fillet(s, &q);
|
||||
}
|
||||
let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?;
|
||||
let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?;
|
||||
@@ -1737,7 +2025,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.fillet";
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let picks = edge_points(s, p, cmd)?;
|
||||
let radius = req_expr(cmd, p, "radius")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
|
||||
@@ -1840,9 +2128,9 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Fillet {
|
||||
edges: edges.into_iter().map(Position).collect(),
|
||||
edges: picks.points.into_iter().map(Position).collect(),
|
||||
radius,
|
||||
body: str_(p, "body").map(BodyName::from),
|
||||
body: picks.body.map(BodyName::from),
|
||||
style,
|
||||
fit,
|
||||
corner,
|
||||
@@ -1866,7 +2154,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.chamfer";
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let picks = edge_points(s, p, cmd)?;
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
|
||||
@@ -1884,12 +2172,12 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Chamfer {
|
||||
edges: edges.into_iter().map(Position).collect(),
|
||||
edges: picks.points.into_iter().map(Position).collect(),
|
||||
distance,
|
||||
distance2,
|
||||
angle,
|
||||
flip,
|
||||
body: str_(p, "body").map(BodyName::from),
|
||||
body: picks.body.map(BodyName::from),
|
||||
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
|
||||
},
|
||||
)
|
||||
|
||||
+135
-10
@@ -39,7 +39,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
|
||||
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made); crease_angle_degrees?: 0..180 (0 clears imported shading normals for the previous flat-shading behavior; positive angles regenerate angle-weighted normals across manifold edges below this crease angle; omitted keeps authored OBJ normals/smoothing groups, or flat shading when absent; geometry stays unchanged)"),
|
||||
CommandSpec::new("file.save", "Save", save)
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
@@ -262,11 +262,19 @@ fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
|
||||
/// Add a MeshImport feature for a 3MF, STL, OBJ or glTF file, in a new component named after the
|
||||
/// file. A glTF file's nodes become components in it, and its materials the bodies' looks; it is
|
||||
/// in metres with Y up, so unless `y_up` is false its component is turned to Z up (#1505).
|
||||
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>, unit: Option<(&'static str, f64)>, origin: bool) -> Result<Value> {
|
||||
fn add_mesh(
|
||||
s: &mut Session,
|
||||
path: &str,
|
||||
name: Option<&str>,
|
||||
y_up: Option<bool>,
|
||||
unit: Option<(&'static str, f64)>,
|
||||
origin: bool,
|
||||
crease_angle: Option<f64>,
|
||||
) -> Result<Value> {
|
||||
let err = |e: solidcraft_io::IoError| EngineError::Other(format!("{path}: {e}"));
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let gltf = solidcraft_io::is_gltf_path(path);
|
||||
let (mf, plan, looks) = if gltf {
|
||||
let (mut mf, plan, looks) = if gltf {
|
||||
let dir = std::path::Path::new(path).parent().map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let mut load =
|
||||
|name: &str| solidcraft_io::vfs::read(&dir.join(name).to_string_lossy(), solidcraft_io::MAX_GLTF_BYTES).map_err(|e| e.to_string());
|
||||
@@ -278,6 +286,19 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
|
||||
} else {
|
||||
(solidcraft_io::mesh_import_feature_in(&bytes, path, unit.map_or(1.0, |(_, mm)| mm)).map_err(err)?, None, Vec::new())
|
||||
};
|
||||
if let Some(angle) = crease_angle
|
||||
&& let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mut mf.kind
|
||||
{
|
||||
for mesh in meshes {
|
||||
if angle == 0.0 {
|
||||
// Keep the original flat display path, including its normalization
|
||||
// and storage behavior, when the caller opts out of imported shading.
|
||||
mesh.corner_normals = Default::default();
|
||||
} else {
|
||||
mesh.generate_crease_normals(angle).map_err(|e| bad("file.insert_mesh", e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
|
||||
let cname = import::component_name(path, name);
|
||||
let tree = match &plan {
|
||||
@@ -365,7 +386,16 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let up = mesh_up(p, path, cmd)?;
|
||||
let unit = mesh_units(p, path, cmd)?;
|
||||
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?)
|
||||
let crease_angle = match p.get("crease_angle_degrees") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && (0.0..=180.0).contains(v))
|
||||
.ok_or_else(|| bad(cmd, "crease_angle_degrees must be between 0 and 180 degrees"))?,
|
||||
),
|
||||
};
|
||||
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?, crease_angle)
|
||||
}
|
||||
|
||||
/// `origin`: whether a mesh import is moved to the origin (#12543). Off unless asked, so a mesh
|
||||
@@ -458,7 +488,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = if solidcraft_io::is_step_path(path) {
|
||||
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
|
||||
} else {
|
||||
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?)?
|
||||
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?, None)?
|
||||
};
|
||||
// The design is the file: its component is the one being worked in.
|
||||
if let Some(c) = r["component"].as_u64() {
|
||||
@@ -834,7 +864,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
for body in &world.bodies {
|
||||
let mesh = body.cached_mesh()?;
|
||||
bytes =
|
||||
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
|
||||
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(84)).saturating_add(32);
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
if bytes > 8 << 20 || triangles > 100_000 {
|
||||
return None;
|
||||
@@ -844,6 +874,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: s.doc.body_color(body),
|
||||
corner_normals: mesh.triangles.iter().flatten().filter_map(|&i| mesh.normals.get(i as usize)).flat_map(|n| [n.x, n.y, n.z]).collect(),
|
||||
});
|
||||
}
|
||||
Some(preview)
|
||||
@@ -1393,13 +1424,18 @@ mod tests {
|
||||
assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64), "{cmd}");
|
||||
assert!(bytes.starts_with(signature), "{cmd}");
|
||||
}
|
||||
// The triangles `file.export` writes (the 80-byte header holds the file's name): a box is
|
||||
// 12 triangles, 84 + 50 bytes each.
|
||||
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
|
||||
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
|
||||
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
|
||||
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
|
||||
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
|
||||
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
|
||||
assert_eq!(std::fs::metadata(at("box.stl")).unwrap().len(), 84 + 50 * 12);
|
||||
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
|
||||
let stl = std::fs::read(at("box.stl")).unwrap();
|
||||
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
|
||||
assert_eq!(stl.len(), 84 + 50 * triangles);
|
||||
let obj = std::fs::read_to_string(at("Obj.txt")).unwrap();
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12, "{obj}");
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), triangles, "{obj}");
|
||||
let r = s.execute("file.export_urdf", &json!({"path": at("robot.urdf")})).unwrap();
|
||||
assert_eq!(r["format"], "Urdf");
|
||||
assert!(std::fs::read_to_string(at("robot.urdf")).unwrap().contains("<robot"));
|
||||
@@ -1978,6 +2014,95 @@ mod tests {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_import_explicit_crease_normals_are_undoable_and_survive_cached_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-normals-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("fold.obj");
|
||||
std::fs::write(&path, "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\nf 1 2 3\nf 2 1 4\n").unwrap();
|
||||
let mut session = Session::default();
|
||||
let before = session.doc.to_json();
|
||||
for bad_angle in [json!(-1), json!(181), json!("smooth")] {
|
||||
let result = session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":bad_angle}));
|
||||
assert!(result.unwrap_err().to_string().contains("crease_angle_degrees"));
|
||||
assert_eq!(session.doc.to_json(), before);
|
||||
}
|
||||
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":40})).unwrap();
|
||||
let normals = |s: &Session| s.model.state().bodies[0].mesh().normals.clone();
|
||||
let expected = normals(&session);
|
||||
assert!(expected[0].y > 0.2 && expected[0].z > 0.9);
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.model.state().bodies.is_empty());
|
||||
session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(normals(&session), expected);
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let cached = solidcraft_io::read_design_geometry(&bytes).expect("shaded mesh retains native cache");
|
||||
assert!(!cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
|
||||
let saved_path = dir.join("shaded.sc");
|
||||
std::fs::write(&saved_path, &bytes).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
|
||||
assert_eq!(normals(&reopened), expected);
|
||||
let doc = solidcraft_io::read_design(&bytes).unwrap();
|
||||
let cold = Session::new(doc);
|
||||
assert_eq!(normals(&cold), expected);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_import_zero_crease_restores_legacy_flat_shading_and_survives_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-obj-flat-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";
|
||||
let faces = "f 1 2 3\nf 2 1 4\n";
|
||||
let reference_path = dir.join("reference.obj");
|
||||
std::fs::write(&reference_path, format!("{vertices}{faces}")).unwrap();
|
||||
let mut reference = Session::default();
|
||||
reference.execute("file.insert_mesh", &json!({"path":reference_path})).unwrap();
|
||||
let display = |s: &Session| {
|
||||
let mesh = s.model.state().bodies[0].mesh();
|
||||
(mesh.positions.clone(), mesh.triangles.clone(), mesh.normals.clone())
|
||||
};
|
||||
let expected = display(&reference);
|
||||
for (name, source) in
|
||||
[("groups", format!("{vertices}s 1\n{faces}")), ("authored", format!("{vertices}vn 0 1 1\nf 1//1 2//1 3//1\nf 2//1 1//1 4//1\n"))]
|
||||
{
|
||||
let path = dir.join(format!("{name}.obj"));
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut annotated = Session::default();
|
||||
annotated.execute("file.insert_mesh", &json!({"path":path})).unwrap();
|
||||
assert_ne!(display(&annotated).2, expected.2, "{name} must exercise imported shading");
|
||||
let mut session = Session::default();
|
||||
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":0})).unwrap();
|
||||
assert_eq!(display(&session), expected, "{name} uses the exact former flat display path");
|
||||
let meshes = session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::MeshImport { meshes, .. } => Some(meshes),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(meshes.iter().all(|mesh| mesh.corner_normals.is_empty()));
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.model.state().bodies.is_empty());
|
||||
session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(display(&session), expected);
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let cached = solidcraft_io::read_design_geometry(&bytes).unwrap();
|
||||
assert!(cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
|
||||
let saved_path = dir.join(format!("{name}.sc"));
|
||||
std::fs::write(&saved_path, &bytes).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
|
||||
assert_eq!(display(&reopened), expected);
|
||||
let cold = Session::new(solidcraft_io::read_design(&bytes).unwrap());
|
||||
assert_eq!(display(&cold), expected);
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// #9170: a design holding a mesh body (an inserted STL) keeps its saved model and opens from
|
||||
/// it. One mesh body left the cache out for the whole model, so every feature, the boxes
|
||||
/// too, was evaluated again on opening.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::{Loop2, Seg2, Vec2};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::frame::{self, Anchor, MemberProfile, Profile, ProfileSpec};
|
||||
@@ -17,12 +17,23 @@ use super::features::{add_feature, check_expr, sketch_id};
|
||||
use super::sketch::edit;
|
||||
use super::{CommandSpec, in_sketch, not_in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, str_, string_list, vec2};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::frame::{
|
||||
CreatedIds, FrameMemberParams, ProfileInsertParams, ProfileInsertResult, ProfileKindInfo, ProfileListParams, ProfileListResult, ProfileOut,
|
||||
ProfilePropertiesParams, ProfilePropertiesResult, ProfileSection,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frame_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const MEMBER_CONTRACT: TypedContract<FrameMemberParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ProfileListParams> = TypedContract::new();
|
||||
const PROPERTIES_CONTRACT: TypedContract<ProfilePropertiesParams> = TypedContract::new();
|
||||
const INSERT_CONTRACT: TypedContract<ProfileInsertParams> = TypedContract::new();
|
||||
|
||||
/// The profile parameter, as every frame command documents it.
|
||||
macro_rules! profile_doc {
|
||||
() => {
|
||||
@@ -31,20 +42,40 @@ macro_rules! profile_doc {
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("frame.member", "Frame Member", member).at("WELDMENTS", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
|
||||
CommandSpec::new("frame.member", "Frame Member", crate::checked!("frame.member", member))
|
||||
.at("WELDMENTS", "CREATE")
|
||||
.icon("frame_member")
|
||||
.enabled(not_in_sketch)
|
||||
.contract(&MEMBER_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(concat!(
|
||||
"sketch: the frame sketch (id or name; 2D or 3D); lines?: [curve ids] (one member each; default: every straight line drawn in the sketch: not construction, not projected or otherwise linked, not of no length); ",
|
||||
profile_doc!(),
|
||||
" | profile_sketch: a sketch whose profile is the section, in its own x and y around its origin, with profiles?: picks as solid.extrude takes them (default: its material, nested loops alternating, so two circles make a tube); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point that sits on each line, default origin); angle?: angle expr (the profile turned about the line; default 0: its y as near world up as the line allows, a vertical member's x along world X); mirror?: bool (the profile mirrored across its y axis); name?; body_name? (what the members are named after, then numbered: `Rail`, `Rail (2)`…; default the profile's description, `Rect Tube 40x40x3`, or the profile sketch's name); names?: {line id: name} (names to keep, as Edit Feature keeps them). One body per line, from its start to its end; each member keeps its name when other lines are added or deleted. Returns the feature and the bodies it made"
|
||||
)),
|
||||
CommandSpec::new("frame.profile.list", "Profile Library", list).icon("library")
|
||||
CommandSpec::new("frame.profile.list", "Profile Library", crate::checked!("frame.profile.list", list))
|
||||
.icon("library")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ProfileListResult>)
|
||||
.params("Returns kinds: [{kind, label, required, optional, example}]: the built-in profiles, each SolidCraft's own parametric definition"),
|
||||
CommandSpec::new("frame.profile.properties", "Profile Properties", properties).icon("measure").noundo().doc_only().params(concat!(
|
||||
CommandSpec::new("frame.profile.properties", "Profile Properties", crate::checked!("frame.profile.properties", properties))
|
||||
.icon("measure")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<ProfilePropertiesResult>)
|
||||
.params(concat!(
|
||||
profile_doc!(),
|
||||
"; density?: kg/m³ (adds mass_per_m, kg/m). Returns profile (every default filled in), area (mm²), centroid [x,y] (mm, from the bounding box centre), ixx, iyy, ixy (mm⁴ about the centroid), width, height"
|
||||
)),
|
||||
CommandSpec::new("frame.profile.insert", "Insert Profile", insert).icon("import").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("frame.profile.insert", "Insert Profile", crate::checked!("frame.profile.insert", insert))
|
||||
.icon("import")
|
||||
.enabled(in_sketch)
|
||||
.contract(&INSERT_CONTRACT)
|
||||
.output(output_schema_for::<ProfileInsertResult>)
|
||||
.params(concat!(
|
||||
profile_doc!(),
|
||||
"; at?: [x,y] (sketch mm, default the origin); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point placed on `at`: its origin, the default and a built-in profile's centre, its bounding box's centre, its centroid, or a corner or edge middle of its bounding box); angle?: radians, counter-clockwise; fixed?: bool (default true: the curves are locked, so solving the sketch keeps the shape); sketch?: id|name (default: the sketch being edited). Returns the section as frame.profile.properties does",
|
||||
created_doc!()
|
||||
@@ -73,29 +104,32 @@ fn profile(s: &Session, cmd: &str, p: &Value) -> Result<Profile> {
|
||||
resolve(s, cmd, &profile_spec(cmd, p)?)
|
||||
}
|
||||
|
||||
/// A frame command's reply, built from its schema's type so the two can't drift apart.
|
||||
fn frame_reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"kinds": frame::profile_kinds()}))
|
||||
let kinds = frame::profile_kinds().iter().map(ProfileKindInfo::from).collect();
|
||||
frame_reply("frame.profile.list", ProfileListResult { kinds, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The profile's section as the commands report it.
|
||||
fn section_json(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<Value> {
|
||||
fn section(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<ProfileSection> {
|
||||
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let s = frame::section(®ion);
|
||||
let mut out = json!({
|
||||
"profile": profile.filled(),
|
||||
"area": s.area,
|
||||
"centroid": s.centroid,
|
||||
"ixx": s.ixx,
|
||||
"iyy": s.iyy,
|
||||
"ixy": s.ixy,
|
||||
"width": s.width,
|
||||
"height": s.height,
|
||||
});
|
||||
if let (Some(rho), Some(o)) = (density, out.as_object_mut()) {
|
||||
Ok(ProfileSection {
|
||||
profile: ProfileOut(profile.filled()),
|
||||
area: s.area,
|
||||
centroid: s.centroid,
|
||||
ixx: s.ixx,
|
||||
iyy: s.iyy,
|
||||
ixy: s.ixy,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
// mm² × 1e-6 m²/mm² × kg/m³ = kg/m.
|
||||
o.insert("mass_per_m".into(), json!(s.area * 1e-6 * rho));
|
||||
}
|
||||
Ok(out)
|
||||
mass_per_m: density.map(|rho| s.area * 1e-6 * rho),
|
||||
})
|
||||
}
|
||||
|
||||
fn properties(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -109,7 +143,7 @@ fn properties(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, "`density` must be a positive number of kg/m³ (at most 1e6)"))?,
|
||||
),
|
||||
};
|
||||
section_json(cmd, &profile, density)
|
||||
frame_reply(cmd, ProfilePropertiesResult { section: section(cmd, &profile, density)?, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -135,15 +169,16 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let placed = frame::placed(®ion, frame::anchor_point(std::slice::from_ref(®ion), anchor), at, angle);
|
||||
let section = section_json(cmd, &profile, None)?;
|
||||
let section = section(cmd, &profile, None)?;
|
||||
let (created, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let before = sk.clone();
|
||||
for l in std::iter::once(&placed.outer).chain(&placed.holes) {
|
||||
add_loop(sk, l, fixed, cmd)?;
|
||||
}
|
||||
Ok(super::sketch_created(Some(&before), sk))
|
||||
serde_json::from_value::<CreatedIds>(super::sketch_created(Some(&before), sk)).map_err(|e| bad(cmd, e.to_string()))
|
||||
})?;
|
||||
Ok(json!({"created": created, "section": section, "sketch": info}))
|
||||
let sketch = serde_json::from_value(info).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
frame_reply(cmd, ProfileInsertResult { created, section, sketch, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Draw the closed loop `l` (lines and arcs) into the sketch, each segment sharing its end
|
||||
|
||||
+328
-139
@@ -12,13 +12,25 @@ use solidcraft_sketch::CurveKind;
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::schema::inspect::{
|
||||
BodyListParams, FaceCylinder, ModelEdge, ModelEdges, ModelFace, ModelFaces, ModelThread, ModelThreads, ModelThreadsParams,
|
||||
BodyFailure, BodyFeaturesParams, BodyFigures, BodyListParams, BodyMeasurement, BodyName, BodyNames, BodyOutcome, BoundingBox, CommandEntry,
|
||||
CommandsParams, CurveShape, DocumentBody, DocumentBodyDetail, DocumentInspectParams, DocumentInspectResult, DocumentParam, DocumentSketch,
|
||||
FaceCylinder, KernelCounts, ListedMaterial, MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams,
|
||||
MeasureResult, MeasureTotal, Measured, MeasuredBodies, MeasuredBody, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces,
|
||||
ModelThread, ModelThreads, ModelThreadsParams, SketchConstraint, SketchCurve, SketchInspectParams, SketchInspected, SketchPoint, SketchProfile,
|
||||
SketchWire, TimelineEntry, WireArcInfo,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const BODY_LIST_CONTRACT: TypedContract<BodyListParams> = TypedContract::new();
|
||||
const THREADS_CONTRACT: TypedContract<ModelThreadsParams> = TypedContract::new();
|
||||
const BODY_FEATURES_CONTRACT: TypedContract<BodyFeaturesParams> = TypedContract::new();
|
||||
const MEASURE_CONTRACT: TypedContract<MeasureParams> = TypedContract::new();
|
||||
const DOCUMENT_INSPECT_CONTRACT: TypedContract<DocumentInspectParams> = TypedContract::new();
|
||||
const SKETCH_INSPECT_CONTRACT: TypedContract<SketchInspectParams> = TypedContract::new();
|
||||
const MATERIAL_ASSIGN_CONTRACT: TypedContract<MaterialAssignParams> = TypedContract::new();
|
||||
const MATERIAL_LIST_CONTRACT: TypedContract<MaterialListParams> = TypedContract::new();
|
||||
const COMMANDS_CONTRACT: TypedContract<CommandsParams> = TypedContract::new();
|
||||
|
||||
#[path = "appearance_chain.rs"]
|
||||
mod appearance_chain;
|
||||
@@ -26,23 +38,39 @@ mod appearance_chain;
|
||||
#[path = "appearance_gestures.rs"]
|
||||
pub(super) mod appearance_gestures;
|
||||
|
||||
#[path = "body_features.rs"]
|
||||
mod body_features;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
|
||||
.at("SOLID", "INSPECT").icon("colored_components").noundo()
|
||||
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
|
||||
CommandSpec::new("inspect.measure", "Measure", measure)
|
||||
CommandSpec::new("inspect.measure", "Measure", crate::checked!("inspect.measure", measure))
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("measure")
|
||||
.key("I")
|
||||
.noundo()
|
||||
.contract(&MEASURE_CONTRACT)
|
||||
.output(output_schema_for::<MeasureResult>)
|
||||
.params("bodies?: [names] (default all); names_only?: bool (each world body's name and its definition's, nothing measured) → closed per body; volume_mm3, center_of_mass, density_g_cm3 and mass_g only for closed, consistently oriented bodies; area, bbox and counts for all; total.open_bodies_skipped counts bodies excluded from volume/mass totals; or items: [1-2 selections] (face/edge: type, body, point; index optional) → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", crate::checked!("document.inspect", inspect))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&DOCUMENT_INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<DocumentInspectResult>)
|
||||
.params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
|
||||
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
|
||||
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
|
||||
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
|
||||
never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only",
|
||||
),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).at("SKETCH", "INSPECT").icon("inspect").noundo().params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", crate::checked!("sketch.inspect", sketch_inspect))
|
||||
.at("SKETCH", "INSPECT")
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&SKETCH_INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<SketchInspected>)
|
||||
.params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("model.edges", "List Edges", crate::checked!("model.edges", model_edges))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
@@ -65,9 +93,20 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&THREADS_CONTRACT)
|
||||
.output(output_schema_for::<ModelThreads>),
|
||||
CommandSpec::new("material.assign", "Physical Material", physical_material)
|
||||
CommandSpec::new("model.body_features", "List Body Features", crate::checked!("model.body_features", body_features::model_body_features))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&BODY_FEATURES_CONTRACT)
|
||||
.output(output_schema_for::<ModelBodyFeatures>)
|
||||
.params(
|
||||
"body?: name (a placed copy's name means the body it shows; default every body) → bodies: [{body, features: [{id, name}]}], \
|
||||
in timeline order the feature that created each body, every later one that changed it, and those of bodies consumed into it. Read-only",
|
||||
),
|
||||
CommandSpec::new("material.assign", "Physical Material", crate::checked!("material.assign", physical_material))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.contract(&MATERIAL_ASSIGN_CONTRACT)
|
||||
.output(output_schema_for::<MaterialAssignResult>)
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", crate::checked!("appearance.assign", appearance))
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -104,11 +143,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<AppearanceListResult>)
|
||||
.params("body?: name (or a placed copy's, `KB (K:2)`: the body it shows) → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
|
||||
CommandSpec::new("material.list", "List Materials", crate::checked!("material.list", material_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&MATERIAL_LIST_CONTRACT)
|
||||
.output(output_schema_for::<MaterialListResult>)
|
||||
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
|
||||
CommandSpec::new("engine.commands", "List Commands", crate::checked!("engine.commands", commands))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&COMMANDS_CONTRACT)
|
||||
.output(output_schema_for::<Vec<CommandEntry>>),
|
||||
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", describe))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
@@ -128,7 +174,7 @@ pub(crate) fn encloses_volume(body: &solidcraft_kernel::Body) -> bool {
|
||||
}
|
||||
|
||||
pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
|
||||
measure_sheet_json(b, None)
|
||||
serde_json::to_value(measure_sheet(b, None)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn measure_body(
|
||||
@@ -150,27 +196,53 @@ pub(super) fn measure_body(
|
||||
Ok(measured)
|
||||
}
|
||||
|
||||
fn measure_sheet_json(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> Value {
|
||||
fn measure_sheet(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> BodyMeasurement {
|
||||
let name = b.name.clone();
|
||||
match measure_body(b, definitions) {
|
||||
Ok(m) => {
|
||||
let closed = encloses_volume(&b.body);
|
||||
json!({
|
||||
"name": b.name,
|
||||
"closed": closed,
|
||||
"volume_mm3": closed.then_some(m.volume),
|
||||
"area_mm2": m.area,
|
||||
"center_of_mass": closed.then_some(m.centroid),
|
||||
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
|
||||
"faces": m.merged.faces,
|
||||
"edges": m.merged.edges,
|
||||
"vertices": m.merged.vertices,
|
||||
"face_types": m.merged.face_types,
|
||||
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
|
||||
"measured_by": m.measured_by,
|
||||
"notes": m.notes,
|
||||
})
|
||||
let outcome = BodyOutcome::Measured(BodyFigures {
|
||||
closed,
|
||||
volume_mm3: closed.then_some(m.volume),
|
||||
area_mm2: m.area,
|
||||
center_of_mass: closed.then(|| m.centroid.into()),
|
||||
bbox: BoundingBox { min: m.bbox.min.into(), max: m.bbox.max.into() },
|
||||
faces: count(m.merged.faces),
|
||||
edges: count(m.merged.edges),
|
||||
vertices: count(m.merged.vertices),
|
||||
face_types: m.merged.face_types,
|
||||
kernel: KernelCounts { faces: m.faces, edges: m.edges, vertices: m.vertices, shells: m.shells },
|
||||
measured_by: m.measured_by.to_string(),
|
||||
notes: m.notes,
|
||||
});
|
||||
BodyMeasurement { name, outcome }
|
||||
}
|
||||
Err(e) => measure_failed(name, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_failed(name: String, error: String) -> BodyMeasurement {
|
||||
BodyMeasurement { name, outcome: BodyOutcome::Failed(BodyFailure { error, faces: None, edges: None, vertices: None }) }
|
||||
}
|
||||
|
||||
/// A topology count as the replies give it.
|
||||
fn count(n: usize) -> i64 {
|
||||
i64::try_from(n).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A reply as JSON: the commands build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn typed_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
impl BodyMeasurement {
|
||||
/// The volume, when the body has one that is a number.
|
||||
fn volume(&self) -> Option<f64> {
|
||||
match &self.outcome {
|
||||
BodyOutcome::Measured(f) => f.volume_mm3.filter(|v| v.is_finite()),
|
||||
BodyOutcome::Failed(_) => None,
|
||||
}
|
||||
Err(e) => json!({"name": b.name, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,138 +259,217 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "unknown body name"));
|
||||
}
|
||||
if bool_(p, "names_only").unwrap_or(false) {
|
||||
let bodies: Vec<_> = selected.iter().map(|b| json!({"name": b.name, "definition": b.definition_name()})).collect();
|
||||
return Ok(json!({"body_count": bodies.len(), "bodies": bodies}));
|
||||
let bodies: Vec<_> = selected.iter().map(|b| BodyName { name: b.name.clone(), definition: b.definition_name().to_string() }).collect();
|
||||
let measured = Measured::Names(BodyNames { body_count: bodies.len(), bodies });
|
||||
return typed_reply(cmd, MeasureResult { measured, param_warnings: Vec::new() });
|
||||
}
|
||||
// Each body is measured on its own: on all cores, in body order.
|
||||
let mut bodies: Vec<Value> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet_json(b, Some(&definitions)))
|
||||
let measurements: Vec<BodyMeasurement> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet(b, Some(&definitions)))
|
||||
.into_iter()
|
||||
.zip(&selected)
|
||||
.map(|(m, b)| m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})))
|
||||
.map(|(m, b)| m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into())))
|
||||
.collect();
|
||||
// Mass from each body's material.
|
||||
for (b, body) in bodies.iter_mut().zip(selected) {
|
||||
let mut bodies: Vec<MeasuredBody> = Vec::with_capacity(measurements.len());
|
||||
for (measurement, body) in measurements.into_iter().zip(selected) {
|
||||
let source = body.definition_name();
|
||||
// Every body's material, measured or not: naming it takes no measuring.
|
||||
b["material"] = json!(s.doc.body_material(source));
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let mut b = MeasuredBody { measurement, material: s.doc.body_material(source), density_g_cm3: None, mass_g: None, sheet_metal: None };
|
||||
if let Some(v) = b.measurement.volume() {
|
||||
let rho = s.doc.density(source);
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
b.density_g_cm3 = Some(rho);
|
||||
b.mass_g = Some(v * rho / 1000.0);
|
||||
}
|
||||
// Only Measure exposes sheet boundary counts; document.inspect keeps kernel counts.
|
||||
if let Some(sheet) = definitions.sheets.iter().find(|sheet| sheet.body == source) {
|
||||
let (df, de, dv) = sheet.round_hole_correction();
|
||||
for (key, delta) in [("faces", df), ("edges", de), ("vertices", dv)] {
|
||||
b[key] = json!(b["kernel"][key].as_i64().unwrap_or(0) + delta);
|
||||
match &mut b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => {
|
||||
f.faces = count(f.kernel.faces) + df;
|
||||
f.edges = count(f.kernel.edges) + de;
|
||||
f.vertices = count(f.kernel.vertices) + dv;
|
||||
}
|
||||
BodyOutcome::Failed(x) => (x.faces, x.edges, x.vertices) = (Some(df), Some(de), Some(dv)),
|
||||
}
|
||||
b["sheet_metal"] = json!(true);
|
||||
b.sheet_metal = Some(true);
|
||||
}
|
||||
bodies.push(b);
|
||||
}
|
||||
fn figures(b: &MeasuredBody) -> Option<&BodyFigures> {
|
||||
match &b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => Some(f),
|
||||
BodyOutcome::Failed(_) => None,
|
||||
}
|
||||
}
|
||||
let skipped = bodies.iter().filter(|b| b["closed"] == false).count();
|
||||
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
|
||||
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
|
||||
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
|
||||
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
|
||||
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
|
||||
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
|
||||
Ok(
|
||||
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"open_bodies_skipped": skipped, "volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
|
||||
let counted = |n: Option<i64>| n.and_then(|n| u64::try_from(n).ok());
|
||||
let faces = |b: &MeasuredBody| match &b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => (counted(Some(f.faces)), counted(Some(f.edges)), counted(Some(f.vertices))),
|
||||
BodyOutcome::Failed(x) => (counted(x.faces), counted(x.edges), counted(x.vertices)),
|
||||
};
|
||||
let total = MeasureTotal {
|
||||
open_bodies_skipped: bodies.iter().filter(|b| figures(b).is_some_and(|f| !f.closed)).count(),
|
||||
volume_mm3: bodies.iter().filter_map(|b| b.measurement.volume()).sum(),
|
||||
area_mm2: bodies.iter().filter_map(|b| figures(b)).map(|f| f.area_mm2).filter(|a| a.is_finite()).sum(),
|
||||
mass_g: bodies.iter().filter_map(|b| b.mass_g).filter(|m| m.is_finite()).sum(),
|
||||
faces: bodies.iter().filter_map(|b| faces(b).0).sum(),
|
||||
edges: bodies.iter().filter_map(|b| faces(b).1).sum(),
|
||||
vertices: bodies.iter().filter_map(|b| faces(b).2).sum(),
|
||||
};
|
||||
typed_reply(
|
||||
cmd,
|
||||
MeasureResult { measured: Measured::Bodies(MeasuredBodies { body_count: bodies.len(), bodies, total }), param_warnings: Vec::new() },
|
||||
)
|
||||
}
|
||||
|
||||
/// A 2D position as the replies give it.
|
||||
fn at2(p: solidcore_record::Vec2) -> [f64; 2] {
|
||||
[p.x, p.y]
|
||||
}
|
||||
|
||||
/// Sketch `id`, placed where the placed model `world` has it (its component's placement).
|
||||
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<Value> {
|
||||
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<SketchInspected> {
|
||||
let state = s.model.state();
|
||||
let ss = state.sketch(id)?;
|
||||
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
|
||||
let sk = &ss.sketch;
|
||||
let curves: Vec<Value> = sk
|
||||
let curves: Vec<SketchCurve> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
|
||||
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
|
||||
let mut v = match c.kind {
|
||||
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => {
|
||||
let mut v = json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det});
|
||||
// A control spline with its own knots (a projected free-form edge) says them.
|
||||
if let (Some(n), Some(o)) = (nurbs, v.as_object_mut()) {
|
||||
o.insert("knots".into(), json!(n.knots));
|
||||
if !n.weights.is_empty() {
|
||||
o.insert("weights".into(), json!(n.weights));
|
||||
}
|
||||
}
|
||||
v
|
||||
let at = |k: usize| sk.point(k).map(at2);
|
||||
let shape = match c.kind {
|
||||
CurveKind::Line { a, b } => CurveShape::Line { start: pid(a), end: pid(b), start_at: at(a), end_at: at(b) },
|
||||
CurveKind::Circle { c: cc, r } => CurveShape::Circle { center: pid(cc), center_at: at(cc), radius: r },
|
||||
CurveKind::Arc { c: cc, a, b } => CurveShape::Arc {
|
||||
center: pid(cc),
|
||||
start: pid(a),
|
||||
end: pid(b),
|
||||
center_at: at(cc),
|
||||
start_at: at(a),
|
||||
end_at: at(b),
|
||||
radius: sk.radius(i),
|
||||
},
|
||||
CurveKind::Ellipse { c: cc, m, r } => {
|
||||
CurveShape::Ellipse { center: pid(cc), major: pid(m), center_at: at(cc), major_at: at(m), minor_radius: r }
|
||||
}
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => CurveShape::Spline {
|
||||
points: pts.iter().map(|q| pid(*q)).collect(),
|
||||
points_at: pts.iter().map(|q| at(*q)).collect(),
|
||||
control,
|
||||
degree,
|
||||
periodic,
|
||||
// A control spline with its own knots (a projected free-form edge) says them.
|
||||
knots: nurbs.as_ref().map(|n| n.knots.clone()),
|
||||
weights: nurbs.as_ref().filter(|n| !n.weights.is_empty()).map(|n| n.weights.clone()),
|
||||
},
|
||||
CurveKind::Conic { a, b, apex, rho } => {
|
||||
CurveShape::Conic { start: pid(a), end: pid(b), apex: pid(apex), start_at: at(a), end_at: at(b), apex_at: at(apex), rho }
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
o.insert("link".into(), json!(l));
|
||||
SketchCurve {
|
||||
id: c.id.clone(),
|
||||
shape,
|
||||
construction: c.construction,
|
||||
fixed: sk.curve_locked(i),
|
||||
fully_constrained: det,
|
||||
link: c.link.clone(),
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let locked = sk.locked_points();
|
||||
let points: Vec<Value> = sk
|
||||
let points: Vec<SketchPoint> = sk
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.map(|(i, p)| SketchPoint {
|
||||
id: p.id.clone(),
|
||||
at: at2(p.pos),
|
||||
world: plane.to_world(p.pos).into(),
|
||||
fixed: locked.get(i).copied().unwrap_or(false),
|
||||
link: p.link.clone(),
|
||||
fully_constrained: ss.report.point_determined.get(i).copied().unwrap_or(false),
|
||||
})
|
||||
.collect();
|
||||
let constraints: Vec<Value> = sk
|
||||
let constraints: Vec<SketchConstraint> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c.kind).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("id".into(), json!(c.id));
|
||||
o.insert("name".into(), json!(c.kind.name()));
|
||||
if let Some(p) = &c.param {
|
||||
o.insert("param".into(), json!(p));
|
||||
o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone())));
|
||||
}
|
||||
let mut kind = match serde_json::to_value(&c.kind) {
|
||||
Ok(Value::Object(kind)) => kind,
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The constraint's own keys win over any of the kind's.
|
||||
for key in ["id", "name", "param", "expression"] {
|
||||
kind.remove(key);
|
||||
}
|
||||
SketchConstraint {
|
||||
id: c.id.clone(),
|
||||
name: c.kind.name().to_string(),
|
||||
param: c.param.clone(),
|
||||
expression: c.param.as_ref().map(|p| s.doc.param(p).map(|x| x.expr.clone())),
|
||||
kind,
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let profiles: Vec<Value> = ss
|
||||
let profiles: Vec<SketchProfile> = ss
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
// A loop records one entry per piece it walks, and a conic or spline is walked as many
|
||||
// short pieces: each loop names a curve once, in loop order.
|
||||
.map(|(i, p)| {
|
||||
let holes: Vec<Vec<String>> = p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect();
|
||||
json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": solidcraft_sketch::distinct_curves(&p.outer_curves), "holes": holes})
|
||||
.map(|(i, p)| SketchProfile {
|
||||
index: i,
|
||||
area_mm2: p.area,
|
||||
centroid_sketch: at2(p.centroid),
|
||||
curves: solidcraft_sketch::distinct_curves(&p.outer_curves),
|
||||
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
|
||||
})
|
||||
.collect();
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"name": ss.name,
|
||||
"plane": ss.plane,
|
||||
"status": ss.report.status,
|
||||
"dof": ss.report.dof,
|
||||
"fully_constrained": ss.report.fully_constrained(),
|
||||
"failing": ss.report.failing,
|
||||
"curves": curves,
|
||||
"points": points,
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link, "arc": w.arc.map(|a| json!({"center": a.center, "radius": a.radius, "start": a.start, "end": a.end, "sweep_deg": solidcraft_sketch::arc_sweep(&a).to_degrees()}))})).collect::<Vec<_>>(),
|
||||
"visible": crate::view::sketch_shown(s, id),
|
||||
}))
|
||||
let wires = sk
|
||||
.wires
|
||||
.iter()
|
||||
.map(|w| SketchWire {
|
||||
id: w.id.clone(),
|
||||
points: w.pts.len(),
|
||||
start: w.pts.first().map(|&p| p.into()),
|
||||
end: w.pts.last().map(|&p| p.into()),
|
||||
link: w.link.clone(),
|
||||
arc: w.arc.map(|a| WireArcInfo {
|
||||
center: a.center.into(),
|
||||
radius: a.radius,
|
||||
start: a.start.into(),
|
||||
end: a.end.into(),
|
||||
sweep_deg: solidcraft_sketch::arc_sweep(&a).to_degrees(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
Some(SketchInspected {
|
||||
id,
|
||||
name: ss.name.clone(),
|
||||
plane: serde_json::to_value(ss.plane).unwrap_or_default(),
|
||||
status: ss.report.status.into(),
|
||||
dof: ss.report.dof,
|
||||
fully_constrained: ss.report.fully_constrained(),
|
||||
failing: ss.report.failing.clone(),
|
||||
curves,
|
||||
points,
|
||||
constraints,
|
||||
profiles,
|
||||
links: serde_json::to_value(&sk.links).unwrap_or_default(),
|
||||
wires,
|
||||
visible: crate::view::sketch_shown(s, id),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::sketch_store::target(s, p, "sketch.inspect")?;
|
||||
sketch_json(s, &*s.world_state()?, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
|
||||
let inspected = sketch_json(s, &*s.world_state()?, id)
|
||||
.ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))?;
|
||||
typed_reply("sketch.inspect", inspected)
|
||||
}
|
||||
|
||||
/// Revisions of every design of this run (#6646).
|
||||
@@ -377,24 +528,38 @@ fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let (vals, perr) = s.doc.param_values();
|
||||
let params: Vec<Value> = s
|
||||
let params: Vec<DocumentParam> = s
|
||||
.doc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)}))
|
||||
.map(|p| DocumentParam {
|
||||
name: p.name.clone(),
|
||||
expression: p.expr.clone(),
|
||||
unit: p.unit.clone(),
|
||||
value: vals.get(&p.name).map(|v| v.v),
|
||||
model: p.model,
|
||||
comment: p.comment.clone(),
|
||||
error: perr.get(&p.name).cloned(),
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
|
||||
let timeline: Vec<Value> = s
|
||||
let timeline: Vec<TimelineEntry> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let r = s.model.result(f.id);
|
||||
json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
||||
})
|
||||
TimelineEntry {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: f.kind.type_name().to_string(),
|
||||
suppressed: f.suppressed,
|
||||
rolled_back: i >= marker,
|
||||
error: r.and_then(|r| r.error.clone()),
|
||||
warning: r.and_then(|r| r.warning.clone()),
|
||||
ms: r.map(|r| r.ms),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let definitions = s.model.state();
|
||||
@@ -402,46 +567,69 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let with_counts = bool_(p, "sketch_counts").unwrap_or(false);
|
||||
// Each body is measured on its own: on all cores, in body order (as `inspect.measure`).
|
||||
let mut measured =
|
||||
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet_json(b, Some(&definitions))) } else { Vec::new() }
|
||||
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet(b, Some(&definitions))) } else { Vec::new() }
|
||||
.into_iter();
|
||||
let bodies: Vec<Value> = st
|
||||
let bodies: Vec<DocumentBody> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut v = match measured.next() {
|
||||
Some(m) => m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})),
|
||||
None => json!({"name": b.name, "feature": b.feature}),
|
||||
};
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(s.body_visible(&b.name)));
|
||||
if s.body_removed_later(&b.name) {
|
||||
// Editing a sketch rolls the timeline back; this body is deleted further on.
|
||||
o.insert("removed_later".into(), json!(true));
|
||||
let detail = match measured.next() {
|
||||
Some(m) => {
|
||||
DocumentBodyDetail::Measured(Box::new(m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into()))))
|
||||
}
|
||||
}
|
||||
v
|
||||
None => DocumentBodyDetail::Listed { name: b.name.clone(), feature: b.feature },
|
||||
};
|
||||
// Editing a sketch rolls the timeline back; a body deleted further on is `removed_later`.
|
||||
DocumentBody { detail, visible: s.body_visible(&b.name), removed_later: s.body_removed_later(&b.name).then_some(true) }
|
||||
})
|
||||
.collect();
|
||||
let sketches: Vec<Value> = st
|
||||
let sketches: Vec<DocumentSketch> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| {
|
||||
let mut v = json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)});
|
||||
let mut v = DocumentSketch {
|
||||
id: ss.feature,
|
||||
name: ss.name.clone(),
|
||||
dof: ss.report.dof,
|
||||
status: ss.report.status.into(),
|
||||
curves: ss.sketch.curves.len(),
|
||||
profiles: ss.profiles.len(),
|
||||
visible: crate::view::sketch_shown(s, ss.feature),
|
||||
plane: None,
|
||||
points: None,
|
||||
constraints: None,
|
||||
};
|
||||
// A summary that stands in for the sketch's contents (the compact MCP inspect_design).
|
||||
if with_counts && let Some(o) = v.as_object_mut() {
|
||||
o.insert("plane".into(), json!(ss.plane));
|
||||
o.insert("points".into(), json!(ss.sketch.points.len()));
|
||||
o.insert("constraints".into(), json!(ss.sketch.constraints.len()));
|
||||
if with_counts {
|
||||
v.plane = Some(serde_json::to_value(ss.plane).unwrap_or_default());
|
||||
v.points = Some(ss.sketch.points.len());
|
||||
v.constraints = Some(ss.sketch.constraints.len());
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
|
||||
}))
|
||||
typed_reply(
|
||||
"document.inspect",
|
||||
DocumentInspectResult {
|
||||
name: s.doc.name.clone(),
|
||||
units: s.doc.units.clone(),
|
||||
metadata: s.doc.metadata.0.clone(),
|
||||
path: s.path.clone(),
|
||||
dirty: s.is_dirty(),
|
||||
revision: s.revision,
|
||||
active_sketch: s.active_sketch,
|
||||
marker,
|
||||
params,
|
||||
timeline,
|
||||
bodies,
|
||||
sketches,
|
||||
selection: serde_json::to_value(&s.selection).unwrap_or_default(),
|
||||
undo: s.undo.len(),
|
||||
redo: s.redo.len(),
|
||||
hidden_origin: s.visibility.hidden_origin.clone(),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn body_of<'a>(st: &'a solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solidcraft_doc::ModelBody> {
|
||||
@@ -530,7 +718,8 @@ fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
|
||||
let list: Vec<CommandEntry> = crate::command_specs().iter().map(|c| c.info(s).into()).collect();
|
||||
typed_reply("engine.commands", list)
|
||||
}
|
||||
|
||||
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -554,7 +743,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.doc_mut().materials.insert(b.clone(), canonical.clone());
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "material": canonical}))
|
||||
typed_reply(cmd, MaterialAssignResult { bodies, material: canonical, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
@@ -901,18 +1090,18 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| ListedMaterial::BuiltIn { name: n.to_string(), density_g_cm3: *d });
|
||||
let user = s
|
||||
.doc
|
||||
.user_materials
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut out = super::materials::material_json(m)?;
|
||||
out["appearance"] = json!(s.doc.material_look(&m.name).as_ref().map(look_json));
|
||||
Ok(out)
|
||||
let mut out = super::materials::listed_material(m)?;
|
||||
out.appearance = s.doc.material_look(&m.name).as_ref().map(LookReply::from);
|
||||
Ok(ListedMaterial::Design(Box::new(out)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
typed_reply("material.list", MaterialListResult { materials: built_in.chain(user).collect(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn colored_components(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
+315
-158
@@ -15,20 +15,54 @@ use solidcraft_geom::Aabb3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, vec3};
|
||||
use crate::schema::joints::EditJointResult;
|
||||
use crate::schema::joints::{
|
||||
AsBuiltJointParams, CamLift, CamParams, CamResult, ContactStop, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult,
|
||||
DriveJointParams, DriveJointResult, EditJointResult, FloatingChain, GearParams, HeldValue, InspectJointParams, InspectJointResult,
|
||||
JointAddedResult, JointDeleted, JointFrame, JointLimitsParams, JointProblem, LimitSide, LimitStop, LinkInfo, ListJointsParams, ListJointsResult,
|
||||
ListedJoint, ListedOrigin, LoopStop, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, NamedValue, OccurrenceDof, RackPinionParams,
|
||||
ReachLoopStop, ReachParams, ReachResult, ReachedJoint, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveReport,
|
||||
SolveResult, ValueUnit,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const EDIT_CONTRACT: TypedContract<crate::schema::joints::EditJointParams> = TypedContract::new();
|
||||
const CREATE_CONTRACT: TypedContract<CreateJointParams> = TypedContract::new();
|
||||
const AS_BUILT_CONTRACT: TypedContract<AsBuiltJointParams> = TypedContract::new();
|
||||
const DRIVE_CONTRACT: TypedContract<DriveJointParams> = TypedContract::new();
|
||||
const LIMITS_CONTRACT: TypedContract<JointLimitsParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<DeleteJointParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ListJointsParams> = TypedContract::new();
|
||||
const INSPECT_CONTRACT: TypedContract<InspectJointParams> = TypedContract::new();
|
||||
const SOLVE_CONTRACT: TypedContract<SolveJointsParams> = TypedContract::new();
|
||||
const ORIGIN_CONTRACT: TypedContract<MakeJointOriginParams> = TypedContract::new();
|
||||
const RIGID_GROUP_CONTRACT: TypedContract<RigidGroupParams> = TypedContract::new();
|
||||
const REACH_CONTRACT: TypedContract<ReachParams> = TypedContract::new();
|
||||
const MOTION_LINK_CONTRACT: TypedContract<MotionLinkParams> = TypedContract::new();
|
||||
const GEAR_CONTRACT: TypedContract<GearParams> = TypedContract::new();
|
||||
const RACK_PINION_CONTRACT: TypedContract<RackPinionParams> = TypedContract::new();
|
||||
const SCREW_CONTRACT: TypedContract<ScrewParams> = TypedContract::new();
|
||||
const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
|
||||
|
||||
#[path = "joints_inspect.rs"]
|
||||
mod inspect;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
|
||||
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("joint_origin")
|
||||
.contract(&ORIGIN_CONTRACT)
|
||||
.output(output_schema_for::<MakeJointOriginResult>)
|
||||
.params(
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size; z?: [x,y,z] the frame's z instead of the face's normal, along the edge when it runs that way, x chosen from z: an edge or corner picked as it is, kept as a point where it can't be found by name) | point: [x,y,z], z?: [x,y,z]; a circle's z?: [x,y,z] (a round edge's centre with the way out of it: its end by name when that is the frame it shows, else a point there); body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
),
|
||||
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
|
||||
CommandSpec::new("joint.create", "Joint", crate::checked!("joint.create", joint))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("joint")
|
||||
.key("J")
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<JointAddedResult>)
|
||||
.params(
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball, or a relation: parallel|perpendicular|angle|point_on_plane|point_on_line|line_on_plane \
|
||||
(ties only some directions or points, made where the parts are; several between two parts add up); a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; z? for a point, end?, x? as joint.origin} | origin name); \
|
||||
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
|
||||
@@ -40,12 +74,27 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
kept with the joint and worked out again whenever the design changes, so the joint follows the parameters it names; a joint whose \
|
||||
expression no longer works out (a parameter deleted) is left out of the solve with the reason in its errors",
|
||||
),
|
||||
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
|
||||
CommandSpec::new("joint.as_built", "As-Built Joint", crate::checked!("joint.as_built", as_built))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("as_built")
|
||||
.contract(&AS_BUILT_CONTRACT)
|
||||
.output(output_schema_for::<JointAddedResult>)
|
||||
.params(
|
||||
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; one on a or b is kept on its face or hole there, so the joint follows it as the design changes; \
|
||||
name?; limits?: [[min, max] | null…]; quiet?: bool",
|
||||
),
|
||||
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
|
||||
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
|
||||
CommandSpec::new("joint.rigid_group", "Rigid Group", crate::checked!("joint.rigid_group", rigid_group))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("rigid_group")
|
||||
.contract(&RIGID_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<RigidGroupResult>)
|
||||
.params("occurrences: [ids or names] (move together, as they are)"),
|
||||
CommandSpec::new("joint.drive", "Drive Joints", crate::checked!("joint.drive", drive))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("drive")
|
||||
.contract(&DRIVE_CONTRACT)
|
||||
.output(output_schema_for::<DriveJointResult>)
|
||||
.params(
|
||||
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; a value driven drops the expression joint.create gave it; clamped to the limits; the other joints of a \
|
||||
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
|
||||
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; requested: the values asked for, in the same form; \
|
||||
@@ -54,36 +103,66 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
reason: why the joint can move nothing (suppressed: nothing is stored; its origins lost; its chain joined to nothing grounded → unconnected: \
|
||||
the occurrence named; or, when nothing moved, the first held value's why); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)",
|
||||
),
|
||||
CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params(
|
||||
CommandSpec::new("joint.reach", "Reach Point", crate::checked!("joint.reach", reach))
|
||||
.icon("reach")
|
||||
.contract(&REACH_CONTRACT)
|
||||
.output(output_schema_for::<ReachResult>)
|
||||
.params(
|
||||
"occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move",
|
||||
),
|
||||
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
|
||||
CommandSpec::new("joint.motion_link", "Motion Link", crate::checked!("joint.motion_link", motion_link))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("motion_link")
|
||||
.contract(&MOTION_LINK_CONTRACT)
|
||||
.output(output_schema_for::<SolveResult>)
|
||||
.params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"),
|
||||
CommandSpec::new("joint.gear", "Gear", gear).at("SOLID", "ASSEMBLE").icon("gear").params(
|
||||
CommandSpec::new("joint.gear", "Gear", crate::checked!("joint.gear", gear))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("gear")
|
||||
.contract(&GEAR_CONTRACT)
|
||||
.output(output_schema_for::<CouplingResult>)
|
||||
.params(
|
||||
"a, b: joints that turn (revolute), b driven by a; ra, rb: the gears' pitch radii (or tooth counts): mm, an expression, or \
|
||||
{circle: [x,y,z], occurrence?}: the round face there, its radius (read again whenever the design changes, so the ratio follows \
|
||||
edits); internal?: bool (one is a ring gear: both turn the same way; by default b turns the other way); reverse?: bool; \
|
||||
link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is, also when the link replaces \
|
||||
another (at the current pose)",
|
||||
),
|
||||
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", rack_pinion).at("SOLID", "ASSEMBLE").icon("rack_pinion").params(
|
||||
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", crate::checked!("joint.rack_pinion", rack_pinion))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("rack_pinion")
|
||||
.contract(&RACK_PINION_CONTRACT)
|
||||
.output(output_schema_for::<CouplingResult>)
|
||||
.params(
|
||||
"a, b: a joint that turns (revolute: the pinion) and one that slides (slider: the rack), in either order, b driven by a; \
|
||||
radius: the pinion's pitch radius (mm, an expression, or {circle: [x,y,z], occurrence?}): the rack travels radius × the turn in \
|
||||
radians; reverse?: bool; link?: index (replace that motion link) → link, ratio (mm per degree, or degrees per mm); b keeps \
|
||||
where it is, also when the link replaces another (at the current pose)",
|
||||
),
|
||||
CommandSpec::new("joint.screw", "Screw", screw).at("SOLID", "ASSEMBLE").icon("screw").params(
|
||||
CommandSpec::new("joint.screw", "Screw", crate::checked!("joint.screw", screw))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("screw")
|
||||
.contract(&SCREW_CONTRACT)
|
||||
.output(output_schema_for::<CouplingResult>)
|
||||
.params(
|
||||
"joint: a cylindrical joint (its turn drives its travel) | a, b: a joint that turns and one that slides, b driven by a; pitch: \
|
||||
travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \
|
||||
ratio (mm per degree, or degrees per mm); b keeps where it is, also when the link replaces another (at the current \
|
||||
pose)",
|
||||
),
|
||||
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
|
||||
CommandSpec::new("joint.cam", "Cam", crate::checked!("joint.cam", cam_cmd))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("cam")
|
||||
.contract(&CAM_CONTRACT)
|
||||
.output(output_schema_for::<CamResult>)
|
||||
.params(
|
||||
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is, also when the link replaces another (at the current pose)",
|
||||
),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", crate::checked!("joint.limits", limits))
|
||||
.icon("joint")
|
||||
.contract(&LIMITS_CONTRACT)
|
||||
.output(output_schema_for::<SolveResult>)
|
||||
.params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", crate::checked!("joint.edit", edit))
|
||||
.icon("edit")
|
||||
.contract(&EDIT_CONTRACT)
|
||||
@@ -94,12 +173,26 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
its limits and expressions (a relation's values start again where its parts are); an edit that places it again (not a rename alone) \
|
||||
meets a loop it closes as joint.create does, turning an angle a half turn where only that meets it",
|
||||
),
|
||||
CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"),
|
||||
CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params(
|
||||
CommandSpec::new("joint.delete", "Delete Joint", crate::checked!("joint.delete", delete))
|
||||
.icon("delete")
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(output_schema_for::<DeleteJointResult>)
|
||||
.params("joint: id|name (or origin: name, link: index)"),
|
||||
CommandSpec::new("joint.list", "List Joints", crate::checked!("joint.list", list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ListJointsResult>)
|
||||
.params(
|
||||
"→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
|
||||
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
|
||||
),
|
||||
CommandSpec::new("joint.inspect", "Inspect Joint", inspect::inspect).icon("inspect").noundo().params(
|
||||
CommandSpec::new("joint.inspect", "Inspect Joint", crate::checked!("joint.inspect", inspect::inspect))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<InspectJointResult>)
|
||||
.params(
|
||||
"joint?: id|name (default: every joint) → joints: [{id, name, type, suppressed, visible, flip, offset (mm), angle (deg), values: as joint.list \
|
||||
gives them, dofs, expressions?: {offset?, angle?, values?}, a, b: {occurrence, occurrence_name, component, component_name, path \
|
||||
(occurrence names from the top, `/` between), snap (as saved: type, pick, face, end, edge, corner, x, point, z, in the component's \
|
||||
@@ -108,7 +201,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
unverified?: a name saved before exact names}], resolves, frame?: {origin, z, x} in the world, error?: why the origin is lost}, \
|
||||
links: [{index, role: driver|driven, …as joint.list}], errors, conflicts}] (reads only: nothing changes)",
|
||||
),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", crate::checked!("joint.solve", solve_cmd))
|
||||
.icon("compute")
|
||||
.contract(&SOLVE_CONTRACT)
|
||||
.output(output_schema_for::<SolveResult>)
|
||||
.params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", crate::checked!("inspect.interference", super::interference::interference))
|
||||
.contract(&crate::schema::TypedContract::<crate::schema::interference::InterferenceParams>::new())
|
||||
.output(crate::schema::output_schema_for::<crate::schema::interference::InterferenceResult>)
|
||||
@@ -228,30 +325,52 @@ fn response_analysis(p: &Value) -> joints::Analysis {
|
||||
}
|
||||
|
||||
/// A solve's answer; `quiet: true` in the command's params leaves out the per-occurrence lists.
|
||||
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
|
||||
let mut out = json!({
|
||||
"conflicts": sol.conflicts.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
"errors": sol.errors.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
});
|
||||
if !bool_(p, "quiet").unwrap_or(false) {
|
||||
out["dof"] = sol.dof.iter().map(|(o, d)| json!({"occurrence": o, "dof": d})).collect();
|
||||
out["fixed"] = json!(sol.fixed);
|
||||
fn solve_report(sol: &joints::Solution, p: &Value) -> SolveReport {
|
||||
let problems = |list: &[(u64, String, String)]| -> Vec<JointProblem> {
|
||||
list.iter().map(|(joint, name, why)| JointProblem { joint: *joint, name: name.clone(), why: why.clone() }).collect()
|
||||
};
|
||||
let quiet = bool_(p, "quiet").unwrap_or(false);
|
||||
SolveReport {
|
||||
conflicts: problems(&sol.conflicts),
|
||||
errors: problems(&sol.errors),
|
||||
dof: (!quiet).then(|| sol.dof.iter().map(|(o, d)| OccurrenceDof { occurrence: *o, dof: *d }).collect()),
|
||||
fixed: (!quiet).then(|| sol.fixed.clone()),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`solve_report`] as JSON, for the commands that add to it.
|
||||
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
|
||||
serde_json::to_value(solve_report(sol, p)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A command's reply as JSON: the joint commands that have output schemas build their replies from
|
||||
/// the types those describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with
|
||||
/// its name and limits. The numbers are rounded for reading (#10747); the joint keeps full
|
||||
/// precision.
|
||||
pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
pub(crate) fn named_value_list(j: &Joint, q: &[f64]) -> Vec<NamedValue> {
|
||||
j.kind
|
||||
.dofs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (v, angle) = (q.get(i).copied().unwrap_or(0.0), j.kind.is_angle(i));
|
||||
json!({"name": n, "value": expr::round_reported(if angle { v.to_degrees() } else { v }), "unit": if angle { "deg" } else { "mm" },
|
||||
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())] } else { [expr::round_reported(a), expr::round_reported(b)] })})
|
||||
NamedValue {
|
||||
name: (*n).to_string(),
|
||||
value: expr::round_reported(if angle { v.to_degrees() } else { v }),
|
||||
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
|
||||
limits: j.limits.get(i).copied().flatten().map(|(a, b)| {
|
||||
if angle {
|
||||
[expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())]
|
||||
} else {
|
||||
[expr::round_reported(a), expr::round_reported(b)]
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -259,9 +378,14 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
/// A joint's values as the solver places it (after motion links, so a screw's travel follows its
|
||||
/// turn), named as `joint.list` gives them.
|
||||
pub(crate) fn effective_values(s: &Session, id: u64) -> Option<Vec<Value>> {
|
||||
effective_value_list(s, id).map(|list| list.into_iter().map(|v| serde_json::to_value(v).unwrap_or_default()).collect())
|
||||
}
|
||||
|
||||
/// [`effective_values`], typed.
|
||||
fn effective_value_list(s: &Session, id: u64) -> Option<Vec<NamedValue>> {
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
Some(named_values(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
|
||||
Some(named_value_list(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
|
||||
}
|
||||
|
||||
fn occurrence_name(s: &Session, id: u64) -> String {
|
||||
@@ -293,7 +417,7 @@ pub(crate) fn cannot_move(s: &Session, sol: &joints::Solution, id: u64) -> Optio
|
||||
|
||||
/// Joint chains joined to nothing grounded, which the solver places none of: each chain's
|
||||
/// occurrences and joints, by name.
|
||||
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
|
||||
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<FloatingChain> {
|
||||
let placed = |o: u64| o == 0 || sol.world.contains_key(&o);
|
||||
let errors: BTreeSet<u64> = sol.errors.iter().map(|e| e.0).collect();
|
||||
let mut next: BTreeMap<u64, Vec<(u64, &Joint)>> = BTreeMap::new();
|
||||
@@ -319,8 +443,10 @@ fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(json!({"occurrences": occs.iter().map(|o| occurrence_name(s, *o)).collect::<Vec<_>>(),
|
||||
"joints": js.iter().map(|(_, n)| *n).collect::<Vec<_>>()}));
|
||||
out.push(FloatingChain {
|
||||
occurrences: occs.iter().map(|o| occurrence_name(s, *o)).collect(),
|
||||
joints: js.iter().map(|(_, n)| (*n).to_string()).collect(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -501,7 +627,7 @@ fn same_frame(s: &Session, o: &JointOrigin, at: Vec3, z: Vec3) -> bool {
|
||||
/// The longest face name a joint origin takes. A face that cuts split and merged keeps the names
|
||||
/// of the faces around it (`<name>#[<name>,…]`): several hundred characters on a part a text or
|
||||
/// a pattern of holes was cut through.
|
||||
const MAX_FACE_NAME: usize = 16_384;
|
||||
pub(crate) const MAX_FACE_NAME: usize = 16_384;
|
||||
|
||||
/// The component an occurrence places (0 for the root, or an unknown occurrence).
|
||||
pub(crate) fn component_of(s: &Session, occurrence: u64) -> u64 {
|
||||
@@ -754,21 +880,27 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
let mut q = j.values.clone();
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
joints::clamp(&j, &mut q);
|
||||
let round = |v: &Value| v.as_f64().map(|x| (x * 1e6).round() / 1e6).unwrap_or(f64::NAN);
|
||||
let warnings: Vec<String> = named_values(&j, &q)
|
||||
let round = |v: f64| (v * 1e6).round() / 1e6;
|
||||
// As the reply shows them: a value that is not a number reads as null, and null is null.
|
||||
let same = |a: f64, b: f64| a == b || (a.is_nan() && b.is_nan());
|
||||
let warnings: Vec<String> = named_value_list(&j, &q)
|
||||
.iter()
|
||||
.zip(named_values(&j, &j.values))
|
||||
.filter(|(now, was)| now["value"] != was["value"])
|
||||
.zip(named_value_list(&j, &j.values))
|
||||
.filter(|(now, was)| !same(now.value, was.value))
|
||||
.map(|(now, was)| {
|
||||
let unit = now["unit"].as_str().unwrap_or("");
|
||||
let unit = match now.unit {
|
||||
ValueUnit::Deg => "deg",
|
||||
ValueUnit::Mm => "mm",
|
||||
};
|
||||
let [lo, hi] = now.limits.unwrap_or([f64::NAN; 2]);
|
||||
format!(
|
||||
"{} {} starts at {} {unit}, inside its limits [{}, {}], not at {} {unit}: the part moved there as it was joined",
|
||||
j.name,
|
||||
now["name"].as_str().unwrap_or(""),
|
||||
round(&now["value"]),
|
||||
round(&now["limits"][0]),
|
||||
round(&now["limits"][1]),
|
||||
round(&was["value"])
|
||||
now.name,
|
||||
round(now.value),
|
||||
round(lo),
|
||||
round(hi),
|
||||
round(was.value)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -782,13 +914,7 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
if sol.conflicts.iter().any(|(c, ..)| *c == id) && close_by_half_turn(s, id, &sol) {
|
||||
sol = resolve(s);
|
||||
}
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joint"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
if !warnings.is_empty() {
|
||||
out["warnings"] = json!(warnings);
|
||||
}
|
||||
Ok(out)
|
||||
reply("joint.create", JointAddedResult { solved: solve_report(&sol, p), joint: id, name, warnings, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Sets of angle values a new loop-closing joint tries a half turn round, at most.
|
||||
@@ -892,7 +1018,7 @@ fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("there is already a joint origin `{name}`")));
|
||||
}
|
||||
s.doc_mut().assembly.origins.push(NamedOrigin { name: name.clone(), origin });
|
||||
Ok(json!({"origin": name}))
|
||||
reply(cmd, MakeJointOriginResult { origin: name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
|
||||
@@ -1013,9 +1139,9 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut made = Vec::new();
|
||||
for other in ids.iter().skip(1) {
|
||||
let r = as_built(s, &json!({"type": "rigid", "a": first, "b": other, "name": format!("Rigid Group {first}:{other}")}))?;
|
||||
made.push(r["joint"].clone());
|
||||
made.push(r["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint made has no id"))?);
|
||||
}
|
||||
Ok(json!({"joints": made}))
|
||||
reply(cmd, RigidGroupResult { joints: made, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1087,41 +1213,48 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? }
|
||||
};
|
||||
let sol = solution(s, &BTreeMap::new(), response_analysis(p));
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joint"] = json!(id);
|
||||
out["values"] = json!(effective_values(s, id));
|
||||
if bool_(p, "continuous") == Some(true) {
|
||||
out["continuous"] = json!(true);
|
||||
}
|
||||
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
|
||||
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
|
||||
let moved = super::motion::moved(&before, &super::motion::positions(s)).next().is_some();
|
||||
out["moved"] = json!(moved);
|
||||
// How motion stopped, as `motion::drive_with_contact` says: an object, or null.
|
||||
let stop = |key: &str| r.get(key).filter(|v| v.is_object()).cloned();
|
||||
let stopped_by_contact =
|
||||
stop("stopped_by_contact").map(serde_json::from_value::<ContactStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let stopped_by_loop = stop("stopped_by_loop").map(serde_json::from_value::<LoopStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut out = DriveJointResult {
|
||||
solved: solve_report(&sol, p),
|
||||
joint: id,
|
||||
values: effective_value_list(s, id),
|
||||
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
|
||||
stopped_by_contact,
|
||||
stopped_by_loop,
|
||||
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
|
||||
requested: None,
|
||||
held: None,
|
||||
reason: None,
|
||||
unconnected: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) {
|
||||
out["requested"] = json!(named_values(j, &requested));
|
||||
out.requested = Some(named_value_list(j, &requested));
|
||||
let held = if hold { Vec::new() } else { held_values(s, j, &requested, &driven, &r) };
|
||||
if let Some(first) = held.first()
|
||||
&& !moved
|
||||
&& !out.moved
|
||||
{
|
||||
out["reason"] = first["why"].clone();
|
||||
out.reason = Some(first.why.clone());
|
||||
}
|
||||
if !held.is_empty() {
|
||||
out["held"] = json!(held);
|
||||
out.held = Some(held);
|
||||
}
|
||||
}
|
||||
if let Some((why, unconnected)) = cannot_move(s, &sol, id) {
|
||||
out["reason"] = json!(why);
|
||||
if let Some(o) = unconnected {
|
||||
out["unconnected"] = json!(o);
|
||||
}
|
||||
out.reason = Some(why);
|
||||
out.unconnected = unconnected;
|
||||
}
|
||||
Ok(out)
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The driven values of `j` that ended up other than `requested`, each with why: a motion link
|
||||
/// sets it from another coordinate (a screw's travel follows its turn), a limit holds it, or
|
||||
/// contact or a closed loop stopped the motion short. Degrees and mm, as `values` (#9742).
|
||||
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<Value> {
|
||||
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<HeldValue> {
|
||||
let now = joints::joint_values(&s.doc.assembly).remove(&j.id).unwrap_or_else(|| j.values.clone());
|
||||
let names = j.kind.dofs();
|
||||
let mut out = Vec::new();
|
||||
@@ -1159,13 +1292,19 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
out.push(json!({"name": name, "requested": shown(want), "value": shown(got), "unit": if angle { "deg" } else { "mm" }, "why": why}));
|
||||
out.push(HeldValue {
|
||||
name: name.to_string(),
|
||||
requested: shown(want),
|
||||
value: shown(got),
|
||||
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
|
||||
why,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Joints `joint.reach` takes at most in `joints`.
|
||||
const MAX_REACH_JOINTS: usize = 1000;
|
||||
pub(crate) const MAX_REACH_JOINTS: usize = 1000;
|
||||
|
||||
fn reach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.reach";
|
||||
@@ -1216,49 +1355,54 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sol = if response_analysis(p) == joints::Analysis::Placement { place(s) } else { resolve(s) };
|
||||
let t = joints::occurrence_world(&s.doc, occ);
|
||||
let got = solidcraft_doc::apply_point(&t, at);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["occurrence"] = json!(occurrence_name(s, occ));
|
||||
out["point"] = json!([got.x, got.y, got.z]);
|
||||
out["error_mm"] = json!(got.dist(to));
|
||||
let mut there = got.dist(to) <= joints::REACH_TOL;
|
||||
let mut error_deg = None;
|
||||
if let Some((a, z)) = axis {
|
||||
// From the sine too: the cosine alone loses small angles to rounding.
|
||||
let v = solidcraft_doc::apply_vector(&t, a);
|
||||
let deg = v.cross(z).len().atan2(v.dot(z)).to_degrees();
|
||||
out["error_deg"] = json!(deg);
|
||||
error_deg = Some(deg);
|
||||
there &= deg <= joints::REACH_TOL;
|
||||
}
|
||||
out["reached"] = json!(there);
|
||||
let name = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id);
|
||||
// As joint.list gives them: with what motion links set.
|
||||
let effective = joints::joint_values(&s.doc.assembly);
|
||||
out["values"] = reached
|
||||
let values = reached
|
||||
.values
|
||||
.keys()
|
||||
.filter_map(|id| name(*id))
|
||||
.map(|j| json!({"joint": j.id, "name": j.name, "values": named_values(j, effective.get(&j.id).unwrap_or(&j.values))}))
|
||||
.map(|j| ReachedJoint { joint: j.id, name: j.name.clone(), values: named_value_list(j, effective.get(&j.id).unwrap_or(&j.values)) })
|
||||
.collect();
|
||||
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
|
||||
out["stopped_by_limit"] = if reached.limits.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
let stopped_by_limit = (!reached.limits.is_empty()).then(|| {
|
||||
reached
|
||||
.limits
|
||||
.iter()
|
||||
.filter_map(|(id, i, upper)| {
|
||||
let j = name(*id)?;
|
||||
Some(json!({"joint": j.name, "value": j.kind.dofs().get(*i), "limit": if *upper { "max" } else { "min" }}))
|
||||
Some(LimitStop {
|
||||
joint: j.name.clone(),
|
||||
value: j.kind.dofs().get(*i).map(|d| (*d).to_string()),
|
||||
limit: if *upper { LimitSide::Max } else { LimitSide::Min },
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let result = ReachResult {
|
||||
solved: solve_report(&sol, p),
|
||||
occurrence: occurrence_name(s, occ),
|
||||
point: [got.x, got.y, got.z],
|
||||
error_mm: got.dist(to),
|
||||
error_deg,
|
||||
reached: there,
|
||||
values,
|
||||
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
|
||||
stopped_by_limit,
|
||||
stopped_by_loop: reached.stopped_by_loop.and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
|
||||
reason: r.err(),
|
||||
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
out["stopped_by_loop"] = json!(reached.stopped_by_loop.and_then(name).map(|j| json!({"joint": j.name})));
|
||||
if let Err(why) = r {
|
||||
out["reason"] = json!(why);
|
||||
}
|
||||
if bool_(p, "continuous") == Some(true) {
|
||||
out["continuous"] = json!(true);
|
||||
}
|
||||
Ok(out)
|
||||
reply(cmd, result)
|
||||
}
|
||||
|
||||
/// The centre of the box around an occurrence's bodies, in its own coordinates (its origin when
|
||||
@@ -1289,7 +1433,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ratio = ratio * ratio_scale(s, a, ia, b, ib);
|
||||
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() });
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol, p))
|
||||
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A coupling length: a number (mm), an expression (kept as typed, so it follows the
|
||||
@@ -1344,7 +1488,7 @@ fn gear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
internal: bool_(p, "internal").unwrap_or(false),
|
||||
reverse: reverse(p),
|
||||
};
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
|
||||
}
|
||||
|
||||
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1352,7 +1496,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, b) = joint_pair(s, p, cmd)?;
|
||||
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
|
||||
let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) };
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
|
||||
}
|
||||
|
||||
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1372,7 +1516,7 @@ fn screw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) };
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
|
||||
}
|
||||
|
||||
fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1406,30 +1550,32 @@ fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false);
|
||||
let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let reverse = c.reverse;
|
||||
let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
|
||||
let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX);
|
||||
if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) {
|
||||
o.remove("ratio");
|
||||
o.insert("lift".into(), lift_json(l, oscillating));
|
||||
o.insert("reverse".into(), json!(reverse));
|
||||
if !oscillating {
|
||||
o.insert("follower_offset".into(), json!(offset));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
let coupled = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
|
||||
let lift = s.doc.assembly.links.get(coupled.link).and_then(|l| lift_info(l, oscillating));
|
||||
reply(
|
||||
cmd,
|
||||
CamResult {
|
||||
solved: coupled.solved,
|
||||
link: coupled.link,
|
||||
lift,
|
||||
reverse,
|
||||
follower_offset: (!oscillating).then_some(offset),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their
|
||||
/// difference, in degrees (`angle`) or mm.
|
||||
fn lift_json(l: &MotionLink, angle: bool) -> Value {
|
||||
/// difference, in degrees (`angle`) or mm; `None` for a table that is not whole.
|
||||
fn lift_info(l: &MotionLink, angle: bool) -> Option<CamLift> {
|
||||
// A table that is not whole (a hand-edited file) moves nothing, and says so.
|
||||
if l.lift.len() != cam::CAM_SAMPLES {
|
||||
return Value::Null;
|
||||
return None;
|
||||
}
|
||||
let unit = |v: f64| if angle { v.to_degrees() } else { v };
|
||||
let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset));
|
||||
json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }})
|
||||
Some(CamLift { min: lo, max: hi, rise: hi - lo, unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm } })
|
||||
}
|
||||
|
||||
/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's
|
||||
@@ -1491,7 +1637,7 @@ fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result<CamProfile> {
|
||||
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
|
||||
/// sizes, its offset leaving b where it is (the teeth mesh as they are now), for a new link and
|
||||
/// one put in place of another alike.
|
||||
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Value> {
|
||||
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<CouplingResult> {
|
||||
let links = &s.doc.assembly.links;
|
||||
let replace = match p.get("link") {
|
||||
None => None,
|
||||
@@ -1544,10 +1690,7 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Va
|
||||
}
|
||||
};
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["link"] = json!(index);
|
||||
out["ratio"] = json!(shown);
|
||||
Ok(out)
|
||||
Ok(CouplingResult { solved: solve_report(&sol, p), link: index, ratio: shown, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn is_angle(s: &Session, joint: u64, i: usize) -> bool {
|
||||
@@ -1585,7 +1728,7 @@ fn limits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
joints::clamp(j, &mut q);
|
||||
j.values = q;
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol, p))
|
||||
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1711,7 +1854,10 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.assembly.origins.len() == before {
|
||||
return Err(bad(cmd, format!("no joint origin `{name}`")));
|
||||
}
|
||||
return Ok(json!({"deleted_origin": name, "joints": used}));
|
||||
return reply(
|
||||
cmd,
|
||||
DeleteJointResult { deleted: JointDeleted::Origin { deleted_origin: name.to_string(), joints: used }, param_warnings: Vec::new() },
|
||||
);
|
||||
}
|
||||
if let Some(i) = num(p, "link") {
|
||||
let i = i.max(0.0) as usize;
|
||||
@@ -1720,72 +1866,83 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "no such motion link"));
|
||||
}
|
||||
doc.assembly.links.remove(i);
|
||||
return Ok(json!({"deleted_link": i}));
|
||||
return reply(cmd, DeleteJointResult { deleted: JointDeleted::Link { deleted_link: i }, param_warnings: Vec::new() });
|
||||
}
|
||||
let id = joint_id(s, p.get("joint"), cmd)?;
|
||||
delete_joints(s.doc_mut(), &[id]);
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["deleted"] = json!(id);
|
||||
Ok(out)
|
||||
reply(cmd, DeleteJointResult { deleted: JointDeleted::Joint { solved: solve_report(&sol, p), deleted: id }, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let sol = joints::solve(&s.doc, &st);
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
let js: Vec<Value> = s
|
||||
let js: Vec<ListedJoint> = s
|
||||
.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.map(|j| {
|
||||
let named = named_values(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default());
|
||||
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
|
||||
let mut out = json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
|
||||
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)});
|
||||
if !j.exprs.is_empty() {
|
||||
out["expressions"] = json!(j.exprs);
|
||||
let world = |o: &JointOrigin| {
|
||||
s.doc.origin_world(&st, o).ok().map(|m| JointFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]] })
|
||||
};
|
||||
ListedJoint {
|
||||
id: j.id,
|
||||
name: j.name.clone(),
|
||||
kind: j.kind,
|
||||
a: j.a.occurrence,
|
||||
b: j.b.occurrence,
|
||||
values: named_value_list(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default()),
|
||||
flip: j.flip,
|
||||
offset: j.offset,
|
||||
suppressed: j.suppressed,
|
||||
visible: s.joint_visible(j.id),
|
||||
frame_a: world(&j.a),
|
||||
frame_b: world(&j.b),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<Value> = s.doc.assembly.links.iter().map(|l| link_json(s, &st, l)).collect();
|
||||
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joints"] = json!(js);
|
||||
out["links"] = json!(links);
|
||||
out["origins"] = json!(origins);
|
||||
out["floating"] = json!(floating_chains(s, &sol));
|
||||
Ok(out)
|
||||
let out = ListJointsResult {
|
||||
solved: solve_report(&sol, p),
|
||||
joints: js,
|
||||
links: s.doc.assembly.links.iter().map(|l| link_info(s, &st, l)).collect(),
|
||||
origins: s.doc.assembly.origins.iter().map(|o| ListedOrigin { name: o.name.clone(), occurrence: o.origin.occurrence }).collect(),
|
||||
floating: floating_chains(s, &sol),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply("joint.list", out)
|
||||
}
|
||||
|
||||
/// A motion link as `joint.list` and `joint.inspect` give it: ratio per degree, offset in b's
|
||||
/// units, a coupling's definition (and why it can't be worked out), a cam's lift in place of a ratio.
|
||||
fn link_json(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> Value {
|
||||
fn link_info(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> LinkInfo {
|
||||
let angle = is_angle(s, l.b, l.ib);
|
||||
let offset = if angle { l.offset.to_degrees() } else { l.offset };
|
||||
let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
|
||||
let mut v = json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }});
|
||||
if let Some(c) = &l.coupling {
|
||||
v["coupling"] = json!(c);
|
||||
if let Some(Err(e)) = joints::coupling_values(&s.doc, st, l) {
|
||||
v["error"] = json!(e.to_string());
|
||||
}
|
||||
}
|
||||
// A cam's follower follows its table, not a ratio: its travel instead.
|
||||
if matches!(l.coupling, Some(Coupling::Cam(_)))
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.remove("ratio");
|
||||
o.insert("lift".into(), lift_json(l, angle));
|
||||
let cam = matches!(l.coupling, Some(Coupling::Cam(_)));
|
||||
LinkInfo {
|
||||
a: l.a,
|
||||
ia: l.ia,
|
||||
b: l.b,
|
||||
ib: l.ib,
|
||||
ratio: (!cam).then_some(ratio),
|
||||
offset,
|
||||
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
|
||||
coupling: l.coupling.clone(),
|
||||
error: match &l.coupling {
|
||||
Some(_) => joints::coupling_values(&s.doc, st, l).and_then(|r| r.err()).map(|e| e.to_string()),
|
||||
None => None,
|
||||
},
|
||||
lift: cam.then(|| lift_info(l, angle)),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol, p))
|
||||
reply("joint.solve", SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
//! persistent name refers to), whether each origin is found today and why not, its kind, offset,
|
||||
//! angle, flip, limits, values, expressions and the motion links it takes part in. Reads only.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::joints::{self, Joint, JointOrigin, Snap, XRef};
|
||||
use solidcraft_doc::naming::legacy_name;
|
||||
use solidcraft_doc::persistent_name::{Base, EdgeName, FaceName, Role};
|
||||
use solidcraft_doc::{Document, MAX_PLACEMENT_DEPTH, ModelState};
|
||||
|
||||
use super::{component_of, joint_id, link_json, named_values, occurrence_name};
|
||||
use super::{component_of, joint_id, link_info, named_value_list, occurrence_name, reply};
|
||||
use crate::schema::joints::{
|
||||
EdgeReference, FaceReference, InspectJointResult, InspectedJoint, JointLink, JointSide, LinkRole, MadeBy, Reference, ReferenceRole, Referent,
|
||||
SweptFrom, WorldFrame,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -21,7 +25,7 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let sol = joints::solve(&s.doc, &st);
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
let out: Vec<Value> = ids
|
||||
let out: Vec<InspectedJoint> = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.doc.assembly.joints.iter().find(|j| j.id == *id))
|
||||
.map(|j| {
|
||||
@@ -29,50 +33,42 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
joint_json(s, &st, &sol, j, q)
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "joints": out }))
|
||||
reply(cmd, InspectJointResult { joints: out, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> Value {
|
||||
let links: Vec<Value> = s
|
||||
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> InspectedJoint {
|
||||
let links: Vec<JointLink> = s
|
||||
.doc
|
||||
.assembly
|
||||
.links
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.a == j.id || l.b == j.id)
|
||||
.map(|(i, l)| {
|
||||
let mut v = link_json(s, st, l);
|
||||
v["index"] = json!(i);
|
||||
v["role"] = json!(if l.b == j.id { "driven" } else { "driver" });
|
||||
v
|
||||
})
|
||||
.map(|(i, l)| JointLink { index: i, role: if l.b == j.id { LinkRole::Driven } else { LinkRole::Driver }, link: link_info(s, st, l) })
|
||||
.collect();
|
||||
let why = |list: &[(u64, String, String)]| list.iter().filter(|(id, ..)| *id == j.id).map(|(.., why)| why.clone()).collect::<Vec<_>>();
|
||||
let mut v = json!({
|
||||
"id": j.id,
|
||||
"name": j.name,
|
||||
"type": j.kind,
|
||||
"suppressed": j.suppressed,
|
||||
"visible": s.joint_visible(j.id),
|
||||
"flip": j.flip,
|
||||
"offset": j.offset,
|
||||
"angle": j.angle.to_degrees(),
|
||||
"values": named_values(j, q),
|
||||
"dofs": j.kind.dofs(),
|
||||
"a": side_json(s, st, &j.a),
|
||||
"b": side_json(s, st, &j.b),
|
||||
"links": links,
|
||||
"errors": why(&sol.errors),
|
||||
"conflicts": why(&sol.conflicts),
|
||||
});
|
||||
if !j.exprs.is_empty() {
|
||||
v["expressions"] = json!(j.exprs);
|
||||
InspectedJoint {
|
||||
id: j.id,
|
||||
name: j.name.clone(),
|
||||
kind: j.kind,
|
||||
suppressed: j.suppressed,
|
||||
visible: s.joint_visible(j.id),
|
||||
flip: j.flip,
|
||||
offset: j.offset,
|
||||
angle: j.angle.to_degrees(),
|
||||
values: named_value_list(j, q),
|
||||
dofs: j.kind.dofs().iter().map(|d| (*d).to_string()).collect(),
|
||||
a: side_json(s, st, &j.a),
|
||||
b: side_json(s, st, &j.b),
|
||||
links,
|
||||
errors: why(&sol.errors),
|
||||
conflicts: why(&sol.conflicts),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// One of a joint's origins: what it sits on, what it refers to, and where it is now.
|
||||
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
|
||||
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> JointSide {
|
||||
let component = component_of(s, o.occurrence);
|
||||
let component_name = if component == 0 {
|
||||
"root".to_string()
|
||||
@@ -80,24 +76,24 @@ fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
|
||||
s.doc.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default()
|
||||
};
|
||||
let occurrence_name = if o.occurrence == 0 { "root".to_string() } else { occurrence_name(s, o.occurrence) };
|
||||
let mut v = json!({
|
||||
"occurrence": o.occurrence,
|
||||
"occurrence_name": occurrence_name,
|
||||
"component": component,
|
||||
"component_name": component_name,
|
||||
"path": occurrence_path(&s.doc, o.occurrence),
|
||||
"snap": o.snap,
|
||||
"references": references(&s.doc, &o.snap),
|
||||
});
|
||||
let mut v = JointSide {
|
||||
occurrence: o.occurrence,
|
||||
occurrence_name,
|
||||
component,
|
||||
component_name,
|
||||
path: occurrence_path(&s.doc, o.occurrence),
|
||||
snap: o.snap.clone(),
|
||||
references: references(&s.doc, &o.snap),
|
||||
resolves: false,
|
||||
frame: None,
|
||||
error: None,
|
||||
};
|
||||
match s.doc.origin_world(st, o) {
|
||||
Ok(m) => {
|
||||
v["resolves"] = json!(true);
|
||||
v["frame"] = json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]], "x": [m[0][0], m[0][1], m[0][2]]});
|
||||
}
|
||||
Err(e) => {
|
||||
v["resolves"] = json!(false);
|
||||
v["error"] = json!(e.to_string());
|
||||
v.resolves = true;
|
||||
v.frame = Some(WorldFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]], x: [m[0][0], m[0][1], m[0][2]] });
|
||||
}
|
||||
Err(e) => v.error = Some(e.to_string()),
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -118,36 +114,36 @@ fn occurrence_path(doc: &Document, occurrence: u64) -> String {
|
||||
}
|
||||
|
||||
/// Every persistent name the snap holds, with what it refers to.
|
||||
fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
|
||||
fn references(doc: &Document, snap: &Snap) -> Vec<Reference> {
|
||||
let mut out = Vec::new();
|
||||
let mut face = |role: &str, name: &Option<String>| {
|
||||
let mut face = |role: ReferenceRole, name: &Option<String>| {
|
||||
if let Some(n) = name {
|
||||
out.push(with_role(role, face_ref(doc, n)));
|
||||
out.push(Reference { role, to: Referent::Face(face_ref(doc, n)) });
|
||||
}
|
||||
};
|
||||
match snap {
|
||||
Snap::FaceCenter { face: f, x, .. } => {
|
||||
face("face", f);
|
||||
face(ReferenceRole::Face, f);
|
||||
if let Some(XRef::Face { face: n, .. }) = x {
|
||||
face("x", &Some(n.clone()));
|
||||
face(ReferenceRole::X, &Some(n.clone()));
|
||||
}
|
||||
}
|
||||
Snap::CircleCenter { face: f, end, x, .. } => {
|
||||
face("face", f);
|
||||
face("end", end);
|
||||
face(ReferenceRole::Face, f);
|
||||
face(ReferenceRole::End, end);
|
||||
if let Some(XRef::Face { face: n, .. }) = x {
|
||||
face("x", &Some(n.clone()));
|
||||
face(ReferenceRole::X, &Some(n.clone()));
|
||||
}
|
||||
}
|
||||
Snap::Edge { edge, face: f, corner, .. } => {
|
||||
if let Some(e) = edge {
|
||||
out.push(with_role("edge", edge_ref(doc, e)));
|
||||
out.push(Reference { role: ReferenceRole::Edge, to: Referent::Edge(edge_ref(doc, e)) });
|
||||
}
|
||||
if let Some(n) = f {
|
||||
out.push(with_role("face", face_ref(doc, n)));
|
||||
out.push(Reference { role: ReferenceRole::Face, to: Referent::Face(face_ref(doc, n)) });
|
||||
}
|
||||
if let Some(e) = corner {
|
||||
out.push(with_role("corner", edge_ref(doc, e)));
|
||||
out.push(Reference { role: ReferenceRole::Corner, to: Referent::Edge(edge_ref(doc, e)) });
|
||||
}
|
||||
}
|
||||
Snap::Frame { .. } | Snap::Point { .. } => {}
|
||||
@@ -155,19 +151,14 @@ fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
fn with_role(role: &str, mut v: Value) -> Value {
|
||||
v["role"] = json!(role);
|
||||
v
|
||||
}
|
||||
|
||||
/// What a face's persistent name refers to: `F<feature>:<role>` names the feature that made the
|
||||
/// face, and a side `side:<curve>` (or a later profile's `side:s<k>.<curve>`) the sketch curve
|
||||
/// it was swept from. Names of another form are given as they are.
|
||||
fn face_ref(doc: &Document, name: &str) -> Value {
|
||||
let mut v = json!({ "name": name });
|
||||
fn face_ref(doc: &Document, name: &str) -> FaceReference {
|
||||
let mut v = FaceReference { name: name.to_string(), unverified: None, role_in_feature: None, feature: None, sketch: None };
|
||||
let bare = match legacy_name(name) {
|
||||
Some(n) => {
|
||||
v["unverified"] = json!(true);
|
||||
v.unverified = Some(true);
|
||||
n
|
||||
}
|
||||
None => name,
|
||||
@@ -175,38 +166,36 @@ fn face_ref(doc: &Document, name: &str) -> Value {
|
||||
let Ok(face) = FaceName::parse(bare) else { return v };
|
||||
let Some(fid) = face.made_by().map(|id| id.0) else { return v };
|
||||
let stem = face.stem().to_string();
|
||||
v["role_in_feature"] = json!(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem));
|
||||
v.role_in_feature = Some(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem).to_string());
|
||||
let Some(f) = doc.feature(fid) else {
|
||||
v["feature"] = json!({"id": fid, "error": format!("feature {fid} is gone")});
|
||||
v.feature = Some(MadeBy::Gone { id: fid, error: format!("feature {fid} is gone") });
|
||||
return v;
|
||||
};
|
||||
v["feature"] = json!({"id": f.id, "name": f.name, "type": f.kind.type_name()});
|
||||
v.feature = Some(MadeBy::Feature { id: f.id, name: f.name.clone(), kind: f.kind.type_name().to_string() });
|
||||
// A side swept from a sketch curve (a later profile's too, `side:s<k>.<curve>`).
|
||||
if let Base::Made { role: Role::Side { curve, .. }, .. } = &face.base {
|
||||
let found = f.kind.input_sketches().into_iter().find_map(|id| {
|
||||
// design-only: a joint names a curve of one of the design's sketches.
|
||||
let sk = doc.sketch(id).ok()?;
|
||||
let c = sk.curves.iter().find(|c| c.id == *curve)?;
|
||||
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").cloned()).unwrap_or(Value::Null);
|
||||
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").and_then(Value::as_str).map(str::to_string));
|
||||
let name = doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Some(json!({"id": id, "name": name, "curve": curve, "curve_type": curve_type}))
|
||||
Some(SweptFrom::Curve { id, name, curve: curve.clone(), curve_type })
|
||||
});
|
||||
v["sketch"] = found.unwrap_or_else(|| json!({"curve": curve, "error": format!("no sketch of {} has a curve `{curve}`", f.name)}));
|
||||
v.sketch = Some(
|
||||
found.unwrap_or_else(|| SweptFrom::Missing { curve: curve.clone(), error: format!("no sketch of {} has a curve `{curve}`", f.name) }),
|
||||
);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// An edge's persistent name (`<face A>|<face B>`, `~k` for one of several between them) with
|
||||
/// what each of its faces refers to.
|
||||
fn edge_ref(doc: &Document, name: &str) -> Value {
|
||||
fn edge_ref(doc: &Document, name: &str) -> EdgeReference {
|
||||
let bare = legacy_name(name).unwrap_or(name);
|
||||
let faces: Vec<Value> = match EdgeName::parse(bare) {
|
||||
let faces: Vec<FaceReference> = match EdgeName::parse(bare) {
|
||||
Ok(e) => e.faces.iter().map(|f| face_ref(doc, &f.to_string())).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let mut v = json!({"name": name, "faces": faces});
|
||||
if legacy_name(name).is_some() {
|
||||
v["unverified"] = json!(true);
|
||||
}
|
||||
v
|
||||
EdgeReference { name: name.to_string(), faces, unverified: legacy_name(name).is_some().then_some(true) }
|
||||
}
|
||||
|
||||
@@ -159,9 +159,9 @@ fn material_reply(cmd: &str, m: &UserMaterial) -> Result<MaterialReply> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`material_reply`] as JSON, for `material.list` (`cmd/inspect.rs`), which isn't converted yet.
|
||||
pub(super) fn material_json(m: &UserMaterial) -> Result<Value> {
|
||||
reply("material.list", material_reply("material.list", m)?)
|
||||
/// [`material_reply`] for `material.list` (`cmd/inspect.rs`).
|
||||
pub(super) fn listed_material(m: &UserMaterial) -> Result<MaterialReply> {
|
||||
material_reply("material.list", m)
|
||||
}
|
||||
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
|
||||
@@ -11,6 +11,7 @@ use solidcraft_geom::{Mesh, UnitVec3};
|
||||
|
||||
use super::measure_shape::{Shape, Slot, add_round, centre};
|
||||
use crate::params::bad;
|
||||
use crate::schema::inspect::{MeasureKind, MeasureResult, Measured, MeasuredItems};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
/// Geometry of a picked item, as points or triangles.
|
||||
@@ -665,16 +666,20 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces, bodies, sketch curves or profiles)"))?,
|
||||
);
|
||||
}
|
||||
let mut out = json!({"items": described.iter().map(|d| d.json.clone()).collect::<Vec<_>>()});
|
||||
let mut out =
|
||||
MeasuredItems { items: described.iter().map(|d| d.json.as_object().cloned().unwrap_or_default()).collect(), ..MeasuredItems::default() };
|
||||
if let [one] = described.as_slice() {
|
||||
for k in ["diameter", "radius", "circumference", "angle_deg", "arc_length", "center", "slot"] {
|
||||
if let Some(v) = one.json.get(k) {
|
||||
out[k] = v.clone();
|
||||
}
|
||||
}
|
||||
let copy = |k: &str| one.json.get(k).cloned();
|
||||
out.diameter = copy("diameter");
|
||||
out.radius = copy("radius");
|
||||
out.circumference = copy("circumference");
|
||||
out.angle_deg = copy("angle_deg");
|
||||
out.arc_length = copy("arc_length");
|
||||
out.center = copy("center");
|
||||
out.slot = copy("slot");
|
||||
if let Some(sl) = &one.slot {
|
||||
out["center_from"] = json!(sl.centres.0);
|
||||
out["center_to"] = json!(sl.centres.1);
|
||||
out.center_from = Some(sl.centres.0.into());
|
||||
out.center_to = Some(sl.centres.1.into());
|
||||
}
|
||||
}
|
||||
if let [a, b] = described.as_slice() {
|
||||
@@ -698,42 +703,45 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
max = Some(exact);
|
||||
}
|
||||
if let (Some((p, q)), Some(d)) = (near, min) {
|
||||
out["distance_mm"] = json!(d);
|
||||
out["from"] = json!(p);
|
||||
out["to"] = json!(q);
|
||||
out["delta"] = json!(q - p);
|
||||
out["min"] = json!(d);
|
||||
out.set_closest(d, p, q);
|
||||
}
|
||||
let unbounded = |i: usize| matches!(sels.get(i), Some(Sel::Plane { .. } | Sel::Axis { .. }));
|
||||
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape, near.map(|(p, _)| p), (unbounded(0), unbounded(1)))
|
||||
.filter(|(d, p, q)| d.is_finite() && p.is_finite() && q.is_finite())
|
||||
{
|
||||
out["distance_mm"] = json!(d);
|
||||
out["from"] = json!(p);
|
||||
out["to"] = json!(q);
|
||||
out["delta"] = json!(q - p);
|
||||
out["min"] = json!(d);
|
||||
out.set_closest(d, p, q);
|
||||
}
|
||||
if let (Some((p, q)), Some(d)) = (far, max) {
|
||||
out["max"] = json!(d);
|
||||
out["max_from"] = json!(p);
|
||||
out["max_to"] = json!(q);
|
||||
out["max_delta"] = json!(q - p);
|
||||
out.max = Some(d);
|
||||
out.max_from = Some(p.into());
|
||||
out.max_to = Some(q.into());
|
||||
out.max_delta = Some((q - p).into());
|
||||
}
|
||||
if let Some(c) = c {
|
||||
out["center_distance"] = json!(c.distance);
|
||||
out["center_from"] = json!(c.from);
|
||||
out["center_to"] = json!(c.to);
|
||||
out["center_delta"] = json!(c.to - c.from);
|
||||
out.center_distance = Some(c.distance);
|
||||
out.center_from = Some(c.from.into());
|
||||
out.center_to = Some(c.to.into());
|
||||
out.center_delta = Some((c.to - c.from).into());
|
||||
}
|
||||
out["measure"] = json!(if c.is_some() { "center" } else { "min" });
|
||||
out.measure = Some(if c.is_some() { MeasureKind::Center } else { MeasureKind::Min });
|
||||
if let (Some(da), Some(db)) = (a.dir, b.dir) {
|
||||
let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]);
|
||||
let cos = if both_faces { da.dot(db) } else { corner_cos(a.shape, b.shape).unwrap_or_else(|| da.dot(db).abs()) };
|
||||
out["angle_deg"] = json!(cos.clamp(-1.0, 1.0).acos().to_degrees());
|
||||
out.angle_deg = Some(json!(cos.clamp(-1.0, 1.0).acos().to_degrees()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
serde_json::to_value(MeasureResult { measured: Measured::Items(Box::new(out)), param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
impl MeasuredItems {
|
||||
/// The closest distance between two items and where it is: `distance_mm` and `min` are one.
|
||||
fn set_closest(&mut self, d: f64, p: Vec3, q: Vec3) {
|
||||
self.distance_mm = Some(d);
|
||||
self.min = Some(d);
|
||||
self.from = Some(p.into());
|
||||
self.to = Some(q.into());
|
||||
self.delta = Some((q - p).into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Farthest pair of points between two pieces of geometry (on their samples and vertices).
|
||||
|
||||
@@ -85,8 +85,12 @@ mod point_cloud;
|
||||
mod script;
|
||||
mod section;
|
||||
pub mod settings;
|
||||
pub(crate) use clipboard::{component_path_form, copy_keys};
|
||||
pub(crate) use component::matrix::{rigid as rigid_matrix, tolerance as matrix_tolerance};
|
||||
pub(crate) use component::{base_transform, move_vector};
|
||||
pub(crate) use measure_sel::{measure_items, shape_of};
|
||||
pub(crate) use measure_shape::Shape;
|
||||
pub(crate) use script::step_params;
|
||||
#[cfg(test)]
|
||||
mod kernel_heal_tests;
|
||||
mod panels;
|
||||
@@ -102,15 +106,15 @@ mod render_ui;
|
||||
mod script_tests;
|
||||
mod sheet;
|
||||
pub(crate) use sheet::{
|
||||
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
|
||||
sheet_rule_name, then_list,
|
||||
FlangeType, ThenStep, bend_table_format, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position,
|
||||
perforation_pattern, sheet_points, sheet_rule_name, then_list,
|
||||
};
|
||||
mod silhouette;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
pub(crate) use sketch::collapse_reason;
|
||||
pub(crate) use sketch::created as sketch_created;
|
||||
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form};
|
||||
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form, point_arg_form};
|
||||
mod canvas_sketch;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
@@ -118,6 +122,7 @@ pub mod sketch_clipboard;
|
||||
mod sketch_constraints;
|
||||
mod sketch_construction;
|
||||
mod sketch_create;
|
||||
pub(crate) use sketch_create::{AT_MUST, MAX_TEXT_EDITS};
|
||||
mod sketch_divide;
|
||||
mod sketch_ends;
|
||||
mod sketch_equation;
|
||||
@@ -126,6 +131,7 @@ mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
pub(crate) use sketch_project::{RefForm, ref_form};
|
||||
mod sketch_store;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
|
||||
@@ -16,8 +16,10 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, plac
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::schema::motion::{
|
||||
ContactCheck, ContactCollision, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetChange,
|
||||
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, FindLimitsParams, FindLimitsResult, MotionListParams,
|
||||
MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo, MotionStudyParams, MotionStudySaved,
|
||||
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, ExplodeCreateParams, ExplodeCreated, ExplodeDeleteParams,
|
||||
ExplodeDeleted, ExplodeListParams, ExplodeMoveInfo, ExplodeShowParams, ExplodeShown, ExplodeViews, ExplodedViewInfo, FindLimitsParams,
|
||||
FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo,
|
||||
MotionStudyParams, MotionStudySaved,
|
||||
};
|
||||
use crate::schema::motion_play::{
|
||||
DriveReport, ExportParams, PartPose, PlayParams, Pose, StudyExport, StudyExported, StudyPlayed, StudySample, UnmovedJoint,
|
||||
@@ -53,6 +55,10 @@ const CONTACT_EDIT_CONTRACT: TypedContract<ContactEditParams> = TypedContract::n
|
||||
const STUDY_CONTRACT: TypedContract<MotionStudyParams> = TypedContract::new();
|
||||
const STUDY_DELETE_CONTRACT: TypedContract<MotionStudyDeleteParams> = TypedContract::new();
|
||||
const STUDY_LIST_CONTRACT: TypedContract<MotionListParams> = TypedContract::new();
|
||||
const EXPLODE_CREATE_CONTRACT: TypedContract<ExplodeCreateParams> = TypedContract::new();
|
||||
const EXPLODE_SHOW_CONTRACT: TypedContract<ExplodeShowParams> = TypedContract::new();
|
||||
const EXPLODE_LIST_CONTRACT: TypedContract<ExplodeListParams> = TypedContract::new();
|
||||
const EXPLODE_DELETE_CONTRACT: TypedContract<ExplodeDeleteParams> = TypedContract::new();
|
||||
const PLAY_CONTRACT: TypedContract<PlayParams> = TypedContract::new();
|
||||
const EXPORT_CONTRACT: TypedContract<ExportParams> = TypedContract::new();
|
||||
|
||||
@@ -175,16 +181,32 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&STUDY_DELETE_CONTRACT)
|
||||
.output(output_schema_for::<MotionStudyDeleted>)
|
||||
.params("study"),
|
||||
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").enabled(has_parts_to_explode).params(
|
||||
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
|
||||
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
|
||||
),
|
||||
CommandSpec::new("explode.show", "Show Exploded View", explode_show)
|
||||
CommandSpec::new("explode.create", "Exploded View", crate::checked!("explode.create", explode_create))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("explode")
|
||||
.enabled(has_parts_to_explode)
|
||||
.contract(&EXPLODE_CREATE_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeCreated>)
|
||||
.params(
|
||||
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
|
||||
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
|
||||
),
|
||||
CommandSpec::new("explode.show", "Show Exploded View", crate::checked!("explode.show", explode_show))
|
||||
.icon("explode")
|
||||
.noundo()
|
||||
.contract(&EXPLODE_SHOW_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeShown>)
|
||||
.params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"),
|
||||
CommandSpec::new("explode.list", "List Exploded Views", explode_list).icon("list").noundo(),
|
||||
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).icon("delete").params("view"),
|
||||
CommandSpec::new("explode.list", "List Exploded Views", crate::checked!("explode.list", explode_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&EXPLODE_LIST_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeViews>),
|
||||
CommandSpec::new("explode.delete", "Delete Exploded View", crate::checked!("explode.delete", explode_delete))
|
||||
.icon("delete")
|
||||
.contract(&EXPLODE_DELETE_CONTRACT)
|
||||
.output(output_schema_for::<ExplodeDeleted>)
|
||||
.params("view"),
|
||||
];
|
||||
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
@@ -1233,7 +1255,8 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name =
|
||||
str_(p, "name").map(str::to_string).unwrap_or_else(|| free_name("Exploded View", s.doc.assembly.exploded.iter().map(|v| v.name.as_str())));
|
||||
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
|
||||
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
|
||||
let moves = moves.iter().map(ExplodeMoveInfo::from).collect();
|
||||
typed_reply(cmd, ExplodeCreated { view: id, name, moves, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1242,7 +1265,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.pending_moves.clear();
|
||||
s.exploded.clear();
|
||||
s.revision += 1;
|
||||
return Ok(json!({"view": null}));
|
||||
return typed_reply(cmd, ExplodeShown { view: None, name: None, param_warnings: Vec::new() });
|
||||
}
|
||||
let i = view_index(s, p, cmd)?;
|
||||
let v = s.doc.assembly.exploded.get(i).cloned().ok_or_else(|| bad(cmd, "view"))?;
|
||||
@@ -1254,7 +1277,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.exploded.clone_from(&moves);
|
||||
s.pending_moves = moves;
|
||||
s.revision += 1;
|
||||
Ok(json!({"view": v.id, "name": v.name}))
|
||||
typed_reply(cmd, ExplodeShown { view: Some(v.id), name: Some(v.name), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Whether the pending moves are an exploded view shown (`explode.show`), which places the
|
||||
@@ -1287,13 +1310,14 @@ fn exploded_at(moves: &[(u64, Mat, Vec3)], amount: f64) -> std::collections::BTr
|
||||
}
|
||||
|
||||
fn explode_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"views": s.doc.assembly.exploded}))
|
||||
let views = s.doc.assembly.exploded.iter().map(ExplodedViewInfo::from).collect();
|
||||
typed_reply("explode.list", ExplodeViews { views, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn explode_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = view_index(s, p, "explode.delete")?;
|
||||
let v = s.doc_mut().assembly.exploded.remove(i);
|
||||
Ok(json!({"deleted": v.name}))
|
||||
typed_reply("explode.delete", ExplodeDeleted { deleted: v.name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// World position of a point of an occurrence (tests).
|
||||
|
||||
@@ -108,6 +108,22 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, expected_revision?: reopen retained state, activate and request focus"
|
||||
),
|
||||
command!(
|
||||
"panel.focus.next",
|
||||
"fit",
|
||||
"Focus Next Panel",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.focus.previous",
|
||||
"fit",
|
||||
"Focus Previous Panel",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.list",
|
||||
"list",
|
||||
@@ -124,6 +140,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, destination: new_section, join_tab or relative, activate?: false, expected_revision?"
|
||||
),
|
||||
command!(
|
||||
"panel.move.next_zone",
|
||||
"move",
|
||||
"Move Panel to Next Zone",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.native_close",
|
||||
"restore",
|
||||
@@ -180,4 +204,56 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
crate::panels::LayoutReply,
|
||||
"first, second, first_size, second_size, expected_revision?: conserve adjacent dock pair extent"
|
||||
),
|
||||
command!(
|
||||
"panel.sash.down",
|
||||
"scale",
|
||||
"Resize Panel Sash Down",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Down"),
|
||||
command!(
|
||||
"panel.sash.left",
|
||||
"scale",
|
||||
"Resize Panel Sash Left",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Left"),
|
||||
command!(
|
||||
"panel.sash.reset",
|
||||
"restore",
|
||||
"Reset Focused Panel Sash",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
command!(
|
||||
"panel.sash.right",
|
||||
"scale",
|
||||
"Resize Panel Sash Right",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Right"),
|
||||
command!(
|
||||
"panel.sash.up",
|
||||
"scale",
|
||||
"Resize Panel Sash Up",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
)
|
||||
.key("Up"),
|
||||
command!(
|
||||
"panel.toggle",
|
||||
"fold",
|
||||
"Toggle Focused Panel",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: resolve the focused shared panel control in the live host"
|
||||
),
|
||||
];
|
||||
|
||||
@@ -10,8 +10,11 @@ use solidcraft_sketch::{Analysis, ConstraintKind, CurveKind, Hold, Sketch, solve
|
||||
use super::{CommandSpec, sketch_store};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::schema::parameters::{
|
||||
AddedValue, ParamAddParams, ParamAdded, ParamCommentParams, ParamCommented, ParamDeleted, ParamFavoriteParams, ParamFavorited, ParamMoveParams,
|
||||
ParamMoved, ParamNameParams, ParamRenameParams, ParamRenamed,
|
||||
AddedValue, Completion, CompletionKind, DrivenFeature, EvalKind, ExprCompleteParams, ExprCompletions, ExprEvaluateParams, ExprEvaluated,
|
||||
ExprEvaluatedBody, FailedFeature, ParamAddParams, ParamAdded, ParamChangeParams, ParamChanged, ParamChangedBody, ParamChangedOne,
|
||||
ParamCommentParams, ParamCommented, ParamDeleted, ParamExportParams, ParamExportResult, ParamFavoriteParams, ParamFavorited, ParamGraphParams,
|
||||
ParamGraphResult, ParamImportParams, ParamImportResult, ParamListParams, ParamListResult, ParamMoveParams, ParamMoved, ParamNameParams,
|
||||
ParamRename, ParamRenameParams, ParamRenamed, ParamUsersParams, ParamUsersResult, ParameterRow, ParameterUser, SwitchedFeature,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -22,9 +25,22 @@ const NAME_CONTRACT: TypedContract<ParamNameParams> = TypedContract::new();
|
||||
const COMMENT_CONTRACT: TypedContract<ParamCommentParams> = TypedContract::new();
|
||||
const FAVORITE_CONTRACT: TypedContract<ParamFavoriteParams> = TypedContract::new();
|
||||
const MOVE_CONTRACT: TypedContract<ParamMoveParams> = TypedContract::new();
|
||||
const CHANGE_CONTRACT: TypedContract<ParamChangeParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<ParamListParams> = TypedContract::new();
|
||||
const USERS_CONTRACT: TypedContract<ParamUsersParams> = TypedContract::new();
|
||||
const GRAPH_CONTRACT: TypedContract<ParamGraphParams> = TypedContract::new();
|
||||
const EXPORT_CONTRACT: TypedContract<ParamExportParams> = TypedContract::new();
|
||||
const IMPORT_CONTRACT: TypedContract<ParamImportParams> = TypedContract::new();
|
||||
const EVALUATE_CONTRACT: TypedContract<ExprEvaluateParams> = TypedContract::new();
|
||||
const COMPLETE_CONTRACT: TypedContract<ExprCompleteParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
||||
CommandSpec::new("parameters.change", "Change Parameters", crate::checked!("parameters.change", change_param))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("params")
|
||||
.contract(&CHANGE_CONTRACT)
|
||||
.output(output_schema_for::<ParamChanged>)
|
||||
.params(
|
||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit, a length the design's units; \
|
||||
a text parameter's expression is its text, quotes round it optional: sketch texts' words are built from it), comment?, \
|
||||
favorite?: bool, new_name?: rename (references follow; an expression `new = value` renames to `new` and sets value, \
|
||||
@@ -34,7 +50,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
(the configuration rules that switch a feature's suppression and read a parameter whose value changed run in their order, as on \
|
||||
activation; driven parameters and restrictions wait for a configuration edit or activation)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params(
|
||||
CommandSpec::new("parameters.list", "List Parameters", crate::checked!("parameters.list", list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(output_schema_for::<ParamListResult>)
|
||||
.params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value (mm/deg in every \
|
||||
reply), display_value (in its own unit), source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
),
|
||||
@@ -68,30 +90,42 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&COMMENT_CONTRACT)
|
||||
.output(output_schema_for::<ParamCommented>)
|
||||
.params("name, comment"),
|
||||
CommandSpec::new("parameters.users", "Parameter Users", users)
|
||||
CommandSpec::new("parameters.users", "Parameter Users", crate::checked!("parameters.users", users))
|
||||
.icon("graph")
|
||||
.noundo()
|
||||
.contract(&USERS_CONTRACT)
|
||||
.output(output_schema_for::<ParamUsersResult>)
|
||||
.params("name → users (parameters, feature inputs, dimensions) and the features it drives"),
|
||||
CommandSpec::new("parameters.graph", "Parameter Graph", graph)
|
||||
CommandSpec::new("parameters.graph", "Parameter Graph", crate::checked!("parameters.graph", graph))
|
||||
.icon("graph")
|
||||
.noundo()
|
||||
.contract(&GRAPH_CONTRACT)
|
||||
.output(output_schema_for::<ParamGraphResult>)
|
||||
.params("→ nodes, edges [user, used] (users include Feature:<name>), cycles, errors"),
|
||||
CommandSpec::new("parameters.export", "Export Parameters", export)
|
||||
CommandSpec::new("parameters.export", "Export Parameters", crate::checked!("parameters.export", export))
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.contract(&EXPORT_CONTRACT)
|
||||
.output(output_schema_for::<ParamExportResult>)
|
||||
.params("format?: csv|json (default csv, or from the path), path?: write to a file → text"),
|
||||
CommandSpec::new("parameters.import", "Import Parameters", import)
|
||||
CommandSpec::new("parameters.import", "Import Parameters", crate::checked!("parameters.import", import))
|
||||
.icon("import")
|
||||
.contract(&IMPORT_CONTRACT)
|
||||
.output(output_schema_for::<ParamImportResult>)
|
||||
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", crate::checked!("expr.evaluate", evaluate))
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&EVALUATE_CONTRACT)
|
||||
.output(output_schema_for::<ExprEvaluated>)
|
||||
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…; text: the text a text parameter would hold, as `text`) → ok, value (mm/rad), display_value, references, error"),
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", complete)
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", crate::checked!("parameters.complete", complete))
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&COMPLETE_CONTRACT)
|
||||
.output(output_schema_for::<ExprCompletions>)
|
||||
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
|
||||
];
|
||||
|
||||
@@ -108,8 +142,8 @@ fn param_name<'a>(cmd: &str, p: &'a Value) -> Result<&'a str> {
|
||||
}
|
||||
|
||||
/// Features that fail (name and error).
|
||||
fn failing(s: &Session) -> Vec<Value> {
|
||||
s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect()
|
||||
fn failing(s: &Session) -> Vec<FailedFeature> {
|
||||
FailedFeature::of(&s.model.results)
|
||||
}
|
||||
|
||||
pub(super) fn known(s: &Session, name: &str) -> bool {
|
||||
@@ -130,19 +164,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n.trim())?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
return Ok(json!({"deleted": n.trim()}));
|
||||
return reply(cmd, ParamChanged { body: ParamChangedBody::Deleted { deleted: n.trim().to_string() }, param_warnings: Vec::new() });
|
||||
}
|
||||
let mut name = param_name(cmd, p)?.to_string();
|
||||
let mut out = json!({});
|
||||
let mut renamed: Option<ParamRename> = None;
|
||||
let mut changed_through: Option<String> = None;
|
||||
if let Some(new) = str_(p, "new_name") {
|
||||
let n = s.doc_mut().rename_param(&name, new)?;
|
||||
out["renamed"] = json!({"from": name, "to": new.trim(), "references_updated": n});
|
||||
renamed = Some(ParamRename { from: name, to: new.trim().to_string(), references_updated: n });
|
||||
name = new.trim().to_string();
|
||||
}
|
||||
let has_expr = p.get("expression").is_some() || p.get("value").is_some();
|
||||
let mut e = if has_expr {
|
||||
Some(expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?)
|
||||
} else if out.get("renamed").is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
|
||||
} else if renamed.is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
|
||||
None
|
||||
} else {
|
||||
return Err(bad(cmd, "`expression` must be a number or text"));
|
||||
@@ -151,7 +186,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// `d7`, a feature input's) is renamed, references following, and takes the expression. A
|
||||
// text parameter's text is only text.
|
||||
if let Some((new, rhs)) = e.as_deref().filter(|_| !is_text(s, &name, str_(p, "unit"))).and_then(expr::definition) {
|
||||
if out.get("renamed").is_some() {
|
||||
if renamed.is_some() {
|
||||
return Err(bad(cmd, format!("`{new} = …` renames `{name}` already: give `new_name` or `name = expression`, not both")));
|
||||
}
|
||||
if rhs.is_empty() {
|
||||
@@ -162,7 +197,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// `w = 20` shows `w`, and `w = 30` typed over it sets `w`.
|
||||
let refers_to = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| *n == name).map(|(_, e, _)| e);
|
||||
if refers_to.as_deref().map(str::trim) == Some(new.as_str()) && s.doc.param(&new).is_some() {
|
||||
out["changed_through"] = json!(name);
|
||||
changed_through = Some(name);
|
||||
name = new.clone();
|
||||
}
|
||||
if new != name {
|
||||
@@ -170,7 +205,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("there is already a parameter `{new}`: type `{new}` to use it, or change it in Parameters")));
|
||||
}
|
||||
let n = s.doc_mut().rename_param(&name, &new)?;
|
||||
out["renamed"] = json!({"from": name, "to": new, "references_updated": n});
|
||||
renamed = Some(ParamRename { from: name, to: new.clone(), references_updated: n });
|
||||
name = new;
|
||||
}
|
||||
e = Some(rhs);
|
||||
@@ -197,20 +232,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
if !switched.is_empty() {
|
||||
out["switched"] = json!(switched);
|
||||
}
|
||||
out["name"] = json!(name);
|
||||
out["expression"] = json!(e);
|
||||
if let Some(t) = s.doc.text_param(&name) {
|
||||
out["text"] = json!(t.text);
|
||||
}
|
||||
out["value"] = json!(vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))));
|
||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name).map(expr::round_reported));
|
||||
out["recomputed"] = json!(s.model.last_recomputed);
|
||||
out["restored"] = json!(s.model.last_restored);
|
||||
out["errors"] = json!(failing(s));
|
||||
Ok(out)
|
||||
let one = ParamChangedOne {
|
||||
renamed,
|
||||
changed_through,
|
||||
switched,
|
||||
text: s.doc.text_param(&name).map(|t| t.text.clone()),
|
||||
value: vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))),
|
||||
display_value: s.doc.param_display_value(&vals, &name).map(expr::round_reported),
|
||||
recomputed: s.model.last_recomputed,
|
||||
restored: s.model.last_restored,
|
||||
errors: failing(s),
|
||||
name,
|
||||
expression: e,
|
||||
};
|
||||
reply(cmd, ParamChanged { body: ParamChangedBody::One(Box::new(one)), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The point a length dimension keeps when its value changes: the first it measures from (a
|
||||
@@ -308,19 +343,15 @@ fn change_many(s: &mut Session, changes: &Value, solved: &crate::SketchValues) -
|
||||
}
|
||||
s.settle_value_edit(solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values: serde_json::Map<String, Value> =
|
||||
list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v)))))).collect();
|
||||
let mut out = json!({"changed": values, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored, "errors": failing(s)});
|
||||
if !switched.is_empty() {
|
||||
out["switched"] = json!(switched);
|
||||
}
|
||||
Ok(out)
|
||||
let changed = list.iter().map(|(n, _)| (n.clone(), vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v))))).collect();
|
||||
let body = ParamChangedBody::Many { changed, recomputed: s.model.last_recomputed, restored: s.model.last_restored, errors: failing(s), switched };
|
||||
reply(cmd, ParamChanged { body, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The configuration rules that switch a feature's suppression and read a parameter this edit
|
||||
/// changed (`Document::apply_suppression_rules`), applied before the design is rebuilt, so that
|
||||
/// one rebuild has them: `[{feature, name, suppressed}]` for each feature they switched.
|
||||
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<Value> {
|
||||
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<SwitchedFeature> {
|
||||
if s.doc.configs.rules.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -329,12 +360,13 @@ fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String,
|
||||
.into_iter()
|
||||
.map(|(id, suppressed)| {
|
||||
let name = s.doc.features.iter().find(|f| f.id == id).map(|f| f.name.clone());
|
||||
json!({"feature": id, "name": name, "suppressed": suppressed})
|
||||
SwitchedFeature { feature: id, name, suppressed }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.list";
|
||||
let filter = match (bool_(p, "favorites"), str_(p, "filter")) {
|
||||
(Some(true), _) => "favorites".to_string(),
|
||||
(_, Some(f)) => f.to_ascii_lowercase(),
|
||||
@@ -354,7 +386,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Favourites first; otherwise the table order (user, sketch, feature).
|
||||
rows.sort_by_key(|r| !r.favorite);
|
||||
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
|
||||
Ok(json!({"parameters": rows, "count": rows.len(), "cycles": cycles}))
|
||||
let count = rows.len();
|
||||
let parameters = rows.into_iter().map(ParameterRow::from).collect();
|
||||
reply(cmd, ParamListResult { parameters, count, cycles: cycles.into_iter().collect(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -438,17 +472,24 @@ fn users(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no parameter `{name}`")));
|
||||
}
|
||||
let users = s.doc.param_users(name);
|
||||
let drives: Vec<Value> =
|
||||
s.doc.features_using_param(name).iter().filter_map(|id| s.doc.feature(*id)).map(|f| json!({"id": f.id, "name": f.name})).collect();
|
||||
Ok(json!({"name": name, "users": users, "drives": drives}))
|
||||
let users = users.into_iter().map(ParameterUser::from).collect();
|
||||
let drives = s
|
||||
.doc
|
||||
.features_using_param(name)
|
||||
.iter()
|
||||
.filter_map(|id| s.doc.feature(*id))
|
||||
.map(|f| DrivenFeature { id: f.id, name: f.name.clone() })
|
||||
.collect();
|
||||
reply(cmd, ParamUsersResult { name: name.to_string(), users, drives, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn graph(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.graph";
|
||||
let (_, errs) = s.doc.param_values();
|
||||
let nodes: Vec<String> = s.doc.param_defs().into_iter().map(|d| d.name).collect();
|
||||
let edges: Vec<[String; 2]> = s.doc.param_graph().into_iter().map(|(a, b)| [a, b]).collect();
|
||||
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
|
||||
Ok(json!({"nodes": nodes, "edges": edges, "cycles": cycles, "errors": errs}))
|
||||
reply(cmd, ParamGraphResult { nodes, edges, cycles: cycles.into_iter().collect(), errors: errs, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -477,7 +518,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(path) = path {
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
}
|
||||
Ok(json!({"format": format, "path": path, "text": text}))
|
||||
reply(cmd, ParamExportResult { format, path: path.map(str::to_string), text, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Largest parameter table we read from a file.
|
||||
@@ -495,7 +536,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let solved = s.solved_sketches();
|
||||
let names = s.doc_mut().import_params(&text)?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"imported": names, "errors": failing(s)}))
|
||||
reply(cmd, ParamImportResult { imported: names, errors: failing(s), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -504,7 +545,8 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let unit = str_(p, "unit");
|
||||
// A text parameter's expression is its text.
|
||||
if unit.is_some_and(solidcraft_doc::text_params::is_text_unit) {
|
||||
return Ok(json!({"ok": true, "kind": "text", "text": solidcraft_doc::text_params::unquote(&e), "references": []}));
|
||||
let body = ExprEvaluatedBody::Text { ok: true, kind: "text".into(), text: solidcraft_doc::text_params::unquote(&e), references: Vec::new() };
|
||||
return reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() });
|
||||
}
|
||||
if let Some(u) = unit
|
||||
&& expr::unit_info(u).is_none()
|
||||
@@ -531,7 +573,7 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
// `name = expression` is worth its expression, and names what it would define (#4071).
|
||||
let defines = expr::definition(&e).map(|(n, _)| n);
|
||||
let mut out = match r {
|
||||
let body = match r {
|
||||
Ok((v, k)) => {
|
||||
let value = match k {
|
||||
Kind::Length => expr::Value::length(v),
|
||||
@@ -543,17 +585,27 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Kind::Angle) => v.to_degrees(),
|
||||
(None, _) => v,
|
||||
};
|
||||
json!({"ok": true, "value": expr::round_reported(v), "kind": k, "display_value": expr::round_reported(shown), "references": refs})
|
||||
ExprEvaluatedBody::Value {
|
||||
ok: true,
|
||||
value: expr::round_reported(v),
|
||||
kind: EvalKind::from(k),
|
||||
display_value: expr::round_reported(shown),
|
||||
references: refs,
|
||||
defines: defines.map(str::to_string),
|
||||
}
|
||||
}
|
||||
Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}),
|
||||
Err(err) => ExprEvaluatedBody::Failed {
|
||||
ok: false,
|
||||
error: err.to_string().trim_start_matches("expression: ").to_string(),
|
||||
references: refs,
|
||||
defines: defines.map(str::to_string),
|
||||
},
|
||||
};
|
||||
if let Some(name) = defines {
|
||||
out["defines"] = json!(name);
|
||||
}
|
||||
Ok(out)
|
||||
reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.complete";
|
||||
let text = str_(p, "prefix").unwrap_or("");
|
||||
// The identifier being typed: trailing letters, digits and underscores.
|
||||
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
|
||||
@@ -565,7 +617,7 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// start.
|
||||
let contains = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
|
||||
let within = |n: &str| contains(n) || solidcraft_doc::name_words_match(n, &word);
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut out: Vec<Completion> = Vec::new();
|
||||
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
|
||||
// Names that start with the word, then names that contain it, then names matched only by
|
||||
// the starts of their words; within each, favourites, then user parameters, then the others.
|
||||
@@ -580,22 +632,22 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !r.comment.is_empty() {
|
||||
detail.push_str(&format!(" — {}", r.comment));
|
||||
}
|
||||
out.push(json!({"text": r.name, "kind": "parameter", "detail": detail}));
|
||||
out.push(Completion { text: r.name, kind: CompletionKind::Parameter, detail });
|
||||
}
|
||||
for (f, sig) in expr::FUNCTIONS {
|
||||
if starts(f) {
|
||||
out.push(json!({"text": format!("{f}("), "kind": "function", "detail": sig}));
|
||||
out.push(Completion { text: format!("{f}("), kind: CompletionKind::Function, detail: sig.to_string() });
|
||||
}
|
||||
}
|
||||
if starts("pi") {
|
||||
out.push(json!({"text": "pi", "kind": "constant", "detail": "3.14159…"}));
|
||||
out.push(Completion { text: "pi".into(), kind: CompletionKind::Constant, detail: "3.14159…".into() });
|
||||
}
|
||||
for (u, k, _) in expr::UNITS {
|
||||
if !word.is_empty() && starts(u) {
|
||||
out.push(json!({"text": u, "kind": "unit", "detail": k}));
|
||||
out.push(Completion { text: u.to_string(), kind: CompletionKind::Unit, detail: EvalKind::from(*k).word().to_string() });
|
||||
}
|
||||
}
|
||||
Ok(json!({"word": word, "suggestions": out}))
|
||||
reply(cmd, ExprCompletions { word, suggestions: out, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,30 @@
|
||||
use super::*;
|
||||
use solidcraft_render::job::{CpuHost, JobId};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
// Validate each live receipt before comparing deterministic accounting. RAM
|
||||
// snapshots and other retained leases can change between otherwise equal checks.
|
||||
fn preflight_without_memory_observations(mut reply: Value) -> Value {
|
||||
let total = reply["admission"]["estimated_total_bytes"].as_u64().unwrap();
|
||||
let memory = reply["admission"]["host_memory"].as_object_mut().expect("native preflight reports its RAM admission");
|
||||
let required = memory["required_bytes"].as_u64().unwrap();
|
||||
let admitted = memory["admitted_bytes"].as_u64().unwrap();
|
||||
let available = memory["available_bytes"].as_u64().unwrap();
|
||||
let capacity = memory["capacity_bytes"].as_u64().unwrap();
|
||||
assert_eq!(required, total, "native debit includes the complete public admission");
|
||||
assert!(required <= available, "accepted native debit fits its available RAM: {memory:?}");
|
||||
assert_eq!(admitted.checked_add(available), Some(capacity), "native ledger accounts for the entire capacity snapshot");
|
||||
assert!(
|
||||
matches!(memory["source"].as_str(), Some("system_available" | "system_and_cgroup" | "conservative_fallback")),
|
||||
"native receipt identifies its capacity source"
|
||||
);
|
||||
// Keep required_bytes and every non-observation field in the exact comparison.
|
||||
for field in ["admitted_bytes", "available_bytes", "capacity_bytes", "source"] {
|
||||
memory.remove(field).unwrap();
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
fn session() -> (Session, CpuHost) {
|
||||
let host = CpuHost::cooperative();
|
||||
let mut s = Session::default();
|
||||
@@ -128,7 +152,7 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &named).unwrap();
|
||||
let inline = s.execute("render.preflight", &json!({"settings":saved})).unwrap();
|
||||
assert_eq!(checked, inline);
|
||||
assert_eq!(preflight_without_memory_observations(checked.clone()), preflight_without_memory_observations(inline));
|
||||
assert_eq!(checked["resolved"]["executor"], "gpu-path");
|
||||
assert_eq!(checked["options"]["exposure_ev"], 1.75);
|
||||
assert_eq!(checked["options"]["samples"], 7);
|
||||
@@ -209,7 +233,10 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
|
||||
);
|
||||
assert_eq!(checked["environment"]["source_sha256"], info["sha256"]);
|
||||
assert_eq!(checked["environment"]["kind"], "radiance_hdr");
|
||||
assert_eq!(checked["environment"]["color_policy"], "declared_linear_srgb_d65");
|
||||
@@ -253,7 +280,10 @@ fn saved_cpu_job_reopens_and_supplies_exact_options_and_one_exposure_to_check_an
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let named = json!({"setup":"CPU"});
|
||||
let checked = s.execute("render.preflight", &named).unwrap();
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
|
||||
);
|
||||
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
|
||||
assert_eq!(checked["request"]["integrator"], "raytrace");
|
||||
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
|
||||
@@ -300,7 +330,10 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
|
||||
);
|
||||
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
|
||||
assert_eq!(checked["environment"]["samples"], 64);
|
||||
assert_eq!(checked["environment"]["rotation_radians"], 0.75);
|
||||
@@ -308,7 +341,10 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
|
||||
let runtime = json!({"integrator":"raytrace","settings":{"width":9,"height":9,"background":[13,27,41]},
|
||||
"raytrace":{"lights":[],"shadows":false,"primary_samples":4,"exposure_ev":1.25},
|
||||
"environment":{"resource":info["sha256"],"rotation_radians":0.75,"intensity":2,"samples":64}});
|
||||
assert_eq!(checked, s.execute("render.preflight", &runtime).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &runtime).unwrap())
|
||||
);
|
||||
for environment in [
|
||||
json!({"resource":"0".repeat(64)}),
|
||||
json!({"resource":info["sha256"],"path":"must-not-open.hdr"}),
|
||||
@@ -425,7 +461,7 @@ fn additional_executor_routes_by_capability_and_survives_document_adoption() {
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(session.execute("render.preflight", &request).unwrap()["resolved"]["executor"], "test-progressive");
|
||||
assert!(session.execute("render.status", &json!({"job":id})).is_err());
|
||||
for bad in [json!({"samples":0}), json!({"max_depth":17}), json!({"seed":-1}), json!({"samples":4097})] {
|
||||
for bad in [json!({"samples":0}), json!({"max_depth":65}), json!({"seed":-1}), json!({"samples":65537})] {
|
||||
let mut params = request.clone();
|
||||
for (key, value) in bad.as_object().unwrap() {
|
||||
params[key] = value.clone();
|
||||
@@ -1512,7 +1548,9 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
|
||||
s.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"CPU denoise","set":{"width":8,"height":8,
|
||||
"job":{"method":"cpu_raytrace","options":{"denoise":true,"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
|
||||
"job":{"method":"cpu_raytrace","options":{"threads":2,"supersampling":2,"denoise":true,
|
||||
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false},"denoise_diagnostics":true,
|
||||
"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
@@ -1524,7 +1562,13 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
|
||||
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
|
||||
assert_eq!(checked["request"]["raytrace"]["denoise"], true);
|
||||
assert_eq!(checked["denoising"]["backend"], "native_cpu");
|
||||
assert_eq!(checked["denoising"]["passes"], 3);
|
||||
assert_eq!(checked["denoising"]["passes"], 2);
|
||||
assert_eq!(checked["denoising"]["steps"], json!([1, 2]));
|
||||
assert_eq!(checked["denoising"]["settings"], json!({"passes":2,"strength":0.5,"firefly_suppression":false}));
|
||||
assert_eq!(checked["denoising"]["outlier_policy"], "disabled");
|
||||
assert_eq!(checked["request"]["raytrace"]["threads"], 2);
|
||||
assert_eq!(checked["request"]["raytrace"]["supersampling"], 2);
|
||||
assert_eq!(checked["request"]["raytrace"]["denoise_diagnostics"], true);
|
||||
assert_eq!(checked["denoising"]["raw_linear_accumulator_preserved"], true);
|
||||
let started = s.execute("render.start", &json!({"setup":"CPU denoise"})).unwrap();
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
@@ -1534,13 +1578,43 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
|
||||
assert_eq!(json!(status.request), checked["request"]);
|
||||
let frame = handle.frame().unwrap().unwrap();
|
||||
let raw = frame.linear_exr().unwrap().to_vec();
|
||||
assert_eq!((frame.info.width, frame.info.height), (8, 8));
|
||||
assert_eq!(image::load_from_memory(frame.png()).unwrap().to_rgba8().dimensions(), (8, 8));
|
||||
let report = status.denoise_diagnostics.unwrap();
|
||||
assert_eq!((report.width, report.height), (16, 16));
|
||||
assert_eq!(report.counts.iter().sum::<u64>(), 256);
|
||||
let mask = frame.denoise_mask_png().unwrap().to_vec();
|
||||
let mask_info = frame.denoise_mask_info().unwrap();
|
||||
assert_eq!((mask_info.width, mask_info.height), (8, 8));
|
||||
assert_eq!(mask_info.bytes, mask.len() as u64);
|
||||
assert_ne!(mask_info.sha256, frame.info.png_sha256);
|
||||
let decoded = image::load_from_memory(&mask).unwrap().to_rgba8();
|
||||
assert_eq!(decoded.dimensions(), (8, 8));
|
||||
assert!(decoded.pixels().all(|pixel| solidcraft_render::job::DenoiseGuideClass::ALL.iter().any(|class| class.color() == pixel.0)));
|
||||
let status_reply = s.execute("render.status", &json!({"job":id})).unwrap();
|
||||
assert_eq!(status_reply["denoise_mask"], json!(mask_info));
|
||||
assert_eq!(status_reply["denoise_diagnostics"], json!(report));
|
||||
let viewed = s.execute("render.result.view", &json!({"view":"denoise_mask"})).unwrap();
|
||||
assert_eq!(viewed, json!({"view":"denoise_mask","job":id}));
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-cpu-guide-mask-{}-{}.png", std::process::id(), id.session));
|
||||
let exported = s.execute("render.export", &json!({"job":id,"path":path,"format":"denoise_mask"})).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), mask);
|
||||
assert_eq!(exported["format"], "denoise_mask");
|
||||
assert_eq!(exported["encoding"], "categorical_srgb");
|
||||
assert_eq!(exported["denoise_mask"], json!(mask_info));
|
||||
assert_eq!(exported["frame"]["png_sha256"], frame.info.png_sha256);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let result = s.execute("render.result.regrade", &json!({"job":id,"presentation":{"exposure_ev":-1.0}})).unwrap();
|
||||
let derivative: JobId = serde_json::from_value(result["job"].clone()).unwrap();
|
||||
let derivative = s.render_job_handle(derivative).unwrap();
|
||||
let finished = derivative.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
|
||||
assert_eq!(finished.progress.phase, Phase::Completed);
|
||||
assert!(!finished.presentation.as_ref().unwrap().denoise_applied, "regrade never pretends to reconstruct absent geometry guides");
|
||||
assert_eq!(derivative.frame().unwrap().unwrap().linear_exr().unwrap(), raw);
|
||||
let derivative_frame = derivative.frame().unwrap().unwrap();
|
||||
assert_eq!(derivative_frame.linear_exr().unwrap(), raw);
|
||||
assert_eq!(derivative_frame.denoise_mask_png().unwrap(), mask);
|
||||
assert_eq!(json!(derivative_frame.denoise_mask_info()), json!(Some(mask_info)));
|
||||
assert_eq!(finished.denoise_diagnostics, Some(report));
|
||||
assert_eq!(frame.linear_exr().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&document, &s.doc));
|
||||
assert_eq!((s.revision, s.evaluations, s.undo.len(), s.redo.len()), before);
|
||||
@@ -1594,12 +1668,14 @@ fn saved_cpu_optics_match_runtime_capture_pixels_prefixes_cancel_and_accounting(
|
||||
let extra_bytes = (extra_slots * std::mem::size_of::<solidcraft_render::job::DielectricOptions>()) as u64;
|
||||
assert_eq!(saved["retained_request_bytes"].as_u64().unwrap() + extra_bytes, runtime["retained_request_bytes"].as_u64().unwrap());
|
||||
// Two options copies belong to image work; a third belongs to the retained
|
||||
// immutable request. All other admission fields must remain exactly equal.
|
||||
let mut expected_runtime_admission = saved["admission"].clone();
|
||||
// immutable request. All other deterministic admission fields remain exact.
|
||||
let mut expected_runtime_admission = preflight_without_memory_observations(saved.clone())["admission"].clone();
|
||||
for (field, copies) in [("preparation_bytes", 2), ("estimated_total_bytes", 3)] {
|
||||
expected_runtime_admission[field] = json!(saved["admission"][field].as_u64().unwrap() + copies * extra_bytes);
|
||||
}
|
||||
assert_eq!(expected_runtime_admission, runtime["admission"]);
|
||||
expected_runtime_admission["host_memory"]["required_bytes"] =
|
||||
json!(saved["admission"]["host_memory"]["required_bytes"].as_u64().unwrap() + 3 * extra_bytes);
|
||||
assert_eq!(expected_runtime_admission, preflight_without_memory_observations(runtime.clone())["admission"]);
|
||||
assert_eq!(saved["refraction"], true);
|
||||
let original_mesh = scene.meshes[0].mesh.clone();
|
||||
assert!(!scene.has_transmission(), "saved CPU lowering leaves the captured product appearance and physical material unchanged");
|
||||
@@ -1925,7 +2001,10 @@ fn saved_cpu_aging_cold_reopen_resolves_exact_target_and_changes_real_png_withou
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"CPU aging"})).unwrap();
|
||||
assert_eq!(checked["request"]["raytrace"]["aging"][0]["target"]["body_name"], name);
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
|
||||
);
|
||||
let started = s.execute("render.start", &json!({"setup":"CPU aging"})).unwrap();
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
@@ -2229,8 +2308,9 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
|
||||
raster["environment"]["path"] = json!("must-not-open-raster-stable-glass.hdr");
|
||||
assert!(s.execute("render.preflight", &raster).unwrap_err().to_string().contains("stable optics require"));
|
||||
let mut over_cap = params.clone();
|
||||
let optical_limit = solidcraft_render::job::MAX_OPTICAL_TARGETS;
|
||||
over_cap["settings"]["optics"] = json!(
|
||||
(0..9)
|
||||
(0..=optical_limit)
|
||||
.map(|i| {
|
||||
let mut row = params["settings"]["optics"][0].clone();
|
||||
row["target"]["definition"] = json!(format!("Unique unknown optical body {i}"));
|
||||
@@ -2239,7 +2319,7 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
over_cap["environment"]["path"] = json!("must-not-open-over-cap-stable-glass.hdr");
|
||||
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains("at most eight"));
|
||||
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains(&format!("at most {optical_limit}")));
|
||||
let mut changed_hdr = params.clone();
|
||||
changed_hdr["environment"]["expected_sha256"] = json!("0".repeat(64));
|
||||
assert!(s.execute("render.preflight", &changed_hdr).unwrap_err().to_string().contains("changed since selection"));
|
||||
@@ -2277,7 +2357,7 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
|
||||
s.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"Mixed","set":{"camera":camera,"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{
|
||||
"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
|
||||
"texture_filtering":true,"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
|
||||
"environment":{"color":[1,1,1],"intensity":1},"output":{"linear_exr":true},
|
||||
"aging":[{"kind":"fading","target":panel["stable_target"],"strength":0.25,"coverage":1.0,"upward_bias":0.0}]
|
||||
}}}}),
|
||||
@@ -2301,6 +2381,8 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
|
||||
let history = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"Mixed"})).unwrap();
|
||||
assert_eq!(checked["textures"]["bodies"], 1);
|
||||
assert_eq!(checked["textures"]["filtering"], true);
|
||||
assert_eq!(checked["request"]["raytrace"]["texture_filtering"], true);
|
||||
assert_eq!(checked["request"]["raytrace"]["path"]["max_depth"], 4);
|
||||
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["body_name"], "Glass");
|
||||
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["boundary"], "closed_mesh");
|
||||
@@ -2470,7 +2552,11 @@ fn saved_coat_cpu_direct_and_path_render_match_independent_normal_incidence_laye
|
||||
let mut plain = setup.clone();
|
||||
plain.coats = Default::default();
|
||||
let plain_check = s.execute("render.preflight", &json!({"settings":plain})).unwrap();
|
||||
assert_eq!(checked["admission"], plain_check["admission"], "coat uses existing inline surface metadata and the same bounded producer stages");
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone())["admission"],
|
||||
preflight_without_memory_observations(plain_check.clone())["admission"],
|
||||
"coat uses existing inline surface metadata and the same bounded producer stages"
|
||||
);
|
||||
assert_eq!(checked["retained_request_bytes"], plain_check["retained_request_bytes"]);
|
||||
let mut zero = setup;
|
||||
let row = zero.coats.iter().next().unwrap().clone();
|
||||
@@ -3648,3 +3734,66 @@ fn tessellated_cpu_sheet_saved_and_prospective_bindings_certify_and_render_witho
|
||||
assert!(Arc::ptr_eq(&document, &s.doc) && Arc::ptr_eq(&geometry, &s.model.state()));
|
||||
assert_eq!(history, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn saved_final_cpu_image_preserves_mode_executor_and_authored_quality_through_start() {
|
||||
let mut session = Session::default();
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
session.set_render_job_host(Some(host)).unwrap();
|
||||
session.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
|
||||
session
|
||||
.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"Final OPUS reference","set":{
|
||||
"mode":"final","width":2,"height":2,"job":{"method":"cpu_raytrace","options":{
|
||||
"budget":"workstation","path":{"max_depth":64},"progressive":{"samples_per_pixel":3,"seed":81},
|
||||
"lights":[],"environment":{"color":[0.1,0.2,0.3],"intensity":1},"output":{"linear_exr":true}
|
||||
}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let params = json!({"setup":"Final OPUS reference"});
|
||||
let before = session.doc.clone();
|
||||
let preflight = session.execute("render.preflight", ¶ms).unwrap();
|
||||
assert_eq!(preflight["resolved"]["profile"]["mode"], "final");
|
||||
assert_eq!(preflight["resolved"]["executor"], "cpu-raytrace");
|
||||
let started = session.execute("render.start", ¶ms).unwrap();
|
||||
let id = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
let job = session.render_job_handle(id).unwrap();
|
||||
let status = job.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
|
||||
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed);
|
||||
assert_eq!(status.resolved.profile.mode, solidcraft_render::RenderMode::Final);
|
||||
assert_eq!(status.resolved.executor.as_str(), "cpu-raytrace");
|
||||
let options = status.request.raytrace.unwrap();
|
||||
assert_eq!(options.path.unwrap().max_depth, 64);
|
||||
assert_eq!(options.progressive.unwrap().samples_per_pixel, 3);
|
||||
assert!(job.frame().unwrap().unwrap().linear_master_info().is_some());
|
||||
assert!(Arc::ptr_eq(&before, &session.doc));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn cpu_quality_limits_refuse_before_input_io_and_scene_capture() {
|
||||
let mut s = Session::default();
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let document = s.doc.clone();
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
|
||||
for (width, height, options) in [
|
||||
(1024, 1024, json!({"supersampling":2,"progressive":{"samples_per_pixel":8}})),
|
||||
(8, 8, json!({"threads":65})),
|
||||
(8, 8, json!({"supersampling":3})),
|
||||
(8, 8, json!({"denoise_settings":{"passes":4}})),
|
||||
(8193, 1, json!({"budget":"workstation","denoise_diagnostics":true})),
|
||||
] {
|
||||
let request = json!({"integrator":"raytrace","settings":{"width":width,"height":height},
|
||||
"raytrace":options,"environment":{"path":"must-not-open-quality-controls.hdr"}});
|
||||
for command in ["render.preflight", "render.start"] {
|
||||
let error = s.execute(command, &request).unwrap_err().to_string();
|
||||
assert!(!error.contains("must-not-open-quality-controls.hdr"), "quality must refuse before source IO: {error}");
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert!(Arc::ptr_eq(&document, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ use solidcraft_render::{
|
||||
job::{ImageRequest, Integrator, JobId, RaytraceOptions},
|
||||
};
|
||||
|
||||
const DENOISE_OUTLIER_POLICY: &str = "suppress isolated high radiance on locally matching guides in display output";
|
||||
const DENOISE_OPTICAL_POLICY: &str =
|
||||
"supported sharp reflections and smooth glass use separate reflected and transmitted endpoint geometry and material guides";
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct Start {
|
||||
@@ -48,11 +52,16 @@ impl Default for EnvironmentRequest {
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct ProgressiveStart {
|
||||
supersampling: Option<u32>,
|
||||
denoise_settings: solidcraft_render::job::DenoiseSettings,
|
||||
denoise_diagnostics: bool,
|
||||
caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
studio_weights: Option<[f64; 3]>,
|
||||
aging: solidcraft_doc::render_aging::SavedAgingStack,
|
||||
#[serde(default)]
|
||||
nested_media: bool,
|
||||
texture_filtering: bool,
|
||||
metal_energy_compensation: bool,
|
||||
volume: Option<solidcraft_render::job::volume::HomogeneousVolume>,
|
||||
adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
|
||||
transmission_samples: Option<u32>,
|
||||
@@ -193,8 +202,9 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
|
||||
"samples":options.samples,"max_depth":options.max_depth,"seed":options.seed,
|
||||
"lens":options.lens,"lighting":options.lighting,"tone_map":options.tone_map,
|
||||
"denoise":options.denoise,"environment":options.environment,
|
||||
"supersampling":options.supersampling,"denoise_settings":options.denoise_settings,"denoise_diagnostics":options.denoise_diagnostics,
|
||||
"finishing":options.finishing,"backdrop":options.backdrop,"linear_exr":options.linear_exr,
|
||||
"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
|
||||
"texture_filtering":options.texture_filtering,"metal_energy_compensation":options.metal_energy_compensation,"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -246,7 +256,7 @@ pub(crate) fn prepare_with_metadata(
|
||||
params.get("raytrace").and_then(Value::as_object).is_some_and(|o| ["clear_coats", "textures"].iter().any(|key| o.contains_key(*key)));
|
||||
if gpu_raytrace
|
||||
|| params.get("environment").is_some_and(|e| e.get("kind").is_some())
|
||||
|| ["lens", "lighting", "tone_map"].iter().any(|key| params.get(*key).is_some())
|
||||
|| ["lens", "lighting", "tone_map", "supersampling", "denoise_settings", "denoise_diagnostics"].iter().any(|key| params.get(*key).is_some())
|
||||
|| params.get("denoise").is_some_and(|value| value != &json!(false))
|
||||
{
|
||||
return Err(bad(
|
||||
@@ -350,7 +360,15 @@ pub(crate) fn prepare_with_metadata(
|
||||
let defaults = RenderSetup::default();
|
||||
(defaults.width, defaults.height)
|
||||
};
|
||||
options.budget.validate_image(dimensions.0, dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
|
||||
let trace_dimensions = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
|
||||
dimensions
|
||||
} else {
|
||||
solidcraft_render::job::supersampled_dimensions(dimensions.0, dimensions.1, options.supersampling).map_err(job_error)?
|
||||
};
|
||||
options.budget.validate_image(trace_dimensions.0, trace_dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
|
||||
if options.denoise || options.denoise_diagnostics {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(trace_dimensions.0, trace_dimensions.1).map_err(job_error)?;
|
||||
}
|
||||
if let Some(lens) = options.lens {
|
||||
let camera = input
|
||||
.setup
|
||||
@@ -430,6 +448,19 @@ pub(crate) fn prepare_with_metadata(
|
||||
}
|
||||
options.dielectrics = super::optical_bindings::resolve_cpu_path(s, &scene, bindings, options.path.is_some(), command)?;
|
||||
options.validate().map_err(job_error)?;
|
||||
// An explicit optical binding owns transmission. Viewport opacity is
|
||||
// only a display proxy and must not enter CPU transport.
|
||||
for optical in &options.dielectrics {
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(optical.body_index)
|
||||
.filter(|body| body.name == optical.body_name)
|
||||
.ok_or_else(|| bad(command, "saved optical target changed during capture"))?;
|
||||
body.opacity = 1.0;
|
||||
for face in &mut body.faces {
|
||||
face.opacity = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
super::coat_bindings::apply(s, &mut scene, &coats, input.raytrace.as_ref(), command)?;
|
||||
if !input.aging.is_empty() {
|
||||
@@ -519,7 +550,13 @@ pub(crate) fn prepare_with_metadata(
|
||||
}
|
||||
let textures = if let Some(bindings) = saved_surfaces {
|
||||
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
|
||||
super::textures::capture_with_resources(&scene, &overrides, Some(&s.doc.render.texture_resources), command)?
|
||||
super::textures::capture_with_resources_and_filtering(
|
||||
&scene,
|
||||
&overrides,
|
||||
Some(&s.doc.render.texture_resources),
|
||||
command,
|
||||
input.raytrace.as_ref().is_some_and(|options| options.texture_filtering),
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -535,9 +572,13 @@ pub(crate) fn prepare_with_metadata(
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
};
|
||||
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let submission =
|
||||
let environment_bytes = environment
|
||||
.as_ref()
|
||||
.map_or(Ok(0), |e| e.required_bytes_with_importance(request.raytrace.as_ref().is_some_and(|options| options.environment_importance_sampling)))
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut submission =
|
||||
host.prepare_submission_with_metadata(scene, request, Default::default(), environment, cancel.clone(), retained).map_err(job_error)?;
|
||||
submission.select_image_mode(settings.mode).map_err(job_error)?;
|
||||
let request = submission.request();
|
||||
let mut admission = submission.admission();
|
||||
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
|
||||
@@ -579,12 +620,16 @@ pub(crate) fn prepare_with_metadata(
|
||||
if !coats.is_empty() {
|
||||
reply["coats"] = json!(coats);
|
||||
}
|
||||
if request.raytrace.as_ref().is_some_and(|options| options.denoise) {
|
||||
if let Some(options) = request.raytrace.as_ref().filter(|options| options.denoise) {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":3, "steps":[1,2,4],
|
||||
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":options.denoise_settings.passes,
|
||||
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
|
||||
"diagnostics_requested":options.denoise_diagnostics,
|
||||
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
|
||||
"optical_policy":DENOISE_OPTICAL_POLICY,
|
||||
"guides":"deterministic_center_ray_albedo_normal_camera_relative_position_depth",
|
||||
"bypass":"glass, sharp CPU mirror overrides, smooth metal/coat, emitter, background and partial coverage first hits",
|
||||
"limitation":"biased spatial filter; first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
|
||||
"bypass":"unsupported or ambiguous optical paths, emitter, background and partial coverage",
|
||||
"limitation":"biased spatial filter; optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
|
||||
"raw_linear_accumulator_preserved":true,"raw_linear_exr_preserved":true,
|
||||
"output_stage":"linear_before_tone_map", "work_units":"one pixel visit per pass, each with at most 25 taps",
|
||||
"checkpoints":"every complete published pass uses the same filter"
|
||||
@@ -710,16 +755,22 @@ fn prepare_progressive(
|
||||
options.lighting = input.lighting;
|
||||
options.tone_map = input.tone_map.unwrap_or_default();
|
||||
options.denoise = input.denoise;
|
||||
options.denoise_settings = input.denoise_settings;
|
||||
options.denoise_diagnostics = input.denoise_diagnostics;
|
||||
options.supersampling = input.supersampling.unwrap_or(1);
|
||||
options.finishing = input.finishing;
|
||||
options.backdrop = input.backdrop;
|
||||
options.linear_exr = input.linear_exr;
|
||||
options.aging = super::effects::resolve_aging(s, &scene, &input.aging, command)?;
|
||||
options.nested_media = input.nested_media;
|
||||
options.texture_filtering = input.texture_filtering;
|
||||
options.metal_energy_compensation = input.metal_energy_compensation;
|
||||
options.volume = input.volume;
|
||||
options.caustics = input.caustics;
|
||||
options.adaptive = input.adaptive;
|
||||
options.transmission_samples = input.transmission_samples.unwrap_or(1);
|
||||
options.validate().map_err(job_error)?;
|
||||
solidcraft_render::job::supersampled_dimensions(settings.width, settings.height, options.supersampling).map_err(job_error)?;
|
||||
if let Some(super::environment::Input::BuiltinHdr { id, .. }) = &input.environment
|
||||
&& options.lighting.as_ref().is_some_and(|lighting| lighting.sun.is_some())
|
||||
&& super::library::owns_sun(id, command)?
|
||||
@@ -794,10 +845,17 @@ fn prepare_progressive(
|
||||
height: settings.height,
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
textures: super::textures::capture_with_resources(&scene, &input.raytrace.textures, Some(&s.doc.render.texture_resources), command)?,
|
||||
textures: super::textures::capture_with_resources_and_filtering(
|
||||
&scene,
|
||||
&input.raytrace.textures,
|
||||
Some(&s.doc.render.texture_resources),
|
||||
command,
|
||||
input.texture_filtering,
|
||||
)?,
|
||||
};
|
||||
let refraction = input.progressive && scene.has_transmission();
|
||||
let submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
|
||||
let mut submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
|
||||
submission.select_image_mode(settings.mode).map_err(job_error)?;
|
||||
let request = submission.request();
|
||||
let mut admission = submission.admission();
|
||||
let retained_options_bytes = options.retained_bytes().map_err(job_error)?;
|
||||
@@ -814,10 +872,14 @@ fn prepare_progressive(
|
||||
}
|
||||
if options.denoise {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
|
||||
"method":"edge_aware_atrous", "passes":options.denoise_settings.passes,
|
||||
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
|
||||
"diagnostics_requested":options.denoise_diagnostics,
|
||||
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
|
||||
"optical_policy":DENOISE_OPTICAL_POLICY,
|
||||
"guides":"deterministic_center_ray_albedo_normal_position_depth",
|
||||
"bypass":"glass, smooth metal/coat, emitter, background and partial coverage first hits",
|
||||
"limitation":"first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
|
||||
"bypass":"unsupported or ambiguous optical paths, emitter, background and partial coverage",
|
||||
"limitation":"optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
|
||||
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
|
||||
});
|
||||
}
|
||||
@@ -952,12 +1014,20 @@ enum ExportFormat {
|
||||
#[default]
|
||||
Png,
|
||||
Exr,
|
||||
DenoiseMask,
|
||||
}
|
||||
impl ExportFormat {
|
||||
fn extension(self) -> &'static str {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Exr => "exr",
|
||||
Self::DenoiseMask => "denoise_mask",
|
||||
}
|
||||
}
|
||||
fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png | Self::DenoiseMask => "png",
|
||||
Self::Exr => "exr",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1058,9 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let frame = s.render_jobs.frame(job).map_err(job_error)?;
|
||||
let bytes = match input.format {
|
||||
ExportFormat::Png => frame.png(),
|
||||
ExportFormat::DenoiseMask => frame
|
||||
.denoise_mask_png()
|
||||
.ok_or_else(|| bad("render.export", "this completed job has no guide mask; enable denoising diagnostics before rendering"))?,
|
||||
ExportFormat::Exr => frame
|
||||
.linear_exr()
|
||||
.ok_or_else(|| bad("render.export", "this completed job has no raw linear master; enable retention before rendering"))?,
|
||||
@@ -998,10 +1071,14 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if input.overwrite { solidcraft_io::write_atomic(path, bytes, false) } else { solidcraft_io::safe_write::create_atomic(path, bytes) };
|
||||
result.map_err(|e| bad("render.export", format!("could not export completed {}: {e}", input.format.extension())))?;
|
||||
Ok(
|
||||
json!({"job":job,"path":input.path,"bytes":bytes.len(),"format":input.format.extension(),"encoding":if input.format==ExportFormat::Exr {"scene_linear_srgb_d65"} else {"srgb"},"alpha":if input.format==ExportFormat::Exr {"coverage_premultiplied"} else {"straight"},"frame":frame.info,"linear_master":frame.linear_master_info(),"presentation":frame.presentation()}),
|
||||
json!({"job":job,"path":input.path,"bytes":bytes.len(),"format":input.format.name(),"encoding":match input.format {ExportFormat::Exr=>"scene_linear_srgb_d65",ExportFormat::DenoiseMask=>"categorical_srgb",ExportFormat::Png=>"srgb"},"alpha":match input.format {ExportFormat::Exr=>"coverage_premultiplied",ExportFormat::DenoiseMask=>"opaque",ExportFormat::Png=>"straight"},"frame":frame.info,"linear_master":frame.linear_master_info(),"presentation":frame.presentation(),"denoise_mask":frame.denoise_mask_info()}),
|
||||
)
|
||||
}
|
||||
|
||||
fn denoise_steps(passes: u32) -> Vec<u32> {
|
||||
(0..passes.min(3)).map(|pass| 1 << pass).collect()
|
||||
}
|
||||
|
||||
fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<Option<[f64; 3]>> {
|
||||
if weights.is_some_and(|weights| weights.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) {
|
||||
return Err(bad(command, "studio visibility weights must be finite in 0..1"));
|
||||
|
||||
@@ -784,7 +784,7 @@ fn invalid_saved_jobs_never_partially_replace_a_setup() {
|
||||
save(&mut s, json!({"job":{"method":"cpu_raytrace","options":{"primary_samples":4}},"exposure_ev":2}));
|
||||
for patch in [
|
||||
json!({"width":800,"job":{"method":"cpu_raytrace","options":{"primary_samples":3}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"samples":4097}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"samples":65537}}}),
|
||||
json!({"job":{"method":"cpu_raytrace","options":{"emissions":[]}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-opened.hdr"}}}}),
|
||||
json!({"job":{"method":"raster"}}),
|
||||
|
||||
@@ -115,11 +115,23 @@ pub(super) fn validate_targets(scene: &ProductScene, overrides: &[Override], com
|
||||
pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &str) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
capture_with_resources(scene, overrides, None, command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn capture_with_resources(
|
||||
scene: &ProductScene,
|
||||
overrides: &[Override],
|
||||
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
|
||||
command: &str,
|
||||
) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
capture_with_resources_and_filtering(scene, overrides, resources, command, false)
|
||||
}
|
||||
|
||||
pub(super) fn capture_with_resources_and_filtering(
|
||||
scene: &ProductScene,
|
||||
overrides: &[Override],
|
||||
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
|
||||
command: &str,
|
||||
filtering: bool,
|
||||
) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
if overrides.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -164,7 +176,7 @@ pub(super) fn capture_with_resources(
|
||||
flip_v: input.flip_v,
|
||||
});
|
||||
}
|
||||
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?;
|
||||
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?.with_filtering(filtering);
|
||||
resources.validate_scene(scene).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(Some(Arc::new(resources)))
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ fn bounded_dielectrics<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::R
|
||||
impl<'de> serde::de::Visitor<'de> for Inputs {
|
||||
type Value = Vec<Override>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("at most 32 disjoint dielectric body overrides")
|
||||
f.write_str("at most 128 dielectric body overrides")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut result = Vec::new();
|
||||
@@ -74,7 +74,7 @@ impl TryFrom<String> for Boundary {
|
||||
}
|
||||
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
|
||||
if overrides.len() > solidcraft_render::job::MAX_GPU_DIELECTRICS {
|
||||
return Err(bad(command, "at most 32 runtime dielectric overrides are supported"));
|
||||
return Err(bad(command, "at most 128 runtime dielectric overrides are supported"));
|
||||
}
|
||||
let mut ordinals = std::collections::BTreeSet::new();
|
||||
for input in overrides {
|
||||
|
||||
@@ -65,12 +65,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job."),
|
||||
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job. The UI separately limits new selections to 8192 per side, 33554432 output pixels and the selected rendering profile."),
|
||||
CommandSpec::new("render.result.view", "View Render Image", result_view)
|
||||
.icon("render_image")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("view: model|image|fit|actual. Validated result canvas request; headless echo only, never starts a job."),
|
||||
.params("view: model|image|fit|actual|denoise_mask. Guide-mask view requires a selected completed result captured with denoising diagnostics. Validated result canvas request; headless echo only, never starts a job."),
|
||||
CommandSpec::new("render.result.close", "Close Render Image", close_result)
|
||||
.icon("close")
|
||||
.key("Escape")
|
||||
@@ -275,8 +275,18 @@ struct ResultView {
|
||||
}
|
||||
fn result_view(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: ResultView = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.view", e.to_string()))?;
|
||||
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual") {
|
||||
return Err(bad("render.result.view", "view must be model, image, fit or actual"));
|
||||
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual" | "denoise_mask") {
|
||||
return Err(bad("render.result.view", "view must be model, image, fit, actual or denoise_mask"));
|
||||
}
|
||||
if input.view == "denoise_mask" {
|
||||
s.render_jobs.collect().map_err(crate::render_jobs::job_error)?;
|
||||
let job = s.render_selected_result().ok_or_else(|| bad("render.result.view", "no completed result is selected"))?;
|
||||
let frame = s.render_jobs.frame(job).map_err(crate::render_jobs::job_error)?;
|
||||
if frame.denoise_mask_png().is_none() {
|
||||
return Err(bad("render.result.view", "the selected result has no denoising guide mask"));
|
||||
}
|
||||
s.render_jobs.set_comparison(None);
|
||||
return Ok(json!({"view":input.view,"job":job}));
|
||||
}
|
||||
if input.view == "model" {
|
||||
s.render_jobs.set_comparison(None);
|
||||
@@ -297,7 +307,16 @@ fn output_viewer_requests_are_validated_headless_echoes() {
|
||||
let mut s = Session::default();
|
||||
let document = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.evaluations);
|
||||
for size in [json!({"width":1280,"height":720}), json!({"width":1920,"height":1080})] {
|
||||
// Headless requests keep the established per-side range. They allocate no
|
||||
// image; the UI and actual renderer independently enforce their envelopes.
|
||||
for size in [
|
||||
json!({"width":1280,"height":720}),
|
||||
json!({"width":1920,"height":1080}),
|
||||
json!({"width":7680,"height":4320}),
|
||||
json!({"width":16384,"height":1}),
|
||||
json!({"width":1,"height":16384}),
|
||||
json!({"width":16384,"height":16384}),
|
||||
] {
|
||||
assert_eq!(s.execute("render.output.size", &size).unwrap(), size);
|
||||
}
|
||||
let custom = json!({"width":1920,"height":1080,"custom":true});
|
||||
@@ -307,10 +326,18 @@ fn output_viewer_requests_are_validated_headless_echoes() {
|
||||
let request = json!({"view":view});
|
||||
assert_eq!(s.execute("render.result.view", &request).unwrap(), request);
|
||||
}
|
||||
assert!(s.execute("render.result.view", &json!({"view":"denoise_mask"})).is_err());
|
||||
assert_eq!(s.execute("render.result.close", &json!({})).unwrap(), json!({"view":"model"}));
|
||||
for request in
|
||||
[json!(null), json!({}), json!({"width":0,"height":1}), json!({"width":u64::MAX,"height":1}), json!({"width":1,"height":1,"extra":true})]
|
||||
{
|
||||
for request in [
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!({"width":0,"height":1}),
|
||||
json!({"width":1,"height":0}),
|
||||
json!({"width":16385,"height":1}),
|
||||
json!({"width":1,"height":16385}),
|
||||
json!({"width":u64::MAX,"height":1}),
|
||||
json!({"width":1,"height":1,"extra":true}),
|
||||
] {
|
||||
assert!(s.execute("render.output.size", &request).is_err());
|
||||
}
|
||||
for request in [json!(null), json!({"view":"unknown"}), json!({"view":"image","extra":true})] {
|
||||
|
||||
@@ -11,15 +11,30 @@ use solidcraft_doc::{Document, Feature, FeatureKind, Param, expr};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::timeline_clipboard::{AddParams, AddResult, ExportScriptParams, ExportScriptResult, Script, ScriptAbout, ScriptStep};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
|
||||
const EXPORT_SCRIPT_CONTRACT: TypedContract<ExportScriptParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.add", "Add Recorded Feature", add).icon("new").params(
|
||||
CommandSpec::new("timeline.add", "Add Recorded Feature", crate::checked!("timeline.add", add))
|
||||
.icon("new")
|
||||
.contract(&ADD_CONTRACT)
|
||||
.output(output_schema_for::<AddResult>)
|
||||
.params(
|
||||
"feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; the name is kept even when another \
|
||||
feature has it, the id is kept and refused when another feature has it, so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \
|
||||
dimensions use); the feature goes at the marker → feature, name, error, warning",
|
||||
),
|
||||
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params(
|
||||
CommandSpec::new("timeline.export_script", "Export Command Script", crate::checked!("timeline.export_script", export_script))
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&EXPORT_SCRIPT_CONTRACT)
|
||||
.output(output_schema_for::<ExportScriptResult>)
|
||||
.params(
|
||||
"path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \
|
||||
steps, omitted: what the script leaves out (appearances, folders, views, configurations, print-face assignments); components and assemblies \
|
||||
are not supported yet",
|
||||
@@ -56,7 +71,14 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.refresh();
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({"feature": id, "name": name, "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone())}))
|
||||
let reply = AddResult {
|
||||
feature: id,
|
||||
name,
|
||||
error: r.and_then(|r| r.error.clone()),
|
||||
warning: r.and_then(|r| r.warning.clone()),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// timeline.add keeps accepting legacy name-keyed pattern and mirror seeds and construction
|
||||
@@ -139,7 +161,8 @@ fn recorded_feature(cmd: &str, raw: &Map<String, Value>) -> Result<Feature> {
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
|
||||
/// The model parameters of a `timeline.add` step. The reader the command's contract uses too.
|
||||
pub(crate) fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
|
||||
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
|
||||
let list = v.as_array().filter(|a| a.len() <= MAX_STEP_PARAMS).ok_or_else(|| bad(cmd, "`params` must be a list of parameters"))?;
|
||||
let text = |p: &Value, k: &str| match p.get(k) {
|
||||
@@ -164,15 +187,15 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.export_script";
|
||||
let (commands, omitted) = script_steps(&s.doc)?;
|
||||
let steps = commands.len();
|
||||
let script = json!({
|
||||
"about": {
|
||||
"design": s.doc.name,
|
||||
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"replay": "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)",
|
||||
let script = Script {
|
||||
about: ScriptAbout {
|
||||
design: s.doc.name.clone(),
|
||||
format: solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
|
||||
replay: "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
},
|
||||
"commands": commands,
|
||||
});
|
||||
commands,
|
||||
};
|
||||
let path = str_(p, "path");
|
||||
if let Some(path) = path {
|
||||
let text = serde_json::to_string_pretty(&script).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -180,11 +203,12 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else if p.get("path").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`path` must be a file path"));
|
||||
}
|
||||
Ok(json!({"script": script, "steps": steps, "omitted": omitted, "path": path}))
|
||||
let path = path.map(str::to_string);
|
||||
serde_json::to_value(ExportScriptResult { omitted, path, script, steps, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn step(command: &str, params: Value) -> Value {
|
||||
json!({"command": command, "params": params})
|
||||
serde_json::to_value(ScriptStep { command: command.to_string(), params }).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn not_supported(what: &str) -> EngineError {
|
||||
|
||||
+110
-51
@@ -16,8 +16,10 @@ use super::features::{add_feature, check_expr, feature_sketch, profiles, sketch_
|
||||
use crate::params::{bad, bool_, expr, expr_problem, req_expr, str_, string_list, vec3};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::sheet::{
|
||||
ConvertParams, FlangeParams, FoldParams, HemParams, ListedRule, ManageRulesParams, ManageRulesResult, PerforateParams, RuleFeatureError,
|
||||
RuleReply, RulesParams, RulesResult, UnfoldParams,
|
||||
BendDirection, BendTableFormat, BendTableParams, BendTableResult, ConvertParams, FlangeParams, FlatBend, FlatBore, FlatCircle, FlatPatternParams,
|
||||
FlatPatternResult, FlatPerforation, FlatPerforationPattern, FlatPocket, FlatSegment, FlatShape, FoldParams, HemParams, ListedRule,
|
||||
ManageRulesParams, ManageRulesResult, PathFlangeParams, PerforateParams, PocketSide, RuleFeatureError, RuleReply, RulesParams, RulesResult,
|
||||
UnfoldParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -32,6 +34,10 @@ const CONVERT_CONTRACT: TypedContract<ConvertParams> = TypedContract::new();
|
||||
const RULES_CONTRACT: TypedContract<RulesParams> = TypedContract::new();
|
||||
const MANAGE_RULES_CONTRACT: TypedContract<ManageRulesParams> = TypedContract::new();
|
||||
|
||||
const PATH_FLANGE_CONTRACT: TypedContract<PathFlangeParams> = TypedContract::new();
|
||||
const FLAT_PATTERN_CONTRACT: TypedContract<FlatPatternParams> = TypedContract::new();
|
||||
const BEND_TABLE_CONTRACT: TypedContract<BendTableParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.flange", "Flange", crate::checked!("sheet.flange", flange))
|
||||
.at("SHEET METAL", "CREATE")
|
||||
@@ -53,7 +59,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \
|
||||
and so is the corner relief on a base or contour flange",
|
||||
),
|
||||
CommandSpec::new("sheet.path_flange", "Path Flange", path_flange).at("SHEET METAL", "CREATE").icon("path_flange").params(
|
||||
CommandSpec::new("sheet.path_flange", "Path Flange", crate::checked!("sheet.path_flange", path_flange))
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("path_flange")
|
||||
.contract(&PATH_FLANGE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines drawn on a plane across \
|
||||
that edge, from the edge out (each line one flange as long as the line, to the outer face from the virtual sharp; each \
|
||||
corner one bend, turning the way the lines turn); radius? (default: rule); body?. One feature, one body",
|
||||
@@ -121,10 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", crate::checked!("sheet.rules", list_rules))
|
||||
.contract(&RULES_CONTRACT).output(output_schema_for::<RulesResult>).icon("sheet_rules").noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", crate::checked!("sheet.flat_pattern", flat_pattern))
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
.noundo()
|
||||
.contract(&FLAT_PATTERN_CONTRACT)
|
||||
.output(output_schema_for::<FlatPatternResult>)
|
||||
.params(
|
||||
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
|
||||
),
|
||||
@@ -141,8 +154,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
→ DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \
|
||||
POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM (one per side and depth) and PERFORATION; inner contours come before the outline",
|
||||
),
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table).icon("bendtable")
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", crate::checked!("sheet.export_bend_table", export_bend_table)).icon("bendtable")
|
||||
.noundo()
|
||||
.contract(&BEND_TABLE_CONTRACT)
|
||||
.output(output_schema_for::<BendTableResult>)
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; angle_deg is the bend angle and included_angle_deg the angle left between the bend's sides (180° less it); a bend that cut-outs split has a row for each piece"),
|
||||
];
|
||||
|
||||
@@ -734,24 +749,24 @@ fn loop_circle(lp: &solidcore_record::Loop2) -> Option<(Vec2, f64)> {
|
||||
|
||||
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
|
||||
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
|
||||
fn loop_shape(lp: &solidcore_record::Loop2) -> Value {
|
||||
fn loop_shape(lp: &solidcore_record::Loop2) -> FlatShape {
|
||||
use solidcore_record::Seg2;
|
||||
let pt = |v: Vec2| json!([v.x, v.y]);
|
||||
let pt = |v: Vec2| [v.x, v.y];
|
||||
if let Some((center, radius)) = loop_circle(lp) {
|
||||
return json!({"circle": {"center": pt(center), "radius": radius}});
|
||||
return FlatShape::Circle { circle: FlatCircle { center: pt(center), radius } };
|
||||
}
|
||||
let segments: Vec<Value> = lp
|
||||
let segments: Vec<FlatSegment> = lp
|
||||
.segs
|
||||
.iter()
|
||||
.map(|seg| match *seg {
|
||||
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
|
||||
Seg2::Line { a, b } => FlatSegment::Line([pt(a), pt(b)]),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
|
||||
FlatSegment::Arc { center: pt(center), radius, start_deg: start.to_degrees(), sweep_deg: sweep.to_degrees() }
|
||||
}
|
||||
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
|
||||
ref other => FlatSegment::Polyline(other.polyline(1e-3).into_iter().map(pt).collect()),
|
||||
})
|
||||
.collect();
|
||||
json!({"segments": segments})
|
||||
FlatShape::Segments { segments }
|
||||
}
|
||||
|
||||
fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
@@ -759,63 +774,98 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let loops: Vec<Ring<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
|
||||
let size = loops.iter().fold(Aabb2::EMPTY, |b, l| b.union(&l.bounds())).size();
|
||||
let (w, h) = (size.x, size.y);
|
||||
let pt = |v: &Vec2| json!([v.x, v.y]);
|
||||
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
|
||||
let bends: Vec<Value> = sh
|
||||
let pt = |v: &Vec2| [v.x, v.y];
|
||||
let outline: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| l.iter().map(pt).collect()).collect();
|
||||
let holes: Vec<Vec<[f64; 2]>> = regions.iter().flat_map(|r| &r.holes).map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect();
|
||||
let hole_shapes: Vec<FlatShape> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
|
||||
let bends: Vec<FlatBend> = sh
|
||||
.bends()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
json!({"bend": b.index, "angle_deg": b.angle_deg, "included_angle_deg": b.included_angle_deg(), "radius": b.radius, "direction": if b.up { "up" } else { "down" },
|
||||
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
|
||||
.map(|b| FlatBend {
|
||||
bend: b.index,
|
||||
angle_deg: b.angle_deg,
|
||||
included_angle_deg: b.included_angle_deg(),
|
||||
radius: b.radius,
|
||||
direction: if b.up { BendDirection::Up } else { BendDirection::Down },
|
||||
allowance: b.allowance,
|
||||
length: b.length,
|
||||
start: pt(&b.start),
|
||||
end: pt(&b.end),
|
||||
})
|
||||
.collect();
|
||||
let pockets: Vec<Value> = sh
|
||||
let pockets: Vec<FlatPocket> = sh
|
||||
.machining()?
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"bottom"
|
||||
PocketSide::Bottom
|
||||
} else if p.z1 == sh.t {
|
||||
"top"
|
||||
PocketSide::Top
|
||||
} else {
|
||||
"internal"
|
||||
PocketSide::Internal
|
||||
};
|
||||
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
|
||||
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
|
||||
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
|
||||
FlatPocket {
|
||||
outline: p.region.outer.polyline(1e-3).iter().map(pt).collect(),
|
||||
cutouts: p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect(),
|
||||
cutout_shapes: p.region.holes.iter().map(loop_shape).collect(),
|
||||
side,
|
||||
depth_mm: p.z1 - p.z0,
|
||||
z0_mm: p.z0,
|
||||
z1_mm: p.z1,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Holes drilled along a panel into its rim: drilled after cutting, not in the outline.
|
||||
let bores: Vec<Value> = sh
|
||||
let bores: Vec<FlatBore> = sh
|
||||
.bores
|
||||
.iter()
|
||||
.map(|b| json!({"start": [b.base.x, b.base.y, b.base.z], "axis": [b.axis.x, b.axis.y, b.axis.z], "diameter": b.radius * 2.0, "length": b.height.abs()}))
|
||||
.map(|b| FlatBore {
|
||||
start: [b.base.x, b.base.y, b.base.z],
|
||||
axis: [b.axis.x, b.axis.y, b.axis.z],
|
||||
diameter: b.radius * 2.0,
|
||||
length: b.height.abs(),
|
||||
})
|
||||
.collect();
|
||||
let mut perforations = Vec::new();
|
||||
for p in &sh.perforations {
|
||||
let n = sh.perforation_holes(p)?.len();
|
||||
perforations.push(json!({"feature": p.feature, "diameter": p.diameter, "pitch": p.pitch, "margin": p.margin,
|
||||
"pattern": if p.square { "square" } else { "staggered" }, "count": n,
|
||||
"open_area_mm2": n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2)}));
|
||||
perforations.push(FlatPerforation {
|
||||
feature: p.feature,
|
||||
diameter: p.diameter,
|
||||
pitch: p.pitch,
|
||||
margin: p.margin,
|
||||
pattern: if p.square { FlatPerforationPattern::Square } else { FlatPerforationPattern::Staggered },
|
||||
count: n,
|
||||
open_area_mm2: n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2),
|
||||
});
|
||||
}
|
||||
let bend_zones: Vec<Value> = sh
|
||||
let bend_zones: Vec<[f64; 4]> = sh
|
||||
.bend_zones()
|
||||
.iter()
|
||||
.map(|z| {
|
||||
let pts = z.outer.polyline(1e-3);
|
||||
let f = |g: fn(&Vec2) -> f64, m: fn(f64, f64) -> f64, s: f64| pts.iter().map(g).fold(s, m);
|
||||
json!([f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)])
|
||||
[f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)]
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat,
|
||||
"perforations": perforations, "bend_zones": bend_zones});
|
||||
if !bores.is_empty() {
|
||||
out["bores"] = Value::Array(bores);
|
||||
}
|
||||
Ok(out)
|
||||
let out = FlatPatternResult {
|
||||
body: sh.body.clone(),
|
||||
rule: sh.rule.clone(),
|
||||
thickness: sh.t,
|
||||
k_factor: sh.k,
|
||||
flat_size_mm: [w, h, sh.t],
|
||||
outline,
|
||||
cutouts: holes,
|
||||
cutout_shapes: hole_shapes,
|
||||
pockets,
|
||||
bends,
|
||||
unfolded: sh.flat,
|
||||
perforations,
|
||||
bend_zones,
|
||||
bores,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(out).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1137,16 +1187,22 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
/// A bend table's `format`: csv or json, in any case.
|
||||
pub(crate) fn bend_table_format(t: &str) -> Option<BendTableFormat> {
|
||||
match t.to_ascii_lowercase().as_str() {
|
||||
"csv" => Some(BendTableFormat::Csv),
|
||||
"json" => Some(BendTableFormat::Json),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_bend_table";
|
||||
let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
|
||||
if !matches!(format.as_str(), "csv" | "json") {
|
||||
return Err(bad(cmd, "`format` must be csv or json"));
|
||||
}
|
||||
let format = str_(p, "format").map_or(Some(BendTableFormat::Csv), bend_table_format).ok_or_else(|| bad(cmd, "`format` must be csv or json"))?;
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
sh.flat_matches_body()?;
|
||||
let table = flat_json(&sh)?;
|
||||
let text = if format == "json" {
|
||||
let text = if format == BendTableFormat::Json {
|
||||
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
// Always quote text fields: body/rule names may include commas, quotes or newlines.
|
||||
@@ -1176,11 +1232,14 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
csv
|
||||
};
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
let bends = sh.bends().len();
|
||||
let reply = if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
|
||||
}
|
||||
Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
|
||||
BendTableResult::Written { path: path.to_string(), format, bends, param_warnings: Vec::new() }
|
||||
} else {
|
||||
BendTableResult::Text { format, text, bends, param_warnings: Vec::new() }
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+426
-154
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,30 @@ use super::sketch::edit;
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, req_vec3, vec3};
|
||||
use crate::schema::sketch_3d::{Curves3dResult, Line3dParams, Move3dParams, Move3dResult, Point3dParams, Point3dResult, Spline3dParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const LINE3D_CONTRACT: TypedContract<Line3dParams> = TypedContract::new();
|
||||
const SPLINE3D_CONTRACT: TypedContract<Spline3dParams> = TypedContract::new();
|
||||
const POINT3D_CONTRACT: TypedContract<Point3dParams> = TypedContract::new();
|
||||
const MOVE3D_CONTRACT: TypedContract<Move3dParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch_3d).params(concat!(
|
||||
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
|
||||
CommandSpec::new("sketch.line3d", "3D Line", crate::checked!("sketch.line3d", line3d))
|
||||
.icon("line")
|
||||
.enabled(in_sketch_3d)
|
||||
.contract(&LINE3D_CONTRACT)
|
||||
.output(output_schema_for::<Curves3dResult>)
|
||||
.params(concat!(
|
||||
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline3d", "3D Spline", crate::checked!("sketch.spline3d", spline3d))
|
||||
.icon("spline")
|
||||
.enabled(in_sketch_3d)
|
||||
.contract(&SPLINE3D_CONTRACT)
|
||||
.output(output_schema_for::<Curves3dResult>)
|
||||
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
|
||||
CommandSpec::new("sketch.arc3d", "3D Arc", arc3d)
|
||||
.icon("arc3")
|
||||
@@ -29,16 +43,26 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"start, through, end: [x,y,z] world points: an exact circular arc through them, from start through through to end (needs 3D Sketch on). Stored exactly (centre, radius, angles), so path consumers can treat it as a true arc",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.point3d", "3D Point", point3d)
|
||||
CommandSpec::new("sketch.point3d", "3D Point", crate::checked!("sketch.point3d", point3d))
|
||||
.icon("point")
|
||||
.enabled(in_sketch_3d)
|
||||
.contract(&POINT3D_CONTRACT)
|
||||
.output(output_schema_for::<Point3dResult>)
|
||||
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
|
||||
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
|
||||
CommandSpec::new("sketch.move3d", "Move 3D Point", crate::checked!("sketch.move3d", move3d))
|
||||
.icon("sketch_move")
|
||||
.enabled(in_sketch)
|
||||
.contract(&MOVE3D_CONTRACT)
|
||||
.output(output_schema_for::<Move3dResult>)
|
||||
.params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"),
|
||||
];
|
||||
|
||||
/// A command's reply as JSON: built from the type its output schema describes, so the two can't
|
||||
/// drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn points(cmd: &str, p: &Value, min: usize) -> Result<Vec<Vec3>> {
|
||||
let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must list [x,y,z] points"))?;
|
||||
if a.len() < min || a.len() > solidcraft_sketch::MAX_FIT_POINTS {
|
||||
@@ -106,7 +130,7 @@ fn line3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
needs_3d(sk, cmd)?;
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
})?;
|
||||
Ok(json!({"curves": ids, "sketch": info}))
|
||||
reply(cmd, Curves3dResult { curves: ids, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -117,7 +141,7 @@ fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"curves": [id], "sketch": info}))
|
||||
reply(cmd, Curves3dResult { curves: vec![id], sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn arc3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -145,7 +169,7 @@ fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"point": id, "sketch": info}))
|
||||
reply(cmd, Point3dResult { point: id, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -183,7 +207,7 @@ fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
Ok(sk.move_drawn_point(&wire, index, to)?)
|
||||
})?;
|
||||
Ok(json!({"moved": n, "sketch": info}))
|
||||
reply(cmd, Move3dResult { moved: n, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -95,6 +95,23 @@ fn a_real_curve_named_like_an_axis_wins() {
|
||||
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// Tangent also takes a curve's end point (#12092), but `x` and `y` still name the axes before
|
||||
/// their lines exist: a point called `x` does not take the axis's place.
|
||||
#[test]
|
||||
fn a_point_named_like_an_axis_leaves_tangent_on_the_axis() {
|
||||
for (a, b) in [("c", "x"), ("x", "c")] {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.point", json!({"point": [3, 7], "id": "x", "connect": false}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
|
||||
run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(axis_lines(&sk).iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["x"], "{a}, {b}: the x axis's line");
|
||||
let CurveKind::Circle { c: centre, r } = sk.curves[sk.curve_index("c").unwrap()].kind else { panic!() };
|
||||
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "{a}, {b}: tangent to x");
|
||||
assert!(pt(&sk, "x").dist(Vec2::new(3.0, 7.0)) < 1e-9, "{a}, {b}: the point called x moved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
|
||||
let mut s = on(json!("XY"));
|
||||
|
||||
@@ -1,54 +1,81 @@
|
||||
//! More sketch constraints (curvature, polygon), constraint glyph anchors and snapping
|
||||
//! inference for interactive tools.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
|
||||
use super::sketch::{add_c, dof_now, edit, keep_redundant, listed, redundancy, residual_of};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
|
||||
use crate::schema::sketch_constraints::{ToggleDiameterParams, ToggleDiameterResult};
|
||||
use crate::schema::sketch_constraints::{
|
||||
AutoConstrainDatumParams, AutoConstrainParams, AutoConstrainResult, ConstrainerParams, ConstrainerResult, ConstraintAdded, CurvatureParams,
|
||||
FinishAutoConstrainParams, Glyph, GlyphsParams, GlyphsResult, PolygonParams, PolygonResult, Snap, SnapParams, SnapResult, ToggleDiameterParams,
|
||||
ToggleDiameterResult, ToggleDrivenParams, ToggleDrivenResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const TOGGLE_DIAMETER_CONTRACT: TypedContract<ToggleDiameterParams> = TypedContract::new();
|
||||
const CURVATURE_CONTRACT: TypedContract<CurvatureParams> = TypedContract::new();
|
||||
const POLYGON_CONTRACT: TypedContract<PolygonParams> = TypedContract::new();
|
||||
const AUTO_CONSTRAIN_CONTRACT: TypedContract<AutoConstrainParams> = TypedContract::new();
|
||||
const AUTO_CONSTRAIN_DATUM_CONTRACT: TypedContract<AutoConstrainDatumParams> = TypedContract::new();
|
||||
const FINISH_AUTO_CONSTRAIN_CONTRACT: TypedContract<FinishAutoConstrainParams> = TypedContract::new();
|
||||
const CONSTRAINER_CONTRACT: TypedContract<ConstrainerParams> = TypedContract::new();
|
||||
const TOGGLE_DRIVEN_CONTRACT: TypedContract<ToggleDrivenParams> = TypedContract::new();
|
||||
const GLYPHS_CONTRACT: TypedContract<GlyphsParams> = TypedContract::new();
|
||||
const SNAP_CONTRACT: TypedContract<SnapParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
|
||||
CommandSpec::new("sketch.constraint.curvature", "Curvature", crate::checked!("sketch.constraint.curvature", c_smooth))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_smooth")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CURVATURE_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintAdded>)
|
||||
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
|
||||
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
|
||||
CommandSpec::new("sketch.constraint.polygon", "Polygon", crate::checked!("sketch.constraint.polygon", c_polygon))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_polygon")
|
||||
.enabled(in_sketch)
|
||||
.contract(&POLYGON_CONTRACT)
|
||||
.output(output_schema_for::<PolygonResult>)
|
||||
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
|
||||
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
|
||||
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", crate::checked!("sketch.auto_constrain", auto_constrain))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("auto_constrain")
|
||||
.enabled(in_sketch)
|
||||
.contract(&AUTO_CONSTRAIN_CONTRACT)
|
||||
.output(output_schema_for::<AutoConstrainResult>)
|
||||
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
|
||||
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", auto_constrain_datum)
|
||||
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", crate::checked!("sketch.auto_constrain_from_datum", auto_constrain_datum))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("auto_constrain")
|
||||
.enabled(in_sketch)
|
||||
.contract(&AUTO_CONSTRAIN_DATUM_CONTRACT)
|
||||
.output(output_schema_for::<AutoConstrainResult>)
|
||||
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
|
||||
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
|
||||
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", crate::checked!("sketch.finish_auto_constrain", auto_constrain_finish))
|
||||
.at("SKETCH", "FINISH SKETCH")
|
||||
.icon("finish")
|
||||
.enabled(in_sketch)
|
||||
.contract(&FINISH_AUTO_CONSTRAIN_CONTRACT)
|
||||
.output(output_schema_for::<AutoConstrainResult>)
|
||||
.params("like AutoConstrain, then Finish Sketch"),
|
||||
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
|
||||
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", crate::checked!("sketch.constrainer", smart_constrain))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("constrainer")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CONSTRAINER_CONTRACT)
|
||||
.output(output_schema_for::<ConstrainerResult>)
|
||||
.params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"),
|
||||
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven).icon("dimension")
|
||||
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", crate::checked!("sketch.toggle_driven", toggle_driven)).icon("dimension")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TOGGLE_DRIVEN_CONTRACT)
|
||||
.output(output_schema_for::<ToggleDrivenResult>)
|
||||
.params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"),
|
||||
CommandSpec::new("sketch.toggle_diameter", "Toggle Radius / Diameter", crate::checked!("sketch.toggle_diameter", toggle_diameter))
|
||||
.icon("dimension")
|
||||
@@ -56,13 +83,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&TOGGLE_DIAMETER_CONTRACT)
|
||||
.output(output_schema_for::<ToggleDiameterResult>)
|
||||
.params("constraint: a circle's or an arc's radius or diameter dimension id; diameter?: bool (default: the other one); sketch?: id|name (default: the one being edited). The circle stays the same size: its parameter is halved or doubled (a plain value is rewritten, an expression becomes `(e) / 2` or `2 * (e)`); refused when anything else uses that parameter"),
|
||||
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs).icon("eye")
|
||||
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", crate::checked!("sketch.glyphs", glyphs)).icon("eye")
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.contract(&GLYPHS_CONTRACT)
|
||||
.output(output_schema_for::<GlyphsResult>)
|
||||
.params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"),
|
||||
CommandSpec::new("sketch.snap", "Snap Inference", snap).icon("snap")
|
||||
CommandSpec::new("sketch.snap", "Snap Inference", crate::checked!("sketch.snap", snap)).icon("snap")
|
||||
.enabled(in_sketch)
|
||||
.noundo()
|
||||
.contract(&SNAP_CONTRACT)
|
||||
.output(output_schema_for::<SnapResult>)
|
||||
.params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"),
|
||||
];
|
||||
|
||||
@@ -78,7 +109,26 @@ fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(id)
|
||||
})?;
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, Value::Null)
|
||||
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
|
||||
reply(cmd, ConstraintAdded { constraint: id, sketch: info, redundant, determined_by, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A command's reply as JSON: they build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// What a new constraint's reply says of redundancy: whether it is, and by which constraints. A
|
||||
/// redundant one is refused when the call asked for `if_redundant: "refuse"`.
|
||||
fn redundant_part(p: &Value, cmd: &str, by: Option<Vec<String>>) -> Result<(bool, Option<Vec<String>>)> {
|
||||
match by {
|
||||
Some(by) => {
|
||||
keep_redundant(p, cmd, &by)?;
|
||||
Ok((true, Some(by)))
|
||||
}
|
||||
None => Ok((false, None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
@@ -126,7 +176,7 @@ fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(cons)
|
||||
})?;
|
||||
Ok(json!({"constraints": out, "sketch": info}))
|
||||
reply(cmd, PolygonResult { constraints: out, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Most constraints AutoConstrain tries.
|
||||
@@ -273,7 +323,7 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
// its freedom whenever they change constraints; no separate initial solve is needed.
|
||||
Ok(run_auto(sk, doc, p, datum, rep.dof))
|
||||
})?;
|
||||
Ok(json!({"added": added, "dof": dof, "sketch": info}))
|
||||
reply(cmd, AutoConstrainResult { added, dof, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -375,7 +425,19 @@ fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(id)
|
||||
})?;
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, json!({"type": name}))
|
||||
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
|
||||
reply(
|
||||
cmd,
|
||||
ConstrainerResult {
|
||||
constraint: id,
|
||||
sketch: info,
|
||||
redundant,
|
||||
determined_by,
|
||||
kind: name.to_string(),
|
||||
created: None,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -417,7 +479,7 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.doc_mut().prune_model_params();
|
||||
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
|
||||
reply(cmd, ToggleDrivenResult { constraint: cid, driven, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn toggle_diameter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -528,26 +590,32 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
|
||||
let failing = &ss.report.failing;
|
||||
let list: Vec<Value> = ss
|
||||
let glyphs: Vec<Glyph> = ss
|
||||
.sketch
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"name": c.kind.name(),
|
||||
"dimension": c.kind.is_dimension(),
|
||||
"driven": c.driven,
|
||||
"value": c.kind.value(),
|
||||
"param": c.param,
|
||||
"at": anchor(&ss.sketch, &c.kind),
|
||||
"failing": failing.contains(&c.id),
|
||||
// Implied by the others (it removes no freedom) but holding: shown dimmed.
|
||||
"redundant": ss.report.redundant.contains(&c.id),
|
||||
})
|
||||
.map(|c| Glyph {
|
||||
id: c.id.clone(),
|
||||
name: c.kind.name().to_string(),
|
||||
dimension: c.kind.is_dimension(),
|
||||
driven: c.driven,
|
||||
value: c.kind.value(),
|
||||
param: c.param.clone(),
|
||||
at: anchor(&ss.sketch, &c.kind).map(|a| [a.x, a.y]),
|
||||
failing: failing.contains(&c.id),
|
||||
// Implied by the others (it removes no freedom) but holding: shown dimmed.
|
||||
redundant: ss.report.redundant.contains(&c.id),
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"sketch": id, "glyphs": list, "dof": ss.report.dof, "fully_constrained": ss.report.fully_constrained(), "conflicts": failing}))
|
||||
let result = GlyphsResult {
|
||||
sketch: id,
|
||||
glyphs,
|
||||
dof: ss.report.dof,
|
||||
fully_constrained: ss.report.fully_constrained(),
|
||||
conflicts: failing.clone(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply("sketch.glyphs", result)
|
||||
}
|
||||
|
||||
/// Snap inference for a click at `at`: the nearest sketch point (coincident), the midpoint of a
|
||||
@@ -562,12 +630,12 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
|
||||
let sk = &ss.sketch;
|
||||
let mut out: Vec<(f64, Value)> = Vec::new();
|
||||
let mut out: Vec<(f64, Snap)> = Vec::new();
|
||||
// Points (end points, centres, the origin).
|
||||
for q in &sk.points {
|
||||
let d = q.pos.dist(at);
|
||||
if d <= r {
|
||||
out.push((d * 0.5, json!({"type": "coincident", "point": q.id, "at": q.pos})));
|
||||
out.push((d * 0.5, Snap::Coincident { point: q.id.clone(), at: [q.pos.x, q.pos.y] }));
|
||||
}
|
||||
}
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
@@ -577,13 +645,13 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = (pa + pb) * 0.5;
|
||||
let d = m.dist(at);
|
||||
if d <= r {
|
||||
out.push((d * 0.6, json!({"type": "midpoint", "curve": c.id, "at": m})));
|
||||
out.push((d * 0.6, Snap::Midpoint { curve: c.id.clone(), at: [m.x, m.y] }));
|
||||
}
|
||||
}
|
||||
if let Some(sh) = sk.shape(i) {
|
||||
let q = sh.project(at);
|
||||
if sh.dist(at) <= r && q.dist(at) <= r {
|
||||
out.push((sh.dist(at) + r * 0.5, json!({"type": "on_curve", "curve": c.id, "at": q})));
|
||||
out.push((sh.dist(at) + r * 0.5, Snap::OnCurve { curve: c.id.clone(), at: [q.x, q.y] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -591,15 +659,15 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = from {
|
||||
let d = at - f;
|
||||
if d.y.abs() <= r && d.x.abs() > r {
|
||||
out.push((d.y.abs() + r, json!({"type": "horizontal", "at": [at.x, f.y]})));
|
||||
out.push((d.y.abs() + r, Snap::Horizontal { at: [at.x, f.y] }));
|
||||
}
|
||||
if d.x.abs() <= r && d.y.abs() > r {
|
||||
out.push((d.x.abs() + r, json!({"type": "vertical", "at": [f.x, at.y]})));
|
||||
out.push((d.x.abs() + r, Snap::Vertical { at: [f.x, at.y] }));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let list: Vec<Value> = out.into_iter().take(8).map(|x| x.1).collect();
|
||||
Ok(json!({"snaps": list}))
|
||||
let snaps: Vec<Snap> = out.into_iter().take(8).map(|x| x.1).collect();
|
||||
reply(cmd, SnapResult { snaps, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,11 +13,16 @@ macro_rules! text_style_doc {
|
||||
};
|
||||
}
|
||||
|
||||
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
|
||||
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, slot_parts};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list};
|
||||
use crate::schema::sketch_create::{SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams, SplineReverseResult};
|
||||
use crate::schema::sketch_create::{
|
||||
ArcSlotCenterParams, ArcSlotResult, ArcSlotThreePointParams, ArcTangentParams, BlendCurveParams, CenterlineParams, ConicParams,
|
||||
ControlPointSplineParams, DrawResult, EditTextParams, EditTextResult, EllipseParams, FitPointSplineParams, InfiniteLineParams, LineTypeResult,
|
||||
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams,
|
||||
SplineReverseResult, TextChange, TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -29,62 +34,108 @@ mod text_move_tests;
|
||||
#[path = "sketch_text_expr_tests.rs"]
|
||||
mod text_expr_tests;
|
||||
|
||||
const MIDPOINT_LINE_CONTRACT: TypedContract<MidpointLineParams> = TypedContract::new();
|
||||
const ARC_TANGENT_CONTRACT: TypedContract<ArcTangentParams> = TypedContract::new();
|
||||
const TWO_TANGENT_CONTRACT: TypedContract<TwoTangentCircleParams> = TypedContract::new();
|
||||
const THREE_TANGENT_CONTRACT: TypedContract<ThreeTangentCircleParams> = TypedContract::new();
|
||||
const SLOT_CENTER_POINT_CONTRACT: TypedContract<SlotCenterPointParams> = TypedContract::new();
|
||||
const ARC_SLOT_THREE_CONTRACT: TypedContract<ArcSlotThreePointParams> = TypedContract::new();
|
||||
const ARC_SLOT_CENTER_CONTRACT: TypedContract<ArcSlotCenterParams> = TypedContract::new();
|
||||
const ELLIPSE_CONTRACT: TypedContract<EllipseParams> = TypedContract::new();
|
||||
const SPLINE_FIT_CONTRACT: TypedContract<FitPointSplineParams> = TypedContract::new();
|
||||
const SPLINE_CONTROL_CONTRACT: TypedContract<ControlPointSplineParams> = TypedContract::new();
|
||||
const CONIC_CONTRACT: TypedContract<ConicParams> = TypedContract::new();
|
||||
const BLEND_CONTRACT: TypedContract<BlendCurveParams> = TypedContract::new();
|
||||
const CENTERLINE_CONTRACT: TypedContract<CenterlineParams> = TypedContract::new();
|
||||
const INFINITE_CONTRACT: TypedContract<InfiniteLineParams> = TypedContract::new();
|
||||
const TEXT_CONTRACT: TypedContract<SketchTextParams> = TypedContract::new();
|
||||
const EDIT_TEXT_CONTRACT: TypedContract<EditTextParams> = TypedContract::new();
|
||||
const SPLINE_REVERSE_CONTRACT: TypedContract<SplineReverseParams> = TypedContract::new();
|
||||
const SPLINE_REMOVE_POINT_CONTRACT: TypedContract<SplineRemovePointParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", crate::checked!("sketch.line.midpoint", midpoint_line))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("line_mid")
|
||||
.enabled(in_sketch)
|
||||
.contract(&MIDPOINT_LINE_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("mid: [x,y], point ref or snap (\"mid:<line>\", \"on:<curve>:x,y\", model vertex/edge); end: [x,y]; construction?", created_doc!())),
|
||||
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", crate::checked!("sketch.arc.tangent", arc_tangent))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_tangent")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ARC_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!(
|
||||
"start: end point of a line or arc (\"l1.end\"), end: [x,y] or an existing point's id (\"l2.start\": the arc ends on that point); curve?: the curve to be tangent to (default: the one ending at start)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
|
||||
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", crate::checked!("sketch.circle.two_tangent", circle_tan_tan))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_tt")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TWO_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())),
|
||||
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
|
||||
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", crate::checked!("sketch.circle.three_tangent", circle_three_tan))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_ttt")
|
||||
.enabled(in_sketch)
|
||||
.contract(&THREE_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
|
||||
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", crate::checked!("sketch.slot.center_point", slot_center_point))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot_center")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SLOT_CENTER_POINT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
|
||||
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", crate::checked!("sketch.slot.arc_three_point", arc_slot_three))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ARC_SLOT_THREE_CONTRACT)
|
||||
.output(output_schema_for::<ArcSlotResult>)
|
||||
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
|
||||
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", crate::checked!("sketch.slot.arc_center", arc_slot_center))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot_center")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ARC_SLOT_CENTER_CONTRACT)
|
||||
.output(output_schema_for::<ArcSlotResult>)
|
||||
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise) or sweep: degrees (0 < sweep < 360), width; returns centre_arc", created_doc!())),
|
||||
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("sketch.ellipse", "Ellipse", crate::checked!("sketch.ellipse", ellipse))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("ellipse")
|
||||
.enabled(in_sketch)
|
||||
.contract(&ELLIPSE_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!(
|
||||
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius. Also adds its major and minor axes as construction lines through the centre (their ends: the major end and the point opposite it, and the two ends of the minor axis on the ellipse), held by midpoint, perpendicular and point-on-curve constraints, so a half-axis is the distance from the centre to an axis end and an axis line's length is a full axis; axes?: bool (default true; false: the ellipse alone, as the Ellipse tool draws it with Draw ellipses with their axes off)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
|
||||
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", crate::checked!("sketch.spline.fit_point", spline_fit))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_FIT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", crate::checked!("sketch.spline.control_point", spline_cv3))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_CONTROL_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: control points (2…500), cubic", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
|
||||
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", crate::checked!("sketch.spline.control_point_5", spline_cv5))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_CONTROL_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: control points (2…500), degree 5", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.insert_point", "Add Spline Point", spline_insert_point)
|
||||
.icon("spline")
|
||||
@@ -102,36 +153,54 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&SPLINE_REVERSE_CONTRACT)
|
||||
.output(output_schema_for::<SplineReverseResult>)
|
||||
.params("curve: spline id (fit point, control point or closed): its last point becomes its first and the curve runs the other way along the same path; its points and the constraints on them stay. Refused for a projected spline and for one an offset uses or makes. Returns curve"),
|
||||
CommandSpec::new("sketch.conic", "Conic Curve", conic)
|
||||
CommandSpec::new("sketch.conic", "Conic Curve", crate::checked!("sketch.conic", conic))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("conic")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CONIC_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())),
|
||||
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
|
||||
CommandSpec::new("sketch.blend_curve", "Blend Curve", crate::checked!("sketch.blend_curve", blend))
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("blend")
|
||||
.enabled(in_sketch)
|
||||
.contract(&BLEND_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!(
|
||||
"a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.centerline", "Centerline", centerline)
|
||||
CommandSpec::new("sketch.centerline", "Centerline", crate::checked!("sketch.centerline", centerline))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("centerline")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CENTERLINE_CONTRACT)
|
||||
.output(output_schema_for::<LineTypeResult>)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite")
|
||||
CommandSpec::new("sketch.infinite", "Infinite Line", crate::checked!("sketch.infinite", infinite_line))
|
||||
.icon("infinite")
|
||||
.enabled(in_sketch)
|
||||
.contract(&INFINITE_CONTRACT)
|
||||
.output(output_schema_for::<LineTypeResult>)
|
||||
.params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"),
|
||||
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!(
|
||||
CommandSpec::new("sketch.text", "Text", crate::checked!("sketch.text", text))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_text")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TEXT_CONTRACT)
|
||||
.output(output_schema_for::<TextResult>)
|
||||
.params(concat!(
|
||||
"text: string (\\n breaks lines), or text_expression: the words built from text parameters (parameters.add with unit text), number parameters and quoted texts joined by + (\"Label + '-L'\", \"'W' + Width\"; a number parameter shows its value in the design's units, \"85 mm\", an angle in degrees, \"30°\"; number(Width) is the number alone and number(Width, 2) has 2 decimals): the text keeps it, its words follow the parameters (a parameter edit or a configuration row relabels it) and `exprs` returns it as `text`; at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
|
||||
font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
|
||||
text_style_doc!(),
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text).icon("edit")
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", crate::checked!("sketch.edit_text", edit_text))
|
||||
.icon("edit")
|
||||
.enabled(in_sketch)
|
||||
.live(edit_text_live)
|
||||
.contract(&EDIT_TEXT_CONTRACT)
|
||||
.output(output_schema_for::<EditTextResult>)
|
||||
.params(concat!(
|
||||
"link: the text's link id (or entity: one of its curves), text? or text_expression? (as in sketch.text; typed words replace the expression), at?, height?, angle?: deg (numbers or expressions as in sketch.text; a number replaces an expression, and so does moving the text), font?: family or file path (empty: the built-in font), ",
|
||||
text_style_doc!(),
|
||||
@@ -141,6 +210,20 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
)),
|
||||
];
|
||||
|
||||
/// A reply made of a typed value, which is also what its output schema describes.
|
||||
fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
|
||||
serde_json::to_value(value).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// What a drawing command returns: the curves and constraints it made, and the sketch's solve.
|
||||
fn drawn_result(out: (Vec<String>, Vec<String>), info: Value) -> DrawResult {
|
||||
DrawResult { curves: out.0, constraints: out.1, sketch: info, created: None, param_warnings: Vec::new() }
|
||||
}
|
||||
|
||||
fn drawn(out: (Vec<String>, Vec<String>), info: Value) -> Result<Value> {
|
||||
reply(drawn_result(out, info))
|
||||
}
|
||||
|
||||
/// The style a command's parameters give, over `old`.
|
||||
fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
|
||||
let mut style = old;
|
||||
@@ -181,7 +264,7 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
|
||||
}
|
||||
|
||||
/// What a text's `at` must be.
|
||||
const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
|
||||
pub(crate) const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
|
||||
|
||||
/// One number of a text, from a command's parameter `key`. A number is that number (mm, or
|
||||
/// degrees for the angle). A string is an expression over the design's parameters: a bare number
|
||||
@@ -278,15 +361,17 @@ fn text_source(doc: &solidcraft_doc::Document, p: &Value, cmd: &str, old: Option
|
||||
}
|
||||
|
||||
/// The expressions behind a text's numbers, for a command's reply (`{"height": "plate_h / 10"}`).
|
||||
fn text_exprs_json(src: &LinkSource) -> Option<Value> {
|
||||
fn text_exprs(src: &LinkSource) -> Option<TextExpressions> {
|
||||
let LinkSource::Text { exprs, .. } = src else { return None };
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, e) in [("x", &exprs.x), ("y", &exprs.y), ("height", &exprs.height), ("angle", &exprs.angle), ("text", &exprs.text)] {
|
||||
if let Some(e) = e {
|
||||
out.insert(key.into(), json!(e));
|
||||
}
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
let out = TextExpressions {
|
||||
x: exprs.x.clone(),
|
||||
y: exprs.y.clone(),
|
||||
height: exprs.height.clone(),
|
||||
angle: exprs.angle.clone(),
|
||||
text: exprs.text.clone(),
|
||||
};
|
||||
let any = [&out.x, &out.y, &out.height, &out.angle, &out.text].iter().any(|e| e.is_some());
|
||||
any.then_some(out)
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -296,7 +381,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let src = text_source(&s.doc, p, cmd, None)?;
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
let exprs = text_exprs_json(&src);
|
||||
let exprs = text_exprs(&src);
|
||||
let (l, curves, guide) = sketch_store::update(s, id, |_, _, sk| {
|
||||
// A text needs nothing outside its sketch (a path is one of its curves).
|
||||
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
@@ -308,11 +393,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let curves = ids_of(sk, &sk.link_curves(&l));
|
||||
Ok((l, curves, guide))
|
||||
})?;
|
||||
let mut out = json!({"link": l, "curves": curves, "guide": guide});
|
||||
if let Some(exprs) = exprs {
|
||||
out["exprs"] = exprs;
|
||||
}
|
||||
Ok(out)
|
||||
reply(TextResult { change: TextChange { link: l, curves, guide, exprs }, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A text's path is its guide: the curve it follows becomes construction. Left as it was, it
|
||||
@@ -378,17 +459,18 @@ pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation:
|
||||
}
|
||||
|
||||
/// The most texts one `sketch.edit_text {edits}` changes.
|
||||
const MAX_TEXT_EDITS: usize = 1000;
|
||||
pub(crate) const MAX_TEXT_EDITS: usize = 1000;
|
||||
|
||||
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.edit_text";
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
sketch_store::update(s, id, |_, doc, sk| {
|
||||
let edited = sketch_store::update(s, id, |_, doc, sk| {
|
||||
// The numbers its expressions give now (a parameter may have changed since the sketch was
|
||||
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
|
||||
let _ = doc.apply_text_values(&doc.param_values().0, sk);
|
||||
apply_text_edits(sk, doc, p)
|
||||
})
|
||||
})?;
|
||||
reply(edited)
|
||||
}
|
||||
|
||||
/// One frame of an interactive resize or rotation: the sketch is solved and shown, the
|
||||
@@ -399,21 +481,24 @@ fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::Li
|
||||
let (out, report) = super::sketch::edit_solved(s, id, false, solidcraft_sketch::Analysis::Full, |sk, doc| {
|
||||
Ok((apply_text_edits(sk, doc, p)?, super::sketch::Left::Unsolved))
|
||||
})?;
|
||||
Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
Ok((reply(out)?, report.map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
}
|
||||
|
||||
/// `sketch.edit_text`'s change: one text's (`link` or `entity`), or each of `edits` in turn.
|
||||
/// One command, so the features after the sketch are rebuilt once for all of them.
|
||||
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
|
||||
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<EditTextResult> {
|
||||
let cmd = "sketch.edit_text";
|
||||
match p.get("edits") {
|
||||
None | Some(Value::Null) => apply_text_edit(sk, doc, p),
|
||||
None | Some(Value::Null) => {
|
||||
let change = apply_text_edit(sk, doc, p)?;
|
||||
Ok(EditTextResult::One(TextResult { change, created: None, param_warnings: Vec::new() }))
|
||||
}
|
||||
Some(Value::Array(edits)) => {
|
||||
if edits.is_empty() || edits.len() > MAX_TEXT_EDITS {
|
||||
return Err(bad(cmd, format!("`edits` must hold 1 to {MAX_TEXT_EDITS} edits")));
|
||||
}
|
||||
let outs = edits.iter().map(|e| apply_text_edit(sk, doc, e)).collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"edits": outs}))
|
||||
Ok(EditTextResult::Many(TextEditsResult { edits: outs, created: None, param_warnings: Vec::new() }))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`edits` must be a list of edits")),
|
||||
}
|
||||
@@ -421,7 +506,7 @@ fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value)
|
||||
|
||||
/// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines
|
||||
/// regenerated from them.
|
||||
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
|
||||
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<TextChange> {
|
||||
let cmd = "sketch.edit_text";
|
||||
let l = match (str_(p, "link"), str_(p, "entity")) {
|
||||
(Some(l), _) => l.to_string(),
|
||||
@@ -438,15 +523,11 @@ fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -
|
||||
return Err(bad(cmd, "the text makes no outline"));
|
||||
}
|
||||
let guide = guide(sk, &src);
|
||||
let exprs = text_exprs_json(&src);
|
||||
let exprs = text_exprs(&src);
|
||||
sk.set_link_source(&l, src.clone());
|
||||
sk.update_link(&l, &geom)?;
|
||||
let curves = ids_of(sk, &sk.link_curves(&l));
|
||||
let mut out = json!({"link": l, "curves": curves, "guide": guide});
|
||||
if let Some(exprs) = exprs {
|
||||
out["exprs"] = exprs;
|
||||
}
|
||||
Ok(out)
|
||||
Ok(TextChange { link: l, curves, guide, exprs })
|
||||
}
|
||||
|
||||
/// The curve ending at point `pi` and the direction leaving it there.
|
||||
@@ -552,7 +633,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
solve_new_only(sk, first_new);
|
||||
Ok((ids_of(sk, &[sp]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -577,7 +658,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(ids.len())
|
||||
})?;
|
||||
Ok(json!({"changed": n, "sketch": info}))
|
||||
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -601,7 +682,7 @@ fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(ids.len())
|
||||
})?;
|
||||
Ok(json!({"changed": n, "sketch": info}))
|
||||
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -659,7 +740,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
];
|
||||
Ok((ids_of(sk, &[e]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn point_list(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
@@ -704,7 +785,7 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
|
||||
mark_construction(sk, &[c], p);
|
||||
Ok((ids_of(sk, &[c]), Vec::new()))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -790,7 +871,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &[c], p);
|
||||
Ok((ids_of(sk, &[c]), Vec::new()))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -812,7 +893,7 @@ fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mark_construction(sk, &[l], p);
|
||||
Ok((ids_of(sk, &[l]), vec![k]))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
/// Direction leaving curve `ci` at its end point `pi` (continuing the curve past that end).
|
||||
@@ -966,7 +1047,7 @@ fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = add_c(sk, ConstraintKind::Tangent { a: ci, b: a })?;
|
||||
Ok((ids_of(sk, &[a]), vec![k]))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
/// Initial radius for a circle near `at` touching the curves: the mean distance to them.
|
||||
@@ -1052,7 +1133,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
|
||||
if info["solved"] != json!(true) {
|
||||
return Err(bad(cmd, "no tangent circle there"));
|
||||
}
|
||||
Ok(result(out, info))
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1079,7 +1160,8 @@ fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.slot.center_point";
|
||||
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
|
||||
let w = width(p, cmd)?;
|
||||
slot(s, p, cmd, c * 2.0 - e, e, w)
|
||||
let (out, info) = slot_parts(s, p, cmd, c * 2.0 - e, e, w)?;
|
||||
drawn(out, info)
|
||||
}
|
||||
|
||||
/// Arc slot around `o` (centre-line radius `r`) from angle `t0` sweeping `sweep` (> 0).
|
||||
@@ -1117,9 +1199,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
|
||||
}
|
||||
Ok((ids_of(sk, &[outer, cap_e, inner, cap_s]), cons))
|
||||
})?;
|
||||
let mut out = result(out, info);
|
||||
out["centre_arc"] = json!(centre_arc);
|
||||
Ok(out)
|
||||
reply(ArcSlotResult { drawn: drawn_result(out, info), centre_arc })
|
||||
}
|
||||
|
||||
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1534,7 +1614,7 @@ mod blend_tests {
|
||||
assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}");
|
||||
assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}");
|
||||
assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}");
|
||||
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "ids": "chord"}));
|
||||
// Drop the two references: what is left is the blend and its chord, one closed region.
|
||||
run(&mut s, "sketch.delete", json!({"entities": [a, b]}));
|
||||
let id = s.active_sketch.unwrap();
|
||||
|
||||
@@ -5,31 +5,50 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_io::{Geom2, MAX_DRAWING_BYTES};
|
||||
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
|
||||
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::sketch::{edit, ids_of};
|
||||
use super::sketch::{Left, edit_solved, ids_of};
|
||||
use super::sketch_store;
|
||||
use crate::params::{bad, num, str_, vec2};
|
||||
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const INSERT_DXF_CONTRACT: TypedContract<InsertDxfParams> = TypedContract::new();
|
||||
const INSERT_SVG_CONTRACT: TypedContract<InsertSvgParams> = TypedContract::new();
|
||||
const EXPORT_DXF_CONTRACT: TypedContract<ExportDxfParams> = TypedContract::new();
|
||||
|
||||
/// A command's reply as JSON, built from the type its output schema describes so the two can't
|
||||
/// drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.insert_dxf", insert_dxf))
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("sketch_dxf")
|
||||
.contract(&INSERT_DXF_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("svg")
|
||||
.contract(&INSERT_SVG_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
|
||||
.icon("svg")
|
||||
.noundo()
|
||||
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
|
||||
.icon("sketch_dxf")
|
||||
.noundo()
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
|
||||
.contract(&EXPORT_DXF_CONTRACT)
|
||||
.output(output_schema_for::<ExportDxfResult>)
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
|
||||
];
|
||||
|
||||
/// The sketch import command for a drawing file name, regardless of extension case.
|
||||
@@ -251,10 +270,13 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
if geom.len() > solidcraft_sketch::MAX_POINTS {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
// `sketch::edit`, but keeping the solve as a typed value for the reply.
|
||||
let target = sketch_store::target(s, p, cmd)?;
|
||||
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
|
||||
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
|
||||
Ok((ids_of(sk, &cs), texts))
|
||||
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
|
||||
})?;
|
||||
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
|
||||
// Text becomes sketch text.
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
|
||||
let mut n_text = 0;
|
||||
@@ -279,7 +301,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
|
||||
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -290,39 +312,249 @@ fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert(s, p, "sketch.insert_svg", true)
|
||||
}
|
||||
|
||||
fn sorted_curve_ids<'a>(ids: &'a [String]) -> Vec<&'a str> {
|
||||
let mut v: Vec<&'a str> = ids.iter().map(String::as_str).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.export_dxf";
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
|
||||
let sk = &ss.sketch;
|
||||
let mut geom = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default();
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
|
||||
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
|
||||
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
|
||||
|
||||
let units_val = p.get("units").filter(|v| !v.is_null());
|
||||
if let Some(v) = units_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`units` must be a string"));
|
||||
}
|
||||
let units_param = str_(p, "units").map(str::to_ascii_lowercase);
|
||||
let (insunits, scale) = match units_param.as_deref() {
|
||||
None => (4, 1.0),
|
||||
Some("design") => {
|
||||
let doc_units = solidcraft_doc::expr::length_unit_or_mm(&s.doc.units);
|
||||
match doc_units.to_ascii_lowercase().as_str() {
|
||||
"in" | "inch" => (1, 1.0 / 25.4),
|
||||
"ft" => (2, 1.0 / 304.8),
|
||||
"cm" => (5, 0.1),
|
||||
"m" => (6, 0.001),
|
||||
_ => (4, 1.0),
|
||||
}
|
||||
}
|
||||
Some("in" | "inch") => (1, 1.0 / 25.4),
|
||||
Some("ft") => (2, 1.0 / 304.8),
|
||||
Some("mm") => (4, 1.0),
|
||||
Some("cm") => (5, 0.1),
|
||||
Some("m") => (6, 0.001),
|
||||
Some(other) => {
|
||||
return Err(bad(cmd, format!("unsupported unit `{other}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")")));
|
||||
}
|
||||
};
|
||||
|
||||
let layer_val = p.get("layer").filter(|v| !v.is_null());
|
||||
if let Some(v) = layer_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`layer` must be a string"));
|
||||
}
|
||||
let text = solidcraft_io::write_dxf(&geom);
|
||||
let custom_layer = str_(p, "layer").map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
let layers_val = p.get("layers").filter(|v| !v.is_null());
|
||||
if let Some(v) = layers_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`layers` must be \"single\" or \"roles\""));
|
||||
}
|
||||
let roles_layers = match str_(p, "layers").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("single") => false,
|
||||
Some("roles") => true,
|
||||
Some(_) => return Err(bad(cmd, "`layers` must be \"single\" or \"roles\"")),
|
||||
};
|
||||
|
||||
let join = match p.get("join").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`join` must be true or false")),
|
||||
};
|
||||
|
||||
let path_val = p.get("path").filter(|v| !v.is_null());
|
||||
if let Some(v) = path_val
|
||||
&& !v.is_string()
|
||||
{
|
||||
return Err(bad(cmd, "`path` must be a string"));
|
||||
}
|
||||
|
||||
let is_default = units_val.is_none() && layer_val.is_none() && layers_val.is_none() && !join;
|
||||
|
||||
let (text, entity_count) = if is_default {
|
||||
let mut geom = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default();
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
|
||||
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
|
||||
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
|
||||
}
|
||||
}
|
||||
}
|
||||
let count = geom.len();
|
||||
(solidcraft_io::write_dxf(&geom), count)
|
||||
} else if !join {
|
||||
let hole_keys: std::collections::HashSet<Vec<&str>> =
|
||||
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
|
||||
|
||||
let mut outline_ids = std::collections::HashSet::new();
|
||||
let mut cut_ids = std::collections::HashSet::new();
|
||||
if roles_layers {
|
||||
for prof in &ss.profiles {
|
||||
for holes in &prof.hole_curves {
|
||||
for id in holes {
|
||||
cut_ids.insert(id.as_str());
|
||||
}
|
||||
}
|
||||
if !hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
|
||||
for id in &prof.outer_curves {
|
||||
outline_ids.insert(id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let base_layer = custom_layer.unwrap_or("0");
|
||||
let mut entities = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let layer = if roles_layers {
|
||||
if cut_ids.contains(c.id.as_str()) {
|
||||
"CUT"
|
||||
} else if outline_ids.contains(c.id.as_str()) {
|
||||
"OUTLINE"
|
||||
} else {
|
||||
base_layer
|
||||
}
|
||||
} else {
|
||||
base_layer
|
||||
};
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => entities.push((layer, Geom2::Line(pt(*a), pt(*b)))),
|
||||
CurveKind::Circle { c, r } => entities.push((layer, Geom2::Circle(pt(*c), *r * scale))),
|
||||
CurveKind::Arc { c, a, b } => entities.push((layer, Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) })),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
for w in poly.windows(2) {
|
||||
entities.push((layer, Geom2::Line(w[0] * scale, w[1] * scale)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let count = entities.len();
|
||||
(solidcraft_io::write_dxf_entities(&entities, insunits), count)
|
||||
} else {
|
||||
// join: true -> emit closed loops as LWPOLYLINE with bulges for arcs
|
||||
let hole_keys: std::collections::HashSet<Vec<&str>> =
|
||||
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
|
||||
|
||||
let mut outline_ids = std::collections::HashSet::new();
|
||||
let mut cut_ids = std::collections::HashSet::new();
|
||||
for prof in &ss.profiles {
|
||||
for id in &prof.outer_curves {
|
||||
outline_ids.insert(id.as_str());
|
||||
}
|
||||
for holes in &prof.hole_curves {
|
||||
for id in holes {
|
||||
cut_ids.insert(id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
let base_layer = custom_layer.unwrap_or("0");
|
||||
let mut entities = Vec::new();
|
||||
for prof in &ss.profiles {
|
||||
if hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
|
||||
continue;
|
||||
}
|
||||
let outer_layer = if roles_layers { "OUTLINE" } else { base_layer };
|
||||
let shape = solidcraft_io::loop_lwpolyline_scaled(&prof.region.outer, scale);
|
||||
entities.push(DxfEntity { layer: outer_layer.into(), shape });
|
||||
|
||||
for hole in &prof.region.holes {
|
||||
let hole_layer = if roles_layers { "CUT" } else { base_layer };
|
||||
let shape = solidcraft_io::loop_lwpolyline_scaled(hole, scale);
|
||||
entities.push(DxfEntity { layer: hole_layer.into(), shape });
|
||||
}
|
||||
}
|
||||
// Preserve any non-profile open curves as disconnected entities so nothing is lost
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction || outline_ids.contains(c.id.as_str()) || cut_ids.contains(c.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
|
||||
match &c.kind {
|
||||
CurveKind::Line { a, b } => {
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Line(pt(*a), pt(*b)) })
|
||||
}
|
||||
CurveKind::Circle { c, r } => {
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Circle { c: pt(*c), r: *r * scale } })
|
||||
}
|
||||
CurveKind::Arc { c, a, b } => {
|
||||
let center = pt(*c);
|
||||
let pa = pt(*a);
|
||||
let pb = pt(*b);
|
||||
let r = center.dist(pa);
|
||||
let a0 = (pa - center).angle().to_degrees().rem_euclid(360.0);
|
||||
let a1 = (pb - center).angle().to_degrees().rem_euclid(360.0);
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Arc { c: center, r, a0, a1 } });
|
||||
}
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
let points: Vec<Vec2> = poly.into_iter().map(|p| p * scale).collect();
|
||||
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Polyline { points } });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut layer_names = std::collections::BTreeSet::new();
|
||||
if roles_layers {
|
||||
layer_names.insert("OUTLINE".to_string());
|
||||
layer_names.insert("CUT".to_string());
|
||||
}
|
||||
for e in &entities {
|
||||
layer_names.insert(e.layer.clone());
|
||||
}
|
||||
let layers: Vec<DxfLayer> = layer_names
|
||||
.iter()
|
||||
.map(|name| match name.as_str() {
|
||||
"OUTLINE" => DxfLayer::new(name, 7),
|
||||
"CUT" => DxfLayer::new(name, 4),
|
||||
_ => DxfLayer::new(name, 7),
|
||||
})
|
||||
.collect();
|
||||
let count = entities.len();
|
||||
(solidcraft_io::write_dxf_drawing_units(&layers, &entities, insunits)?, count)
|
||||
};
|
||||
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "entities": geom.len()}));
|
||||
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
|
||||
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
|
||||
}
|
||||
Ok(json!({"dxf": text, "entities": geom.len()}))
|
||||
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_io::Geom2;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -495,4 +727,197 @@ mod tests {
|
||||
assert!(s.execute("sketch.insert_svg", &p).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_default_is_byte_identical_to_before() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [40, 30]})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [15, 15], "radius": 5})).unwrap();
|
||||
|
||||
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
|
||||
let text = out["dxf"].as_str().unwrap();
|
||||
|
||||
// Build expected R12 DXF exactly as before
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(1).unwrap();
|
||||
let sk = &ss.sketch;
|
||||
let mut geom = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
let pt = |k: usize| sk.point(k).unwrap_or_default();
|
||||
match &c.kind {
|
||||
solidcraft_sketch::CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
|
||||
solidcraft_sketch::CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
|
||||
solidcraft_sketch::CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
|
||||
_ => {
|
||||
let poly = sk.polyline(i);
|
||||
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected = solidcraft_io::write_dxf(&geom);
|
||||
assert_eq!(text, expected, "default output must be byte-identical to previous write_dxf output");
|
||||
|
||||
// Null options must be treated as omitted (default behavior)
|
||||
for null_params in [
|
||||
json!({"units": null}),
|
||||
json!({"layer": null}),
|
||||
json!({"layers": null}),
|
||||
json!({"join": null}),
|
||||
json!({"path": null}),
|
||||
json!({"units": null, "layer": null, "layers": null, "join": null, "path": null}),
|
||||
] {
|
||||
let out_null = s.execute("sketch.export_dxf", &null_params).unwrap();
|
||||
assert_eq!(out_null["dxf"].as_str().unwrap(), expected, "null options must behave identically to default options");
|
||||
assert_eq!(out_null["entities"], out["entities"]);
|
||||
}
|
||||
|
||||
// Hostile inputs
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"units": 123})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"units": "unknown_unit"})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"layer": 456})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"layers": 123})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"layers": "all"})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"join": "true"})).is_err());
|
||||
assert!(s.execute("sketch.export_dxf", &json!({"path": 123})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_layers_present() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50, 50]})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [25, 25], "radius": 10})).unwrap();
|
||||
|
||||
// 1. Single custom layer
|
||||
let out_cut = s.execute("sketch.export_dxf", &json!({"layer": "CUT"})).unwrap();
|
||||
let dxf_cut = out_cut["dxf"].as_str().unwrap();
|
||||
assert!(dxf_cut.contains("\n8\nCUT\n"));
|
||||
assert!(!dxf_cut.contains("\n8\n0\n"));
|
||||
|
||||
// 2. Roles layers (OUTLINE for outer profile, CUT for holes)
|
||||
let out_roles = s.execute("sketch.export_dxf", &json!({"layers": "roles"})).unwrap();
|
||||
let dxf_roles = out_roles["dxf"].as_str().unwrap();
|
||||
assert!(dxf_roles.contains("\n8\nOUTLINE\n"));
|
||||
assert!(dxf_roles.contains("\n8\nCUT\n"));
|
||||
|
||||
// 3. Roles layers with join: true
|
||||
let out_joined_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
|
||||
assert_eq!(out_joined_roles["entities"], 2);
|
||||
let dxf_joined = out_joined_roles["dxf"].as_str().unwrap();
|
||||
assert!(dxf_joined.contains("AcDbPolyline"));
|
||||
assert!(dxf_joined.contains("\n 8\nOUTLINE\n") || dxf_joined.contains("\n8\nOUTLINE\n"));
|
||||
assert!(dxf_joined.contains("\n 8\nCUT\n") || dxf_joined.contains("\n8\nCUT\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_join_does_not_duplicate_holes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [100, 80]})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [30, 40], "radius": 5})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center": [70, 40], "radius": 8})).unwrap();
|
||||
|
||||
// With join: true, layers: "roles": exactly 3 entities (1 outer outline, 2 hole cuts)
|
||||
let out_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
|
||||
assert_eq!(out_roles["entities"], 3, "expected 1 outer outline and 2 hole cuts, got {}", out_roles["entities"]);
|
||||
let dxf_roles = out_roles["dxf"].as_str().unwrap();
|
||||
let lwpolyline_count = dxf_roles.lines().filter(|l| l.trim() == "LWPOLYLINE").count();
|
||||
assert_eq!(lwpolyline_count, 3, "expected exactly 3 LWPOLYLINE entities in DXF");
|
||||
|
||||
// Without roles (single layer): still exactly 3 entities
|
||||
let out_single = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
|
||||
assert_eq!(out_single["entities"], 3, "entities without roles must also be exactly 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_join_closed_lwpolyline_with_bulge() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
// Quarter-pie shape: center at (0, 0), arc from (10, 0) to (0, 10), and lines to (0, 0)
|
||||
s.execute("sketch.line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("sketch.arc.center_point", &json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]})).unwrap();
|
||||
s.execute("sketch.line", &json!({"points": [[0, 10], [0, 0]]})).unwrap();
|
||||
|
||||
let out = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
|
||||
let dxf = out["dxf"].as_str().unwrap();
|
||||
assert!(dxf.contains("LWPOLYLINE"));
|
||||
// 70: bit 1 (closed) + bit 128 (plinegen) = 129
|
||||
assert!(dxf.contains("\n 70\n129\n") || dxf.contains("\n70\n129\n"));
|
||||
// Bulge: 90° arc has sweep = pi/2, bulge = tan(sweep / 4) = tan(pi / 8) = sqrt(2) - 1 ≈ 0.414213562373
|
||||
let expected_bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan();
|
||||
let has_bulge = dxf
|
||||
.lines()
|
||||
.zip(dxf.lines().skip(1))
|
||||
.any(|(k, v)| k.trim() == "42" && (v.trim().parse::<f64>().unwrap_or(0.0).abs() - expected_bulge).abs() < 1e-6);
|
||||
assert!(has_bulge, "DXF must contain group 42 bulge approx {expected_bulge}");
|
||||
|
||||
// Re-import the joined DXF into a new sketch and verify round-trip profile area
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
|
||||
let id = r["sketch"].as_u64().unwrap();
|
||||
let ss = s.model.state().sketch(id).unwrap().clone();
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let expected_area = std::f64::consts::PI * 100.0 / 4.0; // quarter circle of radius 10
|
||||
assert!((ss.profiles[0].area - expected_area).abs() < 1e-4, "area was {}", ss.profiles[0].area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_inch_design_round_trip() {
|
||||
let mut s = Session::default();
|
||||
s.doc_mut().units = "in".to_string();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
// A 2 in by 3 in rectangle in an inch design (stored internally as 50.8 mm by 76.2 mm)
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50.8, 76.2]})).unwrap();
|
||||
|
||||
// 1. Export with units: "design"
|
||||
let out = s.execute("sketch.export_dxf", &json!({"units": "design"})).unwrap();
|
||||
let dxf = out["dxf"].as_str().unwrap();
|
||||
// $INSUNITS must be 1 (inches)
|
||||
assert!(dxf.contains("\n$INSUNITS\n70\n1\n"));
|
||||
// Sizes in inches: 2.0 and 3.0
|
||||
assert!(dxf.contains("2.000000000"));
|
||||
assert!(dxf.contains("3.000000000"));
|
||||
|
||||
// Round-trip through read_dxf: reads $INSUNITS=1, scales by 25.4 to mm
|
||||
let geom = solidcraft_io::read_dxf(dxf).unwrap();
|
||||
let mut widths = Vec::new();
|
||||
let mut heights = Vec::new();
|
||||
for g in &geom {
|
||||
if let Geom2::Line(a, b) = g {
|
||||
let dx = (b.x - a.x).abs() / 25.4;
|
||||
let dy = (b.y - a.y).abs() / 25.4;
|
||||
if dx > 0.1 {
|
||||
widths.push(dx);
|
||||
}
|
||||
if dy > 0.1 {
|
||||
heights.push(dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
for w in widths {
|
||||
assert!((w - 2.0).abs() < 1e-8, "width in inches was {w}");
|
||||
}
|
||||
for h in heights {
|
||||
assert!((h - 3.0).abs() < 1e-8, "height in inches was {h}");
|
||||
}
|
||||
|
||||
// 2. Export with join: true and explicit units: "in"
|
||||
let out_join = s.execute("sketch.export_dxf", &json!({"join": true, "units": "in"})).unwrap();
|
||||
let dxf_join = out_join["dxf"].as_str().unwrap();
|
||||
assert!(dxf_join.contains("LWPOLYLINE"));
|
||||
assert!(dxf_join.contains("\n$INSUNITS\n 70\n1\n") || dxf_join.contains("\n$INSUNITS\n70\n1\n"));
|
||||
|
||||
let geom_join = solidcraft_io::read_dxf(dxf_join).unwrap();
|
||||
assert_eq!(geom_join.len(), 4);
|
||||
for g in &geom_join {
|
||||
if let Geom2::Line(a, b) = g {
|
||||
let len_in = a.dist(*b) / 25.4;
|
||||
assert!((len_in - 2.0).abs() < 1e-8 || (len_in - 3.0).abs() < 1e-8, "edge length {len_in}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::schema::sketch_project::{
|
||||
AutoProjectParams, AutoProjectResult, FitCurvesParams, FitSectionResult, Include3dParams, IntersectParams, IntersectionCurveParams,
|
||||
IsoCurveParams, LinkMade, ProjectParams, ProjectResult, ProjectToSurfaceParams, ProjectToSurfaceResult, ProjectedLink, SketchBreakLinkParams,
|
||||
SketchBreakLinkResult, SketchLinkResult, SpunProfileParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
macro_rules! refs_doc {
|
||||
@@ -21,105 +27,160 @@ macro_rules! refs_doc {
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT_CONTRACT: TypedContract<ProjectParams> = TypedContract::new();
|
||||
const INTERSECT_CONTRACT: TypedContract<IntersectParams> = TypedContract::new();
|
||||
const INCLUDE_3D_CONTRACT: TypedContract<Include3dParams> = TypedContract::new();
|
||||
const PROJECT_TO_SURFACE_CONTRACT: TypedContract<ProjectToSurfaceParams> = TypedContract::new();
|
||||
const INTERSECTION_CURVE_CONTRACT: TypedContract<IntersectionCurveParams> = TypedContract::new();
|
||||
const SPUN_PROFILE_CONTRACT: TypedContract<SpunProfileParams> = TypedContract::new();
|
||||
const ISO_CURVE_CONTRACT: TypedContract<IsoCurveParams> = TypedContract::new();
|
||||
const FIT_SECTION_CONTRACT: TypedContract<FitCurvesParams> = TypedContract::new();
|
||||
const BREAK_LINK_CONTRACT: TypedContract<SketchBreakLinkParams> = TypedContract::new();
|
||||
const AUTO_PROJECT_CONTRACT: TypedContract<AutoProjectParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
|
||||
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
|
||||
CommandSpec::new("sketch.project", "Project", crate::checked!("sketch.project", project_cmd)).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).contract(&PROJECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
|
||||
concat!(refs_doc!(), "; projection_link?: bool (default true); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
|
||||
),
|
||||
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
|
||||
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
|
||||
CommandSpec::new("sketch.intersect", "Intersect", crate::checked!("sketch.intersect", intersect_cmd)).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).contract(&INTERSECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
|
||||
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
|
||||
),
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", crate::checked!("sketch.include_3d", include_cmd))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("include")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
|
||||
.contract(&INCLUDE_3D_CONTRACT).output(output_schema_for::<ProjectResult>).params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it; construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", crate::checked!("sketch.project_to_surface", project_to_surface))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("project_surface")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
|
||||
.contract(&PROJECT_TO_SURFACE_CONTRACT).output(output_schema_for::<ProjectToSurfaceResult>).params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", crate::checked!("sketch.intersection_curve", intersection_curve))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("intersection_curve")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
|
||||
.contract(&INTERSECTION_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.spun_profile", "Spun Profile", crate::checked!("sketch.spun_profile", spun_profile))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spun")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
|
||||
.contract(&SPUN_PROFILE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", crate::checked!("sketch.isoparametric_curve", iso_curve))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("iso_curve")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
|
||||
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
|
||||
.contract(&ISO_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
|
||||
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", crate::checked!("sketch.fit_curves_to_section", fit_section))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("fit_section")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break")
|
||||
.contract(&FIT_SECTION_CONTRACT).output(output_schema_for::<FitSectionResult>).params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", crate::checked!("sketch.break_link", break_link)).icon("sketch_break")
|
||||
.enabled(in_sketch)
|
||||
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project")
|
||||
.contract(&BREAK_LINK_CONTRACT).output(output_schema_for::<SketchBreakLinkResult>).params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", crate::checked!("sketch.auto_project", auto_project)).icon("project")
|
||||
.noundo()
|
||||
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
.contract(&AUTO_PROJECT_CONTRACT).output(output_schema_for::<AutoProjectResult>).params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
];
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
/// What a reference says in its JSON, before the design is asked what it names (`source` goes on
|
||||
/// from here, and so does the contract of the commands that take references).
|
||||
pub(crate) enum RefForm<'a> {
|
||||
/// Text: `origin`, an axis letter or a body name, which the design decides.
|
||||
Name(&'a str),
|
||||
/// `{edge: [x,y,z], body?}`.
|
||||
Edge { body: String, at: Vec3 },
|
||||
/// `{face: [x,y,z], body?}`.
|
||||
Face { body: String, at: Vec3 },
|
||||
/// `{vertex: [x,y,z], body?}`.
|
||||
Vertex { body: String, at: Vec3 },
|
||||
/// `{sketch, curve}` or `{sketch, point}`: the sketch as the lookup reads it (an id or a name)
|
||||
/// and the `curve` or `point` given. With neither, the handler refuses it once the sketch is
|
||||
/// found.
|
||||
Sketch { key: String, curve: Option<&'a str>, point: Option<&'a str> },
|
||||
/// `{body: name}`.
|
||||
Body(&'a str),
|
||||
/// `{axis: X|Y|Z|name}`.
|
||||
Axis(&'a str),
|
||||
/// `{plane: XY|XZ|YZ|name}`.
|
||||
Plane(&'a str),
|
||||
/// `{origin: …}`.
|
||||
Origin,
|
||||
}
|
||||
|
||||
/// The form of one reference, or the message (without the command's name) for what no design
|
||||
/// could make sense of. The forms are tried in this order, the first that fits wins: an edge, face
|
||||
/// or vertex whose value is not a point is passed over, not refused.
|
||||
pub(crate) fn ref_form(v: &Value) -> std::result::Result<RefForm<'_>, String> {
|
||||
if let Some(name) = v.as_str() {
|
||||
return match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
};
|
||||
return Ok(RefForm::Name(name));
|
||||
}
|
||||
let body = || str_(v, "body").unwrap_or("").to_string();
|
||||
if let Some(at) = v.get("edge").and_then(vec3) {
|
||||
return Ok(LinkSource::Edge { body: body(), at });
|
||||
return Ok(RefForm::Edge { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("face").and_then(vec3) {
|
||||
return Ok(LinkSource::Face { body: body(), at });
|
||||
return Ok(RefForm::Face { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("vertex").and_then(vec3) {
|
||||
return Ok(LinkSource::Vertex { body: body(), at });
|
||||
return Ok(RefForm::Vertex { body: body(), at });
|
||||
}
|
||||
if let Some(sk) = v.get("sketch") {
|
||||
let key = match sk {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
|
||||
_ => return Err("`sketch` must be an id or a name".into()),
|
||||
};
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = str_(v, "curve") {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = str_(v, "point") {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
return Err(bad(cmd, "a sketch reference needs `curve` or `point`"));
|
||||
return Ok(RefForm::Sketch { key, curve: str_(v, "curve"), point: str_(v, "point") });
|
||||
}
|
||||
if let Some(b) = str_(v, "body") {
|
||||
return Ok(LinkSource::Body { body: b.to_string() });
|
||||
return Ok(RefForm::Body(b));
|
||||
}
|
||||
if let Some(a) = str_(v, "axis") {
|
||||
return axis_source(s, a, cmd);
|
||||
return Ok(RefForm::Axis(a));
|
||||
}
|
||||
if let Some(p) = str_(v, "plane") {
|
||||
return Ok(LinkSource::Plane { name: p.to_string() });
|
||||
return Ok(RefForm::Plane(p));
|
||||
}
|
||||
if v.get("origin").is_some() {
|
||||
return Ok(LinkSource::Origin);
|
||||
return Ok(RefForm::Origin);
|
||||
}
|
||||
Err(format!("cannot project {v}"))
|
||||
}
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
match ref_form(v).map_err(|why| bad(cmd, why))? {
|
||||
RefForm::Name(name) => match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
},
|
||||
RefForm::Edge { body, at } => Ok(LinkSource::Edge { body, at }),
|
||||
RefForm::Face { body, at } => Ok(LinkSource::Face { body, at }),
|
||||
RefForm::Vertex { body, at } => Ok(LinkSource::Vertex { body, at }),
|
||||
RefForm::Sketch { key, curve, point } => {
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = curve {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = point {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
Err(bad(cmd, "a sketch reference needs `curve` or `point`"))
|
||||
}
|
||||
RefForm::Body(b) => Ok(LinkSource::Body { body: b.to_string() }),
|
||||
RefForm::Axis(a) => axis_source(s, a, cmd),
|
||||
RefForm::Plane(p) => Ok(LinkSource::Plane { name: p.to_string() }),
|
||||
RefForm::Origin => Ok(LinkSource::Origin),
|
||||
}
|
||||
Err(bad(cmd, format!("cannot project {v}")))
|
||||
}
|
||||
|
||||
/// An origin axis (`X`, `Y` or `Z`, in any case), or a construction axis by its name (#9324).
|
||||
@@ -391,10 +452,37 @@ fn add_link_unless_present(
|
||||
Ok(Some(link))
|
||||
}
|
||||
|
||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||
/// Make the curves of `link` construction geometry: references to draw against, left out of
|
||||
/// profiles (#13142). They stay linked; a link rebuilt because its shape changed comes back as
|
||||
/// normal geometry, as curves switched to construction by hand do.
|
||||
fn mark_construction(sk: &mut Sketch, link: &str) {
|
||||
for c in sk.link_curves(link) {
|
||||
if let Some(curve) = sk.curves.get_mut(c) {
|
||||
curve.construction = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`mark_construction`] for `link` in sketch `id`, as the session's preference asks of the face
|
||||
/// edges a new sketch projects (#13142).
|
||||
pub(super) fn construct_link(s: &mut Session, id: u64, link: &str) -> Result<()> {
|
||||
sketch_store::update(s, id, |_, _, sk| {
|
||||
mark_construction(sk, link);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// A command's reply as JSON: they build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The ids of the curves and of the points that link `link` made.
|
||||
fn link_parts(sk: &Sketch, link: &str) -> (Vec<String>, Vec<String>) {
|
||||
let curves: Vec<String> = sk.link_curves(link).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
|
||||
let points: Vec<String> = sk.link_points(link).iter().filter_map(|p| sk.points.get(*p).map(|p| p.id.clone())).collect();
|
||||
json!({"link": link, "curves": curves, "points": points})
|
||||
(curves, points)
|
||||
}
|
||||
|
||||
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
|
||||
@@ -406,6 +494,11 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
if refs.is_empty() {
|
||||
return Err(bad(cmd, "`refs` is empty"));
|
||||
}
|
||||
let construction = match p.get("construction") {
|
||||
None => s.project_construction,
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(_) => return Err(bad(cmd, "`construction` must be a boolean")),
|
||||
};
|
||||
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
let sources: Vec<LinkSource> = refs.iter().map(|v| source(s, v, cmd)).collect::<Result<_>>()?;
|
||||
let (out, present) = sketch_store::update(s, id, |s, doc, sk| {
|
||||
@@ -418,9 +511,13 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
let what = src.describe();
|
||||
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
|
||||
Ok(Some(l)) => {
|
||||
let mut result = link_json(sk, &l);
|
||||
let (curves, points) = link_parts(sk, &l);
|
||||
if construction {
|
||||
mark_construction(sk, &l);
|
||||
}
|
||||
let mut result = ProjectedLink { link: Some(l.clone()), curves, points };
|
||||
if !projection_link {
|
||||
result["link"] = Value::Null;
|
||||
result.link = None;
|
||||
sk.break_link(&l)?;
|
||||
}
|
||||
out.push(result);
|
||||
@@ -435,11 +532,7 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
}
|
||||
Ok((out, present))
|
||||
})?;
|
||||
let mut r = json!({"links": out});
|
||||
if !present.is_empty() {
|
||||
r["already_projected"] = json!(present);
|
||||
}
|
||||
Ok(r)
|
||||
reply(cmd, ProjectResult { links: out, already_projected: present, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -543,16 +636,15 @@ pub(super) fn project_entities(s: &mut Session, p: &Value, cmd: &str) -> Result<
|
||||
}
|
||||
|
||||
/// Add one link from `src` to the active sketch and report it.
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<LinkMade> {
|
||||
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
sketch_store::update(s, id, |s, doc, sk| {
|
||||
refresh(s, doc, id, sk);
|
||||
let what = src.describe();
|
||||
let l = add_link(s, doc, id, sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
|
||||
let mut out = link_json(sk, &l);
|
||||
let (curves, points) = link_parts(sk, &l);
|
||||
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
|
||||
out["wires"] = json!(wires);
|
||||
Ok(out)
|
||||
Ok(LinkMade { link: l, curves, points, wires })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -571,7 +663,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
out.push(one_link(s, cmd, LinkKind::Project, LinkSource::OnSurface { sketch, curve, body: body.clone(), at })?);
|
||||
}
|
||||
Ok(json!({"links": out}))
|
||||
reply(cmd, ProjectToSurfaceResult { links: out, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -584,7 +676,8 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (a, b) = (side("a")?, side("b")?);
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })
|
||||
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -605,7 +698,8 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(wa, wb - wa)
|
||||
}
|
||||
};
|
||||
one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })
|
||||
let made = one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -620,7 +714,8 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format!("{},{},{}", d.x, d.y, d.z)
|
||||
}
|
||||
};
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })
|
||||
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -636,7 +731,7 @@ fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.break_link(&l)?;
|
||||
Ok(curves)
|
||||
})?;
|
||||
Ok(json!({"curves": curves}))
|
||||
reply(cmd, FitSectionResult { curves, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -670,12 +765,12 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(links)
|
||||
})?;
|
||||
Ok(json!({"broken": links}))
|
||||
reply(cmd, SketchBreakLinkResult { broken: links, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
|
||||
Ok(json!({"auto_project": s.auto_project}))
|
||||
reply("sketch.auto_project", AutoProjectResult { auto_project: s.auto_project, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// `"x:<a>+<b>:x,y"` from interactive snapping: two distinct curve ids and a hint point.
|
||||
|
||||
@@ -1763,3 +1763,43 @@ fn a_second_placements_face_and_edge_project_where_it_is_placed() {
|
||||
check(&s, 15.0);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()), 15.0);
|
||||
}
|
||||
|
||||
/// #13142: projected references can start as construction geometry, asked for per call or for
|
||||
/// the whole session (a preference, kept for designs started and opened like `auto_project`).
|
||||
#[test]
|
||||
fn projected_references_can_start_as_construction() {
|
||||
let flags = |s: &mut Session| linked_curves(&inspect(s)).iter().map(|c| c["construction"] == true).collect::<Vec<_>>();
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
// Off (the default): the face loop is normal geometry and closes a profile.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
let on_face = flags(&mut s);
|
||||
assert!(on_face.len() == 4 && on_face.iter().all(|c| !c), "{on_face:?}");
|
||||
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(1));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Asked for in the call, and not.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}], "construction": true}));
|
||||
assert_eq!(flags(&mut s), vec![true]);
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}]}));
|
||||
assert_eq!(flags(&mut s), vec![true, false]);
|
||||
run(&mut s, "sketch.intersect", json!({"refs": [{"edge": [0, 0, 10]}], "construction": true}));
|
||||
let e = s.execute("sketch.project", &json!({"refs": [{"edge": [20, 30, 20]}], "construction": 1}));
|
||||
assert!(e.is_err(), "construction must be a boolean");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// The session's preference: a sketch on a face starts with a construction loop and no
|
||||
// profile, and it holds for the next design; `construction: false` still asks for normal.
|
||||
s.project_construction = true;
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert!(s.project_construction, "file.new");
|
||||
box_part(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
let on_face = flags(&mut s);
|
||||
assert!(on_face.len() == 4 && on_face.iter().all(|c| *c), "{on_face:?}");
|
||||
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(0));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}]}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
|
||||
assert_eq!(flags(&mut s), vec![true, false]);
|
||||
}
|
||||
|
||||
@@ -340,6 +340,63 @@ fn tangent_moves_only_the_second_pick() {
|
||||
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
|
||||
}
|
||||
|
||||
/// #12092: Tangent with a line's end point and a circle puts that end on the circle and makes the
|
||||
/// line tangent there, in one step. In either pick order only the picked end moves: the line turns
|
||||
/// about its other end and gets longer or shorter, and the circle stays.
|
||||
#[test]
|
||||
fn tangent_at_a_lines_end_puts_the_end_on_the_circle() {
|
||||
// Where the line ends up: its end's distance from the centre, and the line's.
|
||||
let touch = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let (a, b) = (pt(s, "l1.start"), pt(s, "l1.end"));
|
||||
let c = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap();
|
||||
let line = (b - a).cross(c - a).abs() / a.dist(b);
|
||||
(b.dist(c), line, sk.radius(sk.curve_index("c1").unwrap()).unwrap())
|
||||
};
|
||||
for (a, b) in [("c1", "l1.end"), ("l1.end", "c1")] {
|
||||
let mut s = line_and_circle();
|
||||
let circle = snap(&s, "c1");
|
||||
let undo = s.undo.len();
|
||||
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
|
||||
let (end, line, radius) = touch(&s);
|
||||
assert!((radius - 5.0).abs() < 1e-9, "{a}, {b}: the circle keeps its size: {radius}");
|
||||
assert!((end - 5.0).abs() < 1e-6 && (line - 5.0).abs() < 1e-6, "{a}, {b}: end {end}, line {line} from the centre");
|
||||
assert!(same(&snap(&s, "c1"), &circle), "{a}, {b}: the circle moved");
|
||||
let start = pt(&s, "l1.start");
|
||||
assert!(start.dist(solidcore_record::Vec2::new(5.0, 3.0)) < 1e-9, "{a}, {b}: the line's other end moved to {start:?}");
|
||||
assert_eq!(s.undo.len(), undo + 1, "{a}, {b}: one undo step");
|
||||
}
|
||||
// A point that ends no curve names no curve to be tangent: the refusal says what to pick.
|
||||
let mut s = line_and_circle();
|
||||
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1.center"})).unwrap_err().to_string();
|
||||
assert!(e.contains("end"), "{e}");
|
||||
}
|
||||
|
||||
/// A line whose end is already joined to an arc's end (the coincident a click there adds): Tangent
|
||||
/// on that end and the arc, or on the two ends in either order, adds only the tangent, since the
|
||||
/// end already lies on the arc. The joint holds. With the end and the arc the line stays and the
|
||||
/// arc moves; with two ends the first pick stays.
|
||||
#[test]
|
||||
fn tangent_at_a_joint_adds_only_the_tangent() {
|
||||
for (a, b, stays) in [("l1.end", "a1", "l1"), ("a1", "l1.end", "l1"), ("l1.end", "a1.start", "l1"), ("a1.start", "l1.end", "a1")] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[2, 3], [12, 3]]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [16, 9], "start": [12, 3], "sweep": 90}));
|
||||
let joints = sketch(&s).constraints.len();
|
||||
let kept = snap(&s, stays);
|
||||
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
assert!(same(&snap(&s, stays), &kept), "{a}, {b}: {stays} moved");
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(1), "{a}, {b}: {r}");
|
||||
assert_eq!(sketch(&s).constraints.len(), joints + 1, "{a}, {b}: only the tangent is new");
|
||||
let sk = sketch(&s);
|
||||
let arc = sk.curve_index("a1").unwrap();
|
||||
let (p, q, c) = (pt(&s, "l1.start"), pt(&s, "l1.end"), sk.center(arc).unwrap());
|
||||
assert!(q.dist(pt(&s, "a1.start")) < 1e-6, "{a}, {b}: the joint came apart");
|
||||
assert!(((q - p).cross(c - p).abs() / p.dist(q) - sk.radius(arc).unwrap()).abs() < 1e-6, "{a}, {b}: not tangent");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_second_pick_makes_the_first_move() {
|
||||
let mut s = line_and_circle();
|
||||
|
||||
+33
-16
@@ -72,7 +72,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"solid.thread" => &[("face", Pick), ("from", Pick), ("up_to", Plane)],
|
||||
"solid.emboss" => &[("face", Make)],
|
||||
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
|
||||
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
|
||||
"solid.press_pull" => &[("face", Pick), ("edges", Picks), ("body", Body)],
|
||||
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
|
||||
// Align finds its picks where they are shown and takes both ends into the moved bodies'
|
||||
// frame itself: a pick mapped into the frame of the body under it would put the two ends
|
||||
@@ -227,11 +227,11 @@ impl Mapper<'_> {
|
||||
let m = if self.here { self.f.sketch } else { under.as_ref().map(|(m, _)| *m).unwrap_or(self.f.active) };
|
||||
self.first.get_or_insert(m);
|
||||
let out = self.local_point(&m, p);
|
||||
// The command sees the pick in the frame of the body under it. Remember the bodies
|
||||
// whose faces meet there, so a face of that body is found and a body that only
|
||||
// shares its place in the model does not make the pick ambiguous (#419).
|
||||
if let Some((_, names)) = &under {
|
||||
remember(out.as_ref().and_then(vec3).unwrap_or(p), names.clone());
|
||||
// The command sees the pick in the frame of the body under it. Remember where it was
|
||||
// picked and the bodies whose faces meet there, so a face of that body is found and a
|
||||
// body that only shares its place in the model does not make the pick ambiguous (#419).
|
||||
if !self.here {
|
||||
remember(out.as_ref().and_then(vec3).unwrap_or(p), p, under.map(|(_, names)| names).unwrap_or_default());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -361,9 +361,19 @@ thread_local! {
|
||||
/// The outermost command is `solid.move`, whose parameters stay in the world when its bodies
|
||||
/// lie in differently turned frames (`split_move`).
|
||||
static OUTER_MOVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// The picks of the running command: the point it sees and the bodies whose faces meet
|
||||
/// there (the first is the body under it; more than one means the pick is ambiguous).
|
||||
static PICKS: std::cell::RefCell<Vec<(Vec3, Vec<String>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
/// The picks of the running command.
|
||||
static PICKS: std::cell::RefCell<Vec<Pick>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// A pick of the running command.
|
||||
struct Pick {
|
||||
/// The point the command sees.
|
||||
local: Vec3,
|
||||
/// Where it was picked.
|
||||
world: Vec3,
|
||||
/// The bodies whose faces meet there: the first is the body under it, more than one means
|
||||
/// the pick is ambiguous, and none that no face is under it.
|
||||
bodies: Vec<String>,
|
||||
}
|
||||
|
||||
/// How many picks of one command are remembered (a command takes few).
|
||||
@@ -371,21 +381,28 @@ const MAX_PICKS: usize = 4096;
|
||||
/// How many bodies may meet at one pick (an ambiguous pick names them).
|
||||
const MAX_PICK_BODIES: usize = 64;
|
||||
|
||||
/// Remember the bodies whose faces meet at a pick, against the point the command sees.
|
||||
fn remember(point: Vec3, names: Vec<String>) {
|
||||
let names: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
|
||||
/// Remember where a pick was made and the bodies whose faces meet there, against the point the
|
||||
/// command sees.
|
||||
fn remember(local: Vec3, world: Vec3, names: Vec<String>) {
|
||||
let bodies: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
|
||||
PICKS.with(|picks| {
|
||||
let mut picks = picks.borrow_mut();
|
||||
if picks.len() < MAX_PICKS {
|
||||
picks.push((point, names));
|
||||
picks.push(Pick { local, world, bodies });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The bodies whose faces meet at `point`, when the running command picked it: the first is
|
||||
/// the body under the pick, and more than one means the pick is ambiguous.
|
||||
/// The bodies whose faces meet at `point`, when the running command picked it on a face: the
|
||||
/// first is the body under the pick, and more than one means the pick is ambiguous.
|
||||
pub(crate) fn picked(point: Vec3) -> Option<Vec<String>> {
|
||||
PICKS.with(|picks| picks.borrow().iter().find(|(p, _)| *p == point).map(|(_, names)| names.clone()))
|
||||
PICKS.with(|picks| picks.borrow().iter().find(|p| p.local == point && !p.bodies.is_empty()).map(|p| p.bodies.clone()))
|
||||
}
|
||||
|
||||
/// Where the running command's `nth` pick seen at `point` was picked (counting from 0: two picks
|
||||
/// on parts placed apart can be the same point in their own frames).
|
||||
pub(crate) fn picked_world(point: Vec3, nth: usize) -> Option<Vec3> {
|
||||
PICKS.with(|picks| picks.borrow().iter().filter(|p| p.local == point).nth(nth).map(|p| p.world))
|
||||
}
|
||||
|
||||
/// Forget the picks of the last command: a new one starts with none.
|
||||
|
||||
@@ -611,3 +611,399 @@ fn a_combine_tool_stays_where_it_is_placed() {
|
||||
assert_eq!(lumps(&s), Some(2), "the boss stays where it is in the world");
|
||||
assert!((volume(&mut s) - 17000.0).abs() < 1e-6, "{}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// The volume of one body in its own frame (a component's body once, however often it is placed).
|
||||
fn body_volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).unwrap_or_else(|| panic!("no body {body}")).measure().unwrap().volume
|
||||
}
|
||||
|
||||
/// The body the last feature, a fillet or chamfer, keeps.
|
||||
fn blend_body(s: &Session) -> Option<String> {
|
||||
match &s.doc.features.last().unwrap().kind {
|
||||
solidcraft_doc::FeatureKind::Fillet { body, .. } | solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The points the last feature, a fillet or chamfer, keeps.
|
||||
fn blend_edges(s: &Session) -> Vec<Vec3> {
|
||||
match &s.doc.features.last().unwrap().kind {
|
||||
solidcraft_doc::FeatureKind::Fillet { edges, .. } | solidcraft_doc::FeatureKind::Chamfer { edges, .. } => edges.iter().map(|p| p.0).collect(),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, id: &str, p: Value) -> String {
|
||||
let features = s.doc.features.len();
|
||||
let err = s.execute(id, &p).expect_err(&format!("{id} {p}")).to_string();
|
||||
assert_eq!(s.doc.features.len(), features, "{id} {p}: {err}");
|
||||
err
|
||||
}
|
||||
|
||||
/// A 1 mm chamfer of a 40 mm edge between square faces.
|
||||
const CUT: f64 = 20.0;
|
||||
/// A box 40 x 30 x 10.
|
||||
const BLOCK: f64 = 12000.0;
|
||||
|
||||
/// Component Base holds "Base block" and component Cap "Cap block", each a 40 x 30 x 10 box,
|
||||
/// Cap's at `cap_z` in its own frame and its placement moved by `moved`; Base is active.
|
||||
fn two_parts(cap_z: f64, moved: [f64; 3]) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Base block"}));
|
||||
run(&mut s, "component.create", json!({"name": "Cap", "parent": "root"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, cap_z], "body_name": "Cap block"}));
|
||||
if moved != [0.0; 3] {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Cap:1", "translate": moved}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component": "Base"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Component Lower holds "Lower block" (40 x 30 x 10) and component Upper "Upper block" (40 x 30
|
||||
/// x 2), both from their frames' origins, so Upper's bottom edges lie on Lower's in their own
|
||||
/// frames. Upper:1 sits on Lower, and a second placement, Upper:2, under it. The root is active.
|
||||
fn lower_and_uppers() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Lower"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
|
||||
run(&mut s, "component.create", json!({"name": "Upper", "parent": "root"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 2, "body_name": "Upper block"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Upper:1", "translate": [0, 0, 10]}));
|
||||
let under = run(&mut s, "occurrence.copy", json!({"component": "Upper"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -12]}));
|
||||
let (lo, hi) = world_bbox(&s, "Upper block (Upper:2)");
|
||||
assert!(near(lo, Vec3::new(0.0, 0.0, -2.0)) && near(hi, Vec3::new(40.0, 30.0, 0.0)), "{lo:?} {hi:?}");
|
||||
(s, under)
|
||||
}
|
||||
|
||||
/// A world pick on an edge of a moved part finds that part, though another part has an edge at
|
||||
/// the same point in its own frame, and the blend keeps the body it found.
|
||||
#[test]
|
||||
fn edge_picks_on_a_moved_part_find_it_past_other_parts_frames() {
|
||||
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
|
||||
for (case, mut s, id, p, body, base, cap) in [
|
||||
("v0", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
|
||||
("v4", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}), "Cap block", BLOCK, BLOCK - CUT),
|
||||
("v5", two_parts(0.0, [500.0, 0.0, 0.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
|
||||
(
|
||||
"v6",
|
||||
two_parts(0.0, [0.0, 0.0, 50.0]),
|
||||
"solid.chamfer",
|
||||
json!({"edges": [[20, 0, 0]], "distance": 1, "tangent_chain": false}),
|
||||
"Base block",
|
||||
BLOCK - CUT,
|
||||
BLOCK,
|
||||
),
|
||||
("fillet", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 1}), "Base block", BLOCK - round, BLOCK),
|
||||
(
|
||||
"press_pull",
|
||||
two_parts(0.0, [0.0, 0.0, 50.0]),
|
||||
"solid.press_pull",
|
||||
json!({"edges": [[20, 0, 0]], "distance": 1}),
|
||||
"Base block",
|
||||
BLOCK - round,
|
||||
BLOCK,
|
||||
),
|
||||
("v1 unplaced", two_parts(50.0, [0.0; 3]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
|
||||
] {
|
||||
if case == "v5" {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
}
|
||||
let features = s.doc.features.len();
|
||||
run(&mut s, id, p.clone());
|
||||
assert_eq!(s.doc.features.len(), features + 1, "{case}");
|
||||
assert_eq!(blend_body(&s).as_deref(), Some(body), "{case}");
|
||||
let (b, c) = (body_volume(&s, "Base block"), body_volume(&s, "Cap block"));
|
||||
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{case}: Base {b}, Cap {c}");
|
||||
}
|
||||
// The pick on Cap is kept in Cap's frame.
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}));
|
||||
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
/// Where the edges of two parts lie in one place in the world, a pick without `body` is refused,
|
||||
/// naming both; with `body`, or a pick's own `body`, the named part is cut.
|
||||
#[test]
|
||||
fn edge_picks_where_two_parts_meet_name_both() {
|
||||
for (case, cap_z, moved) in [("v2 unplaced", 0.0, [0.0; 3]), ("v3 moved", 50.0, [0.0, 0.0, -50.0])] {
|
||||
for (id, size) in [("solid.chamfer", "distance"), ("solid.fillet", "radius")] {
|
||||
let mut s = two_parts(cap_z, moved);
|
||||
let err = refused(&mut s, id, json!({"edges": [[20, 0, 0]], size: 1}));
|
||||
assert!(
|
||||
err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block") && err.contains("with `body`"),
|
||||
"{case} {id}: {err}"
|
||||
);
|
||||
assert!(!err.contains("hidden"), "{err}");
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
}
|
||||
let mut s = two_parts(cap_z, moved);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
|
||||
let mut s = two_parts(cap_z, moved);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Cap block"}], "distance": 1}));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lower block's outer bottom edge, with a hidden upper block placed under it: that block's
|
||||
/// top edge lies there too, so the pick is refused and the message says it is hidden. A lower
|
||||
/// block's edge whose only twin is in the upper block's own frame is cut.
|
||||
#[test]
|
||||
fn edge_picks_beside_a_hidden_placement_name_it() {
|
||||
let (mut s, under) = lower_and_uppers();
|
||||
run(&mut s, "browser.visibility", json!({"visible": false, "paths": [[under]]}));
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!(
|
||||
err.contains("lies on edges of 2 bodies, Lower block and Upper block (Upper:2)") && err.contains("(Upper block (Upper:2) is hidden)"),
|
||||
"{err}"
|
||||
);
|
||||
run(&mut s, "browser.visibility", json!({"visible": true, "paths": [[under]]}));
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!(err.contains("Lower block and Upper block (Upper:2)") && !err.contains("hidden"), "{err}");
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Lower block"}));
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
// Upper:2 out of the way: the lower block's bottom left edge has a twin only in Upper's frame.
|
||||
let (mut s, under) = lower_and_uppers();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -100]}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[0, 15, 0]], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - 15.0)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
}
|
||||
|
||||
/// The lower block's top edge, where the upper block on it has its bottom edges, is refused
|
||||
/// naming both; `body` cuts either. A second lower block placed on the upper one meets it at the
|
||||
/// upper block's top edges likewise.
|
||||
#[test]
|
||||
fn edge_picks_on_a_top_edge_under_another_part_name_both() {
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
|
||||
assert!(err.contains("[20, 0, 10] lies on edges of 2 bodies, Lower block and Upper block:"), "{err}");
|
||||
for (body, lower, upper) in [("Lower block", BLOCK - CUT, 2400.0), ("Upper block", BLOCK, 2400.0 - CUT)] {
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1, "body": body}));
|
||||
let (b, c) = (body_volume(&s, "Lower block"), body_volume(&s, "Upper block"));
|
||||
assert!((b - lower).abs() < 1e-3 * CUT && (c - upper).abs() < 1e-3 * CUT, "{body}: Lower {b}, Upper {c}");
|
||||
}
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1}));
|
||||
assert!(err.contains("[20, 0, 12] lies on edges of 2 bodies, Upper block and Lower block (Lower:2):"), "{err}");
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
}
|
||||
|
||||
/// A block's top edge inside the bottom face of a plate placed on it: the block alone has an edge
|
||||
/// there.
|
||||
#[test]
|
||||
fn an_edge_inside_another_parts_face_is_picked() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Lower"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
|
||||
run(&mut s, "component.create", json!({"name": "Plate", "parent": "root"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 2, "corner": [-30, -35, 0], "body_name": "Plate block"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Plate:1", "translate": [0, 0, 10]}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Plate block") == 20000.0);
|
||||
}
|
||||
|
||||
/// Round edges of two parts in one place: a pick a little inside them, as a click on the drawn
|
||||
/// edge lands, is refused naming both. A disc edge 0.2 mm out is another edge.
|
||||
#[test]
|
||||
fn coincident_round_edges_of_two_parts_are_refused() {
|
||||
let parts = |b_radius: f64| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "A"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 10, "body_name": "A rod"}));
|
||||
run(&mut s, "component.create", json!({"name": "B", "parent": "root"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": b_radius, "height": 2, "body_name": "B disc"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "B:1", "translate": [0, 0, 10]}));
|
||||
s
|
||||
};
|
||||
let pi = std::f64::consts::PI;
|
||||
let disc = |r: f64| pi * r * r * 2.0;
|
||||
// A round of radius 1 on the rod's top edge, radius 10: its section's area at its centroid's
|
||||
// radius.
|
||||
let (a, c) = (1.0 - pi / 4.0, (10.0 - 3.0 * pi) / (3.0 * (4.0 - pi)));
|
||||
let rounded = pi * 100.0 * 10.0 - a * 2.0 * pi * (10.0 - c);
|
||||
let mut s = parts(10.0);
|
||||
let err = refused(&mut s, "solid.fillet", json!({"edges": [[0, 9.98, 10]], "radius": 1}));
|
||||
assert!(err.contains("lies on edges of 2 bodies, A rod and B disc"), "{err}");
|
||||
for pick in [[0.0, 9.98, 10.0], [0.0, 10.0, 10.0]] {
|
||||
let mut s = parts(10.2);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [pick], "radius": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("A rod"), "{pick:?}");
|
||||
let (v, b) = (body_volume(&s, "A rod"), body_volume(&s, "B disc"));
|
||||
assert!((v - rounded).abs() < 2e-4 * rounded && (b - disc(10.2)).abs() < 1e-6 * b, "{pick:?}: {v} vs {rounded}, {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two picks at one point of their parts' frames, on two parts, in one command: each is looked
|
||||
/// up where it was picked, and as the feature keeps one body or none, it is refused.
|
||||
#[test]
|
||||
fn two_picks_at_one_point_of_two_parts_frames_are_refused() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0], [20, 0, 50]], "distance": 1}));
|
||||
assert!(err.contains("the picks lie on 2 bodies, Base block and Cap block") && err.contains("in a command of its own"), "{err}");
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
}
|
||||
|
||||
/// One part placed twice: its edge picked through both placements, or through two placements in
|
||||
/// one place, is one edge of one body.
|
||||
#[test]
|
||||
fn one_edge_of_two_placements_is_one_edge() {
|
||||
for (offset, edges) in [
|
||||
([0.0, 40.0, 0.0], json!([[20, 0, 0], [20, 40, 0]])),
|
||||
([0.0; 3], json!([[20, 0, 0]])),
|
||||
// Overlapping along the edge: the pick is at x 25 of one placement and x 15 of the other.
|
||||
([10.0, 0.0, 0.0], json!([[25, 0, 0]])),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Pin"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Pin block"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": "Pin"}))["occurrence"].as_u64().unwrap();
|
||||
if offset != [0.0; 3] {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": copy, "space": "world", "translate": offset}));
|
||||
}
|
||||
run(&mut s, "solid.chamfer", json!({"edges": edges, "distance": 1}));
|
||||
assert!((body_volume(&s, "Pin block") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{offset:?}: {}", body_volume(&s, "Pin block"));
|
||||
assert!((volume(&mut s) - 2.0 * (BLOCK - CUT)).abs() < 2e-3 * CUT, "{offset:?}");
|
||||
assert_eq!(blend_body(&s), None, "one body in the model");
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge given by body and index keeps its body on the feature.
|
||||
#[test]
|
||||
fn an_edge_by_index_keeps_its_body() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let tol = 1e-3_f64.max(s.model.state().body("Base block").unwrap().body.size() * 2e-3);
|
||||
let edges = s.model.state().body("Base block").unwrap().body.edges(tol).unwrap();
|
||||
let index = edges.iter().position(|e| e.mid.dist(Vec3::new(20.0, 0.0, 0.0)) < 1e-6).unwrap();
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [{"body": "Base block", "index": index}], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
|
||||
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
|
||||
}
|
||||
|
||||
/// A pick 0.42 mm off a moved part's edge, beyond the faces the placement is found by, is still
|
||||
/// looked up where it was picked: another part's edge there in its own frame is not in the way.
|
||||
#[test]
|
||||
fn a_pick_beside_an_edge_is_looked_up_where_it_was_picked() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, -0.3, -0.3]], "distance": 1}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
|
||||
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
|
||||
}
|
||||
|
||||
/// A chamfer of a moved part, found by a world pick, stays on that part through an edit of
|
||||
/// the other part, a recompute, undo and redo, and Edit Feature.
|
||||
#[test]
|
||||
fn a_blend_found_by_a_world_pick_keeps_its_part() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1, "name": "Edge"}));
|
||||
let check = |s: &Session, base: f64, cap: f64, step: &str| {
|
||||
let (b, c) = (body_volume(s, "Base block"), body_volume(s, "Cap block"));
|
||||
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{step}: Base {b}, Cap {c}");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{step}: {:?}", s.model.results);
|
||||
};
|
||||
check(&s, BLOCK, BLOCK - CUT, "made");
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Box1", "set": {"length": "50"}}));
|
||||
check(&s, 15000.0, BLOCK - CUT, "Base edited");
|
||||
run(&mut s, "timeline.compute_all", json!({}));
|
||||
check(&s, 15000.0, BLOCK - CUT, "computed");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, BLOCK, BLOCK - CUT, "undone");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s, 15000.0, BLOCK - CUT, "redone");
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Edge", "command": "solid.chamfer", "params": {"edges": [[20, 0, 50]], "distance": 2}}));
|
||||
check(&s, 15000.0, BLOCK - 4.0 * CUT, "redefined");
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
|
||||
}
|
||||
|
||||
/// A corner of a moved part is refused at the point picked, in the world.
|
||||
#[test]
|
||||
fn a_corner_of_a_moved_part_is_refused_where_it_was_picked() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[0, 0, 50]], "distance": 1}));
|
||||
assert!(err.contains("[0, 0, 50] is where 3 edges meet"), "{err}");
|
||||
}
|
||||
|
||||
/// Picks far out, or overflowing a placement, in a design with moved parts are refused.
|
||||
#[test]
|
||||
fn hostile_edge_picks_in_a_placed_design_are_refused() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
for pick in [json!([1e300, 0, 0]), json!([1.7e308, -1.7e308, 1.7e308]), json!([[1e300, 0, 0]])] {
|
||||
let err = s.execute("solid.chamfer", &json!({"edges": [pick], "distance": 1})).expect_err("refused");
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }), "{pick}: {err}");
|
||||
}
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
}
|
||||
|
||||
/// The index of the edge of `body` whose midpoint is `mid`.
|
||||
fn edge_index(s: &Session, body: &str, mid: Vec3) -> usize {
|
||||
let b = s.model.state().body(body).unwrap().body.clone();
|
||||
let edges = b.edges(1e-3_f64.max(b.size() * 2e-3)).unwrap();
|
||||
edges.iter().position(|e| e.mid.dist(mid) < 1e-6).unwrap()
|
||||
}
|
||||
|
||||
/// A pick's own `body` must exist and agree with the feature's `body`: the feature keeps one
|
||||
/// body, and the pick is found on its own.
|
||||
#[test]
|
||||
fn a_picks_own_body_must_agree_with_body() {
|
||||
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
|
||||
let index = edge_index(&s, "Cap block", Vec3::new(20.0, 0.0, 0.0));
|
||||
for edge in [json!({"point": [20, 0, 50], "body": "Cap block"}), json!({"body": "Cap block", "index": index})] {
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [edge], "body": "Base block", "distance": 1}));
|
||||
assert!(err.contains("an edge names `Cap block`, but `body` is `Base block`: give one"), "{edge}: {err}");
|
||||
}
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Nobody"}], "distance": 1}));
|
||||
assert!(err.contains("no body `Nobody`"), "{err}");
|
||||
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 50], "body": "Cap block"}], "body": "Cap block", "distance": 1}));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
|
||||
}
|
||||
|
||||
/// Press Pull on an edge is a fillet: where two parts meet it is refused naming both, and
|
||||
/// `body`, by its own name or a placement's, rounds that part.
|
||||
#[test]
|
||||
fn press_pull_on_an_edge_where_two_parts_meet_takes_body() {
|
||||
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
|
||||
let mut s = two_parts(50.0, [0.0, 0.0, -50.0]);
|
||||
let err = refused(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!(err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block: name the one to round with `body`"), "{err}");
|
||||
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
|
||||
assert!((body_volume(&s, "Cap block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
|
||||
let (mut s, _) = lower_and_uppers();
|
||||
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
|
||||
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
|
||||
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
|
||||
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
|
||||
assert!((body_volume(&s, "Lower block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
|
||||
}
|
||||
|
||||
/// A body offset squashed flat, as only a hostile file has, leaves no placement to look a pick
|
||||
/// up through: the pick is looked up in the body's own frame, so one far from every edge is
|
||||
/// refused and one on an edge is cut.
|
||||
#[test]
|
||||
fn edge_picks_with_no_placement_to_look_through_are_checked() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Box"}));
|
||||
let mut flat = solidcraft_doc::IDENTITY;
|
||||
flat[2][2] = 0.0;
|
||||
s.doc_mut().body_offsets.insert("Box".into(), flat);
|
||||
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 15, 10]], "distance": 1}));
|
||||
assert!(err.contains("no edge at [20, 15, 10]"), "{err}");
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
|
||||
assert!((body_volume(&s, "Box") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{}", body_volume(&s, "Box"));
|
||||
}
|
||||
|
||||
+24
-10
@@ -519,6 +519,10 @@ pub struct Session {
|
||||
pub(crate) followed: Option<cmd::inspect::Followed>,
|
||||
/// Project a face's edges into a sketch created on it (the default; `sketch.auto_project`).
|
||||
pub auto_project: bool,
|
||||
/// Project, Intersect, Include 3D Geometry and the face edges projected into a new sketch
|
||||
/// make construction geometry rather than normal geometry (off by default; #13142). A
|
||||
/// preference, kept like `auto_project` when another design is opened or started.
|
||||
pub project_construction: bool,
|
||||
/// How calls that break a command's parameter contract are treated (off by default). It
|
||||
/// belongs to the session, not the design, so it survives opening and starting designs.
|
||||
pub param_check: schema::ParamCheck,
|
||||
@@ -827,6 +831,7 @@ impl Session {
|
||||
document_id: cmd::inspect::new_document_id(),
|
||||
followed: None,
|
||||
auto_project: true,
|
||||
project_construction: false,
|
||||
param_check: schema::ParamCheck::Off,
|
||||
easyprint_upload: None,
|
||||
live_links: None,
|
||||
@@ -890,6 +895,7 @@ impl Session {
|
||||
self.stop_computing();
|
||||
}
|
||||
let auto_project = self.auto_project;
|
||||
let project_construction = self.project_construction;
|
||||
let validation = self.validation();
|
||||
let single_surface_analysis = self.single_surface_analysis;
|
||||
let param_check = self.param_check;
|
||||
@@ -907,6 +913,7 @@ impl Session {
|
||||
self.crash_journal = crash_journal;
|
||||
self.command_check = command_check;
|
||||
self.auto_project = auto_project;
|
||||
self.project_construction = project_construction;
|
||||
// The user's, like `auto_project`: the commands that open a design evaluate it under it
|
||||
// already (`Session::opened_validating`).
|
||||
self.set_validation(validation);
|
||||
@@ -1945,6 +1952,7 @@ impl Session {
|
||||
document_id: self.document_id.clone(),
|
||||
followed: None,
|
||||
auto_project: self.auto_project,
|
||||
project_construction: self.project_construction,
|
||||
param_check: self.param_check,
|
||||
easyprint_upload: self.easyprint_upload.clone(),
|
||||
live_links: None,
|
||||
@@ -2321,16 +2329,7 @@ impl Session {
|
||||
}
|
||||
|
||||
fn run_script_with(&mut self, script: &Value, profile: Option<bool>, each: &mut dyn FnMut(usize, &Value)) -> Result<(Vec<Value>, Option<Value>)> {
|
||||
let list = match script {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(o) => {
|
||||
o.get("commands").and_then(Value::as_array).cloned().ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
|
||||
}
|
||||
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
|
||||
};
|
||||
if list.len() > 100_000 {
|
||||
return Err(EngineError::Other("script too long".into()));
|
||||
}
|
||||
let list = script_steps(script)?;
|
||||
let mut out = Vec::new();
|
||||
let mut steps = Vec::new();
|
||||
let clock = timing::Clock::start();
|
||||
@@ -2358,6 +2357,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// A script's steps: the JSON array itself, or the `commands` array of an object; at most 100 000.
|
||||
pub fn script_steps(script: &Value) -> Result<&Vec<Value>> {
|
||||
let list = match script {
|
||||
Value::Array(a) => a,
|
||||
Value::Object(o) => {
|
||||
o.get("commands").and_then(Value::as_array).ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
|
||||
}
|
||||
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
|
||||
};
|
||||
if list.len() > 100_000 {
|
||||
return Err(EngineError::Other("script too long".into()));
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boolean_regression_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -297,8 +297,11 @@ pub const IDS: &[&str] = &[
|
||||
"panel.dismiss",
|
||||
"panel.float",
|
||||
"panel.focus",
|
||||
"panel.focus.next",
|
||||
"panel.focus.previous",
|
||||
"panel.list",
|
||||
"panel.move",
|
||||
"panel.move.next_zone",
|
||||
"panel.native_close",
|
||||
"panel.open",
|
||||
"panel.peek",
|
||||
@@ -306,6 +309,12 @@ pub const IDS: &[&str] = &[
|
||||
"panel.reset_pair",
|
||||
"panel.resize",
|
||||
"panel.resize_pair",
|
||||
"panel.sash.down",
|
||||
"panel.sash.left",
|
||||
"panel.sash.reset",
|
||||
"panel.sash.right",
|
||||
"panel.sash.up",
|
||||
"panel.toggle",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -314,6 +323,7 @@ pub enum PanelCommand {
|
||||
Get,
|
||||
Open(wire::Open),
|
||||
HeaderAction(wire::HeaderAction),
|
||||
Contextual { action: ContextualPanelAction, params: wire::Revision },
|
||||
Select { operation: &'static str, params: wire::Select },
|
||||
Move { operation: &'static str, params: wire::Move },
|
||||
Resize(wire::Resize),
|
||||
@@ -353,6 +363,11 @@ pub fn parse_command(id: &str, params: &Value) -> PanelResult<PanelCommand> {
|
||||
}
|
||||
serde_json::to_writer(Counter(0), params).map_err(|_| error(Rule::CollectionLimit, "panel command byte cap"))?;
|
||||
let id = crate::find_command(id).map(|s| s.id).ok_or_else(|| error(Rule::MissingTarget, "unknown panel command"))?;
|
||||
if let Some(action) = ContextualPanelAction::from_command_id(id) {
|
||||
let command = PanelCommand::Contextual { action, params: read(params)? };
|
||||
validate_command(&command)?;
|
||||
return Ok(command);
|
||||
}
|
||||
let command = match id {
|
||||
"panel.list" => {
|
||||
let _: wire::Empty = read(params)?;
|
||||
@@ -810,6 +825,11 @@ pub fn execute_command(command: PanelCommand, context: &mut PanelCommandContext<
|
||||
validate_command(&command)?;
|
||||
let state = context.sink.inspect();
|
||||
match command {
|
||||
PanelCommand::Contextual { action, params } => {
|
||||
revision(params.expected_revision, &state)?;
|
||||
let action = context.sink.resolve_contextual(action)?;
|
||||
return value(execute_layout_action(action, context)?);
|
||||
}
|
||||
PanelCommand::List => return value(wire::ListReply { state, kinds: context.registry.specs().cloned().collect() }),
|
||||
PanelCommand::Get => return value(wire::GetReply { state, workspaces: context.catalogue.state().summaries() }),
|
||||
command @ PanelCommand::HeaderAction(_) => {
|
||||
@@ -1038,6 +1058,7 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
|
||||
let mut replacement = false;
|
||||
let mut mutation = false;
|
||||
let mut header_action = false;
|
||||
let mut contextual = false;
|
||||
for step in steps {
|
||||
let id =
|
||||
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
|
||||
@@ -1045,6 +1066,7 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
|
||||
match parse_command(id, step.get("params").unwrap_or(&empty))? {
|
||||
PanelCommand::List | PanelCommand::Get | PanelCommand::Save(_) => {}
|
||||
PanelCommand::HeaderAction(_) => header_action = true,
|
||||
PanelCommand::Contextual { .. } => contextual = true,
|
||||
PanelCommand::Apply(_) | PanelCommand::Reset(_) => replacement = true,
|
||||
PanelCommand::Workspace(params) => {
|
||||
replacement |= params.workspace != selected;
|
||||
@@ -1053,6 +1075,9 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
|
||||
_ => mutation = true,
|
||||
}
|
||||
}
|
||||
if contextual && steps.len() != 1 {
|
||||
return Err(error(Rule::Capability, "contextual panel actions require a standalone transaction"));
|
||||
}
|
||||
if header_action && steps.len() != 1 {
|
||||
return Err(error(Rule::Capability, "header actions require a standalone transaction"));
|
||||
}
|
||||
@@ -1075,7 +1100,7 @@ pub fn execute_batch(steps: &[Value], context: &mut PanelCommandContext<'_>) ->
|
||||
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
|
||||
let empty = json!({});
|
||||
let command = parse_command(id, step.get("params").unwrap_or(&empty))?;
|
||||
if matches!(&command, PanelCommand::HeaderAction(_)) {
|
||||
if matches!(&command, PanelCommand::HeaderAction(_) | PanelCommand::Contextual { .. }) {
|
||||
let result = execute_command(command, context)?;
|
||||
return Ok(json!({"completed":1,"results":[result],"undone":0}));
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub fn synthetic_registry() -> PanelResult<PanelRegistry> {
|
||||
surface: PanelSurface::FollowZone,
|
||||
features: PanelFeatures { multi_instance: multi, ..PanelFeatures::default() },
|
||||
side_height_policy: SideHeightPolicy::Content,
|
||||
fixed_bottom_height: None,
|
||||
header_actions: Vec::new(),
|
||||
text_heavy: false,
|
||||
})?;
|
||||
|
||||
@@ -197,12 +197,33 @@ pub struct PanelSpec {
|
||||
pub features: PanelFeatures,
|
||||
#[serde(default)]
|
||||
pub side_height_policy: SideHeightPolicy,
|
||||
/// A rigid, expanded Bottom row. Other zones retain their ordinary bounds
|
||||
/// and collapse capability. The declared height must satisfy those bounds.
|
||||
#[serde(default)]
|
||||
pub fixed_bottom_height: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub header_actions: Vec<HeaderActionSpec>,
|
||||
#[serde(default)]
|
||||
pub text_heavy: bool,
|
||||
}
|
||||
|
||||
impl PanelSpec {
|
||||
pub fn size_bounds(&self, zone: Zone) -> (Size, Size) {
|
||||
let (mut min, mut max) = (self.min_size, self.max_size);
|
||||
if zone == Zone::Bottom
|
||||
&& let Some(height) = self.fixed_bottom_height
|
||||
{
|
||||
min.height = height;
|
||||
max.height = height;
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
|
||||
pub fn collapsible_in(&self, zone: Zone) -> bool {
|
||||
self.features.collapsible && !(zone == Zone::Bottom && self.fixed_bottom_height.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PanelRegistry {
|
||||
specs: BTreeMap<PanelKind, PanelSpec>,
|
||||
@@ -231,6 +252,11 @@ impl PanelRegistry {
|
||||
if !spec.default_size.within(spec.min_size, spec.max_size) {
|
||||
return Err(error(Rule::Bounds, "min <= default <= max required in both dimensions"));
|
||||
}
|
||||
if spec.fixed_bottom_height.is_some_and(|height| {
|
||||
!height.is_finite() || height < spec.min_size.height || height > spec.max_size.height || !spec.allowed_zones.contains(&Zone::Bottom)
|
||||
}) {
|
||||
return Err(error(Rule::Bounds, "fixed Bottom height must satisfy registered bounds in an allowed Bottom zone"));
|
||||
}
|
||||
if !spec.allowed_zones.contains(&Zone::Floating) || !spec.allowed_zones.contains(&spec.default_zone) {
|
||||
return Err(error(Rule::Placement, "Floating and default_zone must be allowed"));
|
||||
}
|
||||
@@ -875,10 +901,11 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
|
||||
let mut max = Size { width: MAX_LOGICAL, height: MAX_LOGICAL };
|
||||
for id in §ion.tabs {
|
||||
let spec = registry.get(&layout.instance(id)?.kind)?;
|
||||
min.width = min.width.max(spec.min_size.width);
|
||||
min.height = min.height.max(spec.min_size.height);
|
||||
max.width = max.width.min(spec.max_size.width);
|
||||
max.height = max.height.min(spec.max_size.height);
|
||||
let (lower, upper) = spec.size_bounds(section.location.zone());
|
||||
min.width = min.width.max(lower.width);
|
||||
min.height = min.height.max(lower.height);
|
||||
max.width = max.width.min(upper.width);
|
||||
max.height = max.height.min(upper.height);
|
||||
}
|
||||
if min.width > max.width || min.height > max.height {
|
||||
return Err(error(Rule::IncompatibleTabs, "tab size constraints have no intersection"));
|
||||
@@ -886,6 +913,15 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
|
||||
Ok((min, max))
|
||||
}
|
||||
|
||||
pub fn section_collapsible(layout: &Layout, registry: &PanelRegistry, section: &Section) -> PanelResult<bool> {
|
||||
for id in §ion.tabs {
|
||||
if !registry.get(&layout.instance(id)?.kind)?.collapsible_in(section.location.zone()) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn column_bounds(layout: &Layout, registry: &PanelRegistry, column: &ColumnId) -> PanelResult<(f32, f32)> {
|
||||
layout.column(column)?;
|
||||
let mut min: f32 = 0.0;
|
||||
@@ -1024,7 +1060,7 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
if s.tabs.len() > 1 && !spec.features.tabbable {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot join tabs"));
|
||||
}
|
||||
if s.collapsed && !spec.features.collapsible {
|
||||
if s.collapsed && !spec.collapsible_in(zone) {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot collapse"));
|
||||
}
|
||||
if zone == Zone::Window && !spec.features.popout {
|
||||
@@ -1055,9 +1091,9 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
for id in &s.tabs {
|
||||
let spec = registry.get(&layout.instance(id)?.kind)?;
|
||||
if !spec.allowed_zones.contains(&return_to.zone)
|
||||
|| !return_to.size.within(spec.min_size, spec.max_size)
|
||||
|| !return_to.size.within(spec.size_bounds(return_to.zone).0, spec.size_bounds(return_to.zone).1)
|
||||
|| return_to.column_width.is_some_and(|width| width < spec.min_size.width || width > spec.max_size.width)
|
||||
|| (return_to.collapsed && !spec.features.collapsible)
|
||||
|| (return_to.collapsed && !spec.collapsible_in(return_to.zone))
|
||||
{
|
||||
return Err(error(Rule::Placement, "native return violates member constraints"));
|
||||
}
|
||||
|
||||
@@ -180,8 +180,55 @@ pub struct LayoutReply {
|
||||
pub availability: AvailabilityMap,
|
||||
}
|
||||
|
||||
/// Keyboard actions whose target belongs to the live panel host, never the document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContextualPanelAction {
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
Toggle,
|
||||
MoveNextZone,
|
||||
ResizeLeft,
|
||||
ResizeRight,
|
||||
ResizeUp,
|
||||
ResizeDown,
|
||||
ResetSash,
|
||||
}
|
||||
impl ContextualPanelAction {
|
||||
pub const ALL: [Self; 9] = [
|
||||
Self::FocusNext,
|
||||
Self::FocusPrevious,
|
||||
Self::Toggle,
|
||||
Self::MoveNextZone,
|
||||
Self::ResizeLeft,
|
||||
Self::ResizeRight,
|
||||
Self::ResizeUp,
|
||||
Self::ResizeDown,
|
||||
Self::ResetSash,
|
||||
];
|
||||
pub const fn command_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::FocusNext => "panel.focus.next",
|
||||
Self::FocusPrevious => "panel.focus.previous",
|
||||
Self::Toggle => "panel.toggle",
|
||||
Self::MoveNextZone => "panel.move.next_zone",
|
||||
Self::ResizeLeft => "panel.sash.left",
|
||||
Self::ResizeRight => "panel.sash.right",
|
||||
Self::ResizeUp => "panel.sash.up",
|
||||
Self::ResizeDown => "panel.sash.down",
|
||||
Self::ResetSash => "panel.sash.reset",
|
||||
}
|
||||
}
|
||||
pub fn from_command_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|action| action.command_id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Same boundary for GUI and programmatic callers. No mutable Session reference.
|
||||
pub trait ActionSink {
|
||||
/// Optional live-host target resolution. Headless sinks cannot infer UI focus.
|
||||
fn resolve_contextual(&self, _action: ContextualPanelAction) -> PanelResult<LayoutAction> {
|
||||
Err(error(Rule::Availability, "live panel host keyboard context is unavailable"))
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply>;
|
||||
fn inspect(&self) -> LayoutReply;
|
||||
/// Pure projection on the authoritative service. Adapters stage bounded
|
||||
@@ -931,9 +978,15 @@ impl Reservations<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reservations: &Reservations<'_>) -> PanelResult<()> {
|
||||
fn place(
|
||||
layout: &mut Layout,
|
||||
id: &InstanceId,
|
||||
destination: Destination,
|
||||
reservations: &Reservations<'_>,
|
||||
new_column_width: Option<f32>,
|
||||
) -> PanelResult<()> {
|
||||
match destination {
|
||||
Destination::NewSection { section, index, mut location, size } => {
|
||||
Destination::NewSection { section, index, mut location, mut size } => {
|
||||
fresh_section(layout, id, §ion)?;
|
||||
if let SectionLocation::Window { return_to, .. } = &mut location {
|
||||
if return_to.zone.side() && return_to.column.is_none() {
|
||||
@@ -970,11 +1023,19 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
|
||||
.filter(|p| p.memory.section == section && p.memory.location.zone() == location.zone())
|
||||
.map(|p| p.memory.clone());
|
||||
let zone = location.zone();
|
||||
let mut created_column = false;
|
||||
let column = if zone.side() {
|
||||
if let Some(memory) = &memory
|
||||
&& let Some(column) = &memory.column
|
||||
{
|
||||
Some(restore_column(layout, column.clone(), zone, memory.column_index, memory.column_width.unwrap_or(size.width))?)
|
||||
created_column = layout.column(column).is_err();
|
||||
Some(restore_column(
|
||||
layout,
|
||||
column.clone(),
|
||||
zone,
|
||||
memory.column_index,
|
||||
new_column_width.unwrap_or_else(|| memory.column_width.unwrap_or(size.width)),
|
||||
)?)
|
||||
} else {
|
||||
let existing = layout
|
||||
.sections
|
||||
@@ -986,14 +1047,21 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
|
||||
Some(match existing {
|
||||
Some(column) => column,
|
||||
None => {
|
||||
created_column = true;
|
||||
let column = next_column_id_with_reservations(layout, reservations.columns)?;
|
||||
restore_column(layout, column, zone, MAX_SECTIONS, size.width)?
|
||||
restore_column(layout, column, zone, MAX_SECTIONS, new_column_width.unwrap_or(size.width))?
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if created_column
|
||||
&& new_column_width.is_some()
|
||||
&& let Some(column) = &column
|
||||
{
|
||||
size.width = layout.column(column)?.width;
|
||||
}
|
||||
let index = if let Some(column) = &column { column_insert_index(layout, column, index)? } else { index };
|
||||
layout.sections.insert(
|
||||
index,
|
||||
@@ -1019,7 +1087,7 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
|
||||
s.tabs.insert(index, id.clone());
|
||||
// Joining never steals active tab or resizes the group.
|
||||
}
|
||||
Destination::Relative { section, placement, new_section, new_column, size } => {
|
||||
Destination::Relative { section, placement, new_section, new_column, mut size } => {
|
||||
fresh_section(layout, id, &new_section)?;
|
||||
size.validate()?;
|
||||
let target = layout.section(§ion)?.clone();
|
||||
@@ -1039,6 +1107,9 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
|
||||
(target_column.clone(), target_index + usize::from(placement == RelativePlacement::Below), weight)
|
||||
}
|
||||
RelativePlacement::BeforeColumn | RelativePlacement::AfterColumn => {
|
||||
if let Some(width) = new_column_width {
|
||||
size.width = width;
|
||||
}
|
||||
let column = new_column.ok_or_else(|| error(Rule::Placement, "column placement requires identity"))?;
|
||||
valid_id(&column.0)?;
|
||||
if layout.columns.iter().any(|c| c.id == column)
|
||||
@@ -1136,6 +1207,8 @@ fn apply(
|
||||
mutation: Mutation,
|
||||
) -> PanelResult<()> {
|
||||
let MutationContext { registry, availability, caps, reservations, defer_availability } = *context;
|
||||
let changes_placement =
|
||||
matches!(&mutation, Mutation::Open { .. } | Mutation::Move { .. } | Mutation::Focus { .. } | Mutation::NativeClose { .. });
|
||||
match mutation {
|
||||
Mutation::Open { kind, instance, destination } => {
|
||||
valid_id(&instance.0)?;
|
||||
@@ -1156,7 +1229,7 @@ fn apply(
|
||||
let mut destination = destination.unwrap_or_else(|| reopen_destination(layout, &p));
|
||||
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
|
||||
let created = !matches!(&destination, Destination::JoinTab { .. });
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
place(layout, &instance, destination, reservations, None)?;
|
||||
if created {
|
||||
let section = layout.containing(&instance)?.id.clone();
|
||||
let section = section_mut(layout, §ion)?;
|
||||
@@ -1175,7 +1248,7 @@ fn apply(
|
||||
}
|
||||
} else {
|
||||
let destination = destination.ok_or_else(|| error(Rule::Placement, "new instance requires explicit destination"))?;
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
place(layout, &instance, destination, reservations, None)?;
|
||||
let s = layout.containing(&instance)?.clone();
|
||||
layout.instances.push(PanelInstance {
|
||||
id: instance.clone(),
|
||||
@@ -1224,6 +1297,10 @@ fn apply(
|
||||
}
|
||||
let collapsed = layout.containing(&instance)?.collapsed;
|
||||
let source = layout.containing(&instance)?.clone();
|
||||
let column_width = match &source.column {
|
||||
Some(column) => layout.column(column)?.width,
|
||||
None => registry.get(&layout.instance(&instance)?.kind)?.default_size.width,
|
||||
};
|
||||
if let Destination::NewSection { location: SectionLocation::Window { return_to, .. }, .. } = &mut destination
|
||||
&& source.location.zone() == return_to.zone
|
||||
&& source.location.zone().docked()
|
||||
@@ -1257,7 +1334,7 @@ fn apply(
|
||||
Destination::Relative { new_section, .. } => Some(new_section.clone()),
|
||||
_ => None,
|
||||
};
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
place(layout, &instance, destination, reservations, Some(column_width))?;
|
||||
if let Some(id) = new_section {
|
||||
let section = section_mut(layout, &id)?;
|
||||
section.collapsed = collapsed;
|
||||
@@ -1276,7 +1353,7 @@ fn apply(
|
||||
if !p.requested_open {
|
||||
let mut destination = reopen_destination(layout, &p);
|
||||
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
|
||||
place(layout, &instance, destination, reservations)?;
|
||||
place(layout, &instance, destination, reservations, None)?;
|
||||
if let Some(entry) = layout.instances.iter_mut().find(|p| p.id == instance) {
|
||||
entry.requested_open = true;
|
||||
}
|
||||
@@ -1535,6 +1612,7 @@ fn apply(
|
||||
let index = join.index.min(target.tabs.len());
|
||||
drop(target.tabs.splice(index..index, section.tabs));
|
||||
let members = target.tabs.clone();
|
||||
normalize_fixed_bottom_rows(layout, registry)?;
|
||||
for instance in members {
|
||||
remember(layout, &instance)?;
|
||||
}
|
||||
@@ -1566,6 +1644,9 @@ fn apply(
|
||||
layout.sections.insert(index, section);
|
||||
}
|
||||
}
|
||||
if changes_placement {
|
||||
normalize_fixed_bottom_rows(layout, registry)?;
|
||||
}
|
||||
// Every live member gets the same committed group geometry/proportions.
|
||||
// Closed memories are intentionally independent until their panel reopens.
|
||||
let members: Vec<_> = layout.sections.iter().flat_map(|s| s.tabs.iter().cloned()).collect();
|
||||
@@ -1581,6 +1662,26 @@ fn apply(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_fixed_bottom_rows(layout: &mut Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
let mut fixed = Vec::new();
|
||||
for section in layout.sections.iter().filter(|section| section.location.zone() == Zone::Bottom) {
|
||||
let mut declared = false;
|
||||
for instance in §ion.tabs {
|
||||
declared |= registry.get(&layout.instance(instance)?.kind)?.fixed_bottom_height.is_some();
|
||||
}
|
||||
if declared {
|
||||
let (min, _) = tab_bounds(layout, registry, section)?;
|
||||
fixed.push((section.id.clone(), min.height));
|
||||
}
|
||||
}
|
||||
for (id, height) in fixed {
|
||||
let section = section_mut(layout, &id)?;
|
||||
section.size.height = height;
|
||||
section.collapsed = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prune_columns(layout: &mut Layout) {
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
for column in layout.sections.iter().filter_map(|s| s.column.clone()).chain(layout.instances.iter().filter_map(|p| p.memory.column.clone())) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::commands::LayoutCatalogue;
|
||||
use super::*;
|
||||
|
||||
fn id(value: &str) -> InstanceId {
|
||||
@@ -22,6 +23,172 @@ fn reject(service: &mut LayoutService, mutations: Vec<Mutation>, rule: Rule) {
|
||||
assert_eq!(service.inspect(), before, "failed action must preserve layout/revision/interaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_bottom_height_is_validated_without_weakening_other_zone_bounds() {
|
||||
let base = synthetic_registry().unwrap().get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
|
||||
for height in [f32::NAN, f32::INFINITY, 0.0, 119.0, 1201.0] {
|
||||
let mut spec = base.clone();
|
||||
spec.fixed_bottom_height = Some(height);
|
||||
assert_eq!(PanelRegistry::default().register(spec).unwrap_err().rule, Rule::Bounds);
|
||||
}
|
||||
let mut spec = base;
|
||||
spec.fixed_bottom_height = Some(160.0);
|
||||
assert_eq!(spec.size_bounds(Zone::Bottom), (size(160.0, 160.0), size(1200.0, 160.0)));
|
||||
assert_eq!(spec.size_bounds(Zone::Left), (spec.min_size, spec.max_size));
|
||||
assert!(!spec.collapsible_in(Zone::Bottom));
|
||||
assert!(spec.collapsible_in(Zone::Left));
|
||||
PanelRegistry::default().register(spec).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_bottom_rows_normalize_placement_but_reject_collapse_and_height_commands_atomically() {
|
||||
use serde_json::json;
|
||||
let mut registry = synthetic_registry().unwrap();
|
||||
let mut spec = registry.get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
|
||||
spec.kind = PanelKind::new("fixture.rigid-row").unwrap();
|
||||
spec.fixed_bottom_height = Some(160.0);
|
||||
registry.register(spec).unwrap();
|
||||
let mut service =
|
||||
LayoutService::new(registry.clone(), Layout::empty(WorkspaceId::new("fixture.workspace").unwrap()), HostCapabilities::default()).unwrap();
|
||||
service.dispatch(transaction(vec![fixture_open("fixture.rigid-row", "row", "side", Zone::Left, 0).unwrap()])).unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::Collapse { section: section("side"), collapsed: true }])).unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![Mutation::Move {
|
||||
instance: id("row"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("bottom"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Bottom },
|
||||
size: size(500.0, 300.0),
|
||||
},
|
||||
}]))
|
||||
.unwrap();
|
||||
let bottom = service.layout().section(§ion("bottom")).unwrap();
|
||||
assert_eq!(bottom.size.height, 160.0);
|
||||
assert!(!bottom.collapsed);
|
||||
assert!(!section_collapsible(service.layout(), ®istry, bottom).unwrap());
|
||||
reject(&mut service, vec![Mutation::Collapse { section: section("bottom"), collapsed: true }], Rule::FeatureDisabled);
|
||||
reject(&mut service, vec![Mutation::SetSize { section: section("bottom"), size: size(500.0, 161.0) }], Rule::Bounds);
|
||||
reject(&mut service, vec![Mutation::SetBottomHeight { section: section("bottom"), height: 161.0 }], Rule::Bounds);
|
||||
|
||||
let mut backend = commands::InMemoryPanelBackend::new(service, fixture_command_catalogue(®istry).unwrap());
|
||||
for (command, params, rule) in [
|
||||
("panel.collapse", json!({"target":command_target("row"),"collapsed":true}), Rule::FeatureDisabled),
|
||||
("panel.resize", json!({"target":command_target("row"),"size":{"width":500,"height":161}}), Rule::Bounds),
|
||||
("panel.resize", json!({"target":command_target("row"),"size":{"width":1,"height":161},"thickness_only":true}), Rule::Bounds),
|
||||
] {
|
||||
let before = backend.service.inspect();
|
||||
let catalogue = backend.catalogue.state().clone();
|
||||
assert_eq!(backend.execute(command, ¶ms).unwrap_err().rule, rule);
|
||||
assert_eq!(backend.service.inspect(), before);
|
||||
assert_eq!(backend.catalogue.state(), &catalogue);
|
||||
}
|
||||
backend.service.dispatch(transaction(vec![Mutation::Close { instance: id("row") }])).unwrap();
|
||||
backend
|
||||
.service
|
||||
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.rigid-row").unwrap(), instance: id("row"), destination: None }]))
|
||||
.unwrap();
|
||||
assert_eq!(backend.service.layout().containing(&id("row")).unwrap().size.height, 160.0);
|
||||
backend
|
||||
.service
|
||||
.dispatch(transaction(vec![Mutation::Move {
|
||||
instance: id("row"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("right"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Right },
|
||||
size: size(500.0, 300.0),
|
||||
},
|
||||
}]))
|
||||
.unwrap();
|
||||
backend.service.dispatch(transaction(vec![Mutation::Collapse { section: section("right"), collapsed: true }])).unwrap();
|
||||
assert_eq!(backend.service.layout().section(§ion("right")).unwrap().size.height, 300.0);
|
||||
|
||||
backend.service.dispatch(transaction(vec![fixture_open("fixture.log", "ordinary", "ordinary", Zone::Bottom, 1).unwrap()])).unwrap();
|
||||
backend
|
||||
.service
|
||||
.dispatch(transaction(vec![Mutation::Open {
|
||||
kind: PanelKind::new("fixture.rigid-row").unwrap(),
|
||||
instance: id("inactive"),
|
||||
destination: Some(Destination::JoinTab { section: section("ordinary"), index: 1 }),
|
||||
}]))
|
||||
.unwrap();
|
||||
let ordinary = backend.service.layout().section(§ion("ordinary")).unwrap();
|
||||
assert_eq!(ordinary.active, id("ordinary"));
|
||||
assert_eq!(ordinary.size.height, 160.0, "inactive fixed-row metadata still constrains its group");
|
||||
reject(&mut backend.service, vec![Mutation::Collapse { section: section("ordinary"), collapsed: true }], Rule::FeatureDisabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_column_moves_preserve_source_column_width_or_use_the_registered_default() {
|
||||
for source_zone in [Zone::Left, Zone::Right, Zone::Bottom, Zone::Floating] {
|
||||
for relative in [true, false] {
|
||||
let mut service = fixture_service(false).unwrap();
|
||||
let target_zone = if source_zone == Zone::Right { Zone::Left } else { Zone::Right };
|
||||
service
|
||||
.dispatch(transaction(vec![
|
||||
fixture_open("fixture.log", "source", "source", source_zone, 0).unwrap(),
|
||||
fixture_open("fixture.log", "anchor", "anchor", target_zone, 1).unwrap(),
|
||||
]))
|
||||
.unwrap();
|
||||
let target_column = service.layout().containing(&id("anchor")).unwrap().column.clone().unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: target_column.clone(), width: 330.0 }])).unwrap();
|
||||
if source_zone.side() {
|
||||
let source_column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: source_column, width: 410.0 }])).unwrap();
|
||||
} else {
|
||||
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
|
||||
}
|
||||
let destination = if relative {
|
||||
Destination::Relative {
|
||||
section: section("anchor"),
|
||||
placement: RelativePlacement::BeforeColumn,
|
||||
new_section: section("moved"),
|
||||
new_column: Some(ColumnId::new("moved-column").unwrap()),
|
||||
size: size(999.0, 240.0),
|
||||
}
|
||||
} else {
|
||||
Destination::NewSection {
|
||||
section: section("moved"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: target_zone },
|
||||
size: size(999.0, 240.0),
|
||||
}
|
||||
};
|
||||
service.dispatch(transaction(vec![Mutation::Move { instance: id("source"), destination }])).unwrap();
|
||||
let moved = service.layout().containing(&id("source")).unwrap();
|
||||
let column = service.layout().column(moved.column.as_ref().unwrap()).unwrap();
|
||||
let expected = if relative { if source_zone.side() { 410.0 } else { 280.0 } } else { 330.0 };
|
||||
assert_eq!(column.width, expected);
|
||||
assert_eq!(moved.size.width, if relative { expected } else { 999.0 });
|
||||
assert_eq!(service.layout().column(&target_column).unwrap().width, 330.0, "joining an existing column never resizes it");
|
||||
}
|
||||
}
|
||||
|
||||
let mut service = fixture_service(false).unwrap();
|
||||
service.dispatch(transaction(vec![fixture_open("fixture.log", "source", "source", Zone::Floating, 0).unwrap()])).unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![Mutation::Move {
|
||||
instance: id("source"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("moved"),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Left },
|
||||
size: size(999.0, 240.0),
|
||||
},
|
||||
}]))
|
||||
.unwrap();
|
||||
let column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
|
||||
assert_eq!(service.layout().column(&column).unwrap().width, 280.0);
|
||||
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: column.clone(), width: 730.0 }])).unwrap();
|
||||
service.dispatch(transaction(vec![Mutation::Close { instance: id("source") }])).unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.log").unwrap(), instance: id("source"), destination: None }]))
|
||||
.unwrap();
|
||||
assert_eq!(service.layout().column(&column).unwrap().width, 730.0, "reopen restores the saved column width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_constraints_and_identifiers_are_bounded() {
|
||||
let base = synthetic_registry().unwrap().specs().next().unwrap().clone();
|
||||
@@ -1430,7 +1597,7 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
|
||||
floating["location"] = json!({"type":"floating","origin":{"x":20,"y":30}});
|
||||
let mut native = floating.clone();
|
||||
native["location"] = json!({"type":"window","host":"native","origin":{"x":20,"y":30},"return_to":{"zone":"left","index":0,"size":{"width":280,"height":240},"collapsed":false}});
|
||||
vec![
|
||||
let mut inputs = vec![
|
||||
("layout.apply", json!({"name":"Fixture default"})),
|
||||
("layout.get", json!({})),
|
||||
("layout.reset", json!({})),
|
||||
@@ -1458,7 +1625,9 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
|
||||
"panel.resize_pair",
|
||||
json!({"first":"s-one","second":"s-two","first_size":{"width":280,"height":260},"second_size":{"width":280,"height":220}}),
|
||||
),
|
||||
]
|
||||
];
|
||||
inputs.extend(ContextualPanelAction::ALL.into_iter().map(|action| (action.command_id(), json!({}))));
|
||||
inputs
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1510,6 +1679,10 @@ fn panel_command_success_replies_describe_committed_state_for_every_executable_o
|
||||
backend.execute("panel.collapse", &serde_json::json!({"target":command_target("one"),"collapsed":true})).unwrap();
|
||||
}
|
||||
let result = backend.execute(id, &input);
|
||||
if ContextualPanelAction::from_command_id(id).is_some() {
|
||||
assert_eq!(result.unwrap_err().rule, Rule::Availability, "headless cannot infer shared-control focus");
|
||||
continue;
|
||||
}
|
||||
if id == "panel.action" {
|
||||
assert_eq!(result.unwrap_err().rule, Rule::Capability, "headless cannot invoke an unbound controller");
|
||||
continue;
|
||||
@@ -2064,6 +2237,73 @@ fn command_sidecar_collisions_and_aggregate_catalogue_growth_are_rejected_atomic
|
||||
assert!(rejected, "aggregate bytes reject before count cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_panel_commands_resolve_once_and_commit_through_the_registered_sink() {
|
||||
use commands::LayoutCatalogue;
|
||||
struct FocusSink {
|
||||
service: LayoutService,
|
||||
resolutions: std::cell::Cell<usize>,
|
||||
}
|
||||
impl ActionSink for FocusSink {
|
||||
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
|
||||
assert_eq!(action, ContextualPanelAction::FocusNext);
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
Ok(LayoutAction::Apply {
|
||||
expected_revision: Some(self.service.inspect().revision),
|
||||
mutations: vec![Mutation::Focus { instance: InstanceId::new("two").unwrap() }],
|
||||
})
|
||||
}
|
||||
fn inspect(&self) -> LayoutReply {
|
||||
self.service.inspect()
|
||||
}
|
||||
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
|
||||
self.service.project(action)
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
self.service.dispatch(action)
|
||||
}
|
||||
}
|
||||
let backend = command_ready_backend();
|
||||
let mut sink = FocusSink { service: backend.service, resolutions: std::cell::Cell::new(0) };
|
||||
let mut catalogue = backend.catalogue;
|
||||
let registry = sink.service.registry().clone();
|
||||
let before = sink.inspect();
|
||||
let original_catalogue = catalogue.state().clone();
|
||||
let mut context = commands::PanelCommandContext { sink: &mut sink, registry: ®istry, catalogue: &mut catalogue, web: false };
|
||||
assert_eq!(
|
||||
commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":u64::MAX}), &mut context).unwrap_err().rule,
|
||||
Rule::Revision
|
||||
);
|
||||
assert_eq!(context.sink.inspect(), before);
|
||||
let result = commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":before.revision}), &mut context).unwrap();
|
||||
let reply: LayoutReply = serde_json::from_value(result).unwrap();
|
||||
assert_eq!(reply.layout, context.sink.inspect().layout);
|
||||
assert_eq!(reply.revision, context.sink.inspect().revision);
|
||||
assert_eq!(reply.interaction, context.sink.inspect().interaction);
|
||||
assert_eq!(reply.interaction.focused, Some(InstanceId::new("two").unwrap()));
|
||||
assert_eq!(sink.resolutions.get(), 1, "revision rejection precedes live focus resolution");
|
||||
assert_eq!(catalogue.state(), &original_catalogue, "focus does not change the saved arrangement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_panel_commands_without_live_focus_refuse_without_effects_including_batches() {
|
||||
use commands::LayoutCatalogue;
|
||||
for action in ContextualPanelAction::ALL {
|
||||
let mut backend = command_ready_backend();
|
||||
let before = backend.service.inspect();
|
||||
let catalogue = backend.catalogue.state().clone();
|
||||
let reason = backend.execute(action.command_id(), &serde_json::json!({})).unwrap_err();
|
||||
assert_eq!(reason.rule, Rule::Availability);
|
||||
assert!(reason.detail.contains("live panel host keyboard context"));
|
||||
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()})]).unwrap_err();
|
||||
assert_eq!(reason.rule, Rule::Availability);
|
||||
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()}), serde_json::json!({"command":"layout.get"})]).unwrap_err();
|
||||
assert_eq!(reason.rule, Rule::Capability);
|
||||
assert_eq!(backend.service.inspect(), before);
|
||||
assert_eq!(backend.catalogue.state(), &catalogue);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_only_panel_commands_report_capability_without_touching_document_or_model() {
|
||||
let mut session = crate::Session::default();
|
||||
@@ -3846,7 +4086,11 @@ fn closed_panel_reopens_with_column_identity_width_rank_and_saved_weight() {
|
||||
}]))
|
||||
.unwrap();
|
||||
service
|
||||
.dispatch(transaction(vec![Mutation::Collapse { section: section("inner"), collapsed: true }, Mutation::Close { instance: id("two") }]))
|
||||
.dispatch(transaction(vec![
|
||||
Mutation::SetColumnWidth { column: column.clone(), width: 310.0 },
|
||||
Mutation::Collapse { section: section("inner"), collapsed: true },
|
||||
Mutation::Close { instance: id("two") },
|
||||
]))
|
||||
.unwrap();
|
||||
let memory = service.layout().instance(&id("two")).unwrap().memory.clone();
|
||||
assert_eq!(memory.column, Some(column.clone()));
|
||||
|
||||
@@ -83,7 +83,7 @@ fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
|
||||
fn a_lost_profile_is_an_error_not_another_pick() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10], "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 5], "radius": 3, "id": "ring"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let ring = s.model.state().sketches[0].profiles.iter().position(|p| p.outer_curves == ["ring"]).unwrap();
|
||||
|
||||
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use solidcraft_doc::BrowserEntry;
|
||||
|
||||
use super::check::whole;
|
||||
use super::canvas::CanvasPlane;
|
||||
use super::check::{located, whole};
|
||||
use super::types::{FeatureCommon, LengthExpr, feature_common, present};
|
||||
|
||||
/// Repair a part the modelling kernel refuses, or an open part that should be a solid: faces
|
||||
@@ -1107,6 +1108,469 @@ pub struct BodyRenamed {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- Named views and Redefine Sketch (#12499) ----
|
||||
//
|
||||
// As above: each key reads as its handler reads it and fails with the handler's message. A key
|
||||
// the handler can't do without is optional in the type and required in the schema, so the
|
||||
// handler's own message is what a call that leaves it out gets. Whether a view or a sketch
|
||||
// exists, and whether a plane or a face can be found, stays the handler's to check.
|
||||
|
||||
fn view_required(schema: &mut Schema) {
|
||||
require(schema, &["view"]);
|
||||
}
|
||||
|
||||
fn view_and_name_required(schema: &mut Schema) {
|
||||
require(schema, &["view", "name"]);
|
||||
}
|
||||
|
||||
fn camera_required(schema: &mut Schema) {
|
||||
require(schema, &["camera"]);
|
||||
}
|
||||
|
||||
fn sketch_and_plane_required(schema: &mut Schema) {
|
||||
require(schema, &["sketch", "plane"]);
|
||||
}
|
||||
|
||||
/// A named view, as `view.rename` and `view.delete` read `view`: text.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ViewKey(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewKey {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(n) => Ok(ViewKey(n)),
|
||||
_ => Err(D::Error::custom(whole("`view` must be a view name"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewKey {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewKey".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A named view's name.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
fn some_view<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ViewKey>, D::Error> {
|
||||
ViewKey::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// The name `view.save` gives a view: at most 128 bytes once surrounding spaces are cut; nothing
|
||||
/// left means the next free "Named View" number.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SavedViewName(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SavedViewName {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(n) if n.trim().len() > MAX_NAME_BYTES => Err(D::Error::custom(whole("`name` is too long"))),
|
||||
Value::String(n) => Ok(SavedViewName(n)),
|
||||
_ => Err(D::Error::custom("must be text")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SavedViewName {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SavedViewName".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A name of at most 128 bytes once surrounding spaces are cut.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// The point a saved camera looks at, as the schemas of the view commands give it.
|
||||
fn target_schema() -> Value {
|
||||
json!({
|
||||
"description": "The point the camera looks at, in mm: [x, y, z].",
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
})
|
||||
}
|
||||
|
||||
/// The camera `view.save` keeps, as the handler reads it: finite numbers and a positive
|
||||
/// distance. A `name` inside it is replaced by the call's `name`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ViewCamera(pub solidcraft_doc::NamedView);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewCamera {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::browser::saved_view_form(&Value::deserialize(d)?).map(ViewCamera).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewCamera {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewCamera".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An orbit camera. Angles are in radians, about the up axis.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": target_schema(),
|
||||
"yaw": {"description": "Turn about the up axis; 0 looks along +Y, from the front.", "type": "number"},
|
||||
"pitch": {"description": "Elevation of the eye above the ground plane.", "type": "number"},
|
||||
"distance": {"description": "From the target to the eye, in mm.", "type": "number", "exclusiveMinimum": 0},
|
||||
"fov": {"description": "Vertical field of view; 0 is orthographic. Default: 0.", "type": "number", "default": 0.0}
|
||||
},
|
||||
"required": ["target", "yaw", "pitch", "distance"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the camera as a named view of the design. One undo step. A view of the same name is
|
||||
/// replaced.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = camera_required)]
|
||||
pub struct ViewSaveParams {
|
||||
// Optional in the type and required in the schema (`camera_required`): left out, the handler
|
||||
// refuses it with its own message.
|
||||
/// The camera to keep: `{target: [x, y, z], yaw, pitch, distance, fov?}`, finite, with a
|
||||
/// distance above 0.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewCamera")]
|
||||
pub camera: Option<ViewCamera>,
|
||||
/// The view's name, at most 128 bytes once surrounding spaces are cut. Default: "Named View"
|
||||
/// and the first number no view's name has. A value that is not text is reported, though the
|
||||
/// handler would read it as left out.
|
||||
#[serde(default)]
|
||||
pub name: Option<SavedViewName>,
|
||||
}
|
||||
|
||||
/// What `view.save` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewSaved {
|
||||
/// The name the view was kept under.
|
||||
pub view: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Rename a named view. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = view_and_name_required)]
|
||||
pub struct ViewRenameParams {
|
||||
// Required in the schema (`view_and_name_required`), optional in the type, as `group` above.
|
||||
/// The view to rename, by name.
|
||||
#[serde(default, deserialize_with = "some_view", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewKey")]
|
||||
pub view: Option<ViewKey>,
|
||||
/// The new name, 1 to 128 characters once surrounding spaces are cut; no other view may
|
||||
/// have it.
|
||||
#[serde(default, deserialize_with = "some_new_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "NewName")]
|
||||
pub name: Option<NewName>,
|
||||
}
|
||||
|
||||
/// What `view.rename` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewRenamed {
|
||||
/// The view's name now, without surrounding spaces.
|
||||
pub view: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Delete a named view. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = view_required)]
|
||||
pub struct ViewDeleteParams {
|
||||
// Required in the schema (`view_required`), optional in the type.
|
||||
/// The view to delete, by name.
|
||||
#[serde(default, deserialize_with = "some_view", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewKey")]
|
||||
pub view: Option<ViewKey>,
|
||||
}
|
||||
|
||||
/// What `view.delete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewDeleted {
|
||||
/// The name of the view that is gone.
|
||||
pub deleted: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `view.list` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ViewListParams {}
|
||||
|
||||
/// A named view, as `view.list` gives it. Only its schema is described here: the reply carries
|
||||
/// the design's own [`solidcraft_doc::NamedView`] values.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewEntry {
|
||||
/// The view's name.
|
||||
pub name: String,
|
||||
/// The point the camera looks at, in mm.
|
||||
pub target: [f64; 3],
|
||||
/// Turn about the up axis, in radians.
|
||||
pub yaw: f64,
|
||||
/// Elevation of the eye, in radians.
|
||||
pub pitch: f64,
|
||||
/// From the target to the eye, in mm.
|
||||
pub distance: f64,
|
||||
/// Vertical field of view in radians; 0 is orthographic.
|
||||
pub fov: f64,
|
||||
}
|
||||
|
||||
/// What `view.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewListResult {
|
||||
/// The design's named views, in the order they were made.
|
||||
#[schemars(with = "Vec<ViewEntry>")]
|
||||
pub views: Vec<solidcraft_doc::NamedView>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The camera `view.apply` turns to, as the handler reads it: `up` is refused, `roll` is finite,
|
||||
/// the pitch within ±π/2 and the rest finite with a positive distance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ApplyCamera(pub solidcraft_render::Camera);
|
||||
|
||||
impl<'de> Deserialize<'de> for ApplyCamera {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::browser::applied_camera_form(&Value::deserialize(d)?).map(ApplyCamera).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ApplyCamera {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ApplyCamera".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An orbit camera. Angles are in radians, about the up axis, which comes from the design settings.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": target_schema(),
|
||||
"yaw": {"description": "Turn about the up axis; 0 looks along +Y (Front), π/2 along +X (Left), π (Back), -π/2 (Right).", "type": "number"},
|
||||
"pitch": {"description": "Elevation of the eye above the ground plane, within ±π/2; π/2 is Top.", "type": "number", "minimum": -std::f64::consts::FRAC_PI_2, "maximum": std::f64::consts::FRAC_PI_2},
|
||||
"distance": {"description": "From the target to the eye, in mm.", "type": "number", "exclusiveMinimum": 0},
|
||||
"fov": {"description": "Vertical field of view; 0 is orthographic. Default: 0.", "type": "number", "minimum": 0, "default": 0.0},
|
||||
"roll": {"description": "Turn about the viewing direction, clockwise; a finite number, clamped to ±π. Default: 0.", "type": "number", "default": 0.0}
|
||||
},
|
||||
"required": ["target", "yaw", "pitch", "distance"],
|
||||
"not": {"required": ["up"]}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the viewport (and the MCP screenshot) to a standard view, a named view or a camera. View
|
||||
/// state only: not saved with the design and not an undo step. Give `view` or `camera`, not both.
|
||||
/// (A `view` that is not text is read as absent when a `camera` is given, as the handler does.)
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ViewApplyKeys {
|
||||
/// A standard view: front, back, top, bottom, left, right, iso or home (the model framed
|
||||
/// from there), or a named view's name. Not with `camera`.
|
||||
#[serde(default)]
|
||||
pub view: Option<String>,
|
||||
/// A camera to turn to. Not with `view`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ApplyCamera")]
|
||||
pub camera: Option<ApplyCamera>,
|
||||
}
|
||||
|
||||
/// `view.apply`'s parameters: [`ViewApplyKeys`] with the two forms told apart.
|
||||
///
|
||||
/// The handler reads `view` as text and takes anything else as absent, and reads `camera` only
|
||||
/// when `view` is not text, so the same holds here: a `view` that is not text is not looked at
|
||||
/// when a `camera` is given, and one of neither is left to the handler's message.
|
||||
#[derive(Debug)]
|
||||
pub struct ViewApplyParams(pub ViewApplyKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewApplyParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if value.get("view").is_some_and(Value::is_string) {
|
||||
if value.get("camera").is_some() {
|
||||
return Err(D::Error::custom(whole("give `view` or `camera`, not both")));
|
||||
}
|
||||
} else if let Some(keys) = value.as_object_mut() {
|
||||
keys.remove("view");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ViewApplyParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewApplyParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewApplyParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = ViewApplyKeys::json_schema(generator);
|
||||
schema.insert("anyOf".into(), json!([{"required": ["view"]}, {"required": ["camera"]}]));
|
||||
schema.insert("not".into(), json!({"required": ["view", "camera"]}));
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch by id or name, as `sketch.redefine` reads `sketch`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SketchKey(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchKey {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => Ok(SketchKey(n.to_string())),
|
||||
Value::String(s) => Ok(SketchKey(s)),
|
||||
_ => Err(D::Error::custom(whole("`sketch` must be an id or name"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchKey {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchKey".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A sketch's feature id (a number) or its name.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
fn some_sketch<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SketchKey>, D::Error> {
|
||||
SketchKey::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// The coordinates an explicit plane `{origin, x_dir, y_dir}` is given in: `world` or
|
||||
/// `component`, the component's own. (No doc on the variants: they would make the schema a
|
||||
/// `oneOf` of constants instead of a plain `enum`.)
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PlaneFrame {
|
||||
World,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl PlaneFrame {
|
||||
/// The frame a handler names, as `world` or `component`.
|
||||
pub fn from_name(name: &str) -> Option<PlaneFrame> {
|
||||
match name {
|
||||
"world" => Some(PlaneFrame::World),
|
||||
"component" => Some(PlaneFrame::Component),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PlaneFrame {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(f) if f == "world" => Ok(PlaneFrame::World),
|
||||
Value::String(f) if f == "component" => Ok(PlaneFrame::Component),
|
||||
_ => Err(D::Error::custom(whole("`frame` must be \"world\" or \"component\""))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The occurrence ids of the placement a sketch is authored in, from the outermost down, as
|
||||
/// `sketch.redefine` reads `author_path`: at most 64 nonzero whole numbers.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AuthorPath(pub Vec<u64>);
|
||||
|
||||
impl<'de> Deserialize<'de> for AuthorPath {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Array(path) if path.len() <= MAX_PATH => path
|
||||
.iter()
|
||||
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| D::Error::custom(whole("invalid author_path id"))))
|
||||
.collect::<Result<_, _>>()
|
||||
.map(AuthorPath),
|
||||
_ => Err(D::Error::custom(whole("author_path must contain at most64 ids"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for AuthorPath {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AuthorPath".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Occurrence ids, outermost first: at most 64 whole numbers above 0.",
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"maxItems": MAX_PATH
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a sketch to another plane, keeping its geometry and its dimension names: an origin or
|
||||
/// construction plane, an explicit plane, or a planar face of the model. A face it follows is
|
||||
/// refused where the sketch's own geometry would depend on later features. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = sketch_and_plane_required)]
|
||||
pub struct RedefineSketchParams {
|
||||
// Optional in the type and required in the schema (`sketch_and_plane_required`): left out,
|
||||
// the handler refuses it with its own message.
|
||||
/// The sketch to move, by feature id or name.
|
||||
#[serde(default, deserialize_with = "some_sketch", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchKey")]
|
||||
pub sketch: Option<SketchKey>,
|
||||
/// The new plane, as `sketch.create` takes it. A picked face is read as it reads one.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "CanvasPlane")]
|
||||
pub plane: Option<CanvasPlane>,
|
||||
/// Move the plane this far along its normal (mm or a length expression). Default: 0.
|
||||
#[serde(default)]
|
||||
pub offset: Option<LengthExpr>,
|
||||
/// The placement the sketch is authored in, as occurrence ids from the outermost down. Default: the
|
||||
/// sketch's saved one, else the active occurrence, else the component's only placement; a
|
||||
/// component sketch in a design with several placements needs it.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "AuthorPath")]
|
||||
pub author_path: Option<AuthorPath>,
|
||||
/// For an explicit plane `{origin, x_dir, y_dir}`: whether it is in the world's or the
|
||||
/// component's own coordinates. Default: world. A named plane or a face takes none.
|
||||
#[serde(default)]
|
||||
pub frame: Option<PlaneFrame>,
|
||||
}
|
||||
|
||||
/// How a redefined sketch is attached to its plane: `exact_face` (to a face of a body, which it
|
||||
/// follows), `sheet_face` (to a face of a sheet part) or `fixed` (where it is: an origin,
|
||||
/// construction or explicit plane).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RedefineAttachment {
|
||||
ExactFace,
|
||||
SheetFace,
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// What `sketch.redefine` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RedefineSketchResult {
|
||||
/// The sketch's feature id.
|
||||
pub sketch: u64,
|
||||
/// How it is attached now.
|
||||
pub attachment: Option<RedefineAttachment>,
|
||||
/// For an explicit plane: the coordinates it was read in.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<PlaneFrame>,
|
||||
/// For a world plane put into a component that is moved or turned: how it was stored.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// What the session reports for every sketch command: the ids of the points, curves,
|
||||
/// constraints and dimensions the sketch gained, `{points, curves, constraints, dimensions}`.
|
||||
/// The handler leaves it out; the session adds it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "browser_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,10 +3,13 @@ use serde_json::{Value, json};
|
||||
use super::{
|
||||
BodyRenameParams, BodyRenamed, BrowserFoldParams, BrowserFoldResult, BrowserGroupMade, BrowserGroupParams, BrowserGroupRenamed,
|
||||
BrowserMoveParams, BrowserMoved, BrowserOrderParams, BrowserOrdered, BrowserRenameGroupParams, BrowserUngroupParams, BrowserUngrouped,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, RepairParams, RepairResult, VisibilityState,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, RedefineSketchParams, RedefineSketchResult,
|
||||
RepairParams, RepairResult, ViewApplyParams, ViewDeleteParams, ViewDeleted, ViewListParams, ViewListResult, ViewRenameParams, ViewRenamed,
|
||||
ViewSaveParams, ViewSaved, VisibilityState,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::view_navigation::CameraReply;
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
@@ -330,3 +333,413 @@ fn browser_commands_reply_with_their_schemas() {
|
||||
let error = s.execute("browser.visibility", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`itms`"), "{error}");
|
||||
}
|
||||
|
||||
// ---- Named views and Redefine Sketch (#12499) ----
|
||||
|
||||
const CAMERA: fn() -> Value = || json!({"target": [1, 2, 3], "yaw": 0.5, "pitch": -0.6, "distance": 150});
|
||||
|
||||
#[test]
|
||||
fn view_schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ViewSaveParams>(), &["camera", "name"]);
|
||||
assert_keys(input_schema_for::<ViewRenameParams>(), &["name", "view"]);
|
||||
assert_keys(input_schema_for::<ViewDeleteParams>(), &["view"]);
|
||||
assert_keys(input_schema_for::<ViewListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ViewApplyParams>(), &["camera", "view"]);
|
||||
assert_keys(input_schema_for::<RedefineSketchParams>(), &["author_path", "frame", "offset", "plane", "sketch"]);
|
||||
}
|
||||
|
||||
/// Each call has one problem the handler already refuses: strict gives its message. The session
|
||||
/// has a sketch (`1`) and no named view, so a view that must exist is looked for in the second
|
||||
/// test.
|
||||
#[test]
|
||||
fn view_strict_keeps_todays_errors() {
|
||||
let long = "n".repeat(129);
|
||||
let deep: Vec<u64> = (1..=65).collect();
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("view.save", json!({})),
|
||||
("view.save", json!({"name": "A"})),
|
||||
("view.save", json!({"camera": null})),
|
||||
("view.save", json!({"camera": "front"})),
|
||||
("view.save", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0}})),
|
||||
("view.save", json!({"camera": {"target": [0, 0], "yaw": 0, "pitch": 0, "distance": 5}})),
|
||||
("view.save", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}})),
|
||||
("view.save", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -3}})),
|
||||
("view.save", json!({"camera": CAMERA(), "name": long})),
|
||||
("view.rename", json!({})),
|
||||
("view.rename", json!({"name": "B"})),
|
||||
("view.rename", json!({"view": 5, "name": "B"})),
|
||||
("view.rename", json!({"view": null, "name": "B"})),
|
||||
("view.rename", json!({"view": "Nope", "name": "B"})),
|
||||
("view.delete", json!({})),
|
||||
("view.delete", json!({"view": 5})),
|
||||
("view.delete", json!({"view": null})),
|
||||
("view.delete", json!({"view": "Nope"})),
|
||||
("view.apply", json!({})),
|
||||
("view.apply", json!({"view": 5})),
|
||||
("view.apply", json!({"view": null})),
|
||||
("view.apply", json!({"view": "Nope"})),
|
||||
("view.apply", json!({"view": "top", "camera": CAMERA()})),
|
||||
("view.apply", json!({"view": "top", "camera": null})),
|
||||
("view.apply", json!({"camera": null})),
|
||||
("view.apply", json!({"camera": "front"})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "up": "y"}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "up": null}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": "x"}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": null}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 1.6, "distance": 80}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": -1.6, "distance": 80}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}})),
|
||||
("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "fov": -0.5}})),
|
||||
("sketch.redefine", json!({})),
|
||||
("sketch.redefine", json!({"plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": true, "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": null, "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": [1], "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": 999, "plane": "XZ"})),
|
||||
("sketch.redefine", json!({"sketch": 1})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": null})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": true})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": 1.5})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": -1})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"x_dir": [1, 0, 0]}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"x_dir": [1, 0, 0], "y_dir": [2, 0, 0]}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"origin": "1 mm", "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"face": "x"}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": {"face": [1, 2, 3], "body": "Box"}})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "frame": "local"})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "frame": 3})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "frame": "world"})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": "1"})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": null})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": [0]})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": ["a"]})),
|
||||
("sketch.redefine", json!({"sketch": 1, "plane": "XZ", "author_path": deep})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The calls that need a named view to get as far as their message.
|
||||
#[test]
|
||||
fn view_strict_keeps_todays_errors_for_views() {
|
||||
let long = "n".repeat(129);
|
||||
let cases = [
|
||||
("view.rename", json!({"view": "Front 2", "name": ""})),
|
||||
("view.rename", json!({"view": "Front 2", "name": " "})),
|
||||
("view.rename", json!({"view": "Front 2", "name": 5})),
|
||||
("view.rename", json!({"view": "Front 2", "name": null})),
|
||||
("view.rename", json!({"view": "Front 2", "name": long})),
|
||||
("view.rename", json!({"view": "Front 2"})),
|
||||
("view.rename", json!({"view": "Front 2", "name": "Other"})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let run = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for name in ["Front 2", "Other"] {
|
||||
s.execute("view.save", &json!({"camera": CAMERA(), "name": name})).unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = run(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(run(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the handlers refuse is refused by the types with the same message, and what they take
|
||||
/// is taken: the check names no problem for any call that works today, in the forms that read
|
||||
/// different keys (`view` or `camera`) and in each form of `plane`.
|
||||
#[test]
|
||||
fn the_check_reads_each_key_as_the_handler_does() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
let cam = |extra: Value| {
|
||||
let mut c = CAMERA();
|
||||
c["distance"] = json!(80);
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
c[k] = v.clone();
|
||||
}
|
||||
c
|
||||
};
|
||||
// view.apply: a view, or a camera.
|
||||
assert_eq!(issues("view.apply", json!({"view": "top"})), none);
|
||||
assert_eq!(issues("view.apply", json!({"view": "Anything"})), none);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"fov": 0.5, "roll": 1.0}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"roll": 100}))})), none, "a roll past π is clamped");
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"pitch": std::f64::consts::FRAC_PI_2}))})), none);
|
||||
// `view` is read as text only: where `camera` is the form, one that is not text is not looked at.
|
||||
assert_eq!(issues("view.apply", json!({"view": null, "camera": cam(json!({}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"view": 5, "camera": cam(json!({}))})), none);
|
||||
assert_eq!(issues("view.apply", json!({"view": "top", "camera": cam(json!({}))})), ["give `view` or `camera`, not both"]);
|
||||
assert_eq!(issues("view.apply", json!({"view": "top", "camera": null})), ["give `view` or `camera`, not both"]);
|
||||
assert_eq!(
|
||||
issues("view.apply", json!({"camera": cam(json!({"up": "z"}))})),
|
||||
["camera `up` is not supported: the up axis comes from the design settings"]
|
||||
);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"roll": "x"}))})), ["camera `roll` must be a finite number in radians"]);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"pitch": 2}))})), ["camera `pitch` must be within ±π/2 radians (±90°)"]);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"distance": 0}))})), ["the camera must be finite with a positive distance"]);
|
||||
assert_eq!(issues("view.apply", json!({"camera": cam(json!({"fov": -1}))})), ["the camera must be finite with a positive distance"]);
|
||||
assert!(issues("view.apply", json!({"camera": {"yaw": 1}}))[0].starts_with("bad camera: missing field `target`"));
|
||||
// Neither: the handler's own message (the type leaves it to the handler).
|
||||
assert_eq!(issues("view.apply", json!({})), none);
|
||||
// view.save.
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA()})), none);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": " "})), none);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": null})), none);
|
||||
// Not text: the handler reads it as left out; the check reports it.
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": 5})).len(), 1);
|
||||
assert_eq!(
|
||||
issues("view.save", json!({"camera": {"name": "old", "target": [1, 2, 3], "yaw": 0, "pitch": 7, "distance": 1, "fov": 0.3}, "name": "N"})),
|
||||
none
|
||||
);
|
||||
assert_eq!(issues("view.save", json!({"camera": cam(json!({"distance": -1}))})), ["the camera must be finite with a positive distance"]);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": "n".repeat(129)})), ["`name` is too long"]);
|
||||
assert_eq!(issues("view.save", json!({"camera": CAMERA(), "name": format!(" {} ", "n".repeat(128))})), none);
|
||||
// view.rename and view.delete.
|
||||
assert_eq!(issues("view.rename", json!({"view": "a", "name": "b"})), none);
|
||||
assert_eq!(issues("view.rename", json!({"view": 1, "name": "b"})), ["`view` must be a view name"]);
|
||||
assert_eq!(issues("view.rename", json!({"view": "a", "name": ""})), ["`name` must be 1…128 characters"]);
|
||||
assert_eq!(issues("view.delete", json!({"view": null})), ["`view` must be a view name"]);
|
||||
assert_eq!(issues("view.list", json!({})), none);
|
||||
assert_eq!(issues("view.list", json!({"views": 1})).len(), 1);
|
||||
// sketch.redefine: each form of the plane, and `frame` and `author_path` as given.
|
||||
let plane = |p: Value| issues("sketch.redefine", json!({"sketch": "S", "plane": p}));
|
||||
for p in [
|
||||
json!("XY"),
|
||||
json!("front"),
|
||||
json!("Plane1"),
|
||||
json!(7),
|
||||
json!({"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}),
|
||||
json!({"origin": [1, 2, 3], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}),
|
||||
json!({"origin": null, "x_dir": {"x": 1, "y": 0, "z": 0}, "y_dir": [0, 1, 0]}),
|
||||
json!({"face": [1, 2, 3]}),
|
||||
json!({"face": [1, 2, 3], "body": "Box", "index": 2, "path": [1, 2], "fixed": true}),
|
||||
] {
|
||||
assert_eq!(plane(p.clone()), none, "{p}");
|
||||
}
|
||||
assert_eq!(plane(json!(1.5)).len(), 1);
|
||||
assert_eq!(plane(json!(null)), ["`plane` must be XY, XZ, YZ, a construction plane name or id, or an object"]);
|
||||
assert_eq!(plane(json!({"x_dir": [1, 0, 0]})), ["plane needs y_dir"]);
|
||||
let with = |extra: Value| {
|
||||
let mut p = json!({"sketch": 1, "plane": "XZ"});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
issues("sketch.redefine", p)
|
||||
};
|
||||
assert_eq!(with(json!({"offset": 5})), none);
|
||||
assert_eq!(with(json!({"offset": "w / 2"})), none);
|
||||
assert_eq!(with(json!({"frame": null})), none);
|
||||
assert_eq!(with(json!({"frame": "component", "author_path": [1, 2]})), none);
|
||||
assert_eq!(with(json!({"author_path": []})), none);
|
||||
assert_eq!(with(json!({"frame": "x"})), ["`frame` must be \"world\" or \"component\""]);
|
||||
assert_eq!(with(json!({"author_path": [0]})), ["invalid author_path id"]);
|
||||
assert_eq!(with(json!({"author_path": null})), ["author_path must contain at most64 ids"]);
|
||||
assert_eq!(issues("sketch.redefine", json!({"sketch": 2.5, "plane": "XZ"})), none);
|
||||
assert_eq!(issues("sketch.redefine", json!({"sketch": {}, "plane": "XZ"})), ["`sketch` must be an id or name"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need, and no more.
|
||||
#[test]
|
||||
fn the_view_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
let cam = |extra: Value| {
|
||||
let mut c = CAMERA();
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
c[k] = v.clone();
|
||||
}
|
||||
c
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<ViewSaveParams>(),
|
||||
&[
|
||||
(json!({"camera": CAMERA()}), true),
|
||||
(json!({"camera": cam(json!({"fov": 0.4})), "name": "N"}), true),
|
||||
(json!({"name": "N"}), false),
|
||||
(json!({"camera": cam(json!({"distance": 0}))}), false),
|
||||
(json!({"camera": {"yaw": 0}}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ViewApplyParams>(),
|
||||
&[
|
||||
(json!({"view": "top"}), true),
|
||||
(json!({"camera": cam(json!({"roll": 0.3, "fov": 0.5}))}), true),
|
||||
(json!({}), false),
|
||||
(json!({"view": "top", "camera": CAMERA()}), false),
|
||||
(json!({"camera": cam(json!({"up": "z"}))}), false),
|
||||
(json!({"camera": cam(json!({"pitch": 2}))}), false),
|
||||
(json!({"camera": cam(json!({"fov": -1}))}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ViewRenameParams>(),
|
||||
&[(json!({"view": "a", "name": "b"}), true), (json!({"view": "a"}), false), (json!({"name": "b"}), false)],
|
||||
);
|
||||
takes(input_schema_for::<ViewDeleteParams>(), &[(json!({"view": "a"}), true), (json!({}), false), (json!({"view": 1}), false)]);
|
||||
takes(input_schema_for::<ViewListParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
|
||||
takes(
|
||||
input_schema_for::<RedefineSketchParams>(),
|
||||
&[
|
||||
(json!({"sketch": 3, "plane": "XZ"}), true),
|
||||
(
|
||||
json!({"sketch": "S", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "frame": "component", "offset": "2 mm", "author_path": [1]}),
|
||||
true,
|
||||
),
|
||||
(json!({"sketch": "S", "plane": {"face": [1, 2, 3]}}), true),
|
||||
(json!({"plane": "XZ"}), false),
|
||||
(json!({"sketch": 3}), false),
|
||||
(json!({"sketch": 3, "plane": "XZ", "frame": "x"}), false),
|
||||
(json!({"sketch": 3, "plane": "XZ", "author_path": [0]}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Every form of the six commands, in Warn: each real reply matches its output schema and
|
||||
/// carries no warning, and the replies are the JSON the handlers have always returned.
|
||||
#[test]
|
||||
fn view_and_redefine_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value, schema: schemars::Schema| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
assert!(conforms(schema, schema, &r), "{id} {p}: {r} doesn't match {schema}");
|
||||
r
|
||||
};
|
||||
let saved = run("view.save", json!({"camera": CAMERA()}), output_schema_for::<ViewSaved>());
|
||||
assert_eq!(saved, json!({"view": "Named View1"}));
|
||||
let saved = run(
|
||||
"view.save",
|
||||
json!({"camera": {"target": [1, 2, 3], "yaw": 0, "pitch": 0.2, "distance": 9, "fov": 0.4}, "name": " Detail "}),
|
||||
output_schema_for::<ViewSaved>(),
|
||||
);
|
||||
assert_eq!(saved, json!({"view": "Detail"}));
|
||||
run("view.save", json!({"camera": CAMERA(), "name": null}), output_schema_for::<ViewSaved>());
|
||||
let listed = run("view.list", json!({}), output_schema_for::<ViewListResult>());
|
||||
assert_eq!(listed["views"].as_array().map(Vec::len), Some(3), "{listed}");
|
||||
assert_eq!(listed["views"][1]["name"], "Detail", "{listed}");
|
||||
let schema = output_schema_for::<ViewListResult>();
|
||||
let entry = &listed["views"][0];
|
||||
assert!(entry["target"].is_array() && entry["fov"] == 0.0 && entry["distance"] == 150.0, "{listed}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &listed));
|
||||
let renamed = run("view.rename", json!({"view": "Named View1", "name": " Overview "}), output_schema_for::<ViewRenamed>());
|
||||
assert_eq!(renamed, json!({"view": "Overview"}));
|
||||
let deleted = run("view.delete", json!({"view": "Overview"}), output_schema_for::<ViewDeleted>());
|
||||
assert_eq!(deleted, json!({"deleted": "Overview"}));
|
||||
let empty = run("view.list", json!({}), output_schema_for::<ViewListResult>());
|
||||
assert_eq!(empty["views"].as_array().map(Vec::len), Some(2), "{empty}");
|
||||
|
||||
let camera = output_schema_for::<CameraReply>;
|
||||
for view in ["front", "back", "top", "iso", "home"] {
|
||||
run("view.apply", json!({"view": view}), camera());
|
||||
}
|
||||
let named = run("view.apply", json!({"view": "Detail"}), camera());
|
||||
assert!((named["camera"]["pitch"].as_f64().unwrap_or_default() - 0.2).abs() < 1e-12, "{named}");
|
||||
let turned =
|
||||
run("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5, "roll": 1.0}}), camera());
|
||||
assert_eq!(turned["camera"]["roll"], 1.0, "{turned}");
|
||||
let clamped = run("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "roll": 100}}), camera());
|
||||
assert!((clamped["camera"]["roll"].as_f64().unwrap_or_default() - std::f64::consts::PI).abs() < 1e-12, "{clamped}");
|
||||
run("view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": std::f64::consts::FRAC_PI_2, "distance": 80}}), camera());
|
||||
|
||||
// Redefine Sketch: a sketch, moved to a named plane, offset, an explicit plane in each frame,
|
||||
// and a face of a body.
|
||||
let sketch = |s: &mut Session| s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"))["sketch"].clone();
|
||||
let sk = sketch(&mut s);
|
||||
let mut run = |id: &str, p: Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = output_schema_for::<RedefineSketchResult>();
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id} {p}: {r}");
|
||||
assert!(r["created"].is_object(), "the session adds `created`: {r}");
|
||||
let mut r = r;
|
||||
r.as_object_mut().map(|o| o.remove("created"));
|
||||
r
|
||||
};
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed"}));
|
||||
let r = run("sketch.redefine", json!({"sketch": sk.to_string(), "plane": "YZ", "offset": "5 mm", "frame": null}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed"}));
|
||||
let explicit = json!({"origin": [1, 2, 3], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]});
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": explicit}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed", "frame": "world"}));
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": explicit, "frame": "component"}));
|
||||
assert_eq!(r, json!({"sketch": sk, "attachment": "fixed", "frame": "component"}));
|
||||
}
|
||||
|
||||
/// A face of a body that comes before the sketch: the sketch follows it, or stays where the face
|
||||
/// is now with `fixed`.
|
||||
#[test]
|
||||
fn redefine_sketch_onto_a_face_matches_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sk = s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"))["sketch"].clone();
|
||||
let mut run = |p: Value| {
|
||||
let r = s.execute("sketch.redefine", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let schema = output_schema_for::<RedefineSketchResult>();
|
||||
assert!(r.get("param_warnings").is_none() && conforms(schema.as_value(), schema.as_value(), &r), "{p}: {r}");
|
||||
r
|
||||
};
|
||||
let r = run(json!({"sketch": sk, "plane": {"face": [5, 5, 10]}}));
|
||||
assert_eq!(r["attachment"], "exact_face", "{r}");
|
||||
let r = run(json!({"sketch": sk, "plane": {"face": [5, 5, 10], "body": "Box", "index": 0, "fixed": true}}));
|
||||
assert_eq!(r["attachment"], "fixed", "{r}");
|
||||
}
|
||||
|
||||
/// A moved component: a world plane put into it says so in `note`, which the schema describes.
|
||||
#[test]
|
||||
fn redefine_sketch_note_matches_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let part = run("component.create", json!({"name": "Part", "grounded": false}));
|
||||
let sk = run("sketch.create", json!({"plane": "XY"}))["sketch"].clone();
|
||||
run("occurrence.move", json!({"occurrence": part["occurrence"], "translate": [10, 0, 0]}));
|
||||
let r = run("sketch.redefine", json!({"sketch": sk, "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "author_path": [part["occurrence"]]}));
|
||||
let schema = output_schema_for::<RedefineSketchResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
assert_eq!(r["frame"], "world", "{r}");
|
||||
assert!(r["note"].as_str().is_some_and(|n| n.contains("world coordinates")), "{r}");
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn, and says so, with a reply that matches; Strict refuses it.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_for_each_view_command() {
|
||||
let mut s = Session::default();
|
||||
s.execute("view.save", &json!({"camera": CAMERA(), "name": "V"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sk = s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"))["sketch"].clone();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let cases: Vec<(&str, Value, &str, schemars::Schema)> = vec![
|
||||
("view.apply", json!({"view": "top", "camra": 1}), "camra", output_schema_for::<CameraReply>()),
|
||||
("view.list", json!({"view": "V"}), "view", output_schema_for::<ViewListResult>()),
|
||||
("view.rename", json!({"view": "V", "name": "W", "nmae": 1}), "nmae", output_schema_for::<ViewRenamed>()),
|
||||
("view.save", json!({"camera": CAMERA(), "name": "X", "nmae": 1}), "nmae", output_schema_for::<ViewSaved>()),
|
||||
("sketch.redefine", json!({"sketch": sk, "plane": "XZ", "frme": "world"}), "frme", output_schema_for::<RedefineSketchResult>()),
|
||||
("view.delete", json!({"view": "X", "vew": 1}), "vew", output_schema_for::<ViewDeleted>()),
|
||||
];
|
||||
for (id, p, key, schema) in cases {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(r["param_warnings"].to_string().contains(key), "{id}: {r}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r}");
|
||||
}
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("view.apply", &json!({"view": "top", "camra": 1})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`camra`"), "{error}");
|
||||
}
|
||||
|
||||
/// The commands run exactly as before with the check off, whatever the call (the types only
|
||||
/// describe).
|
||||
#[test]
|
||||
fn the_view_commands_still_read_their_calls_as_before_with_the_check_off() {
|
||||
let mut s = Session::default();
|
||||
s.execute("view.save", &json!({"camera": CAMERA(), "name": 5})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(s.execute("view.list", &json!({"x": 1})).unwrap_or_else(|e| panic!("{e}"))["views"][0]["name"], "Named View1");
|
||||
s.execute("view.apply", &json!({"view": 5, "camera": CAMERA()})).unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ impl JsonSchema for ComponentRef {
|
||||
|
||||
/// `key`, a component's id or name, with the handler's message for anything else, `null`
|
||||
/// included.
|
||||
fn component_key<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<ComponentRef>, D::Error> {
|
||||
pub(super) fn component_key<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<ComponentRef>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => Ok(Some(ComponentRef(n.to_string()))),
|
||||
Value::String(s) => Ok(Some(ComponentRef(s))),
|
||||
@@ -95,7 +95,7 @@ fn path_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
|
||||
/// Adds `key` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it when it's left out.
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
pub(super) fn require(schema: &mut Schema, key: &str) {
|
||||
match schema.get_mut("required") {
|
||||
Some(Value::Array(required)) => {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
|
||||
@@ -4,11 +4,15 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
EditInPlaceParams, FinishEditParams, MoveFeaturesParams, MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult,
|
||||
ReparentItemsParams,
|
||||
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, CopyParams, CopyResult, CreateParams, CreateResult, DeleteParams,
|
||||
DeleteResult, DragParams, DragResult, EditInPlaceParams, FinishEditParams, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult,
|
||||
ListParams, ListResult, MoveBodiesParams, MoveBodiesResult, MoveFeaturesParams, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
|
||||
MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams, OccurrencePropertiesResult, PasteNewParams,
|
||||
PasteNewResult, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams, RevertPositionParams, RevertPositionResult,
|
||||
UnlockParams, UnlockResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
@@ -36,6 +40,353 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MoveFeaturesParams>(), &["component", "features"]);
|
||||
assert_keys(input_schema_for::<MoveSketchesToPartsParams>(), &["sketches"]);
|
||||
assert_keys(input_schema_for::<ReparentItemsParams>(), &["bodies", "component", "component_path", "features", "occurrences"]);
|
||||
assert_keys(input_schema_for::<CreateParams>(), &["activate", "grounded", "name", "parent", "parent_path"]);
|
||||
assert_keys(input_schema_for::<FromBodiesParams>(), &["bodies"]);
|
||||
assert_keys(input_schema_for::<ActivateParams>(), &["component", "occurrence", "path"]);
|
||||
assert_keys(input_schema_for::<ListParams>(), &["bodies", "compact"]);
|
||||
assert_keys(input_schema_for::<PropertiesParams>(), &["about", "component", "descendants"]);
|
||||
assert_keys(input_schema_for::<RenameParams>(), &["component", "name"]);
|
||||
assert_keys(input_schema_for::<DeleteParams>(), &["component"]);
|
||||
assert_keys(input_schema_for::<UnlockParams>(), &["component"]);
|
||||
assert_keys(input_schema_for::<CapturePositionParams>(), &[]);
|
||||
assert_keys(input_schema_for::<RevertPositionParams>(), &["occurrences"]);
|
||||
assert_keys(input_schema_for::<GroundParams>(), &["grounded", "occurrence", "path"]);
|
||||
assert_keys(input_schema_for::<CopyParams>(), &["component", "in_place", "occurrence", "parent", "parent_path", "path", "translate"]);
|
||||
assert_keys(input_schema_for::<PasteNewParams>(), &["component", "in_place", "occurrence", "parent", "parent_path", "path", "translate"]);
|
||||
assert_keys(
|
||||
input_schema_for::<MoveParams>(),
|
||||
&["angle", "axis", "base_transform", "capture", "matrix", "move_grounded", "occurrence", "origin", "path", "space", "tolerance", "translate"],
|
||||
);
|
||||
assert_keys(input_schema_for::<DragParams>(), &["at", "continuous", "occurrence", "path", "point"]);
|
||||
assert_keys(input_schema_for::<OccurrencePropertiesParams>(), &["about", "descendants", "occurrence", "path"]);
|
||||
assert_keys(input_schema_for::<MoveBodiesParams>(), &["bodies", "component", "component_path", "from_path"]);
|
||||
assert_keys(input_schema_for::<MoveSketchesParams>(), &["component", "sketches"]);
|
||||
}
|
||||
|
||||
/// The calls these commands already refuse (the handlers' own messages), refused alike in strict.
|
||||
/// An unknown key to `component.create` is left out: its handler lists the accepted parameters,
|
||||
/// where strict gives the check's `unknown key` with a suggestion (refused either way).
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("component.create", json!({"parent": null})),
|
||||
("component.create", json!({"parent": true})),
|
||||
("component.create", json!({"parent": [1]})),
|
||||
("component.create", json!({"parent": "Nope"})),
|
||||
("component.create", json!({"parent_path": "1"})),
|
||||
("component.create", json!({"parent_path": null})),
|
||||
("component.create", json!({"parent_path": [1, "a"]})),
|
||||
("component.create", json!({"parent_path": vec![1; 64]})),
|
||||
("component.create", json!({"grounded": "yes"})),
|
||||
("component.create", json!({"grounded": null})),
|
||||
("component.from_bodies", json!({})),
|
||||
("component.from_bodies", json!({"bodies": null})),
|
||||
("component.from_bodies", json!({"bodies": "Box"})),
|
||||
("component.from_bodies", json!({"bodies": [1]})),
|
||||
("component.from_bodies", json!({"bodies": []})),
|
||||
("component.from_bodies", json!({"bodies": ["A", "A"]})),
|
||||
("component.from_bodies", json!({"bodies": vec!["A"; 1001]})),
|
||||
("component.from_bodies", json!({"bodies": ["Nope"]})),
|
||||
("component.activate", json!({})),
|
||||
("component.activate", json!({"component": null})),
|
||||
("component.activate", json!({"component": [1]})),
|
||||
("component.activate", json!({"component": "Nope"})),
|
||||
("component.activate", json!({"component": 0, "path": [1]})),
|
||||
("component.activate", json!({"component": 0, "occurrence": null})),
|
||||
("component.activate", json!({"component": 0, "occurrence": true})),
|
||||
("component.properties", json!({"component": null})),
|
||||
("component.properties", json!({"component": true})),
|
||||
("component.properties", json!({"descendants": "yes"})),
|
||||
("component.properties", json!({"descendants": null})),
|
||||
("component.properties", json!({"about": [1, 2]})),
|
||||
("component.properties", json!({"about": "origin"})),
|
||||
("component.properties", json!({"about": [1, 2, 1e9]})),
|
||||
("component.rename", json!({})),
|
||||
("component.rename", json!({"component": 0})),
|
||||
("component.rename", json!({"name": "A"})),
|
||||
("component.rename", json!({"component": null, "name": "A"})),
|
||||
("component.rename", json!({"component": 0, "name": ""})),
|
||||
("component.rename", json!({"component": 0, "name": " \t "})),
|
||||
("component.rename", json!({"component": 0, "name": 5})),
|
||||
("component.rename", json!({"component": 0, "name": null})),
|
||||
("component.rename", json!({"component": 0, "name": "x".repeat(129)})),
|
||||
("component.delete", json!({})),
|
||||
("component.delete", json!({"component": false})),
|
||||
("component.delete", json!({"component": 0})),
|
||||
("component.unlock", json!({})),
|
||||
("component.unlock", json!({"component": [0]})),
|
||||
("component.unlock", json!({"component": 0})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A name of 128 bytes is taken once trimmed, and one of 129 is not, as the handler counts.
|
||||
#[test]
|
||||
fn a_name_is_counted_as_the_handler_counts_it() {
|
||||
let issues = |p: Value| crate::schema::issues("component.rename", &p).len();
|
||||
assert_eq!(issues(json!({"component": 0, "name": format!(" {} ", "x".repeat(128))})), 0);
|
||||
assert_eq!(issues(json!({"component": 0, "name": "x".repeat(129)})), 1);
|
||||
// 64 two-byte characters are 128 bytes.
|
||||
assert_eq!(issues(json!({"component": 0, "name": "é".repeat(64)})), 0);
|
||||
assert_eq!(issues(json!({"component": 0, "name": "é".repeat(65)})), 1);
|
||||
}
|
||||
|
||||
/// The contracts say what the handlers need: a key a handler refuses without is required in the
|
||||
/// schema, though the type leaves it to the handler's message.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<CreateParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"name": "Arm", "parent": "Frame", "parent_path": [1, 2], "activate": false, "grounded": true}), true),
|
||||
(json!({"parent": 3, "parent_path": []}), true),
|
||||
(json!({"parent": true}), false),
|
||||
(json!({"parent_path": vec![1; 64]}), false),
|
||||
(json!({"grounded": "yes"}), false),
|
||||
(json!({"bodies": ["A"]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<FromBodiesParams>(),
|
||||
&[
|
||||
(json!({"bodies": ["A", "B"]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"bodies": []}), false),
|
||||
(json!({"bodies": ["A", "A"]}), false),
|
||||
(json!({"bodies": "A"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ActivateParams>(),
|
||||
&[
|
||||
(json!({"component": 0}), true),
|
||||
(json!({"component": "root"}), true),
|
||||
(json!({"component": "Arm", "occurrence": "Arm:2", "path": [1, 2]}), true),
|
||||
(json!({"component": "Arm", "occurrence": 2}), true),
|
||||
(json!({}), false),
|
||||
(json!({"component": true}), false),
|
||||
(json!({"component": 1, "occurrence": true}), false),
|
||||
(json!({"component": 1, "path": []}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ListParams>(),
|
||||
&[(json!({}), true), (json!({"compact": true, "bodies": true}), true), (json!({"compact": "yes"}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<PropertiesParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"component": "Arm", "descendants": false, "about": [1, 2, 3]}), true),
|
||||
(json!({"about": {"x": 1, "y": 2, "z": 3}}), true),
|
||||
(json!({"about": [1, 2]}), false),
|
||||
(json!({"descendants": 1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<RenameParams>(),
|
||||
&[(json!({"component": "Arm", "name": "Bar"}), true), (json!({"component": "Arm"}), false), (json!({"name": "Bar"}), false)],
|
||||
);
|
||||
// `conforms` doesn't read patterns: pin the text that keeps blank names out.
|
||||
let rename = input_schema_for::<RenameParams>();
|
||||
assert_eq!(rename.as_value()["$defs"]["ComponentName"]["pattern"], crate::schema::types::not_blank_pattern());
|
||||
takes(input_schema_for::<DeleteParams>(), &[(json!({"component": 2}), true), (json!({}), false)]);
|
||||
takes(input_schema_for::<UnlockParams>(), &[(json!({"component": "rig"}), true), (json!({}), false)]);
|
||||
}
|
||||
|
||||
/// Keys the handlers read only in some cases are read only in those cases: `bodies` of
|
||||
/// `component.list` with `compact`, `path` of `component.activate` with `occurrence`. And the
|
||||
/// warnings say what is wrong with a key.
|
||||
#[test]
|
||||
fn the_check_reads_keys_as_the_handlers_do() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
assert_eq!(issues("component.list", json!({"bodies": "x"})), none);
|
||||
assert_eq!(issues("component.list", json!({"compact": false, "bodies": 3})), none);
|
||||
assert_eq!(issues("component.list", json!({"compact": true, "bodies": true})), none);
|
||||
assert_eq!(issues("component.list", json!({"compact": true, "bodies": "x"})).len(), 1);
|
||||
assert_eq!(issues("component.list", json!({"compact": "x"})).len(), 1);
|
||||
assert_eq!(issues("component.list", json!({"compat": true})), ["unknown key `compat`; did you mean `compact`?"]);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2})), none);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2, "path": [1, 2]})), none);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "path": [2]})), ["`occurrence` must be an id or name"]);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": null})), ["`occurrence` must be an id or name"]);
|
||||
assert_eq!(issues("component.create", json!({"parent": true})), ["`parent` must be a component id or name"]);
|
||||
assert_eq!(issues("component.create", json!({"parent_path": [1, null]})), ["parent_path must list up to 63 occurrence ids"]);
|
||||
assert_eq!(issues("component.create", json!({"grounded": 1})), ["grounded must be true or false"]);
|
||||
assert_eq!(issues("component.create", json!({"parent_path": vec![1; 63]})), none);
|
||||
assert_eq!(issues("component.create", json!({"parent_path": vec![1; 64]})), ["parent_path must list up to 63 occurrence ids"]);
|
||||
assert_eq!(issues("component.create", json!({"activate": "no"})).len(), 1);
|
||||
assert_eq!(issues("component.from_bodies", json!({"bodies": [1]})), ["bodies must be an array of body names"]);
|
||||
assert_eq!(issues("component.from_bodies", json!({"bodies": ["a", "a"]})), ["bodies must list 1..1000 distinct existing definition bodies"]);
|
||||
assert_eq!(issues("component.properties", json!({"about": [1]})), ["`about` must be a point [x, y, z] in mm"]);
|
||||
assert_eq!(issues("component.properties", json!({"about": null})), none);
|
||||
assert_eq!(issues("component.properties", json!({"descendants": null})), ["`descendants` must be a boolean"]);
|
||||
assert_eq!(issues("component.properties", json!({"component": null})), ["`component` must be a component id or name"]);
|
||||
assert_eq!(issues("component.rename", json!({"component": 0, "name": ""})), ["`name` must be 1…128 characters"]);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// A glTF file whose node `rig` holds a `Part` (one cube): imported, it is a component held
|
||||
/// together by a rigid group that `component.unlock` releases.
|
||||
fn rig_file(name: &str) -> String {
|
||||
let side = 0.01f32;
|
||||
let points: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * side, ((i >> 1) & 1) as f32 * side, ((i >> 2) & 1) as f32 * side]).collect();
|
||||
let tris: [u16; 36] = [0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
|
||||
let mut bin: Vec<u8> = points.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
|
||||
bin.extend(tris.iter().flat_map(|v| v.to_le_bytes()));
|
||||
let doc = json!({
|
||||
"asset": {"version": "2.0"},
|
||||
"scene": 0,
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"nodes": [{"name": "Base", "mesh": 0, "children": [1]}, {"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0]}],
|
||||
"meshes": [{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
|
||||
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [side, side, side]},
|
||||
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
|
||||
],
|
||||
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
|
||||
"buffers": [{"byteLength": bin.len()}],
|
||||
});
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-component-schema-{}-{name}.glb", std::process::id()));
|
||||
std::fs::write(&path, solidcraft_io::glb_container(doc.to_string().as_bytes(), &bin)).unwrap_or_else(|e| panic!("{e}"));
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Every command, in each form it takes, in Warn: the real reply matches its output schema and
|
||||
/// carries no `param_warnings` (Warn runs a call the contract refuses anyway, so the replies
|
||||
/// are also checked for warnings), and a key spelt wrongly on purpose is reported.
|
||||
#[test]
|
||||
fn replies_match_the_output_schemas_of_the_component_commands() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
|
||||
let arm = run("component.create", json!({"name": "Arm"}));
|
||||
assert_eq!(arm["name"], "Arm");
|
||||
assert_eq!(arm["grounded"], true, "the design's first part is grounded: {arm}");
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &arm);
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "ArmBox"}));
|
||||
let hand = run("component.create", json!({"name": "Hand", "parent": "Arm", "activate": false, "grounded": false}));
|
||||
assert!(hand["parent"] == arm["component"] && hand["grounded"] == false, "{hand}");
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &hand);
|
||||
let at = run("component.create", json!({"name": "Thumb", "parent": hand["parent"], "parent_path": [arm["occurrence"]]}));
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &at);
|
||||
let top = run("component.create", json!({"name": "Top", "parent": 0, "parent_path": []}));
|
||||
matches(output_schema_for::<CreateResult>(), "component.create", &top);
|
||||
|
||||
let active = run("component.activate", json!({"component": "root"}));
|
||||
assert_eq!(active["active"], 0);
|
||||
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
|
||||
let active = run("component.activate", json!({"component": arm["component"]}));
|
||||
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
|
||||
let active = run("component.activate", json!({"component": "Arm", "occurrence": arm["occurrence"], "path": [arm["occurrence"]]}));
|
||||
assert_eq!(active["active"], arm["component"], "{active}");
|
||||
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
|
||||
|
||||
run("solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "RootBox"}));
|
||||
run("solid.box", json!({"length": 6, "width": 6, "height": 6, "body_name": "RootBox2", "operation": "new"}));
|
||||
let made = run("component.from_bodies", json!({"bodies": ["RootBox", "RootBox2"]}));
|
||||
assert_eq!(made["components"].as_array().map(Vec::len), Some(2), "{made}");
|
||||
matches(output_schema_for::<FromBodiesResult>(), "component.from_bodies", &made);
|
||||
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"compact": true}),
|
||||
json!({"compact": true, "bodies": true}),
|
||||
json!({"compact": false, "bodies": "ignored"}),
|
||||
json!({"bodies": true}),
|
||||
] {
|
||||
let listed = run("component.list", p.clone());
|
||||
matches(output_schema_for::<ListResult>(), "component.list", &listed);
|
||||
assert!(listed["components"].as_array().is_some_and(|c| c.len() >= 5), "{p}: {listed}");
|
||||
}
|
||||
let moved = run("occurrence.move", json!({"occurrence": hand["occurrence"], "translate": [5, 0, 0], "capture": false, "move_grounded": true}));
|
||||
assert_eq!(moved["pending"], true, "{moved}");
|
||||
run("occurrence.move", json!({"occurrence": top["occurrence"], "translate": [0, 7, 0], "move_grounded": true}));
|
||||
let listed = run("component.list", json!({"compact": true}));
|
||||
assert!(listed["pending_moves"].as_array().is_some_and(|m| !m.is_empty()), "{listed}");
|
||||
// Compact placements carry `transform` only when it is not the identity.
|
||||
let placements: Vec<&Value> =
|
||||
listed["components"].as_array().into_iter().flatten().flat_map(|c| c["occurrences"].as_array().into_iter().flatten()).collect();
|
||||
assert!(placements.iter().any(|o| o.get("transform").is_some()) && placements.iter().any(|o| o.get("transform").is_none()), "{listed}");
|
||||
matches(output_schema_for::<ListResult>(), "component.list", &listed);
|
||||
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"component": "Arm"}),
|
||||
json!({"component": 0, "descendants": false}),
|
||||
json!({"component": "root", "about": [10, 0, 0]}),
|
||||
json!({"about": {"x": 1, "y": 2, "z": 3}, "descendants": true}),
|
||||
json!({"about": null}),
|
||||
json!({"component": "Top"}),
|
||||
] {
|
||||
let measured = run("component.properties", p.clone());
|
||||
matches(output_schema_for::<PropertiesResult>(), "component.properties", &measured);
|
||||
assert_eq!(measured["coordinate_frame"], "world", "{p}");
|
||||
assert_eq!(measured["total"].get("inertia_about").is_some(), p.get("about").is_some_and(|a| !a.is_null()), "{p}: {measured}");
|
||||
}
|
||||
let measured = run("component.properties", json!({"component": 0}));
|
||||
assert!(measured["body_count"].as_u64().is_some_and(|n| n >= 3) && measured["total"]["bbox"].is_object(), "{measured}");
|
||||
let empty = run("component.properties", json!({"component": "Top"}));
|
||||
assert!(empty["total"]["bbox"].is_null() && empty["total"]["center_of_mass"].is_null(), "{empty}");
|
||||
|
||||
let renamed = run("component.rename", json!({"component": "Hand", "name": " Palm "}));
|
||||
assert_eq!(renamed["name"], "Palm");
|
||||
matches(output_schema_for::<RenameResult>(), "component.rename", &renamed);
|
||||
let renamed = run("component.rename", json!({"component": 0, "name": "Whole"}));
|
||||
matches(output_schema_for::<RenameResult>(), "component.rename", &renamed);
|
||||
|
||||
let deleted = run("component.delete", json!({"component": "Arm"}));
|
||||
assert!(deleted["deleted"].as_array().is_some_and(|d| d.len() >= 3), "{deleted}");
|
||||
matches(output_schema_for::<DeleteResult>(), "component.delete", &deleted);
|
||||
|
||||
// Unlock: a glTF node tree is held together by a rigid group.
|
||||
let inserted = run("file.insert_mesh", json!({"path": rig_file("rig"), "up": "z"}));
|
||||
let rig = inserted["component"].clone();
|
||||
let unlocked = run("component.unlock", json!({"component": rig}));
|
||||
assert!(unlocked["joints"].as_array().is_some_and(|j| !j.is_empty()), "{unlocked}");
|
||||
matches(output_schema_for::<UnlockResult>(), "component.unlock", &unlocked);
|
||||
|
||||
// Misspelt keys still run in Warn, say so, and the reply still matches.
|
||||
let cases: [(&str, Value, &str, schemars::Schema); 3] = [
|
||||
("component.list", json!({"compat": true}), "compat", output_schema_for::<ListResult>()),
|
||||
("component.properties", json!({"descendents": false}), "descendents", output_schema_for::<PropertiesResult>()),
|
||||
("component.rename", json!({"component": 0, "nmae": "x", "name": "Again"}), "nmae", output_schema_for::<RenameResult>()),
|
||||
];
|
||||
for (id, p, key, schema) in cases {
|
||||
let warned = run(id, p);
|
||||
let warnings = warned["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains(key)), "{id}: {warned}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{id}: {warned}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown key is refused by `component.create` as before (its own message lists the
|
||||
/// accepted parameters), at every check level but strict, where the check names the key first.
|
||||
#[test]
|
||||
fn create_refuses_an_unknown_key_as_before() {
|
||||
for level in ParamCheck::ALL {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let e = s.execute("component.create", &json!({"nme": "A"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `nme`") ^ (level == ParamCheck::Strict), "{level:?}: {e}");
|
||||
assert!(e.contains("nme"), "{e}");
|
||||
}
|
||||
let mut s = Session::default();
|
||||
let e = s.execute("component.create", &json!({"bodies": ["A"]})).unwrap_err().to_string();
|
||||
assert!(e.contains("use component.move_bodies or component.from_bodies"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -173,3 +524,587 @@ fn sketches_moved_to_a_part_while_an_exploded_view_is_shown_save_what_they_save_
|
||||
assert!(at.dist(assembled_at) < 1e-9, "{at:?} vs {assembled_at:?}");
|
||||
assert!(plane.origin.dist(assembled_plane.origin) < 1e-9, "{plane:?} vs {assembled_plane:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and
|
||||
// Paste New (#13186).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Two ungrounded parts, `A` (with a box, `ABox`, and a sketch, `S`) and an empty `B`, in the
|
||||
/// design: the session at `level`, and the ids of their placements.
|
||||
fn two_parts(level: ParamCheck) -> (Session, u64, u64) {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let a = run("component.create", json!({"name": "A", "parent": 0, "grounded": false}))["occurrence"].as_u64().unwrap_or(0);
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "ABox"}));
|
||||
run("sketch.create", json!({"name": "S", "plane": "XY"}));
|
||||
run("sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [3, 3]}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let b = run("component.create", json!({"name": "B", "parent": 0, "grounded": false, "activate": false}))["occurrence"].as_u64().unwrap_or(0);
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
/// Each case fails today (the handler's message, in Off) and fails the same in strict.
|
||||
fn strict_keeps(cases: impl Fn(u64, u64) -> Vec<(&'static str, Value)>) {
|
||||
let (_, a, b) = two_parts(ParamCheck::Off);
|
||||
for (id, p) in cases(a, b) {
|
||||
let error = |level: ParamCheck| two_parts(level).0.execute(id, &p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
const FLAT_MATRIX: [i32; 12] = [1, 0, 0, 5, 0, 1, 0, 0, 0, 0, 1, 0];
|
||||
|
||||
/// What the occurrence commands already refuse (their handlers' own messages), refused alike in
|
||||
/// strict, including the keys they read only in some cases.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_of_the_occurrence_commands() {
|
||||
strict_keeps(|a, b| {
|
||||
let at = json!([0, 0, 0]);
|
||||
vec![
|
||||
("component.revert_position", json!({"occurrences": null})),
|
||||
("component.revert_position", json!({"occurrences": "1"})),
|
||||
("component.revert_position", json!({"occurrences": []})),
|
||||
("component.revert_position", json!({"occurrences": [a, "x"]})),
|
||||
("component.revert_position", json!({"occurrences": vec![1; 10_001]})),
|
||||
("component.revert_position", json!({"occurrences": [999]})),
|
||||
("occurrence.ground", json!({})),
|
||||
("occurrence.ground", json!({"occurrence": null})),
|
||||
("occurrence.ground", json!({"occurrence": true})),
|
||||
("occurrence.ground", json!({"occurrence": "Nope"})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": "x"})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.ground", json!({"occurrence": a, "path": vec![a; 65]})),
|
||||
("occurrence.copy", json!({})),
|
||||
("occurrence.copy", json!({"component": null})),
|
||||
("occurrence.copy", json!({"component": [1]})),
|
||||
("occurrence.copy", json!({"occurrence": null})),
|
||||
("occurrence.copy", json!({"occurrence": true, "component": "A"})),
|
||||
("occurrence.copy", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.copy", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.copy", json!({"occurrence": a, "parent": null})),
|
||||
("occurrence.copy", json!({"occurrence": a, "parent": true})),
|
||||
("occurrence.copy", json!({"occurrence": a, "parent": "Nope"})),
|
||||
("occurrence.copy", json!({"occurrence": a, "translate": [1, 2]})),
|
||||
("occurrence.copy", json!({"occurrence": a, "translate": null})),
|
||||
("occurrence.copy", json!({"occurrence": a, "translate": [1e9, 0, 0]})),
|
||||
("occurrence.copy", json!({"occurrence": a, "in_place": true, "parent_path": "x"})),
|
||||
("occurrence.copy", json!({"occurrence": a, "in_place": true, "parent_path": vec![1; 65]})),
|
||||
("component.paste_new", json!({})),
|
||||
("component.paste_new", json!({"component": null})),
|
||||
("component.paste_new", json!({"component": true})),
|
||||
("component.paste_new", json!({"component": 0})),
|
||||
("component.paste_new", json!({"component": "A", "occurrence": null})),
|
||||
("component.paste_new", json!({"component": "A", "occurrence": b})),
|
||||
("component.paste_new", json!({"component": "A", "occurrence": a, "path": []})),
|
||||
("component.paste_new", json!({"component": "A", "translate": [1]})),
|
||||
("component.paste_new", json!({"component": "A", "parent": true})),
|
||||
("component.paste_new", json!({"component": "A", "parent": "A"})),
|
||||
("component.paste_new", json!({"component": "A", "in_place": true, "parent_path": 5})),
|
||||
("occurrence.move", json!({})),
|
||||
("occurrence.move", json!({"occurrence": null})),
|
||||
("occurrence.move", json!({"occurrence": [1]})),
|
||||
("occurrence.move", json!({"occurrence": a, "translate": [1, 2]})),
|
||||
("occurrence.move", json!({"occurrence": a, "translate": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "translate": [1e9, 0, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "axis": "z"})),
|
||||
("occurrence.move", json!({"occurrence": a, "origin": [1, 2, "x"]})),
|
||||
("occurrence.move", json!({"occurrence": a, "angle": true})),
|
||||
("occurrence.move", json!({"occurrence": a, "angle": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "angle": 1, "axis": [0, 0, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "space": "up"})),
|
||||
("occurrence.move", json!({"occurrence": a, "space": "World"})),
|
||||
("occurrence.move", json!({"occurrence": a, "space": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.move", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "base_transform": [[1]]})),
|
||||
("occurrence.move", json!({"occurrence": a, "base_transform": [[1, 0, 0, 5], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]})),
|
||||
("occurrence.move", json!({"occurrence": a, "base_transform": [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 1]]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [1, 2, 3]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "translate": [1, 0, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "axis": null})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "angle": 1})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "origin": at})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": 0})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": "x"})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": 0.01})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]})),
|
||||
("occurrence.move", json!({"occurrence": a, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1]})),
|
||||
("occurrence.drag", json!({})),
|
||||
("occurrence.drag", json!({"path": "x", "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"path": [], "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": null, "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": 5})),
|
||||
("occurrence.drag", json!({"occurrence": a, "path": null, "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "path": [], "at": at, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": null, "point": at})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": at, "point": [1, 2]})),
|
||||
("occurrence.drag", json!({"occurrence": a, "at": [1e9, 0, 0], "point": at})),
|
||||
("occurrence.properties", json!({})),
|
||||
("occurrence.properties", json!({"occurrence": null})),
|
||||
("occurrence.properties", json!({"occurrence": false})),
|
||||
("occurrence.properties", json!({"occurrence": a, "path": []})),
|
||||
("occurrence.properties", json!({"occurrence": a, "path": null})),
|
||||
("occurrence.properties", json!({"occurrence": a, "descendants": "yes"})),
|
||||
("occurrence.properties", json!({"occurrence": a, "descendants": null})),
|
||||
("occurrence.properties", json!({"occurrence": a, "about": [1, 2]})),
|
||||
("occurrence.properties", json!({"occurrence": a, "about": "origin"})),
|
||||
("component.move_bodies", json!({})),
|
||||
("component.move_bodies", json!({"bodies": ["ABox"]})),
|
||||
("component.move_bodies", json!({"component": null, "bodies": ["ABox"]})),
|
||||
("component.move_bodies", json!({"component": "B"})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": null})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": "ABox"})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": [1]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": []})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox", "ABox"]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": vec!["ABox"; 1001]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["Nope"]})),
|
||||
("component.move_bodies", json!({"component": "Nope", "bodies": ["ABox"]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": null})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": "x"})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": vec![b; 65]})),
|
||||
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "from_path": [b, "x"]})),
|
||||
("component.move_sketches", json!({})),
|
||||
("component.move_sketches", json!({"sketches": ["S"]})),
|
||||
("component.move_sketches", json!({"component": null, "sketches": ["S"]})),
|
||||
("component.move_sketches", json!({"component": true, "sketches": ["S"]})),
|
||||
("component.move_sketches", json!({"component": "B"})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": null})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": []})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": 5})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": [1]})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": ["S", true]})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": vec!["S"; 10_001]})),
|
||||
("component.move_sketches", json!({"component": "B", "sketches": ["Nope"]})),
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/// The contracts say what the handlers need, in the schemas: required keys are required though the
|
||||
/// types leave them to the handlers' messages, and the ways a command can be called are open.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_occurrence_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
let matrix: Vec<i32> = FLAT_MATRIX.to_vec();
|
||||
takes(input_schema_for::<CapturePositionParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
|
||||
takes(
|
||||
input_schema_for::<RevertPositionParams>(),
|
||||
&[(json!({}), true), (json!({"occurrences": [1, 2]}), true), (json!({"occurrences": []}), false), (json!({"occurrences": ["1"]}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<GroundParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"occurrence": "A:1", "path": [1], "grounded": false}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "path": []}), false),
|
||||
(json!({"occurrence": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<CopyParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"component": "A"}), true),
|
||||
(json!({"occurrence": 1, "translate": {"x": 1, "y": 2, "z": 3}, "in_place": true, "parent_path": [], "path": [1], "parent": 0}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "translate": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<PasteNewParams>(),
|
||||
&[
|
||||
(json!({"component": "A"}), true),
|
||||
(
|
||||
json!({"component": 1, "occurrence": 2, "path": [2], "parent": "B", "translate": [1, 2, 3], "in_place": true, "parent_path": [1]}),
|
||||
true,
|
||||
),
|
||||
(json!({"occurrence": 1}), false),
|
||||
(json!({"component": "A", "translate": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MoveParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"occurrence": 1, "translate": [1, 2, 3], "axis": [0, 0, 1], "angle": "30 deg", "origin": [0, 0, 0], "space": "world"}), true),
|
||||
(json!({"occurrence": 1, "angle": 0.5, "capture": false, "move_grounded": true, "path": [1]}), true),
|
||||
(json!({"occurrence": 1, "matrix": matrix, "tolerance": 1e-6}), true),
|
||||
(json!({"occurrence": 1, "matrix": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}), true),
|
||||
(json!({"occurrence": 1, "base_transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "matrix": FLAT_MATRIX, "translate": [1, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "matrix": FLAT_MATRIX, "origin": [1, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "matrix": [1, 2, 3]}), false),
|
||||
(json!({"occurrence": 1, "space": "up"}), false),
|
||||
(json!({"occurrence": 1, "angle": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<DragParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1, "at": [0, 0, 0], "point": [1, 0, 0]}), true),
|
||||
(json!({"path": [1, 2], "at": {"x": 0, "y": 0, "z": 0}, "point": [1, 0, 0], "continuous": true}), true),
|
||||
(json!({"at": [0, 0, 0], "point": [1, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "at": [0, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "point": [0, 0, 0]}), false),
|
||||
(json!({"occurrence": 1, "path": [], "at": [0, 0, 0], "point": [1, 0, 0]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<OccurrencePropertiesParams>(),
|
||||
&[
|
||||
(json!({"occurrence": 1}), true),
|
||||
(json!({"occurrence": "A:1", "path": [1], "descendants": false, "about": [1, 2, 3]}), true),
|
||||
(json!({"occurrence": 1, "about": null}), true),
|
||||
(json!({}), false),
|
||||
(json!({"occurrence": 1, "descendants": 1}), false),
|
||||
(json!({"occurrence": 1, "about": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MoveBodiesParams>(),
|
||||
&[
|
||||
(json!({"component": "B", "bodies": ["A"]}), true),
|
||||
(json!({"component": 2, "bodies": ["A", "B"], "component_path": [1], "from_path": []}), true),
|
||||
(json!({"bodies": ["A"]}), false),
|
||||
(json!({"component": "B"}), false),
|
||||
(json!({"component": "B", "bodies": "A"}), false),
|
||||
(json!({"component": "B", "bodies": ["A"], "from_path": vec![1; 65]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MoveSketchesParams>(),
|
||||
&[
|
||||
(json!({"component": "B", "sketches": ["S"]}), true),
|
||||
(json!({"component": "B", "sketches": "S"}), true),
|
||||
(json!({"component": 2, "sketches": ["S", "T"]}), true),
|
||||
(json!({"sketches": ["S"]}), false),
|
||||
(json!({"component": "B"}), false),
|
||||
(json!({"component": "B", "sketches": []}), false),
|
||||
(json!({"component": "B", "sketches": [1]}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The types read each key as its handler does: keys the handlers read only in some cases are
|
||||
/// read only in those, and a wrong key says what is wrong with it in the handler's words.
|
||||
#[test]
|
||||
fn the_check_reads_the_occurrence_keys_as_the_handlers_do() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
// `occurrence.copy`: `component` only without `occurrence`, `parent_path` only with `in_place: true`.
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "component": [1], "parent_path": "x"})), none);
|
||||
assert_eq!(issues("occurrence.copy", json!({"component": [1]})), ["`component` must be a component id or name"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": null, "component": 1})), ["`occurrence` must be an id or name"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": false, "parent_path": "x"})), none);
|
||||
assert_eq!(
|
||||
issues("occurrence.copy", json!({"occurrence": 1, "in_place": true, "parent_path": "x"})),
|
||||
["`parent_path` must list 0..64 occurrence ids"]
|
||||
);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": true, "parent_path": []})), none);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "translate": [1, 2]})), ["translate must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": "yes"})).len(), 1);
|
||||
assert_eq!(issues("occurrence.copy", json!({"occurence": 1})), ["unknown key `occurence`; did you mean `occurrence`?"]);
|
||||
// `component.paste_new`: `parent_path` only with `in_place: true`.
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "parent_path": 5})), none);
|
||||
assert_eq!(
|
||||
issues("component.paste_new", json!({"component": 1, "in_place": true, "parent_path": 5})),
|
||||
["`parent_path` must list 0..64 occurrence ids"]
|
||||
);
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "occurrence": 2, "path": [2], "translate": {"x": 1, "y": 2, "z": 3}})), none);
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("component.paste_new", json!({"component": 1, "parent": null})), ["`parent` must be a component id or name"]);
|
||||
// `occurrence.move`: `tolerance` only with `matrix`, which takes the place of the move keys.
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "tolerance": "x"})), none);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "tolerance": 1e-6})), none);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "tolerance": "x"})),
|
||||
["tolerance must be a number above 0 and at most 0.001"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "translate": "x"})),
|
||||
["give either matrix or translate, not both"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "origin": null})),
|
||||
["give either matrix or origin, not both"]
|
||||
);
|
||||
assert!(issues("occurrence.move", json!({"occurrence": 1, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}))[0].starts_with("matrix mirrors"));
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "matrix": [1]})),
|
||||
["matrix must be 12 numbers (3x4, row by row) or 16 (4x4), flat or as rows"]
|
||||
);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "translate": [1, 2]})), ["translate must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "axis": null})), ["axis must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "origin": "x"})), ["origin must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "angle": null})), ["angle must be a number or angle expression"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "angle": "30 deg", "axis": {"x": 0, "y": 1, "z": 0}})), none);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "space": "World"})), ["space must be local or world"]);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "base_transform": [[1]]})), ["base_transform must be a 4x4 affine matrix"]);
|
||||
assert_eq!(
|
||||
issues("occurrence.move", json!({"occurrence": 1, "base_transform": [[1, 0, 0, 2], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]})),
|
||||
["base_transform must be finite, affine and invertible"]
|
||||
);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "capture": "no"})).len(), 1);
|
||||
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "translte": [1, 0, 0]})), ["unknown key `translte`; did you mean `translate`?"]);
|
||||
// `occurrence.drag`: `path` stands in for `occurrence`, and `at` and `point` come together.
|
||||
let at = json!([0, 0, 0]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"path": [1], "at": at, "point": at})), none);
|
||||
assert_eq!(issues("occurrence.drag", json!({"at": at, "point": at})), ["give `occurrence` or `path` (1..64 occurrence ids)"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"path": [], "at": at, "point": at})), ["give `occurrence` or `path` (1..64 occurrence ids)"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "path": [], "at": at, "point": at})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": at})), ["`at` and `point` must both be given"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": 5})), ["`at` and `point` must both be given"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": 5, "point": at})), ["at must contain three finite numbers"]);
|
||||
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": at, "point": [1]})), ["point must contain three finite numbers"]);
|
||||
// The rest.
|
||||
assert_eq!(issues("component.revert_position", json!({"occurrences": []})), ["occurrences must list 1..10000 ids"]);
|
||||
assert_eq!(issues("component.revert_position", json!({"occurrences": null})), ["occurrences must list 1..10000 ids"]);
|
||||
assert_eq!(issues("component.capture_position", json!({"capture": true})), ["unknown key `capture`"]);
|
||||
assert_eq!(issues("occurrence.ground", json!({"occurrence": 1, "grounded": null})), none);
|
||||
assert_eq!(issues("occurrence.ground", json!({"occurrence": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
|
||||
assert_eq!(issues("occurrence.properties", json!({"occurrence": 1, "about": [1]})), ["`about` must be a point [x, y, z] in mm"]);
|
||||
assert_eq!(issues("occurrence.properties", json!({"occurrence": 1, "descendants": null})), ["`descendants` must be a boolean"]);
|
||||
assert_eq!(issues("component.move_bodies", json!({"component": 1, "bodies": "A"})), ["bodies must be an array of body names"]);
|
||||
assert_eq!(
|
||||
issues("component.move_bodies", json!({"component": 1, "bodies": ["A"], "from_path": null})),
|
||||
["`from_path` must list 0..64 occurrence ids"]
|
||||
);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": 5})), ["`sketches` must be a list of names; it is 5"]);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": ["S", 3]})), ["`sketches` must list names; the entry 2 is 3"]);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": null})), ["`sketches` must list sketches"]);
|
||||
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": "S"})), none);
|
||||
}
|
||||
|
||||
/// A part set up with its placements: the design's grounded `Base`, a free `Plate` (a box, and
|
||||
/// the sketch `S`) and an empty free `Spare`.
|
||||
fn plates(s: &mut Session) -> (Value, Value, Value) {
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let base = run("component.create", json!({"name": "Base"}));
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "BaseBox"}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let plate = run("component.create", json!({"name": "Plate", "parent": 0, "grounded": false}));
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "PlateBox"}));
|
||||
run("sketch.create", json!({"name": "S", "plane": "XY"}));
|
||||
run("sketch.rectangle.two_point", json!({"p0": [21, 1], "p1": [23, 3]}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let spare = run("component.create", json!({"name": "Spare", "parent": 0, "grounded": false, "activate": false}));
|
||||
(base, plate, spare)
|
||||
}
|
||||
|
||||
/// Every command, in each form it takes, in Warn: the real reply matches its output schema and
|
||||
/// carries no `param_warnings` (Warn runs a call the contract refuses anyway, so the replies are
|
||||
/// also checked for warnings), and a key spelt wrongly on purpose is reported.
|
||||
#[test]
|
||||
fn replies_match_the_output_schemas_of_the_occurrence_commands() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let (base, plate, spare) = plates(&mut s);
|
||||
let (base_at, pl, spare_at) = (base["occurrence"].clone(), plate["occurrence"].clone(), spare["occurrence"].clone());
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
|
||||
// The placement's name, to give it by name.
|
||||
let listed = run("component.list", json!({}));
|
||||
let plate_name = listed["components"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|c| c["occurrences"].as_array().into_iter().flatten())
|
||||
.find(|o| o["id"] == pl)
|
||||
.map(|o| o["name"].clone())
|
||||
.unwrap_or_default();
|
||||
assert!(plate_name.is_string(), "{listed}");
|
||||
|
||||
// Move Occurrence, in each form.
|
||||
let identity = json!([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]);
|
||||
for p in [
|
||||
json!({"occurrence": pl, "translate": [1, 0, 0]}),
|
||||
json!({"occurrence": pl, "translate": {"x": 0, "y": 1, "z": 0}, "space": "local"}),
|
||||
json!({"occurrence": pl, "space": "world", "path": [pl], "translate": [0, 0, 1]}),
|
||||
json!({"occurrence": pl, "path": [pl], "translate": [0, 0, -1]}),
|
||||
json!({"occurrence": pl, "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}),
|
||||
json!({"occurrence": pl, "axis": {"x": 0, "y": 1, "z": 0}, "angle": 0.5, "origin": {"x": 1, "y": 1, "z": 1}}),
|
||||
json!({"occurrence": pl, "angle": 0}),
|
||||
json!({"occurrence": pl, "tolerance": "ignored without a matrix"}),
|
||||
json!({"occurrence": pl, "matrix": FLAT_MATRIX}),
|
||||
json!({"occurrence": pl, "matrix": FLAT_MATRIX, "tolerance": 1e-6, "space": "world", "path": [pl]}),
|
||||
json!({"occurrence": pl, "matrix": [[1, 0, 0, 1], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}),
|
||||
json!({"occurrence": pl, "base_transform": identity, "translate": [3, 0, 0]}),
|
||||
json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}),
|
||||
json!({"occurrence": base_at, "translate": [0, 0, 0], "move_grounded": true}),
|
||||
] {
|
||||
let moved = run("occurrence.move", p.clone());
|
||||
matches(output_schema_for::<MoveResult>(), "occurrence.move", &moved);
|
||||
assert_eq!(moved["pending"], p.get("capture") == Some(&json!(false)), "{p}: {moved}");
|
||||
}
|
||||
|
||||
// Capture and Revert Position.
|
||||
let captured = run("component.capture_position", json!({}));
|
||||
assert_eq!(captured["captured"], 1, "{captured}");
|
||||
matches(output_schema_for::<CapturePositionResult>(), "component.capture_position", &captured);
|
||||
let captured = run("component.capture_position", json!({}));
|
||||
assert_eq!(captured["captured"], 0, "{captured}");
|
||||
matches(output_schema_for::<CapturePositionResult>(), "component.capture_position", &captured);
|
||||
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
|
||||
let reverted = run("component.revert_position", json!({"occurrences": [pl]}));
|
||||
assert_eq!(reverted["reverted"], 1, "{reverted}");
|
||||
matches(output_schema_for::<RevertPositionResult>(), "component.revert_position", &reverted);
|
||||
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
|
||||
let reverted = run("component.revert_position", json!({}));
|
||||
assert_eq!(reverted["reverted"], 1, "{reverted}");
|
||||
matches(output_schema_for::<RevertPositionResult>(), "component.revert_position", &reverted);
|
||||
|
||||
// Ground.
|
||||
for p in [
|
||||
json!({"occurrence": pl}),
|
||||
json!({"occurrence": pl, "grounded": true, "path": [pl]}),
|
||||
json!({"occurrence": pl, "grounded": null}),
|
||||
json!({"occurrence": pl, "grounded": false}),
|
||||
] {
|
||||
let grounded = run("occurrence.ground", p.clone());
|
||||
matches(output_schema_for::<GroundResult>(), "occurrence.ground", &grounded);
|
||||
assert_eq!(grounded["occurrence"], pl);
|
||||
}
|
||||
|
||||
// Copy (an instance), in each form. What the handler doesn't read for the form is no problem.
|
||||
for p in [
|
||||
json!({"component": "Plate"}),
|
||||
json!({"component": plate["component"], "translate": [0, 30, 0], "parent": 0}),
|
||||
json!({"occurrence": pl, "translate": [0, 40, 0]}),
|
||||
json!({"occurrence": pl, "component": [1, 2], "parent_path": "ignored"}),
|
||||
json!({"occurrence": plate_name, "path": [pl], "translate": {"x": 0, "y": 0, "z": 5}}),
|
||||
json!({"occurrence": pl, "path": [pl], "in_place": true, "parent_path": [], "translate": [0, 0, 5]}),
|
||||
json!({"occurrence": pl, "in_place": false, "parent_path": "ignored", "parent": 0}),
|
||||
] {
|
||||
let copy = run("occurrence.copy", p.clone());
|
||||
matches(output_schema_for::<CopyResult>(), "occurrence.copy", ©);
|
||||
assert_eq!(copy["component"], plate["component"], "{p}: {copy}");
|
||||
}
|
||||
|
||||
// Paste New.
|
||||
for p in [
|
||||
json!({"component": "Plate"}),
|
||||
json!({"component": plate["component"], "occurrence": pl, "translate": [0, 0, 60], "parent": 0}),
|
||||
json!({"component": "Plate", "occurrence": pl, "path": [pl]}),
|
||||
json!({"component": "Plate", "parent_path": "ignored"}),
|
||||
json!({"component": "Plate", "in_place": true, "parent_path": [], "translate": {"x": 5, "y": 0, "z": 0}}),
|
||||
] {
|
||||
let pasted = run("component.paste_new", p.clone());
|
||||
matches(output_schema_for::<PasteNewResult>(), "component.paste_new", &pasted);
|
||||
assert!(pasted["bodies"].as_u64().is_some_and(|n| n >= 1) && pasted["component"] != plate["component"], "{p}: {pasted}");
|
||||
}
|
||||
|
||||
// Drag.
|
||||
for p in [
|
||||
json!({"occurrence": pl, "at": [25, 5, 5], "point": [25, 15, 5]}),
|
||||
json!({"path": [pl], "at": {"x": 25, "y": 15, "z": 5}, "point": {"x": 25, "y": 5, "z": 5}, "continuous": true}),
|
||||
json!({"occurrence": pl, "path": [pl], "at": [0, 0, 0], "point": [0, 0, 0]}),
|
||||
] {
|
||||
let dragged = run("occurrence.drag", p.clone());
|
||||
matches(output_schema_for::<DragResult>(), "occurrence.drag", &dragged);
|
||||
assert_eq!(dragged["continuous"], p.get("continuous") == Some(&json!(true)), "{p}: {dragged}");
|
||||
}
|
||||
|
||||
// Occurrence Physical Properties.
|
||||
for p in [
|
||||
json!({"occurrence": pl}),
|
||||
json!({"occurrence": pl, "path": [pl], "descendants": false}),
|
||||
json!({"occurrence": pl, "about": [1, 2, 3]}),
|
||||
json!({"occurrence": plate_name, "about": {"x": 1, "y": 2, "z": 3}, "descendants": true}),
|
||||
json!({"occurrence": base_at, "about": null}),
|
||||
] {
|
||||
let measured = run("occurrence.properties", p.clone());
|
||||
matches(output_schema_for::<OccurrencePropertiesResult>(), "occurrence.properties", &measured);
|
||||
assert!(measured["name"].is_string() && measured["total"]["volume_mm3"].as_f64().is_some_and(|v| v > 0.0), "{p}: {measured}");
|
||||
assert_eq!(measured["total"].get("inertia_about").is_some(), p.get("about").is_some_and(|a| !a.is_null()), "{p}: {measured}");
|
||||
}
|
||||
|
||||
// Move Bodies to Component, and Move Sketches to Component.
|
||||
let moved = run("component.move_bodies", json!({"bodies": ["BaseBox"], "component": "Spare"}));
|
||||
assert_eq!(moved["features"].as_array().map(Vec::len), Some(1), "{moved}");
|
||||
matches(output_schema_for::<MoveBodiesResult>(), "component.move_bodies", &moved);
|
||||
let moved =
|
||||
run("component.move_bodies", json!({"bodies": ["BaseBox"], "component": "Base", "component_path": [base_at], "from_path": [spare_at]}));
|
||||
matches(output_schema_for::<MoveBodiesResult>(), "component.move_bodies", &moved);
|
||||
let moved = run("component.move_sketches", json!({"sketches": ["S"], "component": "Spare"}));
|
||||
assert_eq!(moved["component"], spare["component"], "{moved}");
|
||||
matches(output_schema_for::<MoveSketchesResult>(), "component.move_sketches", &moved);
|
||||
let moved = run("component.move_sketches", json!({"sketches": "S", "component": "Base"}));
|
||||
matches(output_schema_for::<MoveSketchesResult>(), "component.move_sketches", &moved);
|
||||
|
||||
// Misspelt keys still run in Warn, say so, and the reply still matches.
|
||||
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
|
||||
let cases: [(&str, Value, &str, schemars::Schema); 9] = [
|
||||
("component.capture_position", json!({"captrue": true}), "captrue", output_schema_for::<CapturePositionResult>()),
|
||||
("component.revert_position", json!({"occurence": [pl]}), "occurence", output_schema_for::<RevertPositionResult>()),
|
||||
("occurrence.copy", json!({"component": "Plate", "translat": [1, 0, 0]}), "translat", output_schema_for::<CopyResult>()),
|
||||
("component.paste_new", json!({"component": "Plate", "paretn": 0}), "paretn", output_schema_for::<PasteNewResult>()),
|
||||
("occurrence.move", json!({"occurrence": pl, "translte": [1, 0, 0]}), "translte", output_schema_for::<MoveResult>()),
|
||||
(
|
||||
"occurrence.drag",
|
||||
json!({"occurrence": pl, "at": [0, 0, 0], "point": [1, 0, 0], "contiuous": true}),
|
||||
"contiuous",
|
||||
output_schema_for::<DragResult>(),
|
||||
),
|
||||
("occurrence.properties", json!({"occurrence": pl, "descendents": false}), "descendents", output_schema_for::<OccurrencePropertiesResult>()),
|
||||
(
|
||||
"component.move_bodies",
|
||||
json!({"bodies": ["BaseBox"], "component": "Spare", "compnent_path": [spare_at]}),
|
||||
"compnent_path",
|
||||
output_schema_for::<MoveBodiesResult>(),
|
||||
),
|
||||
(
|
||||
"component.move_sketches",
|
||||
json!({"sketches": ["S"], "component": "Plate", "componnt": 1}),
|
||||
"componnt",
|
||||
output_schema_for::<MoveSketchesResult>(),
|
||||
),
|
||||
];
|
||||
for (id, p, key, schema) in cases {
|
||||
let warned = run(id, p);
|
||||
let warnings = warned["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains(key)), "{id}: {warned}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{id}: {warned}");
|
||||
}
|
||||
// Last, as it toggles the placement's grounding.
|
||||
let warned = run("occurrence.ground", json!({"occurrence": pl, "grouned": true}));
|
||||
let schema = output_schema_for::<GroundResult>();
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("grouned"))), "{warned}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
|
||||
/// A move that joints undo replies with `reverted` and a `warning`, which the output schema
|
||||
/// describes.
|
||||
#[test]
|
||||
fn a_move_its_joints_undo_matches_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let base = run("component.create", json!({"name": "Base"}))["occurrence"].clone();
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
let plate = run("component.create", json!({"name": "Plate", "parent": 0, "grounded": false}))["occurrence"].clone();
|
||||
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
run("component.activate", json!({"component": 0}));
|
||||
run("joint.as_built", json!({"type": "rigid", "a": base, "b": plate, "name": "Mount"}));
|
||||
let moved = run("occurrence.move", json!({"occurrence": plate, "translate": [0, 0, 30]}));
|
||||
assert_eq!(moved["reverted"], true, "{moved}");
|
||||
assert!(moved["warning"].as_str().is_some_and(|w| w.contains("Mount")), "{moved}");
|
||||
matches(output_schema_for::<MoveResult>(), "occurrence.move", &moved);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -529,7 +529,8 @@ pub struct FilletParams {
|
||||
/// The radius; the chord's width with `type: chord`, the equivalent circular setback with
|
||||
/// `type: curvature`, the start radius with `type: variable` or `spline`.
|
||||
pub radius: LengthExpr,
|
||||
/// The body whose edges the points pick. Default: whichever body has an edge there.
|
||||
/// The body whose edges the points pick. Default: each point is looked up on every placed
|
||||
/// body, shown or hidden, and a point on edges of 2 bodies is refused, naming them.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// Round each picked edge's tangent chain with it. Default: true; false rounds exactly the
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Contracts and result types for the commands in `cmd/frame.rs`: `frame.member`,
|
||||
//! `frame.profile.list`, `frame.profile.properties` and `frame.profile.insert` (#12500).
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a sketch exists, a dimension evaluates
|
||||
//! or a profile can be built stays the handler's to check. A key the handler refuses when it is
|
||||
//! left out (`profile`, `sketch`) is optional in the type and required in the schema, so that the
|
||||
//! handler's message, not the check's "is required", is what a caller sees. A call with several
|
||||
//! problems may get a different one first in strict than with the check off (the check goes key
|
||||
//! by key, the handlers in their own order).
|
||||
//!
|
||||
//! `frame.member` reads some keys only in some cases. With `profile` (a built-in profile) it
|
||||
//! never reads `profiles` or `exact_regions`, which pick among a section sketch's profiles; with
|
||||
//! `profile_sketch` it never reads `profile`. Given both, or neither, it refuses the call before
|
||||
//! it reads anything about the section, so none of the three is read then either.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_doc::Expr;
|
||||
use solidcraft_doc::frame::{self, Anchor, MAX_MEMBERS, Profile, ProfileSpec};
|
||||
|
||||
use super::check::{keyed, located, whole};
|
||||
use super::motion::require;
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{AngleExpr, Point2, ProfilesParam, SketchRef, StringList, not_blank_pattern, present};
|
||||
use crate::params;
|
||||
|
||||
/// The words `Anchor` is spelled with, as the handlers' message lists them.
|
||||
const ANCHORS: [&str; 11] =
|
||||
["origin", "center", "centroid", "top_left", "top", "top_right", "left", "right", "bottom_left", "bottom", "bottom_right"];
|
||||
|
||||
/// The longest name a member may be given, in bytes once trimmed, as the handler requires.
|
||||
const MAX_NAME: usize = 128;
|
||||
|
||||
/// `v` as the only key of an object, for the helpers that read `p.get(key)`.
|
||||
fn wrap(key: &str, v: Value) -> Value {
|
||||
json!({ key: v })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A built-in profile as the commands take it: `{kind, …dimensions}`, each dimension a number or
|
||||
/// an expression, read by `ProfileSpec`'s own deserializer. Whether a dimension evaluates, and
|
||||
/// whether the profile can be built from the values (a wall thicker than the tube), is the
|
||||
/// handler's to check, as it has the design.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProfileParam(pub ProfileSpec);
|
||||
|
||||
impl<'de> Deserialize<'de> for ProfileParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
// The handler's `` `profile`: {error} ``, with serde_json's own wording of the error.
|
||||
serde_json::from_value::<ProfileSpec>(Value::deserialize(d)?).map(ProfileParam).map_err(|e| D::Error::custom(keyed(e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema of one dimension, a number or an expression.
|
||||
fn dimension_schema() -> Value {
|
||||
json!({
|
||||
"anyOf": [
|
||||
{"type": "number"},
|
||||
{"type": "string", "minLength": 1, "maxLength": 4096, "pattern": not_blank_pattern()}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// One schema per built-in profile kind: its `kind`, its required dimensions and its optional
|
||||
/// ones, each as `dimension` says, and nothing else.
|
||||
fn profile_kinds_schema(dimension: &Value) -> Vec<Value> {
|
||||
frame::profile_kinds()
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
let mut properties = Map::new();
|
||||
properties.insert("kind".into(), json!({"enum": [k.kind]}));
|
||||
for name in k.required.iter().chain(k.optional) {
|
||||
properties.insert((*name).into(), dimension.clone());
|
||||
}
|
||||
let required: Vec<&str> = std::iter::once("kind").chain(k.required.iter().copied()).collect();
|
||||
json!({"type": "object", "properties": properties, "required": required, "additionalProperties": false})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl JsonSchema for ProfileParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProfileParam".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A built-in profile: {\"kind\": bar | round_bar | rect_tube | round_tube | angle | channel | i_beam, …its dimensions}. Each dimension is a number in the design's units or a length expression over its parameters (\"tube_wall\", \"2 in\"); `frame.profile.list` gives each kind's dimensions and an example. A dimension that is left out and isn't required takes a default worked out from the profile's size.",
|
||||
"anyOf": profile_kinds_schema(&dimension_schema())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A built-in profile as a command reports it, every number in mm: serialized as the profile's
|
||||
/// own JSON (`{kind, …dimensions}`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProfileOut(pub Profile);
|
||||
|
||||
impl JsonSchema for ProfileOut {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProfileOut".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A built-in profile: {\"kind\", …its dimensions}, each in mm.",
|
||||
"anyOf": profile_kinds_schema(&json!({"type": "number"}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a profile is held to its placement point, as `Anchor` is spelled in JSON.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnchorParam(pub Anchor);
|
||||
|
||||
impl<'de> Deserialize<'de> for AnchorParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
serde_json::from_value::<Anchor>(Value::deserialize(d)?).map(AnchorParam).map_err(|_| {
|
||||
D::Error::custom(whole(
|
||||
"`anchor` must be origin, center, centroid, top_left, top, top_right, left, right, bottom_left, bottom or bottom_right",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for AnchorParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AnchorParam".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The profile point that is placed: its origin (the default; a built-in profile's centre), `center` (its bounding box's centre), `centroid`, or a corner or edge middle of its bounding box.",
|
||||
"type": "string",
|
||||
"enum": ANCHORS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.profile.list
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `frame.profile.list` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ProfileListParams {}
|
||||
|
||||
/// One built-in profile kind.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileKindInfo {
|
||||
/// The kind's id, as `profile.kind` takes it.
|
||||
pub kind: String,
|
||||
/// The kind's name for people.
|
||||
pub label: String,
|
||||
/// The dimensions it needs.
|
||||
pub required: Vec<String>,
|
||||
/// The dimensions that default when left out.
|
||||
pub optional: Vec<String>,
|
||||
/// A complete example profile.
|
||||
pub example: ProfileOut,
|
||||
}
|
||||
|
||||
impl From<&frame::ProfileKind> for ProfileKindInfo {
|
||||
fn from(k: &frame::ProfileKind) -> Self {
|
||||
ProfileKindInfo {
|
||||
kind: k.kind.to_string(),
|
||||
label: k.label.to_string(),
|
||||
required: k.required.iter().map(|d| d.to_string()).collect(),
|
||||
optional: k.optional.iter().map(|d| d.to_string()).collect(),
|
||||
example: ProfileOut(k.example.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `frame.profile.list` returns: the built-in profiles, each SolidCraft's own parametric
|
||||
/// definition.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileListResult {
|
||||
/// Every built-in profile kind.
|
||||
pub kinds: Vec<ProfileKindInfo>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.profile.properties and the section both profile commands report
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A density in kg/m³: above 0 and at most 1e6, as `frame.profile.properties` requires.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Density(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for Density {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::num(&wrap("density", Value::deserialize(d)?), "density")
|
||||
.filter(|d| *d > 0.0 && *d <= 1e6)
|
||||
.map(Density)
|
||||
.ok_or_else(|| D::Error::custom(whole("`density` must be a positive number of kg/m³ (at most 1e6)")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Density {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Density".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A density in kg/m³: above 0 and at most 1e6.",
|
||||
"type": "number", "exclusiveMinimum": 0, "maximum": 1e6
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_required(schema: &mut Schema) {
|
||||
require(schema, "profile");
|
||||
}
|
||||
|
||||
/// Work out a built-in profile's section: its area, centroid and second moments, about axes
|
||||
/// through the centroid, parallel to the profile's x and y.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = profile_required)]
|
||||
pub struct ProfilePropertiesParams {
|
||||
// Optional in the type and required in the schema (`profile_required`).
|
||||
/// The profile, as `frame.profile.list` describes the kinds.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfileParam")]
|
||||
pub profile: Option<ProfileParam>,
|
||||
/// The material's density, which adds `mass_per_m`. Default: none, and no `mass_per_m`.
|
||||
#[serde(default)]
|
||||
pub density: Option<Density>,
|
||||
}
|
||||
|
||||
/// A profile's section, as `frame.profile.properties` and `frame.profile.insert` report it.
|
||||
/// Lengths are in mm, areas in mm² and second moments in mm⁴.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileSection {
|
||||
/// The profile with every default filled in.
|
||||
pub profile: ProfileOut,
|
||||
/// The section's area, mm².
|
||||
pub area: f64,
|
||||
/// The centroid [x, y], mm, in the profile's own plane (x across, y up) from the centre of its
|
||||
/// bounding box.
|
||||
pub centroid: [f64; 2],
|
||||
/// ∫ y² dA about the centroid: bending about the x axis, mm⁴.
|
||||
pub ixx: f64,
|
||||
/// ∫ x² dA about the centroid: bending about the y axis, mm⁴.
|
||||
pub iyy: f64,
|
||||
/// ∫ x·y dA about the centroid, mm⁴.
|
||||
pub ixy: f64,
|
||||
/// The bounding box's width, mm.
|
||||
pub width: f64,
|
||||
/// The bounding box's height, mm.
|
||||
pub height: f64,
|
||||
/// With a `density`: the mass of a metre of the profile, kg/m.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mass_per_m: Option<f64>,
|
||||
}
|
||||
|
||||
/// What `frame.profile.properties` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfilePropertiesResult {
|
||||
#[serde(flatten)]
|
||||
pub section: ProfileSection,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.profile.insert
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A point of the sketch in mm: `[x, y]` or `{x, y}`, with `frame.profile.insert`'s message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct InsertAt(pub Vec2);
|
||||
|
||||
impl<'de> Deserialize<'de> for InsertAt {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec2(&Value::deserialize(d)?).map(InsertAt).ok_or_else(|| D::Error::custom(whole("`at` must be [x, y] in sketch millimetres")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for InsertAt {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"InsertAt".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point2>()
|
||||
}
|
||||
}
|
||||
|
||||
/// An angle in radians as `frame.profile.insert` reads it: a number, at most 1e6 in size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Radians(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for Radians {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::num(&wrap("angle", Value::deserialize(d)?), "angle")
|
||||
.filter(|a| a.abs() <= 1e6)
|
||||
.map(Radians)
|
||||
.ok_or_else(|| D::Error::custom(whole("`angle` must be a number of radians")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Radians {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Radians".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An angle in radians, counter-clockwise: a number of at most 1e6 in size.", "type": "number", "minimum": -1e6, "maximum": 1e6})
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a built-in profile into a sketch as lines and arcs, placed by its anchor and turned about
|
||||
/// it. A tube's inside is drawn too, so the wall is one profile. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = profile_required)]
|
||||
pub struct ProfileInsertParams {
|
||||
// Optional in the type and required in the schema (`profile_required`).
|
||||
/// The profile, as `frame.profile.list` describes the kinds.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfileParam")]
|
||||
pub profile: Option<ProfileParam>,
|
||||
/// Where the anchor goes, in sketch mm. Default: the origin.
|
||||
#[serde(default)]
|
||||
pub at: Option<InsertAt>,
|
||||
/// The profile point that is placed on `at`. Default: origin.
|
||||
#[serde(default)]
|
||||
pub anchor: Option<AnchorParam>,
|
||||
/// How far the profile turns about the anchor, in radians counter-clockwise (not degrees).
|
||||
/// Default: 0.
|
||||
#[serde(default)]
|
||||
pub angle: Option<Radians>,
|
||||
/// Lock the curves, so that solving the sketch keeps the shape. Default: true.
|
||||
#[serde(default)]
|
||||
pub fixed: Option<bool>,
|
||||
/// The sketch to draw in, by id or name. Default: the sketch being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// The ids of what a sketch edit added, stable and never reused.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CreatedIds {
|
||||
/// The points added.
|
||||
pub points: Vec<String>,
|
||||
/// The curves added.
|
||||
pub curves: Vec<String>,
|
||||
/// The constraints added, dimensions apart.
|
||||
pub constraints: Vec<String>,
|
||||
/// The dimensions added.
|
||||
pub dimensions: Vec<String>,
|
||||
/// The wires added. Left out when there are none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wires: Vec<String>,
|
||||
}
|
||||
|
||||
/// The sketch's solve after an edit.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SketchSolve {
|
||||
/// Whether the constraints solved, keeping the sketch's orientation.
|
||||
pub solved: bool,
|
||||
/// The degrees of freedom left.
|
||||
pub dof: usize,
|
||||
}
|
||||
|
||||
/// What `frame.profile.insert` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProfileInsertResult {
|
||||
/// What the insertion added to the sketch.
|
||||
pub created: CreatedIds,
|
||||
/// The inserted profile's section, as `frame.profile.properties` reports it.
|
||||
pub section: ProfileSection,
|
||||
/// The sketch's solve, or null when it was not solved again.
|
||||
pub sketch: Option<SketchSolve>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// frame.member
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `lines`: one to 1000 curve ids, none twice, as `frame.member` reads them (a single id isn't
|
||||
/// taken).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LineIds(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for LineIds {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let fail = || D::Error::custom(whole(format!("`lines` must list 1…{MAX_MEMBERS} curve ids")));
|
||||
let Value::Array(a) = Value::deserialize(d)? else { return Err(fail()) };
|
||||
if a.is_empty() || a.len() > MAX_MEMBERS {
|
||||
return Err(fail());
|
||||
}
|
||||
let lines = a.into_iter().map(|v| if let Value::String(s) = v { Some(s) } else { None }).collect::<Option<Vec<_>>>().ok_or_else(fail)?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
match lines.iter().find(|l| !seen.insert(l.as_str())) {
|
||||
Some(twice) => Err(D::Error::custom(whole(format!("line `{twice}` is listed twice")))),
|
||||
None => Ok(LineIds(lines)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for LineIds {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"LineIds".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Curve ids of the frame sketch's lines, each once.",
|
||||
"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_MEMBERS, "uniqueItems": true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The name members are named after: text of 1 to 128 bytes once trimmed, as `frame.member` reads
|
||||
/// `body_name`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberName(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for MemberName {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.trim().is_empty() && s.trim().len() <= MAX_NAME => Ok(MemberName(s)),
|
||||
_ => Err(D::Error::custom(whole(format!("`body_name` must be 1…{MAX_NAME} characters")))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MemberName {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MemberName".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A name: text that isn't blank, at most 128 bytes once trimmed of the whitespace around it.",
|
||||
"type": "string", "pattern": not_blank_pattern()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `names`: up to 1000 line ids, each with a name of 1 to 128 bytes once trimmed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberNames(pub BTreeMap<String, String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for MemberNames {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Object(m) if m.len() <= MAX_MEMBERS => m
|
||||
.into_iter()
|
||||
.map(|(line, v)| match v.as_str().map(str::trim).filter(|n| !n.is_empty() && n.len() <= MAX_NAME) {
|
||||
Some(name) => Ok((line, name.to_string())),
|
||||
None => Err(D::Error::custom(whole(format!("`names`: the name of line `{line}` must be 1…{MAX_NAME} characters")))),
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
.map(MemberNames),
|
||||
_ => Err(D::Error::custom(whole(format!("`names` must map up to {MAX_MEMBERS} line ids to names")))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MemberNames {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MemberNames".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Names to keep, by line id. A name is text that isn't blank, at most 128 bytes once trimmed.",
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string", "pattern": not_blank_pattern()},
|
||||
"maxProperties": MAX_MEMBERS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `angle` as `frame.member` reads it: a number of degrees or an expression, with its message. Whether
|
||||
/// the expression evaluates as an angle is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberAngle(pub Expr);
|
||||
|
||||
impl<'de> Deserialize<'de> for MemberAngle {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::expr(&wrap("angle", Value::deserialize(d)?), "angle")
|
||||
.map(MemberAngle)
|
||||
.ok_or_else(|| D::Error::custom(whole("`angle` must be an angle expression")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MemberAngle {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MemberAngle".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<AngleExpr>()
|
||||
}
|
||||
}
|
||||
|
||||
/// `participants`, read as `features::add_feature` reads it: a name or a list of names, with the
|
||||
/// message of `params::string_list`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Participants(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for Participants {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match params::string_list("", &wrap("participants", Value::deserialize(d)?), "participants") {
|
||||
Ok(names) => Ok(Participants(names)),
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
|
||||
Err(e) => Err(D::Error::custom(whole(e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Participants {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Participants".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<StringList>()
|
||||
}
|
||||
}
|
||||
|
||||
/// `sketch` is required and one of `profile` and `profile_sketch`, not both, as the schema says;
|
||||
/// the type leaves them to the handler's messages.
|
||||
fn member_required(schema: &mut Schema) {
|
||||
require(schema, "sketch");
|
||||
let only = |key: &str, other: &str| json!({"required": [key], "not": {"required": [other]}});
|
||||
schema.insert("anyOf".into(), json!([only("profile", "profile_sketch"), only("profile_sketch", "profile")]));
|
||||
}
|
||||
|
||||
/// Lay structural members along the lines of a frame sketch: one body per line, from its start
|
||||
/// to its end, its cross-section square to the line. The section is a built-in `profile` or the
|
||||
/// profiles of a `profile_sketch`, one of them. Each member keeps its name when other lines are
|
||||
/// added or deleted. Returns the feature and the bodies it made.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = member_required)]
|
||||
pub struct FrameMemberKeys {
|
||||
// Optional in the type and required in the schema (`member_required`).
|
||||
/// The frame sketch, 2D or 3D, by id or name.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub sketch: Option<SketchRef>,
|
||||
/// The lines to lay members along, by curve id, one member each. Default: every straight
|
||||
/// line drawn in the sketch that isn't construction, projected or otherwise linked, or of no
|
||||
/// length.
|
||||
#[serde(default)]
|
||||
pub lines: Option<LineIds>,
|
||||
/// The section as a built-in profile. Give it or `profile_sketch`, not both.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfileParam")]
|
||||
pub profile: Option<ProfileParam>,
|
||||
/// The section as a sketch: its profiles, in the sketch's own x and y around its origin. It
|
||||
/// can't be the frame sketch. Give it or `profile`, not both.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub profile_sketch: Option<SketchRef>,
|
||||
/// With `profile_sketch`: which of its profiles are the section, as `solid.extrude` takes
|
||||
/// them. Default: its material, nested loops alternating, so two circles make a tube. Not
|
||||
/// used with `profile`.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfilesParam")]
|
||||
pub profiles: Option<ProfilesParam>,
|
||||
/// With `profile_sketch`: each index or point pick in `profiles` is exactly the region it
|
||||
/// names, where it would otherwise take the whole drawn profile a face's projected outline
|
||||
/// cuts into regions. Default: false. Not used with `profile`.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The profile point that sits on each line. Default: origin.
|
||||
#[serde(default)]
|
||||
pub anchor: Option<AnchorParam>,
|
||||
/// How far the profile turns about the line, a number of degrees or an expression. Default:
|
||||
/// 0, which puts its y as near world up as the line allows (a vertical member's x along
|
||||
/// world X).
|
||||
#[serde(default)]
|
||||
pub angle: Option<MemberAngle>,
|
||||
/// The profile mirrored across its y axis, before it turns. Default: false.
|
||||
#[serde(default)]
|
||||
pub mirror: Option<bool>,
|
||||
/// The feature's name in the timeline.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// What the members are named after, then numbered: `Rail`, `Rail (2)`… Default: the
|
||||
/// profile's description (`Rect Tube 40x40x3`), or the profile sketch's name.
|
||||
#[serde(default)]
|
||||
pub body_name: Option<MemberName>,
|
||||
/// Names to keep, by line id, as Edit Feature keeps them. A line not listed gets a name from
|
||||
/// `body_name`.
|
||||
#[serde(default)]
|
||||
pub names: Option<MemberNames>,
|
||||
/// The bodies taking part, which every feature records; each an existing body or `*`. Members
|
||||
/// are new bodies, whatever this lists.
|
||||
#[serde(default)]
|
||||
pub participants: Option<Participants>,
|
||||
}
|
||||
|
||||
/// `frame.member`'s parameters: [`FrameMemberKeys`], reading the section's keys only as the
|
||||
/// handler does (see the module docs).
|
||||
#[derive(Debug)]
|
||||
pub struct FrameMemberParams(pub FrameMemberKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for FrameMemberParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut() {
|
||||
// A key counts as given when it is there, `null` included, as the handler tests it.
|
||||
match (keys.contains_key("profile"), keys.contains_key("profile_sketch")) {
|
||||
// A built-in profile: the sketch's profile picks are never read.
|
||||
(true, false) => {
|
||||
keys.remove("profiles");
|
||||
keys.remove("exact_regions");
|
||||
}
|
||||
(false, true) => {}
|
||||
// Both or neither: the handler refuses before it reads any of them.
|
||||
_ => {
|
||||
for key in ["profile", "profile_sketch", "profiles", "exact_regions"] {
|
||||
keys.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(FrameMemberParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FrameMemberParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FrameMemberParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
FrameMemberKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frame_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,568 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::frame::{self, MAX_MEMBERS};
|
||||
|
||||
use super::{
|
||||
FrameMemberParams, ProfileInsertParams, ProfileInsertResult, ProfileListParams, ProfileListResult, ProfilePropertiesParams,
|
||||
ProfilePropertiesResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
fn tube() -> Value {
|
||||
json!({"kind": "rect_tube", "width": 40, "height": 40, "wall": 3})
|
||||
}
|
||||
|
||||
/// The id of the first sketch of `s`.
|
||||
fn first_sketch(s: &Session) -> u64 {
|
||||
s.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id).unwrap_or_else(|| panic!("no sketch"))
|
||||
}
|
||||
|
||||
/// A session with the 600 × 400 rectangle `Frame` (its four line ids returned) and the sketch
|
||||
/// `Section`, two concentric circles: a tube's wall.
|
||||
fn frame_session(level: ParamCheck) -> (Session, Vec<String>) {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run("sketch.create", json!({"plane": "XY", "name": "Frame"}));
|
||||
let rectangle = run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [600, 400]}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("sketch.create", json!({"plane": "XY", "name": "Section"}));
|
||||
run("sketch.circle.center", json!({"center": [0, 0], "radius": 20}));
|
||||
run("sketch.circle.center", json!({"center": [0, 0], "radius": 17}));
|
||||
run("sketch.finish", json!({}));
|
||||
let lines =
|
||||
rectangle["created"]["curves"].as_array().map(|c| c.iter().filter_map(|c| c.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
(s, lines)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<FrameMemberParams>(),
|
||||
&[
|
||||
"anchor",
|
||||
"angle",
|
||||
"body_name",
|
||||
"exact_regions",
|
||||
"lines",
|
||||
"mirror",
|
||||
"name",
|
||||
"names",
|
||||
"participants",
|
||||
"profile",
|
||||
"profile_sketch",
|
||||
"profiles",
|
||||
"sketch",
|
||||
],
|
||||
);
|
||||
assert_keys(input_schema_for::<ProfileListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ProfilePropertiesParams>(), &["density", "profile"]);
|
||||
assert_keys(input_schema_for::<ProfileInsertParams>(), &["anchor", "angle", "at", "fixed", "profile", "sketch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let sketch = {
|
||||
let mut s = Session::default();
|
||||
sketch_rectangle(&mut s);
|
||||
first_sketch(&s)
|
||||
};
|
||||
let member = |extra: Value| {
|
||||
let mut p = json!({"sketch": sketch, "profile": tube()});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
("frame.member", p)
|
||||
};
|
||||
let bar = json!({"kind": "round_bar", "diameter": 10});
|
||||
let insert = |extra: Value| {
|
||||
let mut p = json!({"profile": bar});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
("frame.profile.insert", p)
|
||||
};
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
// frame.member
|
||||
("frame.member", json!({})),
|
||||
("frame.member", json!({"sketch": null, "profile": tube()})),
|
||||
("frame.member", json!({"sketch": true, "profile": tube()})),
|
||||
("frame.member", json!({"sketch": [1], "profile": tube()})),
|
||||
("frame.member", json!({"sketch": sketch})),
|
||||
("frame.member", json!({"sketch": sketch, "profile": tube(), "profile_sketch": sketch})),
|
||||
("frame.member", json!({"sketch": sketch, "profile": tube(), "profile_sketch": null})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": true})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": null})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": sketch})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": "Nowhere"})),
|
||||
("frame.member", json!({"sketch": sketch, "profile_sketch": 99999, "profiles": 3})),
|
||||
("frame.member", json!({"sketch": "Nowhere", "profile": tube()})),
|
||||
member(json!({"profile": null})),
|
||||
member(json!({"profile": "tube"})),
|
||||
member(json!({"profile": {}})),
|
||||
member(json!({"profile": {"kind": "hexagon"}})),
|
||||
member(json!({"profile": {"kind": 5}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": 10}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": 10, "height": 5, "diameter": 3}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": true, "height": 5}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": " ", "height": 5}})),
|
||||
member(json!({"profile": {"kind": "rect_tube", "width": 10, "height": 10, "wall": 5}})),
|
||||
member(json!({"profile": {"kind": "bar", "width": "nonsense(", "height": 5}})),
|
||||
member(json!({"lines": []})),
|
||||
member(json!({"lines": "l1"})),
|
||||
member(json!({"lines": [1]})),
|
||||
member(json!({"lines": ["l1", "l1"]})),
|
||||
member(json!({"lines": (0..=MAX_MEMBERS).map(|i| format!("l{i}")).collect::<Vec<_>>()})),
|
||||
member(json!({"lines": ["nope"]})),
|
||||
member(json!({"body_name": ""})),
|
||||
member(json!({"body_name": " "})),
|
||||
member(json!({"body_name": 5})),
|
||||
member(json!({"body_name": "x".repeat(129)})),
|
||||
member(json!({"anchor": "middle"})),
|
||||
member(json!({"anchor": 5})),
|
||||
member(json!({"anchor": ["top"]})),
|
||||
member(json!({"angle": true})),
|
||||
member(json!({"angle": ""})),
|
||||
member(json!({"angle": "x".repeat(4097)})),
|
||||
member(json!({"angle": "a lot"})),
|
||||
member(json!({"mirror": 3})),
|
||||
member(json!({"mirror": "yes"})),
|
||||
member(json!({"names": ["Rail"]})),
|
||||
member(json!({"names": {"l1": " "}})),
|
||||
member(json!({"names": {"l1": 5}})),
|
||||
member(json!({"names": {"l1": "x".repeat(129)}})),
|
||||
member(json!({"participants": 5})),
|
||||
member(json!({"participants": [5]})),
|
||||
member(json!({"participants": ["Nobody"]})),
|
||||
// frame.profile.properties
|
||||
("frame.profile.properties", json!({})),
|
||||
("frame.profile.properties", json!({"profile": null})),
|
||||
("frame.profile.properties", json!({"profile": {"kind": "hexagon"}})),
|
||||
("frame.profile.properties", json!({"profile": {"kind": "round_bar"}})),
|
||||
("frame.profile.properties", json!({"profile": {"kind": "round_bar", "diameter": -1}})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": -1})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": 0})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": "steel"})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": 1e7})),
|
||||
("frame.profile.properties", json!({"profile": bar, "density": true})),
|
||||
// frame.profile.insert
|
||||
("frame.profile.insert", json!({})),
|
||||
("frame.profile.insert", json!({"profile": {"kind": "bar", "width": 5}})),
|
||||
insert(json!({"at": "here"})),
|
||||
insert(json!({"at": [1]})),
|
||||
insert(json!({"at": [1e300, 0]})),
|
||||
insert(json!({"at": {"x": 1}})),
|
||||
insert(json!({"anchor": "middle"})),
|
||||
insert(json!({"anchor": 3})),
|
||||
insert(json!({"angle": "a lot"})),
|
||||
insert(json!({"angle": 1e7})),
|
||||
insert(json!({"fixed": 3})),
|
||||
insert(json!({"fixed": "false"})),
|
||||
insert(json!({"sketch": true})),
|
||||
insert(json!({"sketch": -1})),
|
||||
insert(json!({"sketch": 1.5})),
|
||||
insert(json!({"sketch": "Nowhere"})),
|
||||
// No sketch is being edited, and none is named.
|
||||
insert(json!({})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Each key is read as its handler reads it: a call the handler takes has no issue, and a call
|
||||
/// it refuses over a key has one. (A call with a key the handler ignores is told apart below.)
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let (_, lines) = frame_session(ParamCheck::Off);
|
||||
let line = lines.first().cloned().unwrap_or_default();
|
||||
let member = |extra: Value| {
|
||||
let mut p = json!({"sketch": "Frame", "profile": tube()});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
p
|
||||
};
|
||||
let section = |extra: Value| {
|
||||
let mut p = json!({"sketch": "Frame", "profile_sketch": "Section"});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
p
|
||||
};
|
||||
// (call, whether the handler takes it)
|
||||
let cases = [
|
||||
(member(json!({})), true),
|
||||
(member(json!({"lines": [line]})), true),
|
||||
(member(json!({"lines": null})), true),
|
||||
(member(json!({"lines": []})), false),
|
||||
(member(json!({"lines": [line, line]})), false),
|
||||
(member(json!({"body_name": "Rail"})), true),
|
||||
(member(json!({"body_name": " Rail "})), true),
|
||||
(member(json!({"body_name": null})), true),
|
||||
(member(json!({"body_name": "x".repeat(128)})), true),
|
||||
(member(json!({"body_name": "x".repeat(129)})), false),
|
||||
(member(json!({"body_name": " "})), false),
|
||||
(member(json!({"anchor": "bottom_left"})), true),
|
||||
(member(json!({"anchor": null})), true),
|
||||
(member(json!({"anchor": "Top"})), false),
|
||||
(member(json!({"angle": "90 deg"})), true),
|
||||
(member(json!({"angle": 45})), true),
|
||||
(member(json!({"angle": " 30 deg "})), true),
|
||||
(member(json!({"angle": null})), true),
|
||||
(member(json!({"angle": ""})), false),
|
||||
(member(json!({"angle": 1e9})), false),
|
||||
(member(json!({"mirror": true})), true),
|
||||
(member(json!({"mirror": null})), true),
|
||||
(member(json!({"mirror": 1})), false),
|
||||
(member(json!({"names": {line.clone(): " Mine "}})), true),
|
||||
(member(json!({"names": {}})), true),
|
||||
(member(json!({"names": null})), true),
|
||||
(member(json!({"names": {line.clone(): ""}})), false),
|
||||
(member(json!({"names": [line]})), false),
|
||||
(member(json!({"name": "Frame members"})), true),
|
||||
(member(json!({"participants": "*"})), true),
|
||||
(member(json!({"participants": ["*"]})), true),
|
||||
(member(json!({"participants": null})), true),
|
||||
(member(json!({"participants": [3]})), false),
|
||||
(member(json!({"participants": {"a": 1}})), false),
|
||||
(member(json!({"profile": {"kind": "bar", "width": "40 mm", "height": 10, "corner_radius": 0}})), true),
|
||||
(member(json!({"profile": {"kind": "bar", "width": 40}})), false),
|
||||
(member(json!({"profile": {"kind": "i_beam", "height": 100, "width": 50, "web": 4.5, "flange": 7, "root_radius": null}})), false),
|
||||
(section(json!({})), true),
|
||||
(section(json!({"profiles": "all"})), true),
|
||||
(section(json!({"profiles": [{"point": [0, 0]}], "exact_regions": true})), true),
|
||||
(section(json!({"profiles": [0]})), true),
|
||||
(section(json!({"profiles": "none"})), false),
|
||||
(section(json!({"profiles": null})), false),
|
||||
(section(json!({"profiles": []})), false),
|
||||
// Keys the handler never reads in this form: no issue, and the call is as good as without.
|
||||
(member(json!({"profiles": "none", "exact_regions": "yes"})), true),
|
||||
(member(json!({"profiles": null})), true),
|
||||
];
|
||||
for (p, takes) in cases {
|
||||
let (mut s, _) = frame_session(ParamCheck::Off);
|
||||
let outcome = s.execute("frame.member", &p);
|
||||
assert_eq!(outcome.is_ok(), takes, "{p}: {outcome:?}");
|
||||
assert_eq!(issues("frame.member", &p).is_empty(), takes, "{p}: {:?}", issues("frame.member", &p));
|
||||
}
|
||||
|
||||
// One of `profile` and `profile_sketch`: with both or neither the handler refuses before it
|
||||
// reads the section's keys, and the check, which can't see the design, leaves it that.
|
||||
for p in [
|
||||
json!({"sketch": "Frame", "profile": tube(), "profile_sketch": "Section"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "profile_sketch": "Section", "profiles": "none", "exact_regions": 1}),
|
||||
json!({"sketch": "Frame", "profile": null, "profile_sketch": null}),
|
||||
json!({"sketch": "Frame"}),
|
||||
json!({"sketch": "Frame", "profiles": "none"}),
|
||||
] {
|
||||
let (mut s, _) = frame_session(ParamCheck::Off);
|
||||
let error = s.execute("frame.member", &p).unwrap_err().to_string();
|
||||
assert!(error.contains("`profile`") && error.contains("one of them"), "{p}: {error}");
|
||||
assert!(issues("frame.member", &p).is_empty(), "{p}: {:?}", issues("frame.member", &p));
|
||||
}
|
||||
|
||||
// The properties and insert commands.
|
||||
let (mut s, _) = frame_session(ParamCheck::Off);
|
||||
let bar = json!({"kind": "round_bar", "diameter": 10});
|
||||
for (p, takes) in [
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "density": 7850}), true),
|
||||
(json!({"profile": bar, "density": null}), true),
|
||||
(json!({"profile": bar, "density": 1e6}), true),
|
||||
(json!({"profile": bar, "density": 1e6 + 1.0}), false),
|
||||
(json!({"profile": bar, "density": 0}), false),
|
||||
(json!({"profile": bar, "density": "7850"}), false),
|
||||
] {
|
||||
let outcome = s.execute("frame.profile.properties", &p);
|
||||
assert_eq!(outcome.is_ok(), takes, "{p}: {outcome:?}");
|
||||
assert_eq!(issues("frame.profile.properties", &p).is_empty(), takes, "{p}");
|
||||
}
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
for (p, takes) in [
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "at": [3, 4], "anchor": "centroid", "angle": 0.5, "fixed": false}), true),
|
||||
(json!({"profile": bar, "at": {"x": 3, "y": 4}}), true),
|
||||
(json!({"profile": bar, "at": null, "anchor": null, "angle": null, "fixed": null, "sketch": null}), true),
|
||||
(json!({"profile": bar, "angle": -1e6}), true),
|
||||
(json!({"profile": bar, "angle": 1e6 + 1.0}), false),
|
||||
(json!({"profile": bar, "at": [1, 2, 3]}), false),
|
||||
(json!({"profile": bar, "fixed": 0}), false),
|
||||
(json!({"profile": bar, "anchor": "CENTER"}), false),
|
||||
(json!({"profile": bar, "sketch": 1.5}), false),
|
||||
] {
|
||||
let outcome = s.execute("frame.profile.insert", &p);
|
||||
assert_eq!(outcome.is_ok(), takes, "{p}: {outcome:?}");
|
||||
assert_eq!(issues("frame.profile.insert", &p).is_empty(), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With a built-in profile, a `profiles` or `exact_regions` that couldn't be read is no warning;
|
||||
/// with a profile sketch it is, and says what the handler says.
|
||||
#[test]
|
||||
fn a_section_sketchs_keys_count_only_with_a_section_sketch() {
|
||||
let messages = |p: Value| issues("frame.member", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(messages(json!({"sketch": 1, "profile": tube(), "profiles": 7, "exact_regions": []})), Vec::<String>::new());
|
||||
assert_eq!(messages(json!({"sketch": 1, "profile_sketch": 2, "profiles": "none"})), ["`profiles` must be \"all\" or a list"]);
|
||||
assert_eq!(messages(json!({"sketch": 1, "profile_sketch": 2, "exact_regions": "yes"})), ["`exact_regions` must be true or false"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: `sketch` and a profile are required, `profile` and
|
||||
/// `profile_sketch` are alternatives, and values have the shape the handlers read.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
let bar = json!({"kind": "round_bar", "diameter": 10});
|
||||
takes(
|
||||
input_schema_for::<FrameMemberParams>(),
|
||||
&[
|
||||
(json!({"sketch": "Frame", "profile": tube()}), true),
|
||||
(json!({"sketch": 3, "profile_sketch": "Section"}), true),
|
||||
(json!({"sketch": 3, "profile_sketch": 4, "profiles": [{"point": [0, 0]}], "exact_regions": true}), true),
|
||||
(
|
||||
json!({
|
||||
"sketch": "Frame", "lines": ["l1", "l2"], "profile": {"kind": "bar", "width": "w", "height": 5, "corner_radius": 1},
|
||||
"anchor": "top_left", "angle": "90 deg", "mirror": true, "name": "N", "body_name": "Rail",
|
||||
"names": {"l1": "A"}, "participants": ["*"]
|
||||
}),
|
||||
true,
|
||||
),
|
||||
(json!({"profile": tube()}), false),
|
||||
(json!({"sketch": "Frame"}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "profile_sketch": "Section"}), false),
|
||||
(json!({"sketch": "Frame", "profile": {"kind": "hexagon"}}), false),
|
||||
(json!({"sketch": "Frame", "profile": {"kind": "bar", "width": 5}}), false),
|
||||
(json!({"sketch": "Frame", "profile": {"kind": "bar", "width": 5, "height": 5, "wall": 1}}), false),
|
||||
(json!({"sketch": true, "profile": tube()}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "lines": []}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "lines": ["a", "a"]}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "anchor": "middle"}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "angle": true}), false),
|
||||
(json!({"sketch": "Frame", "profile": tube(), "body_names": ["a"]}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<ProfileListParams>(), &[(json!({}), true), (json!({"kind": "bar"}), false)]);
|
||||
takes(
|
||||
input_schema_for::<ProfilePropertiesParams>(),
|
||||
&[
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "density": 7850}), true),
|
||||
(json!({"profile": bar, "density": null}), true),
|
||||
(json!({"profile": bar, "density": 0}), false),
|
||||
(json!({"profile": bar, "density": 1e6 + 1.0}), false),
|
||||
(json!({"density": 7850}), false),
|
||||
(json!({"profile": {"kind": "round_bar"}}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ProfileInsertParams>(),
|
||||
&[
|
||||
(json!({"profile": bar}), true),
|
||||
(json!({"profile": bar, "at": [1, 2], "anchor": "bottom", "angle": 2.5, "fixed": false, "sketch": "S"}), true),
|
||||
(json!({"profile": bar, "at": {"x": 1, "y": 2}, "sketch": 4}), true),
|
||||
(json!({}), false),
|
||||
(json!({"profile": bar, "at": [1, 2, 3]}), false),
|
||||
(json!({"profile": bar, "angle": 2e6}), false),
|
||||
(json!({"profile": bar, "fixed": "yes"}), false),
|
||||
(json!({"profile": bar, "sketch": 1.5}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The "not blank" patterns are the shared one, which `conforms` doesn't read: pin where they are.
|
||||
#[test]
|
||||
fn the_name_patterns_are_the_blank_pattern() {
|
||||
let blank = crate::schema::types::not_blank_pattern();
|
||||
let member = input_schema_for::<FrameMemberParams>();
|
||||
let member = member.as_value();
|
||||
assert_eq!(member["properties"]["body_name"]["anyOf"][0]["$ref"], json!("#/$defs/MemberName"));
|
||||
assert_eq!(member["$defs"]["MemberName"]["pattern"], json!(blank));
|
||||
assert_eq!(member["$defs"]["MemberNames"]["additionalProperties"]["pattern"], json!(blank));
|
||||
let properties = input_schema_for::<ProfilePropertiesParams>();
|
||||
let properties = properties.as_value();
|
||||
let kinds = properties["$defs"]["ProfileParam"]["anyOf"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(kinds.len(), frame::profile_kinds().len());
|
||||
assert_eq!(kinds[0]["properties"]["width"]["anyOf"][1]["pattern"], json!(blank));
|
||||
}
|
||||
|
||||
/// The warning for what a key gets wrong, and the check that every built-in kind is described.
|
||||
#[test]
|
||||
fn the_check_names_what_is_wrong_with_a_key() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(issues("frame.profile.properties", json!({"profile": {"kind": "hexagon"}})).len(), 1);
|
||||
assert_eq!(
|
||||
issues("frame.profile.properties", json!({"profile": {"kind": "bar", "width": 1, "height": 1, "wall": 1}})),
|
||||
["`profile`: a bar profile has no dimension `wall`"]
|
||||
);
|
||||
assert_eq!(issues("frame.profile.properties", json!({"profile": {"kind": "bar"}})), ["`profile`: a bar profile needs `width`"]);
|
||||
assert_eq!(
|
||||
issues("frame.profile.properties", json!({"profile": {"kind": "round_bar", "diameter": 1}, "density": -3})),
|
||||
["`density` must be a positive number of kg/m³ (at most 1e6)"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("frame.profile.insert", json!({"profile": {"kind": "round_bar", "diameter": 1}, "at": 3})),
|
||||
["`at` must be [x, y] in sketch millimetres"]
|
||||
);
|
||||
assert_eq!(
|
||||
issues("frame.profile.insert", json!({"profile": {"kind": "round_bar", "diameter": 1}, "fixed": "no"})),
|
||||
["`fixed` must be true or false"]
|
||||
);
|
||||
assert_eq!(issues("frame.member", json!({"sketch": 1, "profile": tube(), "mirror": "no"})), ["`mirror` must be true or false"]);
|
||||
assert_eq!(issues("frame.member", json!({"sketch": 1, "profile": tube(), "lines": ["a", "a"]})), ["line `a` is listed twice"]);
|
||||
assert_eq!(
|
||||
issues("frame.member", json!({"sketch": 1, "profile": tube(), "body_names": ["a"]})),
|
||||
["unknown key `body_names`; did you mean `body_name`?"]
|
||||
);
|
||||
// The schemas describe every built-in kind with its dimensions, and its example is one.
|
||||
let schema = input_schema_for::<ProfilePropertiesParams>();
|
||||
let schema = schema.as_value();
|
||||
for kind in frame::profile_kinds() {
|
||||
let example = serde_json::to_value(&kind.example).unwrap_or_default();
|
||||
let profile = json!({"profile": example});
|
||||
assert!(conforms(schema, schema, &profile), "{profile}");
|
||||
assert!(crate::schema::issues("frame.profile.properties", &profile).is_empty(), "{profile}");
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// The library's list and properties replies match the output schemas and are what the handlers
|
||||
/// built before the typed replies: the same JSON.
|
||||
#[test]
|
||||
fn profile_library_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let listed = run("frame.profile.list", json!({}));
|
||||
matches(output_schema_for::<ProfileListResult>(), "frame.profile.list", &listed);
|
||||
assert_eq!(listed, json!({"kinds": frame::profile_kinds()}));
|
||||
let kinds = listed["kinds"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(kinds.len(), 7);
|
||||
for kind in &kinds {
|
||||
let r = run("frame.profile.properties", json!({"profile": kind["example"]}));
|
||||
matches(output_schema_for::<ProfilePropertiesResult>(), "frame.profile.properties", &r);
|
||||
let profile: frame::Profile = serde_json::from_value(kind["example"].clone()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let region = profile.region().unwrap_or_else(|e| panic!("{e}"));
|
||||
let sec = frame::section(®ion);
|
||||
assert_eq!(
|
||||
r,
|
||||
json!({
|
||||
"profile": profile.filled(), "area": sec.area, "centroid": sec.centroid, "ixx": sec.ixx, "iyy": sec.iyy, "ixy": sec.ixy,
|
||||
"width": sec.width, "height": sec.height
|
||||
})
|
||||
);
|
||||
}
|
||||
let heavy = run("frame.profile.properties", json!({"profile": tube(), "density": 7850}));
|
||||
matches(output_schema_for::<ProfilePropertiesResult>(), "frame.profile.properties", &heavy);
|
||||
let area = heavy["area"].as_f64().unwrap_or_default();
|
||||
assert_eq!(heavy["mass_per_m"], json!(area * 1e-6 * 7850.0), "{heavy}");
|
||||
assert_eq!(heavy["profile"]["outer_radius"], json!(6.0), "{heavy}");
|
||||
// Dimensions as expressions, in a design with parameters.
|
||||
run("parameters.add", json!({"name": "wall", "expression": "3 mm"}));
|
||||
let by_parameter =
|
||||
run("frame.profile.properties", json!({"profile": {"kind": "rect_tube", "width": "40 mm", "height": 40, "wall": "wall"}, "density": null}));
|
||||
matches(output_schema_for::<ProfilePropertiesResult>(), "frame.profile.properties", &by_parameter);
|
||||
assert_eq!(by_parameter["area"], run("frame.profile.properties", json!({"profile": tube()}))["area"]);
|
||||
|
||||
// A misspelt key still runs in Warn and says so, with a reply that matches.
|
||||
for (id, p) in [("frame.profile.list", json!({"kinds": true})), ("frame.profile.properties", json!({"profile": tube(), "densty": 7850}))] {
|
||||
let warned = run(id, p);
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{warned}");
|
||||
let schema =
|
||||
if id == "frame.profile.list" { output_schema_for::<ProfileListResult>() } else { output_schema_for::<ProfilePropertiesResult>() };
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a profile in every form, in Warn: each reply matches its output schema, carries no
|
||||
/// warning and is what the handler built before.
|
||||
#[test]
|
||||
fn insert_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let made = run("sketch.create", json!({"plane": "XY", "name": "Drawing"}));
|
||||
let id = made["sketch"].clone();
|
||||
let plain = run("frame.profile.insert", json!({"profile": tube()}));
|
||||
matches(output_schema_for::<ProfileInsertResult>(), "frame.profile.insert", &plain);
|
||||
assert_eq!(plain["created"]["curves"].as_array().map(Vec::len), Some(16), "{plain}");
|
||||
assert!(plain["sketch"]["solved"].is_boolean() && plain["sketch"]["dof"].is_u64(), "{plain}");
|
||||
let mut keys: Vec<&String> = plain.as_object().map(|o| o.keys().collect()).unwrap_or_default();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["created", "section", "sketch"]);
|
||||
let mut created: Vec<&String> = plain["created"].as_object().map(|o| o.keys().collect()).unwrap_or_default();
|
||||
created.sort();
|
||||
assert_eq!(created, ["constraints", "curves", "dimensions", "points"]);
|
||||
let section = run("frame.profile.properties", json!({"profile": tube()}));
|
||||
// The section is what the library computes for the profile, not just what the other command says.
|
||||
let profile: frame::Profile = serde_json::from_value(tube()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sec = frame::section(&profile.region().unwrap_or_else(|e| panic!("{e}")));
|
||||
assert_eq!(plain["section"]["area"], json!(sec.area), "{plain}");
|
||||
assert_eq!(plain["section"]["centroid"], json!(sec.centroid), "{plain}");
|
||||
assert_eq!(plain["section"]["profile"], serde_json::to_value(profile.filled()).unwrap_or_default(), "{plain}");
|
||||
assert_eq!(plain["section"], section);
|
||||
let placed = run(
|
||||
"frame.profile.insert",
|
||||
json!({
|
||||
"profile": {"kind": "channel", "height": "60 mm", "width": 45, "web": 5, "flange": 6},
|
||||
"at": [100, 50], "anchor": "bottom_left", "angle": 0.5, "fixed": false, "sketch": "Drawing"
|
||||
}),
|
||||
);
|
||||
matches(output_schema_for::<ProfileInsertResult>(), "frame.profile.insert", &placed);
|
||||
let by_id = run(
|
||||
"frame.profile.insert",
|
||||
json!({"profile": {"kind": "round_bar", "diameter": 20}, "sketch": id, "at": {"x": -50, "y": 5}, "anchor": null}),
|
||||
);
|
||||
matches(output_schema_for::<ProfileInsertResult>(), "frame.profile.insert", &by_id);
|
||||
assert_eq!(by_id["created"]["curves"].as_array().map(Vec::len), Some(1), "{by_id}");
|
||||
// A misspelt key still runs in Warn, and says so, with a reply that matches.
|
||||
let warned = run("frame.profile.insert", json!({"profile": {"kind": "round_bar", "diameter": 5}, "sketch": "Drawing", "ancor": "center"}));
|
||||
assert_eq!(warned["param_warnings"], json!(["unknown key `ancor`; did you mean `anchor`?"]), "{warned}");
|
||||
let schema = output_schema_for::<ProfileInsertResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
|
||||
/// Members in every form, in Warn: each reply matches `FeatureResult` and carries no warning.
|
||||
#[test]
|
||||
fn member_replies_match_the_output_schema() {
|
||||
let (mut s, lines) = frame_session(ParamCheck::Warn);
|
||||
let mut run = |p: Value| s.execute("frame.member", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let first = lines.first().cloned().unwrap_or_default();
|
||||
let second = lines.get(1).cloned().unwrap_or_default();
|
||||
let calls = [
|
||||
json!({"sketch": "Frame", "profile": tube()}),
|
||||
json!({"sketch": "Frame", "profile": {"kind": "angle", "width": 60, "height": 40, "thickness": "5 mm"}, "lines": [first], "anchor": "bottom_left"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "lines": [first, second], "angle": "90 deg", "mirror": true, "name": "Rails", "body_name": "Rail"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "lines": [first], "angle": 45, "names": {first.clone(): "Chosen"}, "participants": "*"}),
|
||||
json!({"sketch": "Frame", "profile": tube(), "lines": null}),
|
||||
json!({"sketch": "Frame", "profile_sketch": "Section"}),
|
||||
json!({"sketch": "Frame", "profile_sketch": "Section", "profiles": [{"point": [0, 0]}], "exact_regions": true, "lines": [first], "anchor": "center"}),
|
||||
json!({"sketch": "Frame", "profile_sketch": "Section", "profiles": "all", "mirror": false, "angle": null, "names": null}),
|
||||
];
|
||||
for p in calls {
|
||||
let reply = run(p.clone());
|
||||
matches(output_schema_for::<FeatureResult>(), "frame.member", &reply);
|
||||
assert!(reply["created_bodies"].as_array().is_some_and(|b| !b.is_empty()), "{p}: {reply}");
|
||||
}
|
||||
// By the id of the sketch.
|
||||
let by_id = s_first_sketch_call(&mut s, &first);
|
||||
matches(output_schema_for::<FeatureResult>(), "frame.member", &by_id);
|
||||
|
||||
// A misspelt key still runs in Warn, and says so, with a reply that matches.
|
||||
let warned =
|
||||
s.execute("frame.member", &json!({"sketch": "Frame", "profile": tube(), "lines": [first], "profil": 1})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(warned["param_warnings"], json!(["unknown key `profil`; did you mean `profile`?"]), "{warned}");
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
}
|
||||
|
||||
fn s_first_sketch_call(s: &mut Session, line: &str) -> Value {
|
||||
let id = first_sketch(s);
|
||||
s.execute("frame.member", &json!({"sketch": id, "profile": tube(), "lines": [line]})).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
//! Contracts and result types for the commands in `cmd/inspect.rs`.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a body, a sketch or a material exists
|
||||
//! stays the handler's to check. A key the handler refuses when it is left out (`material.assign`'s
|
||||
//! `bodies` and `material`) is optional in the type and required in the schema, so that the
|
||||
//! handler's message, not the check's "is required", is what a caller sees (#12498).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::check::{located, whole};
|
||||
use super::materials::MaterialReply;
|
||||
use crate::params;
|
||||
|
||||
/// Which command to describe.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -50,6 +64,44 @@ pub struct BodyListParams {
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ModelThreadsParams {}
|
||||
|
||||
/// List the timeline features that built each body (#11945).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct BodyFeaturesParams {
|
||||
/// One body, by name (a placed copy's name means the body it shows); every body when left out.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// What `model.body_features` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ModelBodyFeatures {
|
||||
/// The bodies, in the model's order (the one asked for alone).
|
||||
pub bodies: Vec<BodyFeatures>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A body and the features that built it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFeatures {
|
||||
/// The body's name.
|
||||
pub body: String,
|
||||
/// In timeline order: the feature that created it, every later one that changed its
|
||||
/// geometry, and those of the bodies consumed into it (a Combine's tools, a body joined in).
|
||||
pub features: Vec<BodyFeature>,
|
||||
}
|
||||
|
||||
/// A timeline feature.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFeature {
|
||||
/// The feature's id.
|
||||
pub id: u64,
|
||||
/// Its name in the timeline.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// What `model.edges` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ModelEdges {
|
||||
@@ -240,6 +292,831 @@ impl From<&solidcraft_doc::ThreadInfo> for ModelThread {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `keys` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it when it's left out.
|
||||
fn require_keys(schema: &mut Schema, keys: &[&str]) {
|
||||
let mut required = match schema.get("required") {
|
||||
Some(Value::Array(required)) => required.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for key in keys {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
required.push((*key).into());
|
||||
}
|
||||
}
|
||||
schema.insert("required".into(), Value::Array(required));
|
||||
}
|
||||
|
||||
fn bodies_and_material_required(schema: &mut Schema) {
|
||||
require_keys(schema, &["bodies", "material"]);
|
||||
}
|
||||
|
||||
/// The message of a handler's parameter error, for a reader the contract shares.
|
||||
fn message(e: crate::EngineError) -> String {
|
||||
match e {
|
||||
crate::EngineError::BadParams { msg, .. } => msg,
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bodies`: a name or a list of names, read by `params::string_list` itself, with its messages
|
||||
/// (`null` is no names).
|
||||
fn body_names<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<String>>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
params::string_list("inspect", &json!({ "bodies": v }), "bodies").map(Some).map_err(|e| D::Error::custom(whole(message(e))))
|
||||
}
|
||||
|
||||
fn body_names_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": params::MAX_STRING_LIST}, {"type": "null"}]})
|
||||
}
|
||||
|
||||
/// Read the design: its name, units, properties, parameters, timeline, bodies and sketches, the
|
||||
/// selection and the undo and redo depth. Read-only.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DocumentInspectParams {
|
||||
/// Measure each body too (volume, area, centre of mass, box, counts, as `inspect.measure`
|
||||
/// does), in place of its feature. Default: false.
|
||||
#[serde(default)]
|
||||
pub measure: Option<bool>,
|
||||
/// Give each sketch also its plane and its number of points and constraints. Default: false.
|
||||
#[serde(default)]
|
||||
pub sketch_counts: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `document.inspect` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentInspectResult {
|
||||
/// The design's name.
|
||||
pub name: String,
|
||||
/// Its length unit.
|
||||
pub units: String,
|
||||
/// Its metadata object as saved, other tools' keys included.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// The file it is saved in; `null` when it has none.
|
||||
pub path: Option<String>,
|
||||
/// Whether it has changes that are not saved.
|
||||
pub dirty: bool,
|
||||
/// How many times the session's design changed.
|
||||
pub revision: u64,
|
||||
/// The sketch being edited, by feature id; `null` when none.
|
||||
pub active_sketch: Option<u64>,
|
||||
/// The timeline marker: features from this index on are rolled back.
|
||||
pub marker: usize,
|
||||
/// The design's parameters.
|
||||
pub params: Vec<DocumentParam>,
|
||||
/// Its features, in timeline order.
|
||||
pub timeline: Vec<TimelineEntry>,
|
||||
/// Its bodies.
|
||||
pub bodies: Vec<DocumentBody>,
|
||||
/// Its sketches.
|
||||
pub sketches: Vec<DocumentSketch>,
|
||||
/// What is selected, each as the selection commands take it.
|
||||
#[schemars(with = "Vec<Map<String, Value>>")]
|
||||
pub selection: Value,
|
||||
/// How many undo steps there are.
|
||||
pub undo: usize,
|
||||
/// How many redo steps there are.
|
||||
pub redo: usize,
|
||||
/// The parts of the origin that are hidden, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parameter of the design.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentParam {
|
||||
pub name: String,
|
||||
/// What it is defined as.
|
||||
pub expression: String,
|
||||
/// `mm`, `deg` or empty (no unit).
|
||||
pub unit: String,
|
||||
/// What the expression comes to; `null` when it doesn't evaluate.
|
||||
pub value: Option<f64>,
|
||||
/// Whether a dimension or feature made it, not the user.
|
||||
pub model: bool,
|
||||
pub comment: String,
|
||||
/// Why the expression doesn't evaluate; `null` when it does.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A feature of the timeline.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TimelineEntry {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The feature's kind, such as `Extrude`.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub suppressed: bool,
|
||||
/// Whether the timeline marker has rolled it back.
|
||||
pub rolled_back: bool,
|
||||
/// Why it failed; `null` when it did not.
|
||||
pub error: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
/// How long it took to evaluate (ms); `null` when it has no result.
|
||||
pub ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// A body of the design: measured with `measure`, else with the feature that made it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentBody {
|
||||
#[serde(flatten)]
|
||||
pub detail: DocumentBodyDetail,
|
||||
/// Whether it is shown.
|
||||
pub visible: bool,
|
||||
/// Present, and true, when the timeline is rolled back and the body is deleted further on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub removed_later: Option<bool>,
|
||||
}
|
||||
|
||||
/// What a body entry says of the body itself.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum DocumentBodyDetail {
|
||||
/// Without `measure`.
|
||||
Listed {
|
||||
name: String,
|
||||
/// The feature that made it, by id.
|
||||
feature: u64,
|
||||
},
|
||||
/// With `measure`.
|
||||
Measured(Box<BodyMeasurement>),
|
||||
}
|
||||
|
||||
/// One body measured: its name and the measurement or why there is none.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyMeasurement {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub outcome: BodyOutcome,
|
||||
}
|
||||
|
||||
/// A body's measurement.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum BodyOutcome {
|
||||
Measured(BodyFigures),
|
||||
Failed(BodyFailure),
|
||||
}
|
||||
|
||||
/// The measurements of a body.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFigures {
|
||||
/// Whether it encloses a volume.
|
||||
pub closed: bool,
|
||||
/// Its volume (mm³); `null` unless it is closed.
|
||||
pub volume_mm3: Option<f64>,
|
||||
/// Its surface area (mm²).
|
||||
pub area_mm2: f64,
|
||||
/// Its centre of mass at uniform density; `null` unless it is closed.
|
||||
pub center_of_mass: Option<[f64; 3]>,
|
||||
pub bbox: BoundingBox,
|
||||
/// Faces, with split pieces of one analytic surface counted once (a sheet metal body's in
|
||||
/// `inspect.measure`: with its round holes counted as the design shows them).
|
||||
pub faces: i64,
|
||||
pub edges: i64,
|
||||
pub vertices: i64,
|
||||
/// The merged faces by kind of surface.
|
||||
pub face_types: BTreeMap<String, usize>,
|
||||
/// The kernel's own counts, which split closed curves and surfaces.
|
||||
pub kernel: KernelCounts,
|
||||
/// What volume, area and centre come from: `smlib` (exact) or `mesh` (a mesh body's own
|
||||
/// triangles).
|
||||
pub measured_by: String,
|
||||
/// Anything the measurement says about itself.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why a body has no measurement.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyFailure {
|
||||
pub error: String,
|
||||
/// `inspect.measure` only, for a sheet metal body: its hole-corrected count, which has
|
||||
/// nothing to correct.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub faces: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edges: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vertices: Option<i64>,
|
||||
}
|
||||
|
||||
/// An axis-aligned box (mm).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BoundingBox {
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
}
|
||||
|
||||
/// A body's topology counts as the kernel has them.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct KernelCounts {
|
||||
pub faces: usize,
|
||||
pub edges: usize,
|
||||
pub vertices: usize,
|
||||
pub shells: usize,
|
||||
}
|
||||
|
||||
/// A sketch of the design, in short.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentSketch {
|
||||
/// Its feature id.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Its remaining degrees of freedom (0: fully constrained).
|
||||
pub dof: usize,
|
||||
pub status: SketchStatus,
|
||||
/// How many curves it has.
|
||||
pub curves: usize,
|
||||
/// How many profiles it closes.
|
||||
pub profiles: usize,
|
||||
/// Whether it is shown.
|
||||
pub visible: bool,
|
||||
/// With `sketch_counts`: where it lies.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<Map<String, Value>>")]
|
||||
pub plane: Option<Value>,
|
||||
/// With `sketch_counts`: how many points it has.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub points: Option<usize>,
|
||||
/// With `sketch_counts`: how many constraints it has.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub constraints: Option<usize>,
|
||||
}
|
||||
|
||||
/// Whether a sketch's constraints solved.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SketchStatus {
|
||||
Solved,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl From<solidcraft_sketch::SolveStatus> for SketchStatus {
|
||||
fn from(s: solidcraft_sketch::SolveStatus) -> Self {
|
||||
match s {
|
||||
solidcraft_sketch::SolveStatus::Solved => SketchStatus::Solved,
|
||||
solidcraft_sketch::SolveStatus::Failed => SketchStatus::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `inspect.measure` selects: bodies, or one or two picked items. With `items`, `bodies` and
|
||||
/// `names_only` are not read, so the check leaves them alone.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MeasureKeys {
|
||||
/// The bodies to measure, by name. Default: all. Not read with `items`.
|
||||
#[serde(default, deserialize_with = "body_names", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "body_names_schema")]
|
||||
pub bodies: Option<Vec<String>>,
|
||||
/// Give each body's name and its definition's and measure nothing. Default: false. Not read
|
||||
/// with `items`.
|
||||
#[serde(default)]
|
||||
pub names_only: Option<bool>,
|
||||
/// One or two selections to measure, instead of bodies: a face or edge `{type, body, point}`
|
||||
/// (`index` is optional), or a body, vertex, sketch curve or profile as `Sel` has it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "items_schema")]
|
||||
pub items: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
fn items_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "array", "items": {"type": "object"}, "minItems": 1, "maxItems": 1000})
|
||||
}
|
||||
|
||||
/// `inspect.measure`'s parameters: [`MeasureKeys`], read as the handler reads them: with `items`
|
||||
/// given (even as `null`) only `items` is read; without it, `bodies` and `names_only`.
|
||||
#[derive(Debug)]
|
||||
pub struct MeasureParams(pub MeasureKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for MeasureParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
if let Some(items) = value.get("items") {
|
||||
let list = items.as_array().ok_or_else(|| D::Error::custom(whole("`items` must be a list of selections")))?;
|
||||
if list.is_empty() || list.len() > 1000 {
|
||||
return Err(D::Error::custom(whole("measure one or two items")));
|
||||
}
|
||||
return Ok(MeasureParams(MeasureKeys { bodies: None, names_only: None, items: Some(list.clone()) }));
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(MeasureParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for MeasureParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"MeasureParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
MeasureKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `inspect.measure` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasureResult {
|
||||
#[serde(flatten)]
|
||||
pub measured: Measured,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The shapes of `inspect.measure`'s reply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum Measured {
|
||||
/// With `items`.
|
||||
Items(Box<MeasuredItems>),
|
||||
/// With `names_only`.
|
||||
Names(BodyNames),
|
||||
/// Bodies.
|
||||
Bodies(MeasuredBodies),
|
||||
}
|
||||
|
||||
/// Each world body's name and its definition's.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyNames {
|
||||
pub body_count: usize,
|
||||
pub bodies: Vec<BodyName>,
|
||||
}
|
||||
|
||||
/// A body by its name and its definition's.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyName {
|
||||
pub name: String,
|
||||
pub definition: String,
|
||||
}
|
||||
|
||||
/// The bodies measured, with their totals.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasuredBodies {
|
||||
pub body_count: usize,
|
||||
pub bodies: Vec<MeasuredBody>,
|
||||
pub total: MeasureTotal,
|
||||
}
|
||||
|
||||
/// A body measured, with its material and mass.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasuredBody {
|
||||
#[serde(flatten)]
|
||||
pub measurement: BodyMeasurement,
|
||||
/// Its material's name.
|
||||
pub material: String,
|
||||
/// The material's density; only for a body with a volume.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub density_g_cm3: Option<f64>,
|
||||
/// Its mass (g); only for a body with a volume.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mass_g: Option<f64>,
|
||||
/// True for a sheet metal body, whose counts are its own, with round holes as the design
|
||||
/// shows them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sheet_metal: Option<bool>,
|
||||
}
|
||||
|
||||
/// The sum over the bodies measured.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MeasureTotal {
|
||||
/// How many bodies are left out of the volume and mass because they enclose none.
|
||||
pub open_bodies_skipped: usize,
|
||||
pub volume_mm3: f64,
|
||||
pub area_mm2: f64,
|
||||
pub mass_g: f64,
|
||||
pub faces: u64,
|
||||
pub edges: u64,
|
||||
pub vertices: u64,
|
||||
}
|
||||
|
||||
/// One or two picked items measured: each item, then what its shape gives (for one item) or what
|
||||
/// lies between the two.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct MeasuredItems {
|
||||
/// Each item described: its kind, what it is and its own figures.
|
||||
pub items: Vec<Map<String, Value>>,
|
||||
/// One item that is round: its diameter (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub diameter: Option<Value>,
|
||||
/// Its radius (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub radius: Option<Value>,
|
||||
/// A full circle's circumference (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub circumference: Option<Value>,
|
||||
/// An arc's sweep in degrees; two items: the angle between them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub angle_deg: Option<Value>,
|
||||
/// An arc's length (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub arc_length: Option<Value>,
|
||||
/// One round item's centre.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[f64; 3]>")]
|
||||
pub center: Option<Value>,
|
||||
/// One slot: its `width`, `center_distance`, `overall_length` and the two `centers`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<Map<String, Value>>")]
|
||||
pub slot: Option<Value>,
|
||||
/// One slot: where its first end is centred; two items: the first item's centre.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_from: Option<[f64; 3]>,
|
||||
/// One slot: its second end's centre; two items: the second's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_to: Option<[f64; 3]>,
|
||||
/// Two items: the closest distance between them (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub distance_mm: Option<f64>,
|
||||
/// Two items: where it starts on the first. A coordinate is `null` where it isn't a number: two planes that meet have no
|
||||
/// such point to name (their distance is 0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[Option<f64>; 3]>")]
|
||||
pub from: Option<[f64; 3]>,
|
||||
/// Two items: where it ends on the second. A coordinate is `null` where it isn't a number: two planes that meet have no
|
||||
/// such point to name (their distance is 0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[Option<f64>; 3]>")]
|
||||
pub to: Option<[f64; 3]>,
|
||||
/// Two items: `to` minus `from`. A coordinate is `null` where it isn't a number: two planes that meet have no
|
||||
/// such point to name (their distance is 0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<[Option<f64>; 3]>")]
|
||||
pub delta: Option<[f64; 3]>,
|
||||
/// Two items: the closest distance, as `distance_mm`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min: Option<f64>,
|
||||
/// Two items: the farthest distance (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_from: Option<[f64; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_to: Option<[f64; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_delta: Option<[f64; 3]>,
|
||||
/// Two items: the distance between their centres (or from an axis) (mm).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_distance: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub center_delta: Option<[f64; 3]>,
|
||||
/// Two items: which distance is the one to read, `center` or `min`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub measure: Option<MeasureKind>,
|
||||
}
|
||||
|
||||
/// Which of two items' distances is the one to read.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MeasureKind {
|
||||
Center,
|
||||
Min,
|
||||
}
|
||||
|
||||
/// List every command: its id, label, place on the toolbar, icon, shortcut, parameter prose and
|
||||
/// whether it can run now.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CommandsParams {}
|
||||
|
||||
/// One command of `engine.commands`' list.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CommandEntry {
|
||||
/// The command's id, such as `solid.extrude`.
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// The toolbar tab it is on; empty when it is on none.
|
||||
pub tab: String,
|
||||
/// The panel of that tab.
|
||||
pub panel: String,
|
||||
pub icon: String,
|
||||
pub shortcut: Option<String>,
|
||||
/// The parameter documentation, as prose (`engine.describe` has the schemas).
|
||||
pub params: String,
|
||||
/// Whether it can run in the session's state now.
|
||||
pub enabled: bool,
|
||||
/// Why it can't; `null` when it can.
|
||||
pub disabled_reason: Option<String>,
|
||||
/// For a command that is planned and not built yet: what is planned.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub planned: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::cmd::CommandInfo> for CommandEntry {
|
||||
fn from(c: crate::cmd::CommandInfo) -> Self {
|
||||
CommandEntry {
|
||||
id: c.id.into(),
|
||||
label: c.label.into(),
|
||||
tab: c.tab.into(),
|
||||
panel: c.panel.into(),
|
||||
icon: c.icon.into(),
|
||||
shortcut: c.shortcut.map(Into::into),
|
||||
params: c.params.into(),
|
||||
enabled: c.enabled,
|
||||
disabled_reason: c.disabled_reason,
|
||||
planned: c.planned.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `material.list` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MaterialListParams {}
|
||||
|
||||
/// What `material.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MaterialListResult {
|
||||
/// The built-in materials, then the design's own.
|
||||
pub materials: Vec<ListedMaterial>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A material in the list.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ListedMaterial {
|
||||
/// A built-in material.
|
||||
BuiltIn {
|
||||
name: String,
|
||||
/// Its density (g/cm³).
|
||||
density_g_cm3: f64,
|
||||
},
|
||||
/// One this design defines, with `user` true, its look and its physical card when it has
|
||||
/// one.
|
||||
Design(Box<MaterialReply>),
|
||||
}
|
||||
|
||||
/// A name as `material.assign` reads `material`: text, with the handler's message for anything
|
||||
/// else, `null` included.
|
||||
fn material_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole("`material` must be a material name"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give bodies a physical material (a built-in one, or one made with `material.create`), which
|
||||
/// their mass and density come from. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = bodies_and_material_required)]
|
||||
pub struct MaterialAssignParams {
|
||||
// Optional in the type and required in the schema (`bodies_and_material_required`).
|
||||
/// The bodies, by name: one or a list, at least one, each an existing body.
|
||||
#[serde(default, deserialize_with = "body_names", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "material_bodies_schema")]
|
||||
pub bodies: Option<Vec<String>>,
|
||||
/// The material's name (Steel, Aluminum, ABS Plastic, …, or one made with `material.create`;
|
||||
/// `material.list` lists them). Letter case and spaces at either end don't matter.
|
||||
#[serde(default, deserialize_with = "material_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "material_schema")]
|
||||
pub material: Option<String>,
|
||||
}
|
||||
|
||||
fn material_bodies_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": params::MAX_STRING_LIST}]})
|
||||
}
|
||||
|
||||
fn material_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string"})
|
||||
}
|
||||
|
||||
/// What `material.assign` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MaterialAssignResult {
|
||||
/// The bodies that got the material.
|
||||
pub bodies: Vec<String>,
|
||||
/// The material's name as the design has it.
|
||||
pub material: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A sketch by its feature id (a number) or its name, as `sketch.inspect` reads `sketch`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SketchTarget {
|
||||
Id(u64),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchTarget {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => n.as_u64().map(SketchTarget::Id),
|
||||
Value::String(s) => Some(SketchTarget::Name(s)),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| D::Error::custom(whole("`sketch` must be an id or a name")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchTarget {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchTarget".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A sketch's feature id (a number, never a name) or its name.",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": u64::MAX}, {"type": "string"}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a sketch: its curves, points, constraints, profiles, links and 3D curves, with what is
|
||||
/// determined and what is not. Read-only.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SketchInspectParams {
|
||||
/// The sketch. Default (also `null`): the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// What `sketch.inspect` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchInspected {
|
||||
/// The sketch's feature id.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Where it lies, as saved.
|
||||
#[schemars(with = "Map<String, Value>")]
|
||||
pub plane: Value,
|
||||
pub status: SketchStatus,
|
||||
/// Its remaining degrees of freedom (0: fully constrained).
|
||||
pub dof: usize,
|
||||
pub fully_constrained: bool,
|
||||
/// The constraints still violated after a failed solve.
|
||||
pub failing: Vec<String>,
|
||||
pub curves: Vec<SketchCurve>,
|
||||
pub points: Vec<SketchPoint>,
|
||||
pub constraints: Vec<SketchConstraint>,
|
||||
pub profiles: Vec<SketchProfile>,
|
||||
/// Links to geometry outside the sketch (projections, intersections, includes), as saved.
|
||||
#[schemars(with = "Vec<Map<String, Value>>")]
|
||||
pub links: Value,
|
||||
/// Its 3D curves.
|
||||
pub wires: Vec<SketchWire>,
|
||||
/// Whether it is shown.
|
||||
pub visible: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A curve of a sketch: its id, what it is and how it is held.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchCurve {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub shape: CurveShape,
|
||||
pub construction: bool,
|
||||
/// Whether it is locked (fixed, or linked).
|
||||
pub fixed: bool,
|
||||
/// Whether everything that defines it is determined by constraints.
|
||||
pub fully_constrained: bool,
|
||||
/// For a linked curve: the link that owns it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
/// A curve's kind and its points, by id and (`_at`) by position `[x, y]` in the sketch's plane.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CurveShape {
|
||||
Line {
|
||||
start: String,
|
||||
end: String,
|
||||
start_at: Option<[f64; 2]>,
|
||||
end_at: Option<[f64; 2]>,
|
||||
},
|
||||
Circle {
|
||||
center: String,
|
||||
center_at: Option<[f64; 2]>,
|
||||
radius: f64,
|
||||
},
|
||||
Arc {
|
||||
center: String,
|
||||
start: String,
|
||||
end: String,
|
||||
center_at: Option<[f64; 2]>,
|
||||
start_at: Option<[f64; 2]>,
|
||||
end_at: Option<[f64; 2]>,
|
||||
radius: Option<f64>,
|
||||
},
|
||||
Ellipse {
|
||||
center: String,
|
||||
/// The end of the major axis.
|
||||
major: String,
|
||||
center_at: Option<[f64; 2]>,
|
||||
major_at: Option<[f64; 2]>,
|
||||
minor_radius: f64,
|
||||
},
|
||||
Spline {
|
||||
points: Vec<String>,
|
||||
points_at: Vec<Option<[f64; 2]>>,
|
||||
/// Whether `points` are control points of a B-spline, not fit points.
|
||||
control: bool,
|
||||
degree: u8,
|
||||
periodic: bool,
|
||||
/// A control spline with its own knots (a projected free-form edge).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
knots: Option<Vec<f64>>,
|
||||
/// Its weights, when it has any.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
weights: Option<Vec<f64>>,
|
||||
},
|
||||
Conic {
|
||||
start: String,
|
||||
end: String,
|
||||
apex: String,
|
||||
start_at: Option<[f64; 2]>,
|
||||
end_at: Option<[f64; 2]>,
|
||||
apex_at: Option<[f64; 2]>,
|
||||
rho: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A point of a sketch.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchPoint {
|
||||
pub id: String,
|
||||
/// Its position `[x, y]` in the sketch's plane (mm).
|
||||
pub at: [f64; 2],
|
||||
/// Where that is in the design (mm).
|
||||
pub world: [f64; 3],
|
||||
/// Whether it is locked (fixed, or linked).
|
||||
pub fixed: bool,
|
||||
/// For a linked point: the link that owns it; `null` otherwise.
|
||||
pub link: Option<String>,
|
||||
pub fully_constrained: bool,
|
||||
}
|
||||
|
||||
/// A constraint or dimension: its id, its kind's name and the kind's own fields (`type`, then
|
||||
/// the points and curves it ties, a value…), as saved.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchConstraint {
|
||||
pub id: String,
|
||||
/// The kind in short, such as `Horizontal`.
|
||||
pub name: String,
|
||||
/// For a dimension: the parameter that drives it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub param: Option<String>,
|
||||
/// With `param`: the parameter's expression; `null` when the design has no such parameter.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<String>")]
|
||||
pub expression: Option<Option<String>>,
|
||||
#[serde(flatten)]
|
||||
pub kind: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// A region the sketch closes.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchProfile {
|
||||
pub index: usize,
|
||||
pub area_mm2: f64,
|
||||
/// Its centroid `[x, y]` in the sketch's plane.
|
||||
pub centroid_sketch: [f64; 2],
|
||||
/// The curves of its outer loop, each once, in loop order.
|
||||
pub curves: Vec<String>,
|
||||
/// The curves of each hole's loop.
|
||||
pub holes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// A 3D curve a sketch carries.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchWire {
|
||||
pub id: String,
|
||||
/// How many points its polyline has.
|
||||
pub points: usize,
|
||||
pub start: Option<[f64; 3]>,
|
||||
pub end: Option<[f64; 3]>,
|
||||
/// For a linked curve: the link that owns it; `null` otherwise.
|
||||
pub link: Option<String>,
|
||||
/// For an exact circular arc: its geometry; `null` otherwise.
|
||||
pub arc: Option<WireArcInfo>,
|
||||
}
|
||||
|
||||
/// An exact circular arc of a 3D curve.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct WireArcInfo {
|
||||
pub center: [f64; 3],
|
||||
pub radius: f64,
|
||||
pub start: [f64; 3],
|
||||
pub end: [f64; 3],
|
||||
/// Its sweep (degrees).
|
||||
pub sweep_deg: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inspect_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{BodyListParams, DescribeParams, ModelEdges, ModelFaces, ModelThreads, ModelThreadsParams};
|
||||
use super::{
|
||||
BodyFeaturesParams, BodyListParams, CommandEntry, CommandsParams, DescribeParams, DocumentInspectParams, DocumentInspectResult,
|
||||
MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams, MeasureResult, ModelBodyFeatures, ModelEdges,
|
||||
ModelFaces, ModelThreads, ModelThreadsParams, SketchInspectParams, SketchInspected,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -10,6 +14,7 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DescribeParams>(), &["command"]);
|
||||
assert_keys(input_schema_for::<BodyListParams>(), &["body"]);
|
||||
assert_keys(input_schema_for::<ModelThreadsParams>(), &[]);
|
||||
assert_keys(input_schema_for::<BodyFeaturesParams>(), &["body"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -22,6 +27,7 @@ fn strict_keeps_todays_errors() {
|
||||
("model.edges", json!({"body": "Nope"})),
|
||||
("model.faces", json!({})),
|
||||
("model.faces", json!({"body": "Nope"})),
|
||||
("model.body_features", json!({"body": "Nope"})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -87,3 +93,460 @@ fn describe_returns_both_schemas() {
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DocumentInspectParams>(), &["measure", "sketch_counts"]);
|
||||
assert_keys(input_schema_for::<MeasureParams>(), &["bodies", "items", "names_only"]);
|
||||
assert_keys(input_schema_for::<CommandsParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MaterialListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MaterialAssignParams>(), &["bodies", "material"]);
|
||||
assert_keys(input_schema_for::<SketchInspectParams>(), &["sketch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_of_the_inspect_commands() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("material.assign", json!({})),
|
||||
("material.assign", json!({"bodies": ["A"]})),
|
||||
("material.assign", json!({"bodies": ["A"], "material": 4})),
|
||||
("material.assign", json!({"bodies": ["A"], "material": null})),
|
||||
("material.assign", json!({"bodies": ["A"], "material": ["Steel"]})),
|
||||
("material.assign", json!({"bodies": 5, "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": [5], "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": [], "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": null, "material": "Steel"})),
|
||||
("material.assign", json!({"material": "Steel"})),
|
||||
("material.assign", json!({"bodies": ["Nope"], "material": "Steel"})),
|
||||
("material.assign", json!({"bodies": ["Nope"], "material": "Unobtainium"})),
|
||||
("inspect.measure", json!({"bodies": 5})),
|
||||
("inspect.measure", json!({"bodies": [5]})),
|
||||
("inspect.measure", json!({"bodies": ["Nope"]})),
|
||||
("inspect.measure", json!({"items": null})),
|
||||
("inspect.measure", json!({"items": "x"})),
|
||||
("inspect.measure", json!({"items": {}})),
|
||||
("inspect.measure", json!({"items": []})),
|
||||
("inspect.measure", json!({"items": "x", "bodies": 5})),
|
||||
("inspect.measure", json!({"items": [1], "bodies": 5, "names_only": "yes"})),
|
||||
("inspect.measure", json!({"items": [{"type": "body", "name": "Nope"}]})),
|
||||
("sketch.inspect", json!({"sketch": true})),
|
||||
("sketch.inspect", json!({"sketch": -1})),
|
||||
("sketch.inspect", json!({"sketch": 1.5})),
|
||||
("sketch.inspect", json!({"sketch": [1]})),
|
||||
("sketch.inspect", json!({"sketch": {}})),
|
||||
("sketch.inspect", json!({"sketch": "Nope"})),
|
||||
("sketch.inspect", json!({"sketch": 99999})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Each type reads a key as its handler does: with `items` only `items` is read, without it
|
||||
/// `bodies` and `names_only`; `material.assign` leaves a missing or empty `bodies` and a missing
|
||||
/// `material` to the handler's own message; `sketch` is an id, a name or `null`.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handler_does() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
// inspect.measure: the keys read depend on the form.
|
||||
let pick = json!([{"type": "face", "body": "A", "point": [1, 2, 3]}]);
|
||||
assert_eq!(issues("inspect.measure", json!({})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": ["A"], "names_only": true})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": null, "names_only": null})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": pick})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": pick, "bodies": 5, "names_only": "yes"})), none);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": 5})), ["`bodies` must be a list of names; it is 5"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"bodies": ["A", 7]})), ["`bodies` must list names; the entry 2 is 7"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"names_only": "yes"})), ["`names_only` must be true or false"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": null})), ["`items` must be a list of selections"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": "x", "bodies": 5})), ["`items` must be a list of selections"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": []})), ["measure one or two items"]);
|
||||
assert_eq!(issues("inspect.measure", json!({"items": vec![json!({}); 1001]})), ["measure one or two items"]);
|
||||
// material.assign.
|
||||
assert_eq!(issues("material.assign", json!({"bodies": ["A"], "material": "Steel"})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": "A", "material": " steel "})), none);
|
||||
assert_eq!(issues("material.assign", json!({"material": "Steel"})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": [], "material": "Steel"})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": null})), none);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": 5, "material": "Steel"})), ["`bodies` must be a list of names; it is 5"]);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": ["A"], "material": 4})), ["`material` must be a material name"]);
|
||||
assert_eq!(issues("material.assign", json!({"bodies": ["A"], "material": null})), ["`material` must be a material name"]);
|
||||
// sketch.inspect.
|
||||
for ok in [json!({}), json!({"sketch": null}), json!({"sketch": 3}), json!({"sketch": "Base"}), json!({"sketch": "7"})] {
|
||||
assert_eq!(issues("sketch.inspect", ok.clone()), none, "{ok}");
|
||||
}
|
||||
for bad in [json!(true), json!(-1), json!(1.5), json!([1]), json!({"id": 1})] {
|
||||
assert_eq!(issues("sketch.inspect", json!({ "sketch": bad.clone() })), ["`sketch` must be an id or a name"], "{bad}");
|
||||
}
|
||||
// document.inspect's flags are plain booleans, `null` is left out.
|
||||
assert_eq!(issues("document.inspect", json!({"measure": true, "sketch_counts": null})), none);
|
||||
assert_eq!(issues("document.inspect", json!({"measure": "yes"})), ["`measure` must be true or false"]);
|
||||
// Neither list command takes a key.
|
||||
assert_eq!(issues("engine.commands", json!({"x": 1})), ["unknown key `x`"]);
|
||||
assert_eq!(issues("material.list", json!({"x": 1})), ["unknown key `x`"]);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: `material.assign` can't do without `bodies` and
|
||||
/// `material`, though its type leaves them to the handler's message; `inspect.measure` takes
|
||||
/// bodies or items.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<MaterialAssignParams>(),
|
||||
&[
|
||||
(json!({"bodies": ["A"], "material": "Steel"}), true),
|
||||
(json!({"bodies": "A", "material": "Steel"}), true),
|
||||
(json!({"material": "Steel"}), false),
|
||||
(json!({"bodies": ["A"]}), false),
|
||||
(json!({"bodies": [], "material": "Steel"}), false),
|
||||
(json!({"bodies": ["A"], "material": 4}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<MeasureParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"bodies": ["A"], "names_only": true}), true),
|
||||
(json!({"bodies": "A"}), true),
|
||||
(json!({"bodies": null}), true),
|
||||
(json!({"items": [{"type": "body", "name": "A"}]}), true),
|
||||
(json!({"items": []}), false),
|
||||
(json!({"items": "x"}), false),
|
||||
(json!({"bodies": 5}), false),
|
||||
(json!({"names_only": "yes"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<SketchInspectParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"sketch": 3}), true),
|
||||
(json!({"sketch": "Base"}), true),
|
||||
(json!({"sketch": null}), true),
|
||||
(json!({"sketch": -1}), false),
|
||||
(json!({"sketch": true}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<DocumentInspectParams>(), &[(json!({"measure": true, "sketch_counts": false}), true), (json!({"measure": 1}), false)]);
|
||||
takes(input_schema_for::<CommandsParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A reply matches its output schema and, in Warn, carries `param_warnings` only when told to.
|
||||
fn matches<T: schemars::JsonSchema>(id: &str, reply: &Value, warned: bool) {
|
||||
let schema = output_schema_for::<T>();
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert_eq!(reply.get("param_warnings").is_some(), warned, "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// A design with a box, a cylinder, a sheet metal body, a sketch of every curve kind with a
|
||||
/// dimension, and a sketch that projects a face: returns the first sketch's feature id.
|
||||
fn design(s: &mut Session) -> u64 {
|
||||
run(s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"}));
|
||||
run(s, "solid.cylinder", json!({"radius": 4, "height": 20, "base": [40, 0, 0], "body_name": "Shaft"}));
|
||||
run(s, "material.create", json!({"name": "Alloy", "density_g_cm3": 2.5, "color": "#ff0000"}));
|
||||
run(
|
||||
s,
|
||||
"material.create",
|
||||
json!({"name": "Painted", "density_g_cm3": 1.5, "color": "#00ff00", "pbr": {"metallic": 0.5, "roughness": 0.5, "dielectric_ior": 1.5}}),
|
||||
);
|
||||
run(s, "sheet.manage_rules", json!({"name": "R1", "thickness": "1 mm", "bend_radius": "1 mm", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [60, 0], "p1": [100, 30]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(s, "sketch.create", json!({"plane": "XZ", "name": "Shapes"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 20]]}));
|
||||
run(s, "sketch.circle.center", json!({"center": [10, 10], "radius": 3}));
|
||||
run(s, "sketch.circle.center", json!({"center": [10, 10], "radius": 1}));
|
||||
run(s, "sketch.arc.three_point", json!({"start": [50, 0], "end": [60, 10], "through": [58, 3]}));
|
||||
run(s, "sketch.ellipse", json!({"center": [-30, 0], "major": [-20, 0], "minor_radius": 5}));
|
||||
run(s, "sketch.spline.fit_point", json!({"points": [[0, 40], [10, 50], [20, 40], [30, 50]]}));
|
||||
run(s, "sketch.spline.control_point", json!({"points": [[0, 60], [10, 70], [20, 60], [30, 70]]}));
|
||||
run(s, "sketch.conic", json!({"start": [0, 80], "end": [30, 80], "apex": [15, 100], "rho": 0.4}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["c1"]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
|
||||
run(s, "sketch.construction", json!({"curves": ["l2"]}));
|
||||
let id = run(s, "sketch.inspect", json!({}))["id"].as_u64().unwrap_or_default();
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
|
||||
run(s, "sketch.project", json!({"refs": [{"face": [5, 10, 30], "body": "Block"}]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
id
|
||||
}
|
||||
|
||||
/// Real calls of `document.inspect` in Warn, each form: the replies match the output schema
|
||||
/// without `param_warnings`, and say what the design has.
|
||||
#[test]
|
||||
fn document_inspect_replies_match_its_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
run(&mut s, "sketch.edit", json!({"sketch": shapes}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"measure": true}),
|
||||
json!({"sketch_counts": true}),
|
||||
json!({"measure": true, "sketch_counts": true}),
|
||||
json!({"measure": false, "sketch_counts": null}),
|
||||
] {
|
||||
let r = run(&mut s, "document.inspect", p.clone());
|
||||
matches::<DocumentInspectResult>("document.inspect", &r, false);
|
||||
assert_eq!(r["active_sketch"], json!(shapes), "{r}");
|
||||
assert_eq!(r["bodies"].as_array().map(Vec::len), Some(3), "{p}: {r}");
|
||||
assert_eq!(r["bodies"][0].get("area_mm2").is_some(), p["measure"] == true, "{p}: {r}");
|
||||
assert_eq!(r["bodies"][0].get("feature").is_some(), p["measure"] != true, "{p}: {r}");
|
||||
assert_eq!(r["sketches"][0].get("plane").is_some(), p["sketch_counts"] == true, "{p}: {r}");
|
||||
}
|
||||
let r = run(&mut s, "document.inspect", json!({"measure": true}));
|
||||
assert!(r["params"].as_array().is_some_and(|p| p.iter().any(|x| x["name"].is_string() && x["model"] == true)), "{r}");
|
||||
assert!(r["timeline"].as_array().is_some_and(|t| t.iter().any(|x| x["type"].is_string() && x["ms"].is_number())), "{r}");
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Shaft"], "visible": false}));
|
||||
let r = run(&mut s, "document.inspect", json!({}));
|
||||
matches::<DocumentInspectResult>("document.inspect", &r, false);
|
||||
assert_eq!(r["bodies"][1]["visible"], false, "{r}");
|
||||
// A body that a rolled-back later feature deletes says so.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.remove", json!({"bodies": ["Shaft"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Shapes"}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": shapes}));
|
||||
let r = run(&mut s, "document.inspect", json!({}));
|
||||
matches::<DocumentInspectResult>("document.inspect", &r, false);
|
||||
assert!(r["bodies"].as_array().is_some_and(|b| b.iter().any(|b| b["removed_later"] == true)), "{r}");
|
||||
}
|
||||
|
||||
/// `inspect.measure` in each form: bodies (with sheet metal and a material), names only, one
|
||||
/// item and two, in Warn without warnings. Everything it ignores with `items` is ignored.
|
||||
#[test]
|
||||
fn measure_replies_match_its_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Block"], "material": "Alloy"}));
|
||||
let id = "inspect.measure";
|
||||
let all = run(&mut s, id, json!({}));
|
||||
matches::<MeasureResult>(id, &all, false);
|
||||
assert_eq!(all["body_count"], 3, "{all}");
|
||||
assert_eq!(all["bodies"][0]["material"], "Alloy", "{all}");
|
||||
assert!((all["bodies"][0]["mass_g"].as_f64().unwrap_or_default() - 15.0).abs() < 1e-6, "{all}");
|
||||
assert!(all["bodies"].as_array().is_some_and(|b| b.iter().any(|b| b["sheet_metal"] == true)), "{all}");
|
||||
assert_eq!(all["total"]["open_bodies_skipped"], 0, "{all}");
|
||||
for p in [json!({"bodies": ["Block"]}), json!({"bodies": "Shaft", "names_only": false}), json!({"bodies": null})] {
|
||||
matches::<MeasureResult>(id, &run(&mut s, id, p), false);
|
||||
}
|
||||
let names = run(&mut s, id, json!({"names_only": true}));
|
||||
assert_eq!(names["bodies"][0], json!({"name": "Block", "definition": "Block"}), "{names}");
|
||||
matches::<MeasureResult>(id, &names, false);
|
||||
matches::<MeasureResult>(id, &run(&mut s, id, json!({"bodies": ["Block"], "names_only": true})), false);
|
||||
|
||||
let face = |body: &str, p: [i32; 3]| json!({"type": "face", "body": body, "point": p});
|
||||
let curve = |c: &str| json!({"type": "sketch_curve", "id": c, "sketch": shapes});
|
||||
for items in [
|
||||
json!([face("Block", [5, 10, 30])]),
|
||||
json!([face("Shaft", [44, 0, 10])]),
|
||||
json!([face("Block", [5, 10, 30]), face("Block", [5, 10, 0])]),
|
||||
json!([face("Block", [5, 10, 30]), face("Block", [10, 10, 15])]),
|
||||
json!([face("Shaft", [44, 0, 10]), face("Block", [5, 10, 30])]),
|
||||
json!([{"type": "edge", "body": "Block", "point": [5, 0, 30]}, {"type": "edge", "body": "Block", "point": [5, 20, 30]}]),
|
||||
json!([{"type": "body", "name": "Block"}]),
|
||||
json!([{"type": "body", "name": "Block"}, {"type": "body", "name": "Shaft"}]),
|
||||
json!([{"type": "vertex", "body": "Block", "point": [0, 0, 0]}, {"type": "vertex", "body": "Block", "point": [10, 20, 30]}]),
|
||||
json!([curve("c1")]),
|
||||
json!([curve("a1")]),
|
||||
json!([curve("l1")]),
|
||||
json!([curve("c1"), curve("c2")]),
|
||||
json!([curve("l1"), curve("l2")]),
|
||||
json!([curve("c1"), curve("l1")]),
|
||||
json!([curve("a1"), face("Block", [5, 10, 30])]),
|
||||
] {
|
||||
let r = run(&mut s, id, json!({ "items": items }));
|
||||
matches::<MeasureResult>(id, &r, false);
|
||||
assert!(r["items"].as_array().is_some_and(|i| i.len() == items.as_array().map_or(0, Vec::len)), "{r}");
|
||||
}
|
||||
let one = run(&mut s, id, json!({"items": [curve("c1")]}));
|
||||
assert!(one["diameter"] == 6.0 && one["circumference"].is_number() && one["center"].is_array(), "{one}");
|
||||
let arc = run(&mut s, id, json!({"items": [curve("a1")]}));
|
||||
assert!(arc["arc_length"].is_number() && arc["angle_deg"].is_number() && arc.get("circumference").is_none(), "{arc}");
|
||||
let two = run(&mut s, id, json!({"items": [face("Block", [5, 10, 30]), face("Block", [5, 10, 0])]}));
|
||||
assert!(two["distance_mm"] == 30.0 && two["min"] == 30.0 && two["measure"] == "min" && two["angle_deg"].is_number(), "{two}");
|
||||
let pair = run(&mut s, id, json!({"items": [curve("c1"), curve("c2")]}));
|
||||
assert!(pair["measure"] == "center" && pair["center_distance"].is_number(), "{pair}");
|
||||
// With `items`, `bodies` and `names_only` are not read at all.
|
||||
let r = run(&mut s, id, json!({"items": [face("Block", [5, 10, 30])], "bodies": 7, "names_only": "yes"}));
|
||||
matches::<MeasureResult>(id, &r, false);
|
||||
}
|
||||
|
||||
/// `sketch.inspect` of every curve kind, a dimension and a projected link, by id, by name and as
|
||||
/// the sketch being edited, in Warn without warnings.
|
||||
#[test]
|
||||
fn sketch_inspect_replies_match_its_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
let id = "sketch.inspect";
|
||||
for p in [json!({"sketch": shapes}), json!({"sketch": "Shapes"}), json!({"sketch": "Top"}), json!({"sketch": "Base"})] {
|
||||
matches::<SketchInspected>(id, &run(&mut s, id, p), false);
|
||||
}
|
||||
let r = run(&mut s, id, json!({"sketch": "Shapes"}));
|
||||
let kinds: Vec<&str> = r["curves"].as_array().into_iter().flatten().filter_map(|c| c["type"].as_str()).collect();
|
||||
for kind in ["line", "circle", "arc", "ellipse", "spline", "conic"] {
|
||||
assert!(kinds.contains(&kind), "{kind} in {kinds:?}");
|
||||
}
|
||||
assert!(
|
||||
r["constraints"].as_array().is_some_and(|c| c.iter().any(|c| c["param"].is_string() && c["expression"].is_string() && c["type"].is_string())),
|
||||
"{r}"
|
||||
);
|
||||
assert!(r["profiles"][0]["holes"].is_array() && r["points"][0]["link"].is_null(), "{r}");
|
||||
let top = run(&mut s, id, json!({"sketch": "Top"}));
|
||||
assert!(top["links"][0]["id"].is_string() && top["curves"][0]["link"].is_string(), "{top}");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Shapes"}));
|
||||
for p in [json!({}), json!({"sketch": null}), json!({"sketch": shapes.to_string()})] {
|
||||
let r = run(&mut s, id, p);
|
||||
matches::<SketchInspected>(id, &r, false);
|
||||
assert_eq!(r["name"], "Shapes", "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `material.assign` (a name, a list, the default material) and `material.list` (built-in
|
||||
/// materials and the design's, with and without a look) in Warn without warnings.
|
||||
#[test]
|
||||
fn material_replies_match_their_schemas() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
design(&mut s);
|
||||
let id = "material.assign";
|
||||
let r = run(&mut s, id, json!({"bodies": ["Block"], "material": "alloy"}));
|
||||
assert_eq!(r, json!({"bodies": ["Block"], "material": "Alloy"}));
|
||||
matches::<MaterialAssignResult>(id, &r, false);
|
||||
let r = run(&mut s, id, json!({"bodies": "Shaft", "material": " steel "}));
|
||||
assert_eq!(r["material"], "Steel", "{r}");
|
||||
matches::<MaterialAssignResult>(id, &r, false);
|
||||
matches::<MaterialAssignResult>(id, &run(&mut s, id, json!({"bodies": ["Block", "Shaft"], "material": "Painted"})), false);
|
||||
matches::<MaterialAssignResult>(id, &run(&mut s, id, json!({"bodies": ["Block"], "material": "Steel"})), false);
|
||||
|
||||
let listed = run(&mut s, "material.list", json!({}));
|
||||
matches::<MaterialListResult>("material.list", &listed, false);
|
||||
let materials = listed["materials"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(materials[0].as_object().map(|m| m.len()), Some(2), "{listed}");
|
||||
let mine = |name: &str| materials.iter().find(|m| m["name"] == name).cloned().unwrap_or_default();
|
||||
assert!(mine("Alloy")["user"] == true && mine("Alloy")["appearance"]["color"] == "#ff0000", "{listed}");
|
||||
assert!(mine("Painted")["appearance"]["pbr"]["metallic"] == 0.5, "{listed}");
|
||||
assert!(mine("Steel").get("user").is_none() && mine("Steel").get("appearance").is_none(), "{listed}");
|
||||
run(&mut s, "material.create", json!({"name": "Bare", "density_g_cm3": 1.0}));
|
||||
let listed = run(&mut s, "material.list", json!({}));
|
||||
matches::<MaterialListResult>("material.list", &listed, false);
|
||||
let bare = listed["materials"].as_array().into_iter().flatten().find(|m| m["name"] == "Bare").cloned().unwrap_or_default();
|
||||
assert!(bare["appearance"].is_null() && bare["user"] == true, "{listed}");
|
||||
}
|
||||
|
||||
/// `engine.commands` replies with a list, which carries no `param_warnings`: Strict is where a
|
||||
/// stray key is refused. The list has every command, and each entry matches its schema.
|
||||
#[test]
|
||||
fn the_command_list_matches_its_schema() {
|
||||
let mut strict = Session { param_check: ParamCheck::Strict, ..Session::default() };
|
||||
let list = run(&mut strict, "engine.commands", json!({}));
|
||||
let schema = output_schema_for::<Vec<CommandEntry>>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &list), "{list}");
|
||||
assert_eq!(list.as_array().map(Vec::len), Some(crate::command_specs().len()));
|
||||
assert!(list.as_array().is_some_and(|l| l.iter().any(|c| c["id"] == "engine.commands" && c["enabled"] == true && c["shortcut"].is_null())));
|
||||
assert!(list.as_array().is_some_and(|l| l.iter().any(|c| c["id"] == "inspect.measure" && c["shortcut"] == "I")));
|
||||
let error = strict.execute("engine.commands", &json!({"lst": 1})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("unknown key `lst`"), "{error}");
|
||||
let mut warn = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
assert!(run(&mut warn, "engine.commands", json!({"lst": 1})).is_array());
|
||||
}
|
||||
|
||||
/// A misspelt key runs in Warn and is reported, with a reply that still matches its schema.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let shapes = design(&mut s);
|
||||
let schemas = |id: &str| match id {
|
||||
"document.inspect" => output_schema_for::<DocumentInspectResult>(),
|
||||
"inspect.measure" => output_schema_for::<MeasureResult>(),
|
||||
"material.assign" => output_schema_for::<MaterialAssignResult>(),
|
||||
"material.list" => output_schema_for::<MaterialListResult>(),
|
||||
_ => output_schema_for::<SketchInspected>(),
|
||||
};
|
||||
for (id, p, suggestion) in [
|
||||
("document.inspect", json!({"mesure": true}), "measure"),
|
||||
("document.inspect", json!({"sketch_count": true}), "sketch_counts"),
|
||||
("inspect.measure", json!({"bodes": ["Block"]}), "bodies"),
|
||||
("inspect.measure", json!({"name_only": true}), "names_only"),
|
||||
("material.assign", json!({"bodies": ["Block"], "materal": "Steel", "material": "Steel"}), "material"),
|
||||
("material.list", json!({"materials": true}), ""),
|
||||
("sketch.inspect", json!({"sketch": shapes, "sketchs": 1}), "sketch"),
|
||||
] {
|
||||
let r = run(&mut s, id, p.clone());
|
||||
let warnings: Vec<&str> = r["param_warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
assert_eq!(warnings.len(), 1, "{id} {p}: {r}");
|
||||
assert!(warnings[0].starts_with("unknown key `") && warnings[0].contains(suggestion), "{id}: {warnings:?}");
|
||||
let schema = schemas(id);
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shapes the real designs above don't reach: a body whose measurement failed (a sheet metal
|
||||
/// one carries its corrected counts) and the null coordinates of two planes that meet.
|
||||
#[test]
|
||||
fn rare_reply_shapes_serialize_as_before_and_match_the_schema() {
|
||||
use super::{BodyFailure, BodyMeasurement, BodyOutcome, Measured, MeasuredItems};
|
||||
let failed = BodyMeasurement {
|
||||
name: "B".into(),
|
||||
outcome: BodyOutcome::Failed(BodyFailure { error: "boom".into(), faces: Some(2), edges: None, vertices: None }),
|
||||
};
|
||||
assert_eq!(serde_json::to_value(&failed).unwrap_or_default(), json!({"name": "B", "error": "boom", "faces": 2}));
|
||||
let items = MeasuredItems { from: Some([f64::NAN, 0.0, 0.0]), distance_mm: Some(0.0), ..MeasuredItems::default() };
|
||||
let reply = serde_json::to_value(MeasureResult { measured: Measured::Items(Box::new(items)), param_warnings: Vec::new() }).unwrap_or_default();
|
||||
assert_eq!(reply["from"], json!([null, 0.0, 0.0]));
|
||||
let schema = output_schema_for::<MeasureResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
|
||||
}
|
||||
|
||||
/// #11945: each body lists the features that built it, in timeline order: its own box, the fillet
|
||||
/// that changed it, and the cutting box a Combine consumed into it with that Combine. Another body
|
||||
/// keeps only its own. Rolled back before the Combine, neither it nor the tool's box are listed.
|
||||
#[test]
|
||||
fn body_features_list_what_built_each_body() {
|
||||
let schema = output_schema_for::<ModelBodyFeatures>();
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let id = |r: Value| r["feature"].as_u64().unwrap_or_else(|| panic!("{r}"));
|
||||
let a = id(run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "A"})));
|
||||
let b =
|
||||
id(run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "operation": "new", "body_name": "B"})));
|
||||
let fillet = id(run(&mut s, "solid.fillet", json!({"body": "A", "edges": [[10, 0, 10]], "radius": 1})));
|
||||
let tool =
|
||||
id(run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 20, "corner": [5, 5, -5], "operation": "new", "body_name": "T"})));
|
||||
let combine = id(run(&mut s, "solid.combine", json!({"target": "A", "tools": ["T"], "operation": "cut"})));
|
||||
|
||||
let listed = |r: &Value, body: &str| -> Vec<u64> {
|
||||
let entry = r["bodies"].as_array().into_iter().flatten().find(|e| e["body"] == body).unwrap_or_else(|| panic!("no {body} in {r}"));
|
||||
entry["features"].as_array().into_iter().flatten().filter_map(|f| f["id"].as_u64()).collect()
|
||||
};
|
||||
let r = run(&mut s, "model.body_features", json!({}));
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
assert_eq!(listed(&r, "A"), vec![a, fillet, tool, combine], "{r}");
|
||||
assert_eq!(listed(&r, "B"), vec![b], "{r}");
|
||||
assert_eq!(r["bodies"].as_array().map(Vec::len), Some(2), "the consumed tool is gone: {r}");
|
||||
assert_eq!(r["bodies"][0]["features"][1]["name"], s.doc.feature(fillet).map(|f| f.name.clone()).unwrap_or_default().as_str());
|
||||
|
||||
// One body, and the Session data the browser reads agree.
|
||||
let one = run(&mut s, "model.body_features", json!({"body": "B"}));
|
||||
assert_eq!((one["bodies"].as_array().map(Vec::len), listed(&one, "B")), (Some(1), vec![b]), "{one}");
|
||||
assert_eq!(s.body_features().get("A"), Some(&vec![a, fillet, tool, combine]));
|
||||
|
||||
run(&mut s, "timeline.rollback", json!({"position": 3}));
|
||||
assert_eq!(s.body_features().get("A"), Some(&vec![a, fillet]));
|
||||
assert_eq!(s.body_features().get("B"), Some(&vec![b]));
|
||||
|
||||
// A misspelt key warns; an unknown body is refused.
|
||||
let warned = run(&mut s, "model.body_features", json!({"bdy": "A"}));
|
||||
assert!(warned.get("param_warnings").is_some(), "{warned}");
|
||||
assert!(s.execute("model.body_features", &json!({"body": "Nope"})).is_err());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,7 @@ pub mod edit;
|
||||
pub mod features;
|
||||
pub mod features_more;
|
||||
pub mod file;
|
||||
pub mod frame;
|
||||
pub mod inspect;
|
||||
pub mod interference;
|
||||
pub mod joints;
|
||||
@@ -100,19 +101,26 @@ pub mod render_stage;
|
||||
pub mod render_surface_resources;
|
||||
pub mod render_ui;
|
||||
pub mod section;
|
||||
pub mod select;
|
||||
pub mod sheet;
|
||||
pub mod silhouette;
|
||||
pub mod sketch;
|
||||
pub mod sketch_3d;
|
||||
pub mod sketch_clipboard;
|
||||
pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
pub mod sketch_ends;
|
||||
pub mod sketch_equation;
|
||||
pub mod sketch_import;
|
||||
pub mod sketch_inspect;
|
||||
pub mod sketch_modify;
|
||||
pub mod sketch_project;
|
||||
pub mod sketch_session;
|
||||
pub mod sketch_shapes;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
pub mod surface_match;
|
||||
pub mod timeline_clipboard;
|
||||
pub mod ui_prefs;
|
||||
pub mod view_navigation;
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::check::whole;
|
||||
use solidcraft_geom::Vec3Geom as _;
|
||||
|
||||
use super::check::{located, whole};
|
||||
|
||||
/// Find where each value of a joint can go before the parts it moves touch another body. The
|
||||
/// design is not changed.
|
||||
@@ -625,6 +627,268 @@ pub struct MotionStudyDeleted {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- Exploded views (#12501) ----
|
||||
//
|
||||
// The exploded view handlers read their parameters from the raw JSON, so each key here reads as
|
||||
// its handler reads it and fails with its message. Whether an occurrence or a view exists stays
|
||||
// the handler's to check.
|
||||
|
||||
/// Make an exploded view: occurrences moved apart, shown with `explode.show`. The design keeps
|
||||
/// its placements. One undo step. With `moves`, each occurrence moves as given; without, every
|
||||
/// top-level occurrence is pushed away from the assembly's centre by `scale` times its offset.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExplodeCreateKeys {
|
||||
/// The view's name. Default: "Exploded View" and the first number no view's name has.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// For the automatic view: how far each occurrence is pushed away from the assembly's
|
||||
/// centre, as a multiple of its offset from it (a number from -100 to 100). Default: 1.
|
||||
/// Not read, and so not checked, when `moves` is given.
|
||||
#[serde(default, deserialize_with = "explode_scale", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "explode_scale_schema")]
|
||||
pub scale: Option<f64>,
|
||||
/// The moves, instead of the automatic view (at most 10 000): each an `occurrence` (by id or
|
||||
/// name, or a component's name for its first occurrence) and its `translate`, in mm, as
|
||||
/// [x, y, z] or {x, y, z}, less than 1e7 in length. An occurrence named twice is moved by
|
||||
/// the last.
|
||||
#[serde(default, deserialize_with = "explode_moves", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "explode_moves_schema")]
|
||||
pub moves: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
/// `explode.create`'s parameters: [`ExplodeCreateKeys`], where `scale` is read only without
|
||||
/// `moves`, as the handler does.
|
||||
#[derive(Debug)]
|
||||
pub struct ExplodeCreateParams(pub ExplodeCreateKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ExplodeCreateParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
// `moves`, even `null`, is what the handler goes by: it never reads `scale` then.
|
||||
if let Some(keys) = value.as_object_mut()
|
||||
&& keys.contains_key("moves")
|
||||
{
|
||||
keys.remove("scale");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ExplodeCreateParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ExplodeCreateParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ExplodeCreateParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ExplodeCreateKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// `scale`: a number of at most 100 in size, with the handler's message for anything else,
|
||||
/// `null` included.
|
||||
fn explode_scale<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && x.abs() <= 100.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(whole("`scale` must be a number up to 100")))
|
||||
}
|
||||
|
||||
fn explode_scale_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "For the automatic view: how far each occurrence is pushed away from the assembly's centre, as a multiple of its offset from it (a number from -100 to 100). Default: 1. Not read, and so not checked, when `moves` is given.",
|
||||
"type": "number",
|
||||
"minimum": -100,
|
||||
"maximum": 100
|
||||
})
|
||||
}
|
||||
|
||||
/// Why the handler refuses `item`, one of `moves`, before looking at the design.
|
||||
/// The handler also looks each occurrence up as it goes, so a call with an unknown occurrence in
|
||||
/// an earlier move and a malformed later one gets the handler's "no occurrence" message with the
|
||||
/// check off and this shape message in strict; both refuse the call.
|
||||
fn explode_move_problem(item: &Value) -> Option<&'static str> {
|
||||
if !matches!(item.get("occurrence"), Some(Value::Number(_) | Value::String(_))) {
|
||||
return Some("an occurrence must be an id or a name");
|
||||
}
|
||||
let translate = item.get("translate").and_then(crate::params::vec3).filter(|v| v.len() < 1e7);
|
||||
translate.is_none().then_some("each move needs `translate`: [x, y, z]")
|
||||
}
|
||||
|
||||
/// `moves`: a list of at most 10 000 moves, each checked as [`explode_move_problem`] says. Given,
|
||||
/// `null` included, it must be a list, as the handler reads it.
|
||||
fn explode_moves<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<Value>>, D::Error> {
|
||||
let Value::Array(moves) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`moves` must be a list")));
|
||||
};
|
||||
if moves.len() > MAX_EXPLODE_ITEMS {
|
||||
return Err(D::Error::custom(whole("too many moves")));
|
||||
}
|
||||
if let Some(problem) = moves.iter().find_map(explode_move_problem) {
|
||||
return Err(D::Error::custom(whole(problem)));
|
||||
}
|
||||
Ok(Some(moves))
|
||||
}
|
||||
|
||||
/// The most moves a view takes, and the most views a design has, as `cmd/motion.rs`'s `MAX_ITEMS`.
|
||||
const MAX_EXPLODE_ITEMS: usize = 10_000;
|
||||
|
||||
fn explode_moves_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let c = json!({"type": "number", "exclusiveMinimum": -1e7, "exclusiveMaximum": 1e7});
|
||||
json_schema!({
|
||||
"description": "The moves, instead of the automatic view (at most 10 000): each an `occurrence` (by id or name, or a component's name for its first occurrence) and its `translate`, in mm, as [x, y, z] or {x, y, z}, less than 1e7 in length. An occurrence named twice is moved by the last.",
|
||||
"type": "array",
|
||||
"maxItems": MAX_EXPLODE_ITEMS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["occurrence", "translate"],
|
||||
"properties": {
|
||||
"occurrence": {"type": ["number", "string"]},
|
||||
"translate": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
|
||||
{"type": "object", "properties": {"x": c, "y": c, "z": c}, "required": ["x", "y", "z"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// An exploded view by its id (a number) or its name.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ViewRef(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
v @ (Value::Number(_) | Value::String(_)) => Ok(ViewRef(v)),
|
||||
_ => Err(D::Error::custom(whole("`view` must be an id or a name"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ViewRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ViewRef".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An exploded view's id (a number) or its name.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
/// Show an exploded view in the model: the occurrences are drawn moved apart, and the design
|
||||
/// keeps its placements. Without a view (or `none`), show the assembled placement again.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExplodeShowParams {
|
||||
/// The view to show, by id (a number) or name. Default: none, which shows the assembled
|
||||
/// placement; `null` and the text `none` (in any case) mean the same.
|
||||
#[serde(default)]
|
||||
pub view: Option<ViewRef>,
|
||||
}
|
||||
|
||||
/// List the design's exploded views. Nothing is changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExplodeListParams {}
|
||||
|
||||
/// Delete an exploded view. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = view_required)]
|
||||
pub struct ExplodeDeleteParams {
|
||||
// Required in the schema (`view_required`), optional in the type: left out, the handler
|
||||
// refuses it with its own message, not the check's "is required". `null` is refused too.
|
||||
/// The view to delete, by id (a number) or name.
|
||||
#[serde(default, deserialize_with = "some_view", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ViewRef")]
|
||||
pub view: Option<ViewRef>,
|
||||
}
|
||||
|
||||
/// `view`: an id or a name, with the handler's message for anything else, `null` included.
|
||||
fn some_view<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ViewRef>, D::Error> {
|
||||
ViewRef::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn view_required(schema: &mut Schema) {
|
||||
require(schema, "view");
|
||||
}
|
||||
|
||||
/// One move of an exploded view.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeMoveInfo {
|
||||
/// The occurrence, by id.
|
||||
pub occurrence: u64,
|
||||
/// How far it moves, in mm (world).
|
||||
pub translate: [f64; 3],
|
||||
}
|
||||
|
||||
impl From<&(u64, solidcore_record::Vec3)> for ExplodeMoveInfo {
|
||||
fn from((occurrence, t): &(u64, solidcore_record::Vec3)) -> Self {
|
||||
ExplodeMoveInfo { occurrence: *occurrence, translate: [t.x, t.y, t.z] }
|
||||
}
|
||||
}
|
||||
|
||||
/// What `explode.create` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeCreated {
|
||||
/// The new view's id.
|
||||
pub view: u64,
|
||||
pub name: String,
|
||||
pub moves: Vec<ExplodeMoveInfo>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `explode.show` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeShown {
|
||||
/// The view shown, by id; `null` when the assembled placement is shown.
|
||||
pub view: Option<u64>,
|
||||
/// The view's name; not there when the assembled placement is shown.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `explode.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeViews {
|
||||
pub views: Vec<ExplodedViewInfo>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// An exploded view, as the design saves it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodedViewInfo {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// [occurrence id, translation in mm] pairs.
|
||||
pub moves: Vec<(u64, [f64; 3])>,
|
||||
}
|
||||
|
||||
impl From<&solidcraft_doc::motion::ExplodedView> for ExplodedViewInfo {
|
||||
fn from(view: &solidcraft_doc::motion::ExplodedView) -> Self {
|
||||
let moves = view.moves.iter().map(|(o, t)| (*o, [t.x, t.y, t.z])).collect();
|
||||
ExplodedViewInfo { id: view.id, name: view.name.clone(), moves }
|
||||
}
|
||||
}
|
||||
|
||||
/// What `explode.delete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExplodeDeleted {
|
||||
/// The deleted view's name.
|
||||
pub deleted: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "motion_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,7 +2,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ContactCheck, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetCreated, ContactSetEdited, ContactSets,
|
||||
ContactSwitch, FindLimitsParams, FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted,
|
||||
ContactSwitch, ExplodeCreateParams, ExplodeCreated, ExplodeDeleteParams, ExplodeDeleted, ExplodeListParams, ExplodeShowParams, ExplodeShown,
|
||||
ExplodeViews, FindLimitsParams, FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted,
|
||||
MotionStudyParams, MotionStudySaved,
|
||||
};
|
||||
use crate::Session;
|
||||
@@ -19,6 +20,10 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MotionStudyParams>(), &["keys", "name", "steps", "study"]);
|
||||
assert_keys(input_schema_for::<MotionListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MotionStudyDeleteParams>(), &["study"]);
|
||||
assert_keys(input_schema_for::<ExplodeCreateParams>(), &["moves", "name", "scale"]);
|
||||
assert_keys(input_schema_for::<ExplodeShowParams>(), &["view"]);
|
||||
assert_keys(input_schema_for::<ExplodeListParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ExplodeDeleteParams>(), &["view"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,3 +264,203 @@ fn motion_studies_reply_with_their_schemas() {
|
||||
let error = s.execute("motion.study", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`step`"), "{error}");
|
||||
}
|
||||
|
||||
/// The exploded view commands in a design with three occurrences, in Off and in Strict: each call
|
||||
/// the handler refuses is refused with the same message. They need the door's occurrences, so
|
||||
/// the shared helper doesn't fit.
|
||||
#[test]
|
||||
fn strict_keeps_the_exploded_views_errors() {
|
||||
let many: Vec<Value> = (0..10_001).map(|_| move_of(json!("Door"), json!([1, 2, 3]))).collect();
|
||||
for (id, p) in [
|
||||
("explode.create", json!({"moves": null})),
|
||||
("explode.create", json!({"moves": "Door"})),
|
||||
("explode.create", json!({"moves": {"occurrence": "Door", "translate": [1, 2, 3]}})),
|
||||
("explode.create", json!({"moves": many})),
|
||||
("explode.create", json!({"moves": [{}]})),
|
||||
("explode.create", json!({"moves": [true]})),
|
||||
("explode.create", json!({"moves": [move_of(json!(null), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!(true), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!(["Door"]), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Nope"), json!([1, 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [{"occurrence": "Door"}]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!(null))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([1, 2]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!({"x": 1, "y": 2}))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!(["a", 2, 3]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([1e7, 0, 0]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([8e6, 8e6, 0]))]})),
|
||||
("explode.create", json!({"moves": [move_of(json!("Door"), json!([1, 2, 3])), {"translate": [1, 2, 3]}]})),
|
||||
("explode.create", json!({"scale": "2"})),
|
||||
("explode.create", json!({"scale": null})),
|
||||
("explode.create", json!({"scale": true})),
|
||||
("explode.create", json!({"scale": 100.5})),
|
||||
("explode.create", json!({"scale": -101})),
|
||||
("explode.show", json!({"view": true})),
|
||||
("explode.show", json!({"view": [1]})),
|
||||
("explode.show", json!({"view": {"id": 1}})),
|
||||
("explode.show", json!({"view": "Nope"})),
|
||||
("explode.show", json!({"view": 7})),
|
||||
("explode.delete", json!({})),
|
||||
("explode.delete", json!({"view": null})),
|
||||
("explode.delete", json!({"view": false})),
|
||||
("explode.delete", json!({"view": ["Apart"]})),
|
||||
("explode.delete", json!({"view": "Nope"})),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = door();
|
||||
s.param_check = level;
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn move_of(occurrence: Value, translate: Value) -> Value {
|
||||
json!({"occurrence": occurrence, "translate": translate})
|
||||
}
|
||||
|
||||
/// The exploded view types read each key as the handlers do: `scale` only without `moves`, a move
|
||||
/// by an occurrence's id or name with a point in either form, `view` left out, `null` or `none`
|
||||
/// to show the assembled placement but refused by `explode.delete`, and keys a move carries
|
||||
/// besides its two ignored. Each case is also checked against the schema.
|
||||
#[test]
|
||||
fn the_exploded_view_types_read_the_keys_as_the_handlers_do() {
|
||||
let issues = |id: &str, p: &Value| crate::schema::issues(id, p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let ok = |id: &str, p: Value, takes: bool| assert_eq!(issues(id, &p).is_empty(), takes, "{id} {p}: {:?}", issues(id, &p));
|
||||
let schema_takes = |schema: schemars::Schema, p: Value, takes: bool| {
|
||||
let schema = schema.as_value();
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
};
|
||||
// `scale` is read only when `moves` is not there; `moves` is read when it is there at all.
|
||||
let one = move_of(json!(3), json!({"x": 1, "y": 2, "z": 3}));
|
||||
for (p, takes) in [
|
||||
(json!({}), true),
|
||||
(json!({"name": "Apart", "scale": 2.5}), true),
|
||||
(json!({"scale": -100}), true),
|
||||
(json!({"scale": 101}), false),
|
||||
(json!({"scale": "2"}), false),
|
||||
(json!({"scale": null}), false),
|
||||
(json!({"moves": [], "scale": "ignored"}), true),
|
||||
(json!({"moves": [one.clone()], "scale": null}), true),
|
||||
(json!({"moves": [{"occurrence": "Door", "translate": [0, 0, 5], "note": "ignored"}]}), true),
|
||||
(json!({"moves": null, "scale": 2}), false),
|
||||
(json!({"moves": "Door"}), false),
|
||||
(json!({"moves": [{"occurrence": "Door"}]}), false),
|
||||
(json!({"moves": [{"occurrence": true, "translate": [0, 0, 5]}]}), false),
|
||||
(json!({"moves": [{"occurrence": "Door", "translate": [0, 0, 1e7]}]}), false),
|
||||
] {
|
||||
ok("explode.create", p, takes);
|
||||
}
|
||||
schema_takes(input_schema_for::<ExplodeCreateParams>(), json!({"moves": [one], "name": "N"}), true);
|
||||
schema_takes(input_schema_for::<ExplodeCreateParams>(), json!({"moves": [{"occurrence": "Door"}]}), false);
|
||||
schema_takes(input_schema_for::<ExplodeCreateParams>(), json!({"scale": 101}), false);
|
||||
// Showing: no view, `null` and `none` in any case show the assembled placement.
|
||||
for (p, takes) in [
|
||||
(json!({}), true),
|
||||
(json!({"view": null}), true),
|
||||
(json!({"view": "None"}), true),
|
||||
(json!({"view": "Apart"}), true),
|
||||
(json!({"view": 4}), true),
|
||||
(json!({"view": true}), false),
|
||||
(json!({"view": ["Apart"]}), false),
|
||||
] {
|
||||
ok("explode.show", p.clone(), takes);
|
||||
schema_takes(input_schema_for::<ExplodeShowParams>(), p, takes);
|
||||
}
|
||||
// Deleting needs a view, by id or name.
|
||||
for (p, takes) in [(json!({"view": "Apart"}), true), (json!({"view": 4}), true), (json!({}), false), (json!({"view": null}), false)] {
|
||||
schema_takes(input_schema_for::<ExplodeDeleteParams>(), p, takes);
|
||||
}
|
||||
ok("explode.delete", json!({"view": "Apart"}), true);
|
||||
assert_eq!(issues("explode.delete", &json!({"view": null})), ["`view` must be an id or a name"]);
|
||||
assert_eq!(issues("explode.delete", &json!({})), Vec::<String>::new(), "the handler gives its own message for a missing view");
|
||||
assert_eq!(issues("explode.show", &json!({"view": true})), ["`view` must be an id or a name"]);
|
||||
assert_eq!(issues("explode.create", &json!({"scale": "x"})), ["`scale` must be a number up to 100"]);
|
||||
assert_eq!(issues("explode.create", &json!({"moves": 1})), ["`moves` must be a list"]);
|
||||
assert_eq!(issues("explode.create", &json!({"moves": [{}]})), ["an occurrence must be an id or a name"]);
|
||||
assert_eq!(issues("explode.create", &json!({"moves": [{"occurrence": 1}]})), ["each move needs `translate`: [x, y, z]"]);
|
||||
assert_eq!(issues("explode.list", &json!({"x": 1})).len(), 1);
|
||||
}
|
||||
|
||||
/// Making, showing, listing and deleting exploded views reply as the output schemas say, in Warn
|
||||
/// without warnings, with the JSON the handlers have always returned. A misspelt key gets
|
||||
/// `param_warnings` in Warn, since the handlers ignore unknown keys, and strict refuses it.
|
||||
#[test]
|
||||
fn exploded_views_reply_with_their_schemas() {
|
||||
let mut s = door();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let run = |s: &mut Session, id: &str, p: Value, schema: schemars::Schema| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let auto = run(&mut s, "explode.create", json!({}), output_schema_for::<ExplodeCreated>());
|
||||
assert_eq!(auto["name"], "Exploded View1", "{auto}");
|
||||
assert_eq!(auto["moves"].as_array().map(Vec::len), Some(3), "{auto}");
|
||||
assert!(auto["moves"][0]["occurrence"].is_u64() && auto["moves"][0]["translate"].as_array().is_some_and(|t| t.len() == 3), "{auto}");
|
||||
let scaled = run(&mut s, "explode.create", json!({"name": "Far", "scale": 2.5}), output_schema_for::<ExplodeCreated>());
|
||||
assert_eq!(scaled["name"], "Far", "{scaled}");
|
||||
run(&mut s, "explode.create", json!({"scale": -1}), output_schema_for::<ExplodeCreated>());
|
||||
// `scale` is not read with `moves`, so a bad one is no problem.
|
||||
let by_hand = run(
|
||||
&mut s,
|
||||
"explode.create",
|
||||
json!({"name": "Hand", "scale": "ignored", "moves": [move_of(json!("Door"), json!([0, 0, 50])), move_of(json!("Slide"), json!({"x": 1, "y": 2, "z": 3}))]}),
|
||||
output_schema_for::<ExplodeCreated>(),
|
||||
);
|
||||
let (door_id, slide_id) = (by_hand["moves"][0]["occurrence"].clone(), by_hand["moves"][1]["occurrence"].clone());
|
||||
let expected = json!({
|
||||
"view": by_hand["view"],
|
||||
"name": "Hand",
|
||||
"moves": [{"occurrence": door_id, "translate": [0.0, 0.0, 50.0]}, {"occurrence": slide_id, "translate": [1.0, 2.0, 3.0]}]
|
||||
});
|
||||
assert_eq!(by_hand, expected);
|
||||
run(&mut s, "explode.create", json!({"name": "Empty", "moves": []}), output_schema_for::<ExplodeCreated>());
|
||||
|
||||
let list = run(&mut s, "explode.list", json!({}), output_schema_for::<ExplodeViews>());
|
||||
assert_eq!(list["views"].as_array().map(Vec::len), Some(5), "{list}");
|
||||
// Exactly what the design's own views serialize to, as the handler returned them before.
|
||||
assert_eq!(list["views"], serde_json::to_value(&s.doc.assembly.exploded).unwrap_or_default());
|
||||
assert_eq!(list["views"][3], json!({"id": by_hand["view"], "name": "Hand", "moves": [[door_id, [0.0, 0.0, 50.0]], [slide_id, [1.0, 2.0, 3.0]]]}));
|
||||
|
||||
// Shown by name, by id and, to put the assembled placement back, by nothing, `null` or `none`.
|
||||
let r = run(&mut s, "explode.show", json!({"view": "Hand"}), output_schema_for::<ExplodeShown>());
|
||||
assert_eq!(r, json!({"view": by_hand["view"], "name": "Hand"}));
|
||||
let r = run(&mut s, "explode.show", json!({"view": scaled["view"]}), output_schema_for::<ExplodeShown>());
|
||||
assert_eq!(r, json!({"view": scaled["view"], "name": "Far"}));
|
||||
for p in [json!({}), json!({"view": null}), json!({"view": "None"})] {
|
||||
run(&mut s, "explode.show", json!({"view": "Hand"}), output_schema_for::<ExplodeShown>());
|
||||
let r = run(&mut s, "explode.show", p, output_schema_for::<ExplodeShown>());
|
||||
assert_eq!(r, json!({"view": null}));
|
||||
}
|
||||
|
||||
let r = run(&mut s, "explode.delete", json!({"view": "Hand"}), output_schema_for::<ExplodeDeleted>());
|
||||
assert_eq!(r, json!({"deleted": "Hand"}));
|
||||
let r = run(&mut s, "explode.delete", json!({"view": scaled["view"]}), output_schema_for::<ExplodeDeleted>());
|
||||
assert_eq!(r, json!({"deleted": "Far"}));
|
||||
|
||||
// A misspelt key runs in Warn and says so, with a reply that still matches; strict refuses it.
|
||||
run(&mut s, "explode.create", json!({"name": "Typo"}), output_schema_for::<ExplodeCreated>());
|
||||
for (id, p, schema) in [
|
||||
("explode.create", json!({"nme": "Typo"}), output_schema_for::<ExplodeCreated>()),
|
||||
("explode.show", json!({"veiw": "Exploded View"}), output_schema_for::<ExplodeShown>()),
|
||||
("explode.list", json!({"view": 1}), output_schema_for::<ExplodeViews>()),
|
||||
("explode.delete", json!({"view": "Typo", "name": 1}), output_schema_for::<ExplodeDeleted>()),
|
||||
] {
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()) && conforms(schema, schema, &r), "{id}: {r}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("unknown key"), "{id}: {error}");
|
||||
}
|
||||
let before = s.doc.assembly.exploded.len();
|
||||
s.param_check = ParamCheck::Strict;
|
||||
assert!(s.execute("explode.create", &json!({"nme": "Typo"})).is_err());
|
||||
assert_eq!(s.doc.assembly.exploded.len(), before, "strict ran nothing");
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
//! Contracts and result types for the commands in `cmd/parameters.rs` that manage the design's
|
||||
//! user parameters by name (#11941): `parameters.add`, `.rename`, `.delete`, `.comment`,
|
||||
//! `.favorite` and `.move`.
|
||||
//! Contracts and result types for the commands in `cmd/parameters.rs`: those that manage the
|
||||
//! design's user parameters by name (#11941), `parameters.add`, `.rename`, `.delete`, `.comment`,
|
||||
//! `.favorite` and `.move`, and the rest (#12497): `parameters.change`, `.list`, `.users`,
|
||||
//! `.graph`, `.export`, `.import`, `.complete` and `expr.evaluate`.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a parameter exists, and whether a new
|
||||
//! name, unit or expression is valid, stays the document's to check, with its messages.
|
||||
|
||||
use schemars::{JsonSchema, Schema};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::{ParamRow, ParamUser};
|
||||
|
||||
use super::check::whole;
|
||||
use super::check::{located, whole};
|
||||
|
||||
/// A parameter's name, as `cmd/parameters.rs`'s `param_name` reads `name`: text that isn't blank,
|
||||
/// with its message for anything else. Spaces at either end are dropped.
|
||||
@@ -296,6 +302,854 @@ pub struct ParamMoved {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// #12497: `parameters.change`, `.list`, `.users`, `.graph`, `.export`, `.import`, `.complete`
|
||||
// and `expr.evaluate`.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Adds `key` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it when it's left out.
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
match schema.get_mut("required") {
|
||||
Some(Value::Array(required)) => {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
required.push(key.into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
schema.insert("required".into(), Value::Array(vec![key.into()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` as text: `null` is left out, as `str_` reads it. Anything else is refused, though the
|
||||
/// handler would read it as left out; `message` is the handler's for that case, if it has one.
|
||||
fn text_or_null<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole(message))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `parameters.change` takes, in the form it's read in: see [`ParamChangeParams`].
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = change_forms)]
|
||||
pub struct ParamChangeKeys {
|
||||
/// Several existing parameters at once, `{name: expression}`: they are checked together and
|
||||
/// the design is rebuilt once, all or nothing. Renaming isn't done this way. When given,
|
||||
/// no other key is read.
|
||||
#[serde(default, deserialize_with = "changes_map", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "changes_schema")]
|
||||
pub changes: Option<Map<String, Value>>,
|
||||
/// Delete this user parameter (refused while something uses it). When given as text, no
|
||||
/// other key but `changes` is read.
|
||||
#[serde(default, deserialize_with = "delete_text")]
|
||||
pub delete: Option<String>,
|
||||
/// The parameter to change, by name: a user parameter, a sketch dimension or a feature's
|
||||
/// named input. Spaces at either end are dropped.
|
||||
#[serde(default, deserialize_with = "change_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String", length(min = 1))]
|
||||
pub name: Option<String>,
|
||||
/// The new value: a number, or an expression as text (`25 mm`, `W / 2`). A text
|
||||
/// parameter's expression is its text, quotes round it optional. `name = value` renames
|
||||
/// the parameter to `name` and sets the value (or sets `name` when the value is just
|
||||
/// `name`). Needed unless `new_name`, `favorite` or `comment` is given.
|
||||
#[serde(default, deserialize_with = "expression")]
|
||||
#[schemars(schema_with = "expression_schema")]
|
||||
pub expression: Option<Value>,
|
||||
/// Another name for `expression`, read when `expression` isn't a number or text.
|
||||
#[serde(default, deserialize_with = "expression")]
|
||||
#[schemars(schema_with = "expression_schema")]
|
||||
pub value: Option<Value>,
|
||||
/// The unit: `mm`, `cm`, `m`, `in`, `ft`, `deg`, `rad`, `""` (no unit) or `text`. Default:
|
||||
/// the parameter's own, or for a new one its value's unit, a length in the design's units.
|
||||
#[serde(default, deserialize_with = "unit_text")]
|
||||
pub unit: Option<String>,
|
||||
/// A note to show with the parameter.
|
||||
#[serde(default, deserialize_with = "comment_key")]
|
||||
pub comment: Option<String>,
|
||||
/// List it with the favourites, first, or not.
|
||||
#[serde(default, deserialize_with = "favorite_flag")]
|
||||
pub favorite: Option<bool>,
|
||||
/// Rename the parameter: the expressions, feature inputs and dimensions that use it follow.
|
||||
/// With an `expression` the new value is set too.
|
||||
#[serde(default, deserialize_with = "new_name_key")]
|
||||
pub new_name: Option<String>,
|
||||
}
|
||||
|
||||
fn changes_map<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Map<String, Value>>, D::Error> {
|
||||
let Value::Object(map) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`changes` must be an object of name: expression")));
|
||||
};
|
||||
if map.is_empty() {
|
||||
return Err(D::Error::custom(whole("`changes` is empty")));
|
||||
}
|
||||
for (name, e) in &map {
|
||||
// `expr_arg`: text, or a number.
|
||||
if !matches!(e, Value::String(_) | Value::Number(_)) {
|
||||
return Err(D::Error::custom(whole(format!("`changes.{name}` must be a number or text"))));
|
||||
}
|
||||
}
|
||||
Ok(Some(map))
|
||||
}
|
||||
|
||||
fn changes_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Several existing parameters at once, `{name: expression}`: they are checked together and the design is rebuilt once, all or nothing. Renaming isn't done this way (`name = value` is refused). When given, no other key is read.",
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": {"type": ["number", "string"]}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_text<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`delete` must be text")
|
||||
}
|
||||
|
||||
fn unit_text<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`unit` must be text")
|
||||
}
|
||||
|
||||
fn comment_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`comment` must be text")
|
||||
}
|
||||
|
||||
fn new_name_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`new_name` must be text")
|
||||
}
|
||||
|
||||
fn favorite_flag<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(D::Error::custom(whole("`favorite` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` of a single change, as `param_name` reads it: text that isn't blank, `null` included
|
||||
/// in what is refused.
|
||||
fn change_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
param_name(d).map(Some)
|
||||
}
|
||||
|
||||
/// `parameters.change` takes one of three forms: `changes`, `delete` or `name` with what to
|
||||
/// change about it.
|
||||
fn change_forms(schema: &mut Schema) {
|
||||
let name_with = json!({
|
||||
"required": ["name"],
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"anyOf": [
|
||||
{"required": ["expression"], "properties": {"expression": {"type": ["number", "string"]}}},
|
||||
{"required": ["value"], "properties": {"value": {"type": ["number", "string"]}}},
|
||||
{"required": ["new_name"]},
|
||||
{"required": ["favorite"]},
|
||||
{"required": ["comment"]}
|
||||
]
|
||||
});
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{"required": ["changes"]},
|
||||
{"required": ["delete"], "properties": {"delete": {"type": "string"}}},
|
||||
name_with
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Change a parameter: set its expression, unit, comment or favourite, rename it (references
|
||||
/// follow), delete it, or set several existing parameters at once. The design is rebuilt once
|
||||
/// and the features that changed are named in the reply. One undo step.
|
||||
///
|
||||
/// The handler reads `changes` alone when it is given, else `delete` alone when that is text,
|
||||
/// else the keys of a single change; the keys of a form it doesn't read are left unchecked.
|
||||
#[derive(Debug)]
|
||||
pub struct ParamChangeParams(pub ParamChangeKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ParamChangeParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut() {
|
||||
let only = if keys.contains_key("changes") {
|
||||
Some("changes")
|
||||
} else if matches!(keys.get("delete"), Some(Value::String(_))) {
|
||||
Some("delete")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Otherwise a single change: `changes` isn't there, and `delete` isn't text.
|
||||
if let Some(only) = only {
|
||||
keys.retain(|k, _| k == only || !CHANGE_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
// `value` is read only when `expression` isn't a number or text.
|
||||
else if matches!(keys.get("expression"), Some(Value::String(_) | Value::Number(_))) {
|
||||
keys.remove("value");
|
||||
}
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ParamChangeParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
const CHANGE_KEYS: [&str; 9] = ["changes", "delete", "name", "expression", "value", "unit", "comment", "favorite", "new_name"];
|
||||
|
||||
impl JsonSchema for ParamChangeParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ParamChangeParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ParamChangeKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature that failed to rebuild: its name and error.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FailedFeature {
|
||||
/// The feature's name.
|
||||
pub feature: String,
|
||||
/// Why it failed.
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl FailedFeature {
|
||||
/// The features of `results` that failed.
|
||||
pub fn of(results: &[solidcraft_doc::FeatureResult]) -> Vec<FailedFeature> {
|
||||
results.iter().filter_map(|r| r.error.as_ref().map(|e| FailedFeature { feature: r.name.clone(), error: e.clone() })).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature a configuration rule switched on or off.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SwitchedFeature {
|
||||
/// The feature's id.
|
||||
pub feature: u64,
|
||||
/// Its name; `null` if it is gone.
|
||||
pub name: Option<String>,
|
||||
/// Whether it is suppressed now.
|
||||
pub suppressed: bool,
|
||||
}
|
||||
|
||||
/// A parameter that was renamed.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamRename {
|
||||
/// The old name.
|
||||
pub from: String,
|
||||
/// The new name.
|
||||
pub to: String,
|
||||
/// How many expressions, feature inputs and dimensions were changed to use the new name.
|
||||
pub references_updated: usize,
|
||||
}
|
||||
|
||||
/// What `parameters.change` returns: one of three shapes, by what was asked.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamChanged {
|
||||
#[serde(flatten)]
|
||||
pub body: ParamChangedBody,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The shapes of a `parameters.change` reply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ParamChangedBody {
|
||||
/// A parameter was deleted (`delete`).
|
||||
Deleted {
|
||||
/// The deleted parameter's name.
|
||||
deleted: String,
|
||||
},
|
||||
/// Several parameters were set (`changes`).
|
||||
Many {
|
||||
/// Each changed parameter's value, in mm for a length and degrees for an angle; `null`
|
||||
/// where it can't be worked out.
|
||||
changed: BTreeMap<String, Option<f64>>,
|
||||
/// How many features were rebuilt.
|
||||
recomputed: usize,
|
||||
/// How many features reused an earlier result for the same inputs (a value set back).
|
||||
restored: usize,
|
||||
/// The features that failed to rebuild.
|
||||
errors: Vec<FailedFeature>,
|
||||
/// The features the configuration rules switched, when they did.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
switched: Vec<SwitchedFeature>,
|
||||
},
|
||||
/// One parameter was changed.
|
||||
One(Box<ParamChangedOne>),
|
||||
}
|
||||
|
||||
/// What changing one parameter returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamChangedOne {
|
||||
/// Set when the parameter was renamed (`new_name`, or `name = expression`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub renamed: Option<ParamRename>,
|
||||
/// Set when `name = expression` set the parameter that the changed value's expression named:
|
||||
/// the name asked for.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub changed_through: Option<String>,
|
||||
/// The features the configuration rules switched, when they did.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub switched: Vec<SwitchedFeature>,
|
||||
/// The parameter's name now.
|
||||
pub name: String,
|
||||
/// The expression it has now; `null` when only its name, comment or favourite changed.
|
||||
pub expression: Option<String>,
|
||||
/// A text parameter's text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
/// Its value in mm for a length and degrees for an angle, as in every reply; `null` if it
|
||||
/// can't be worked out.
|
||||
pub value: Option<f64>,
|
||||
/// Its value in its own unit; `null` if it can't be worked out.
|
||||
pub display_value: Option<f64>,
|
||||
/// How many features were rebuilt.
|
||||
pub recomputed: usize,
|
||||
/// How many features reused an earlier result for the same inputs (a value set back).
|
||||
pub restored: usize,
|
||||
/// The features that failed to rebuild.
|
||||
pub errors: Vec<FailedFeature>,
|
||||
}
|
||||
|
||||
/// List the parameters: the user's, the sketch dimensions' and the feature inputs', favourites
|
||||
/// first, with each one's expression, value, uses and users, and any cycles. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamListKeys {
|
||||
/// Only the favourites. When true, `filter` isn't read. Default: false.
|
||||
#[serde(default, deserialize_with = "list_favorites")]
|
||||
pub favorites: Option<bool>,
|
||||
/// Which parameters, ignoring case: `all`, `user`, `sketch` (dimensions), `feature` (inputs),
|
||||
/// `model` (every one but the user's) or `favorites`. Anything else lists all. Default: all.
|
||||
#[serde(default, deserialize_with = "list_filter")]
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
fn list_favorites<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(D::Error::custom(whole("`favorites` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_filter<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`filter` must be text")
|
||||
}
|
||||
|
||||
/// `parameters.list`'s parameters: [`ParamListKeys`], with `filter` left unread when
|
||||
/// `favorites` is true, as the handler leaves it.
|
||||
#[derive(Debug)]
|
||||
pub struct ParamListParams(pub ParamListKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ParamListParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut().filter(|o| o.get("favorites") == Some(&Value::Bool(true))) {
|
||||
keys.remove("filter");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ParamListParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ParamListParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ParamListParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ParamListKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the parameters table.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParameterRow {
|
||||
pub name: String,
|
||||
pub expression: String,
|
||||
/// Its unit; `""` for none, `text` for a text parameter.
|
||||
pub unit: String,
|
||||
/// Its value in mm for a length and degrees for an angle, whatever its own unit; `null` if
|
||||
/// it can't be worked out (a text parameter has none).
|
||||
pub value: Option<f64>,
|
||||
/// Its value in its own unit, as the Parameters window shows it.
|
||||
pub display_value: Option<f64>,
|
||||
/// Why it can't be worked out, when it can't.
|
||||
pub error: Option<String>,
|
||||
pub comment: String,
|
||||
/// `user`, `sketch` (a dimension) or `feature` (a feature's named input).
|
||||
pub source: String,
|
||||
/// For a feature input: the feature.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub feature: Option<String>,
|
||||
/// For a feature input: the input's label.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<String>,
|
||||
pub favorite: bool,
|
||||
/// The names its expression uses.
|
||||
pub uses: Vec<String>,
|
||||
/// The names whose expressions use it.
|
||||
pub used_by: Vec<String>,
|
||||
/// A text parameter's text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ParamRow> for ParameterRow {
|
||||
fn from(r: ParamRow) -> Self {
|
||||
ParameterRow {
|
||||
name: r.name,
|
||||
expression: r.expression,
|
||||
unit: r.unit,
|
||||
value: r.value,
|
||||
display_value: r.display_value,
|
||||
error: r.error,
|
||||
comment: r.comment,
|
||||
source: r.source.to_string(),
|
||||
feature: r.feature,
|
||||
input: r.input.map(str::to_string),
|
||||
favorite: r.favorite,
|
||||
uses: r.uses,
|
||||
used_by: r.used_by,
|
||||
text: r.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `parameters.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamListResult {
|
||||
/// The parameters listed, favourites first.
|
||||
pub parameters: Vec<ParameterRow>,
|
||||
/// How many there are.
|
||||
pub count: usize,
|
||||
/// The names whose expressions refer to themselves, directly or through others.
|
||||
pub cycles: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// List what uses a parameter: other parameters, feature inputs, sketch dimensions,
|
||||
/// configurations, joints, rules and canvases, and the features it drives. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamUsersParams {
|
||||
/// The parameter, by name: a user parameter, a dimension or a feature's named input.
|
||||
#[serde(deserialize_with = "param_name")]
|
||||
#[schemars(length(min = 1))]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Something that uses a parameter.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ParameterUser {
|
||||
/// Another parameter's expression (a stored one or a feature input).
|
||||
Parameter { name: String },
|
||||
/// A feature input (`input` is its label, e.g. `Distance`).
|
||||
Feature { feature: u64, name: String, input: String },
|
||||
/// A sketch dimension (`constraint` is its id).
|
||||
Dimension { feature: u64, name: String, constraint: String },
|
||||
/// A configured column or an expression saved in a configuration row.
|
||||
Configuration { row: Option<String>, column: String },
|
||||
/// A joint's offset, angle or value, or a motion link's gear, rack or screw length (`input`
|
||||
/// says which).
|
||||
Joint { joint: u64, name: String, input: String },
|
||||
/// A sheet metal or plastic rule's value (`kind` says which, `field` which value).
|
||||
Rule { kind: String, name: String, field: String },
|
||||
/// The plane a canvas lies on (`input` is its label, e.g. `Offset`).
|
||||
Canvas { canvas: u64, name: String, input: String },
|
||||
}
|
||||
|
||||
impl From<ParamUser> for ParameterUser {
|
||||
fn from(u: ParamUser) -> Self {
|
||||
match u {
|
||||
ParamUser::Parameter { name } => ParameterUser::Parameter { name },
|
||||
ParamUser::Feature { feature, name, input } => ParameterUser::Feature { feature, name, input },
|
||||
ParamUser::Dimension { feature, name, constraint } => ParameterUser::Dimension { feature, name, constraint },
|
||||
ParamUser::Configuration { row, column } => ParameterUser::Configuration { row, column },
|
||||
ParamUser::Joint { joint, name, input } => ParameterUser::Joint { joint, name, input },
|
||||
ParamUser::Rule { kind, name, field } => ParameterUser::Rule { kind, name, field },
|
||||
ParamUser::Canvas { canvas, name, input } => ParameterUser::Canvas { canvas, name, input },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature a parameter drives.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DrivenFeature {
|
||||
/// The feature's id.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// What `parameters.users` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamUsersResult {
|
||||
/// The parameter's name, spaces at either end dropped.
|
||||
pub name: String,
|
||||
/// What uses it directly.
|
||||
pub users: Vec<ParameterUser>,
|
||||
/// The features it drives, directly or through other parameters.
|
||||
pub drives: Vec<DrivenFeature>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `parameters.graph` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamGraphParams {}
|
||||
|
||||
/// What `parameters.graph` returns: the dependency graph of the parameters.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamGraphResult {
|
||||
/// Every parameter name: the user's, the dimensions' and the feature inputs'.
|
||||
pub nodes: Vec<String>,
|
||||
/// `[user, used]`: the first's expression uses the second; a user may be `Feature:<name>`.
|
||||
pub edges: Vec<[String; 2]>,
|
||||
/// The names whose expressions refer to themselves, directly or through others.
|
||||
pub cycles: Vec<String>,
|
||||
/// The parameters that can't be worked out, with why, by name.
|
||||
pub errors: BTreeMap<String, String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Export the user parameters as CSV or JSON text, and write it to a file when a path is
|
||||
/// given. Changes nothing in the design.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ParamExportParams {
|
||||
/// `csv` or `json`, ignoring case. Default: `json` when `path` ends in `.json`, else `csv`.
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// A file to write the text to. Default: none is written.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// What `parameters.export` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamExportResult {
|
||||
/// The format used, `csv` or `json`.
|
||||
pub format: String,
|
||||
/// The file written; `null` if none was.
|
||||
pub path: Option<String>,
|
||||
/// The exported text.
|
||||
pub text: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `parameters.import` takes, as the handler reads it: see [`ParamImportParams`].
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = import_forms)]
|
||||
pub struct ParamImportKeys {
|
||||
/// The table as text: CSV with a header (`name`, `expression`, and optionally `unit` and
|
||||
/// `comment`), or JSON `[{name, expression, unit?, comment?}]`. When given, `path` isn't
|
||||
/// read.
|
||||
#[serde(default, deserialize_with = "import_text", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub text: Option<String>,
|
||||
/// A file to read the table from, in the same forms. Read when `text` isn't given.
|
||||
#[serde(default, deserialize_with = "import_text", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// `text` and `path`: text; `null` is left out, as `str_` reads it. Anything else gets the
|
||||
/// message the handler gives when it finds neither.
|
||||
fn import_text<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "give `text` or `path`")
|
||||
}
|
||||
|
||||
/// `parameters.import` needs `text` or `path` as text.
|
||||
fn import_forms(schema: &mut Schema) {
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{"required": ["text"], "properties": {"text": {"type": "string"}}},
|
||||
{"required": ["path"], "properties": {"path": {"type": "string"}}}
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add the parameters in a CSV or JSON table, all or nothing. A name that exists is refused.
|
||||
/// One undo step.
|
||||
///
|
||||
/// `path` is not read when `text` is text, as the handler leaves it.
|
||||
#[derive(Debug)]
|
||||
pub struct ParamImportParams(pub ParamImportKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ParamImportParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut() {
|
||||
match keys.get("text") {
|
||||
Some(Value::String(_)) => {
|
||||
keys.remove("path");
|
||||
}
|
||||
// Not text, so the handler reads `path`: when that works, this is the one thing
|
||||
// wrong; when it doesn't, the handler's message says what is needed.
|
||||
Some(Value::Number(_) | Value::Bool(_) | Value::Array(_) | Value::Object(_))
|
||||
if matches!(keys.get("path"), Some(Value::String(_))) =>
|
||||
{
|
||||
return Err(D::Error::custom(whole("`text` must be text")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ParamImportParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ParamImportParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ParamImportParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ParamImportKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `parameters.import` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ParamImportResult {
|
||||
/// The names of the parameters added, in order.
|
||||
pub imported: Vec<String>,
|
||||
/// The features that failed to rebuild with them.
|
||||
pub errors: Vec<FailedFeature>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `expr.evaluate` takes, as the handler reads it: see [`ExprEvaluateParams`].
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = expression_required)]
|
||||
pub struct ExprEvaluateKeys {
|
||||
/// The expression: text (`25 mm`, `W / 2`, `name = 3 * W`) or a number. With unit `text`, the
|
||||
/// text a text parameter would hold.
|
||||
// Optional in the type and required in the schema (`expression_required`): left out, the
|
||||
// handler refuses it with its own message.
|
||||
#[serde(default, deserialize_with = "evaluate_expression", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ExpressionText")]
|
||||
pub expression: Option<ExpressionText>,
|
||||
/// The unit the answer is shown in, and what bare numbers mean: `mm`, `cm`, `m`, `in`, `ft`,
|
||||
/// `deg`, `rad`, `""` (none) or `text` (a text parameter's text). Default: none; bare numbers
|
||||
/// follow the design's length unit.
|
||||
#[serde(default, deserialize_with = "evaluate_unit")]
|
||||
pub unit: Option<String>,
|
||||
/// What it must evaluate to: `length`, `angle` or `unitless` (`number` too). Default: the
|
||||
/// unit's kind, or what the expression is. Not read when `unit` is `text`.
|
||||
#[serde(default, deserialize_with = "evaluate_kind")]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// An expression given as text or as a number, as `expr_arg` reads it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExpressionText(pub String);
|
||||
|
||||
impl JsonSchema for ExpressionText {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ExpressionText".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An expression as text, or a number.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_expression<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ExpressionText>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(Some(ExpressionText(s))),
|
||||
Value::Number(n) => n
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite())
|
||||
.map(|v| Some(ExpressionText(format!("{v}"))))
|
||||
.ok_or_else(|| D::Error::custom(whole("`expression` must be text or a number"))),
|
||||
_ => Err(D::Error::custom(whole("`expression` must be text or a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_unit<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`unit` must be text")
|
||||
}
|
||||
|
||||
fn evaluate_kind<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`kind` must be text")
|
||||
}
|
||||
|
||||
fn expression_required(schema: &mut Schema) {
|
||||
require(schema, "expression");
|
||||
}
|
||||
|
||||
/// Evaluate an expression against the design's parameters, without changing anything: its
|
||||
/// value (in mm for a length, radians for an angle), its value in the unit asked for, what it
|
||||
/// names, or why it fails. `name = expression` is worth its expression and says what it would
|
||||
/// define.
|
||||
///
|
||||
/// `kind` is not read when `unit` is `text`, as the handler leaves it.
|
||||
#[derive(Debug)]
|
||||
pub struct ExprEvaluateParams(pub ExprEvaluateKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for ExprEvaluateParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
if let Some(keys) = value.as_object_mut()
|
||||
&& keys.get("unit").and_then(Value::as_str).is_some_and(solidcraft_doc::text_params::is_text_unit)
|
||||
{
|
||||
keys.remove("kind");
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(ExprEvaluateParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ExprEvaluateParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ExprEvaluateParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
ExprEvaluateKeys::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an evaluated expression is.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvalKind {
|
||||
Length,
|
||||
Angle,
|
||||
Unitless,
|
||||
}
|
||||
|
||||
impl From<Kind> for EvalKind {
|
||||
fn from(k: Kind) -> Self {
|
||||
match k {
|
||||
Kind::Length => EvalKind::Length,
|
||||
Kind::Angle => EvalKind::Angle,
|
||||
Kind::Unitless => EvalKind::Unitless,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EvalKind {
|
||||
/// The word for it, as it's serialized.
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
EvalKind::Length => "length",
|
||||
EvalKind::Angle => "angle",
|
||||
EvalKind::Unitless => "unitless",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `expr.evaluate` returns: one of three shapes.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExprEvaluated {
|
||||
#[serde(flatten)]
|
||||
pub body: ExprEvaluatedBody,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The shapes of an `expr.evaluate` reply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ExprEvaluatedBody {
|
||||
/// With unit `text`: the text a text parameter would hold.
|
||||
Text {
|
||||
/// Always true.
|
||||
ok: bool,
|
||||
/// Always `text`.
|
||||
kind: String,
|
||||
/// The text, without the quotes round it.
|
||||
text: String,
|
||||
/// Always empty.
|
||||
references: Vec<String>,
|
||||
},
|
||||
/// The expression evaluated.
|
||||
Value {
|
||||
/// Always true.
|
||||
ok: bool,
|
||||
/// Its value: mm for a length, radians for an angle.
|
||||
value: f64,
|
||||
/// What it is.
|
||||
kind: EvalKind,
|
||||
/// Its value in the unit asked for; with none, degrees for an angle and the value itself
|
||||
/// otherwise.
|
||||
display_value: f64,
|
||||
/// The parameters it names.
|
||||
references: Vec<String>,
|
||||
/// For `name = expression`: the name it would define.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
defines: Option<String>,
|
||||
},
|
||||
/// The expression didn't evaluate.
|
||||
Failed {
|
||||
/// Always false.
|
||||
ok: bool,
|
||||
/// Why.
|
||||
error: String,
|
||||
/// The parameters it names.
|
||||
references: Vec<String>,
|
||||
/// For `name = expression`: the name it would define.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
defines: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Suggest completions for the word at the end of some expression text: parameters (by name,
|
||||
/// anywhere in it), functions, constants and units. Changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExprCompleteParams {
|
||||
/// The text before the cursor; the word being typed is its trailing letters, digits and
|
||||
/// underscores. Default: empty, which lists every parameter, function and constant.
|
||||
#[serde(default, deserialize_with = "complete_prefix")]
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
fn complete_prefix<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
text_or_null(d, "`prefix` must be text")
|
||||
}
|
||||
|
||||
/// What a completion is.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompletionKind {
|
||||
Parameter,
|
||||
Function,
|
||||
Unit,
|
||||
Constant,
|
||||
}
|
||||
|
||||
/// One suggestion.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Completion {
|
||||
/// The text to insert: a parameter's name, a function's name and `(`, a constant or a unit.
|
||||
pub text: String,
|
||||
pub kind: CompletionKind,
|
||||
/// A parameter's expression and value, a function's signature, or a unit's kind.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// What `parameters.complete` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExprCompletions {
|
||||
/// The word being completed; empty when the text ends in something else.
|
||||
pub word: String,
|
||||
/// Parameters first (names that start with the word, then the rest; favourites and user
|
||||
/// parameters first, at most 200), then functions, the constant `pi` and units.
|
||||
pub suggestions: Vec<Completion>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parameters_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
ParamAddParams, ParamAdded, ParamCommentParams, ParamCommented, ParamDeleted, ParamFavoriteParams, ParamFavorited, ParamMoveParams, ParamMoved,
|
||||
ParamNameParams, ParamRenameParams, ParamRenamed,
|
||||
ExprCompleteParams, ExprCompletions, ExprEvaluateParams, ExprEvaluated, ParamAddParams, ParamAdded, ParamChangeParams, ParamChanged,
|
||||
ParamChangedBody, ParamCommentParams, ParamCommented, ParamDeleted, ParamExportParams, ParamExportResult, ParamFavoriteParams, ParamFavorited,
|
||||
ParamGraphParams, ParamGraphResult, ParamImportParams, ParamImportResult, ParamListParams, ParamListResult, ParamMoveParams, ParamMoved,
|
||||
ParamNameParams, ParamRenameParams, ParamRenamed, ParamUsersParams, ParamUsersResult, SwitchedFeature,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -16,6 +18,17 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ParamCommentParams>(), &["comment", "name"]);
|
||||
assert_keys(input_schema_for::<ParamFavoriteParams>(), &["favorite", "name"]);
|
||||
assert_keys(input_schema_for::<ParamMoveParams>(), &["after", "before", "name"]);
|
||||
assert_keys(
|
||||
input_schema_for::<ParamChangeParams>(),
|
||||
&["changes", "comment", "delete", "expression", "favorite", "name", "new_name", "unit", "value"],
|
||||
);
|
||||
assert_keys(input_schema_for::<ParamListParams>(), &["favorites", "filter"]);
|
||||
assert_keys(input_schema_for::<ParamUsersParams>(), &["name"]);
|
||||
assert_keys(input_schema_for::<ParamGraphParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ParamExportParams>(), &["format", "path"]);
|
||||
assert_keys(input_schema_for::<ParamImportParams>(), &["path", "text"]);
|
||||
assert_keys(input_schema_for::<ExprEvaluateParams>(), &["expression", "kind", "unit"]);
|
||||
assert_keys(input_schema_for::<ExprCompleteParams>(), &["prefix"]);
|
||||
}
|
||||
|
||||
/// The calls fail on a design without parameters, with the handlers' messages or the
|
||||
@@ -49,6 +62,39 @@ fn strict_keeps_todays_errors() {
|
||||
("parameters.move", json!({"name": "W", "before": "A", "after": "B"})),
|
||||
("parameters.move", json!({"name": "W", "before": 5})),
|
||||
("parameters.move", json!({"name": "Nope", "before": "A"})),
|
||||
("parameters.change", json!({})),
|
||||
("parameters.change", json!({"name": "W"})),
|
||||
("parameters.change", json!({"name": " ", "expression": 1})),
|
||||
("parameters.change", json!({"name": "W", "expression": true})),
|
||||
("parameters.change", json!({"name": "W", "expression": null})),
|
||||
("parameters.change", json!({"changes": null})),
|
||||
("parameters.change", json!({"changes": [1]})),
|
||||
("parameters.change", json!({"changes": {}})),
|
||||
("parameters.change", json!({"changes": {"W": true}})),
|
||||
("parameters.change", json!({"changes": {"W": [1]}, "name": "W", "expression": 2})),
|
||||
("parameters.change", json!({"changes": {"Nope": 1}})),
|
||||
("parameters.change", json!({"delete": "Nope"})),
|
||||
("parameters.change", json!({"delete": " "})),
|
||||
("parameters.change", json!({"name": "Nope", "favorite": true})),
|
||||
("parameters.change", json!({"name": "Nope", "new_name": "X"})),
|
||||
("parameters.users", json!({})),
|
||||
("parameters.users", json!({"name": 5})),
|
||||
("parameters.users", json!({"name": "Nope"})),
|
||||
("parameters.export", json!({"format": "xml"})),
|
||||
("parameters.export", json!({"format": "CSV", "path": "/nonexistent-dir/x/y.csv"})),
|
||||
("parameters.import", json!({})),
|
||||
("parameters.import", json!({"text": null, "path": null})),
|
||||
("parameters.import", json!({"text": 5})),
|
||||
("parameters.import", json!({"path": 5})),
|
||||
("parameters.import", json!({"text": "name,expression\n1W,1"})),
|
||||
("parameters.import", json!({"path": "/nonexistent-dir/x/y.csv"})),
|
||||
("expr.evaluate", json!({})),
|
||||
("expr.evaluate", json!({"expression": null})),
|
||||
("expr.evaluate", json!({"expression": true})),
|
||||
("expr.evaluate", json!({"expression": ["1"]})),
|
||||
("expr.evaluate", json!({"expression": "1", "unit": "furlong"})),
|
||||
("expr.evaluate", json!({"expression": "1", "kind": "colour"})),
|
||||
("expr.evaluate", json!({"expression": "1", "unit": "mm", "kind": "colour"})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -144,3 +190,348 @@ fn parameter_edits_reply_with_their_schemas() {
|
||||
let error = warned.execute("parameters.favorite", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`favourite`"), "{error}");
|
||||
}
|
||||
|
||||
/// Whether the contract reports anything for the call, and what: the warnings of Warn.
|
||||
fn issues(id: &str, p: Value) -> Vec<String> {
|
||||
crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect()
|
||||
}
|
||||
|
||||
/// `parameters.change` reads `changes` alone when it is there, `delete` alone when that is text,
|
||||
/// else the keys of one change: a call that works today in any of the forms gets no warning for
|
||||
/// a key of a form it doesn't read, and each form has the handler's messages.
|
||||
#[test]
|
||||
fn change_reads_only_the_keys_of_the_form_it_is_in() {
|
||||
let none = Vec::<String>::new();
|
||||
// The keys of another form are not read: a wrong one is no problem.
|
||||
assert_eq!(
|
||||
issues("parameters.change", json!({"changes": {"W": 1, "H": "2 mm"}, "name": 5, "expression": true, "unit": 1, "favorite": "x"})),
|
||||
none
|
||||
);
|
||||
assert_eq!(issues("parameters.change", json!({"delete": "W", "name": 5, "expression": true, "favorite": "x", "unit": 3})), none);
|
||||
assert_eq!(issues("parameters.change", json!({"changes": {"W": 1}, "delete": 5})), none);
|
||||
// A `delete` that isn't text is read as left out: the single change form it falls to
|
||||
// reports it.
|
||||
assert_eq!(issues("parameters.change", json!({"delete": 5, "name": "W", "expression": 1})), ["`delete` must be text"]);
|
||||
assert_eq!(issues("parameters.change", json!({"delete": null, "name": "W", "expression": 1})), none);
|
||||
// The single change form.
|
||||
for p in [
|
||||
json!({"name": "W", "expression": 5}),
|
||||
json!({"name": " W ", "value": "2 mm", "unit": "mm", "comment": "c", "favorite": true}),
|
||||
json!({"name": "W", "new_name": "X"}),
|
||||
json!({"name": "W", "favorite": false}),
|
||||
json!({"name": "W", "comment": "x"}),
|
||||
json!({"name": "W", "expression": null, "value": 3}),
|
||||
json!({"name": "W", "favorite": null}),
|
||||
] {
|
||||
assert_eq!(issues("parameters.change", p.clone()), none, "{p}");
|
||||
}
|
||||
for (p, message) in [
|
||||
(json!({"name": 5, "expression": 1}), "`name` is required"),
|
||||
(json!({"name": null, "expression": 1}), "`name` is required"),
|
||||
(json!({"name": "W", "expression": true}), "`expression` must be a number or text"),
|
||||
(json!({"name": "W", "new_name": 5}), "`new_name` must be text"),
|
||||
(json!({"name": "W", "expression": 1, "unit": 5}), "`unit` must be text"),
|
||||
(json!({"name": "W", "expression": 1, "comment": 5}), "`comment` must be text"),
|
||||
(json!({"name": "W", "expression": 1, "favorite": "yes"}), "`favorite` must be true or false"),
|
||||
(json!({"changes": null}), "`changes` must be an object of name: expression"),
|
||||
(json!({"changes": ["W"]}), "`changes` must be an object of name: expression"),
|
||||
(json!({"changes": {}}), "`changes` is empty"),
|
||||
(json!({"changes": {"W": 1, "H": null}}), "`changes.H` must be a number or text"),
|
||||
] {
|
||||
assert_eq!(issues("parameters.change", p.clone()), [message], "{p}");
|
||||
}
|
||||
// `value` is read only when `expression` isn't a number or text.
|
||||
assert_eq!(issues("parameters.change", json!({"name": "W", "expression": "5", "value": true})), none);
|
||||
assert_eq!(issues("parameters.change", json!({"name": "W", "expression": null, "value": true})), ["`expression` must be a number or text"]);
|
||||
// A missing key is the handler's to refuse, with its own message.
|
||||
for p in [json!({}), json!({"name": "W"}), json!({"expression": 1})] {
|
||||
assert_eq!(issues("parameters.change", p.clone()), Vec::<String>::new(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `parameters.list` doesn't read `filter` when `favorites` is true; `parameters.import` doesn't
|
||||
/// read `path` when `text` is text; `expr.evaluate` doesn't read `kind` when `unit` is `text`.
|
||||
#[test]
|
||||
fn list_import_and_evaluate_read_keys_only_where_the_handlers_do() {
|
||||
let none = Vec::<String>::new();
|
||||
assert_eq!(issues("parameters.list", json!({"favorites": true, "filter": 5})), none);
|
||||
assert_eq!(issues("parameters.list", json!({"favorites": false, "filter": 5})), ["`filter` must be text"]);
|
||||
assert_eq!(issues("parameters.list", json!({"filter": ["user"]})), ["`filter` must be text"]);
|
||||
assert_eq!(issues("parameters.list", json!({"favorites": "yes"})), ["`favorites` must be true or false"]);
|
||||
assert_eq!(issues("parameters.list", json!({"filter": null, "favorites": null})), none);
|
||||
assert_eq!(issues("parameters.list", json!({"filter": "bogus"})), none);
|
||||
|
||||
assert_eq!(issues("parameters.import", json!({"text": "name,expression\nW,1", "path": 5})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"text": "", "path": []})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"path": "p.csv"})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"text": null, "path": "p.csv"})), none);
|
||||
assert_eq!(issues("parameters.import", json!({"path": 5})), ["give `text` or `path`"]);
|
||||
assert_eq!(issues("parameters.import", json!({"text": 5})), ["give `text` or `path`"]);
|
||||
assert_eq!(issues("parameters.import", json!({"text": 5, "path": "p.csv"})), ["`text` must be text"]);
|
||||
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": "a b", "unit": "text", "kind": 5})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": "a b", "unit": " TEXT ", "kind": "bogus"})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 5, "unit": "mm", "kind": 5})), ["`kind` must be text"]);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 5, "unit": 5})), ["`unit` must be text"]);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 5, "kind": "angle", "unit": null})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": 2.5})), none);
|
||||
assert_eq!(issues("expr.evaluate", json!({"expression": {}})), ["`expression` must be text or a number"]);
|
||||
assert_eq!(issues("parameters.complete", json!({"prefix": 5})), ["`prefix` must be text"]);
|
||||
assert_eq!(issues("parameters.complete", json!({"prefix": null})), none);
|
||||
assert_eq!(issues("parameters.export", json!({"format": "JSON", "path": "x.csv"})), none);
|
||||
assert_eq!(issues("parameters.users", json!({"name": " "})), ["`name` is required"]);
|
||||
assert_eq!(issues("parameters.graph", json!({"nodes": 1})), ["unknown key `nodes`"]);
|
||||
}
|
||||
|
||||
/// The schemas say which forms and keys the handlers need: `parameters.change`'s three forms,
|
||||
/// `parameters.import`'s `text` or `path`, `expr.evaluate`'s `expression`.
|
||||
#[test]
|
||||
fn the_new_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<ParamChangeParams>(),
|
||||
&[
|
||||
(json!({"name": "W", "expression": "10 mm"}), true),
|
||||
(json!({"name": "W", "new_name": "X"}), true),
|
||||
(json!({"name": "W", "favorite": true}), true),
|
||||
(json!({"changes": {"W": 1}}), true),
|
||||
(json!({"delete": "W"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"name": "W"}), false),
|
||||
(json!({"expression": 1}), false),
|
||||
(json!({"delete": 5}), false),
|
||||
(json!({"name": "W", "expression": true}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ParamImportParams>(),
|
||||
&[(json!({"text": "x"}), true), (json!({"path": "p.csv"}), true), (json!({}), false), (json!({"text": 5}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ExprEvaluateParams>(),
|
||||
&[
|
||||
(json!({"expression": "1 + 2"}), true),
|
||||
(json!({"expression": 3, "unit": "mm", "kind": "length"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"expression": true}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<ParamUsersParams>(), &[(json!({"name": "W"}), true), (json!({}), false), (json!({"name": ""}), false)]);
|
||||
takes(
|
||||
input_schema_for::<ParamListParams>(),
|
||||
&[(json!({}), true), (json!({"favorites": true, "filter": "user"}), true), (json!({"favorites": "x"}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ParamExportParams>(),
|
||||
&[(json!({}), true), (json!({"format": "json", "path": "p.json"}), true), (json!({"path": 1}), false)],
|
||||
);
|
||||
takes(input_schema_for::<ExprCompleteParams>(), &[(json!({}), true), (json!({"prefix": "si"}), true), (json!({"prefix": 1}), false)]);
|
||||
// `conforms` doesn't read nested `anyOf` inside a branch's `properties`; the forms are pinned.
|
||||
let change = input_schema_for::<ParamChangeParams>();
|
||||
assert_eq!(
|
||||
change.as_value()["anyOf"],
|
||||
json!([
|
||||
{"required": ["changes"]},
|
||||
{"required": ["delete"], "properties": {"delete": {"type": "string"}}},
|
||||
{
|
||||
"required": ["name"],
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"anyOf": [
|
||||
{"required": ["expression"], "properties": {"expression": {"type": ["number", "string"]}}},
|
||||
{"required": ["value"], "properties": {"value": {"type": ["number", "string"]}}},
|
||||
{"required": ["new_name"]},
|
||||
{"required": ["favorite"]},
|
||||
{"required": ["comment"]}
|
||||
]
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// #12497: every command of the group, run in every form in Warn, replies as its output schema
|
||||
/// says and without warnings (Warn runs a call the contract refuses anyway, so the absence of
|
||||
/// `param_warnings` is what shows the contract accepts the call).
|
||||
#[test]
|
||||
fn the_parameter_commands_reply_with_their_schemas() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-params-schema-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run("parameters.add", json!({"name": "W", "expression": "10 mm", "favorite": true}));
|
||||
run("parameters.add", json!({"name": "H", "expression": 5, "unit": "mm"}));
|
||||
run("parameters.add", json!({"name": "Label", "expression": "Rev A", "unit": "text"}));
|
||||
run("solid.box", json!({"length": "W", "width": "H * 2", "height": 4, "body_name": "Block"}));
|
||||
|
||||
// Change: one parameter in each of its forms, several at once, a delete.
|
||||
let r = run("parameters.change", json!({"name": "W", "expression": "12 mm", "comment": "wall"}));
|
||||
assert!(r["value"] == 12.0 && r["expression"] == "12 mm" && r.get("renamed").is_none(), "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "W", "value": 14, "unit": "mm", "favorite": false}));
|
||||
assert_eq!(r["value"], 14.0, "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "W", "new_name": "Wall"}));
|
||||
assert!(r["renamed"]["from"] == "W" && r["renamed"]["to"] == "Wall" && r["expression"].is_null(), "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "Wall", "comment": "thin", "favorite": true}));
|
||||
assert!(r["expression"].is_null() && r["name"] == "Wall", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "Wall", "expression": "Wide = 16"}));
|
||||
assert!(r["renamed"]["to"] == "Wide" && r["name"] == "Wide", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"name": "Label", "expression": "Rev B"}));
|
||||
assert_eq!(r["text"], "Rev B", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
let r = run("parameters.change", json!({"changes": {"Wide": 20, "H": "6 mm"}}));
|
||||
assert!(r["changed"]["Wide"] == 20.0 && r["changed"]["H"] == 6.0, "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
// The other forms' keys are not read, so a bad one doesn't warn.
|
||||
let r = run("parameters.change", json!({"changes": {"H": 7}, "name": 5, "expression": true}));
|
||||
assert_eq!(r["changed"]["H"], 7.0, "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
run("parameters.add", json!({"name": "Spare", "expression": 1}));
|
||||
let r = run("parameters.change", json!({"delete": " Spare ", "name": 5}));
|
||||
assert_eq!(r["deleted"], "Spare", "{r}");
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &r);
|
||||
|
||||
// List: all forms of the filter, favourites first.
|
||||
let all = run("parameters.list", json!({}));
|
||||
assert_eq!(all["count"], all["parameters"].as_array().map_or(0, Vec::len), "{all}");
|
||||
matches(output_schema_for::<ParamListResult>(), "parameters.list", &all);
|
||||
for p in [
|
||||
json!({"favorites": true}),
|
||||
json!({"favorites": true, "filter": 5}),
|
||||
json!({"filter": "USER"}),
|
||||
json!({"filter": "feature"}),
|
||||
json!({"filter": "sketch"}),
|
||||
json!({"filter": "model"}),
|
||||
json!({"filter": "bogus"}),
|
||||
] {
|
||||
let r = run("parameters.list", p);
|
||||
matches(output_schema_for::<ParamListResult>(), "parameters.list", &r);
|
||||
}
|
||||
let favorites = run("parameters.list", json!({"favorites": true}));
|
||||
assert!(favorites["parameters"].as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|r| r["favorite"] == true)), "{favorites}");
|
||||
let feature = run("parameters.list", json!({"filter": "feature"}));
|
||||
assert!(feature["parameters"].as_array().is_some_and(|a| a.iter().all(|r| r["source"] == "feature")), "{feature}");
|
||||
|
||||
// Users and graph.
|
||||
let users = run("parameters.users", json!({"name": " H "}));
|
||||
assert_eq!(users["name"], "H", "{users}");
|
||||
assert!(users["users"].as_array().is_some_and(|u| !u.is_empty()) && users["drives"][0]["name"] == "Box1", "{users}");
|
||||
matches(output_schema_for::<ParamUsersResult>(), "parameters.users", &users);
|
||||
let graph = run("parameters.graph", json!({}));
|
||||
assert!(graph["nodes"].as_array().is_some_and(|n| n.contains(&json!("H"))), "{graph}");
|
||||
matches(output_schema_for::<ParamGraphResult>(), "parameters.graph", &graph);
|
||||
|
||||
// Export in both formats, to text and to a file; import them back.
|
||||
let csv = run("parameters.export", json!({}));
|
||||
assert!(csv["format"] == "csv" && csv["path"].is_null(), "{csv}");
|
||||
matches(output_schema_for::<ParamExportResult>(), "parameters.export", &csv);
|
||||
let json_text = run("parameters.export", json!({"format": "JSON"}));
|
||||
assert_eq!(json_text["format"], "json", "{json_text}");
|
||||
matches(output_schema_for::<ParamExportResult>(), "parameters.export", &json_text);
|
||||
let file = dir.join("parameters.json");
|
||||
let written = run("parameters.export", json!({"path": file}));
|
||||
assert!(written["format"] == "json" && written["path"].is_string(), "{written}");
|
||||
matches(output_schema_for::<ParamExportResult>(), "parameters.export", &written);
|
||||
let mut other = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let imported = other.execute("parameters.import", &json!({"path": file})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(imported["imported"].as_array().is_some_and(|n| n.len() == 3), "{imported}");
|
||||
matches(output_schema_for::<ParamImportResult>(), "parameters.import", &imported);
|
||||
let from_text = other.execute("parameters.import", &json!({"text": "name,expression\nZ,3", "path": 5})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(from_text["imported"], json!(["Z"]), "{from_text}");
|
||||
matches(output_schema_for::<ParamImportResult>(), "parameters.import", &from_text);
|
||||
|
||||
// Evaluate: a length, an angle, a unitless number, a definition, an error, a text.
|
||||
for p in [
|
||||
json!({"expression": "H * 2"}),
|
||||
json!({"expression": 3, "unit": "in"}),
|
||||
json!({"expression": "90 deg"}),
|
||||
json!({"expression": "30", "unit": "deg"}),
|
||||
json!({"expression": "2 * 3", "kind": "unitless"}),
|
||||
json!({"expression": "Wide", "kind": "length"}),
|
||||
json!({"expression": "len = 3 mm"}),
|
||||
json!({"expression": "nope + 1"}),
|
||||
json!({"expression": "Rev \"A\"", "unit": "text", "kind": 5}),
|
||||
] {
|
||||
let r = run("expr.evaluate", p.clone());
|
||||
matches(output_schema_for::<ExprEvaluated>(), "expr.evaluate", &r);
|
||||
assert_eq!(r["ok"], !p["expression"].as_str().is_some_and(|e| e.starts_with("nope")), "{r}");
|
||||
}
|
||||
let r = run("expr.evaluate", json!({"expression": "len = 3 mm"}));
|
||||
assert!(r["defines"] == "len" && r["kind"] == "length" && r["value"] == 3.0, "{r}");
|
||||
let r = run("expr.evaluate", json!({"expression": "'Rev A'", "unit": "text"}));
|
||||
assert!(r["kind"] == "text" && r["text"] == "Rev A" && r["references"] == json!([]), "{r}");
|
||||
let r = run("expr.evaluate", json!({"expression": "nope + 1"}));
|
||||
assert!(r["ok"] == false && r["error"].is_string() && r["references"] == json!(["nope"]), "{r}");
|
||||
|
||||
// Complete: parameters, functions, constants, units.
|
||||
for p in
|
||||
[json!({}), json!({"prefix": "H"}), json!({"prefix": "2 * si"}), json!({"prefix": "p"}), json!({"prefix": "3 m"}), json!({"prefix": null})]
|
||||
{
|
||||
let r = run("parameters.complete", p);
|
||||
matches(output_schema_for::<ExprCompletions>(), "parameters.complete", &r);
|
||||
}
|
||||
let r = run("parameters.complete", json!({"prefix": "3 m"}));
|
||||
assert!(r["suggestions"].as_array().is_some_and(|a| a.iter().any(|c| c["kind"] == "unit" && c["detail"] == "length")), "{r}");
|
||||
let r = run("parameters.complete", json!({"prefix": "p"}));
|
||||
assert!(r["suggestions"].as_array().is_some_and(|a| a.iter().any(|c| c["kind"] == "constant")), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A misspelt key runs in Warn and is reported, with a reply that still matches; strict refuses
|
||||
/// it. Each command of the group.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_for_each_command() {
|
||||
let calls = [
|
||||
("parameters.change", json!({"name": "W", "comment": "c", "favorit": true}), "favorit"),
|
||||
("parameters.list", json!({"favourites": true}), "favourites"),
|
||||
("parameters.users", json!({"name": "W", "nam": 1}), "nam"),
|
||||
("parameters.graph", json!({"node": 1}), "node"),
|
||||
("parameters.export", json!({"formt": "csv"}), "formt"),
|
||||
("parameters.import", json!({"text": "name,expression\nQ,1", "pth": "x"}), "pth"),
|
||||
("expr.evaluate", json!({"expression": "1", "unt": "mm"}), "unt"),
|
||||
("parameters.complete", json!({"prefx": "W"}), "prefx"),
|
||||
];
|
||||
for (id, p, key) in calls {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
s.execute("parameters.add", &json!({"name": "W", "expression": "10 mm"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(r["param_warnings"].to_string().contains(key), "{id}: {r}");
|
||||
let schema = crate::cmd::find_command(id).and_then(|c| c.output).map(|f| f()).unwrap_or_else(|| panic!("{id} has no output schema"));
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r} doesn't match {}", schema.as_value());
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains(&format!("`{key}`")), "{id}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A configuration rule's switch has the shape the schema describes.
|
||||
#[test]
|
||||
fn a_switched_feature_serializes_as_the_schema_says() {
|
||||
let body = ParamChangedBody::Many {
|
||||
changed: [("W".to_string(), Some(1.0))].into(),
|
||||
recomputed: 2,
|
||||
restored: 0,
|
||||
errors: Vec::new(),
|
||||
switched: vec![SwitchedFeature { feature: 3, name: Some("Hole".into()), suppressed: true }],
|
||||
};
|
||||
let reply = serde_json::to_value(ParamChanged { body, param_warnings: Vec::new() }).unwrap_or_default();
|
||||
assert_eq!(
|
||||
reply,
|
||||
json!({"changed": {"W": 1.0}, "recomputed": 2, "restored": 0, "errors": [], "switched": [{"feature": 3, "name": "Hole", "suppressed": true}]})
|
||||
);
|
||||
matches(output_schema_for::<ParamChanged>(), "parameters.change", &reply);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user