mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
18 Commits
fix-doc-hang
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 27808612a1 | |||
| 92a9a5e0f0 | |||
| 97cedc77e8 | |||
| bcac191847 | |||
| c019e2555c | |||
| 2741df3629 | |||
| 1094503e97 | |||
| 9f61296014 | |||
| f3977d5c2d | |||
| 908fe48c0b | |||
| 38ca98f88b | |||
| bfb32cd6e1 | |||
| a8bc6b2945 | |||
| 81e55b5443 | |||
| 138e3478e6 | |||
| fbd30b0bd0 | |||
| 372136fa6a | |||
| 2aa45ef3ee |
@@ -11,6 +11,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
|
||||
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
|
||||
- New kernel cases go to solidcore: `cargo xtask lists`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
|
||||
@@ -163,6 +163,18 @@ the most complete description of the house style.
|
||||
|
||||
The reference for what the kernel builds is the kernel-cases ladder (`tests/kernel-cases/ladder/`,
|
||||
`cargo xtask kernel-cases --ladder`, [docs/kernel-ladder.md](docs/kernel-ladder.md)).
|
||||
|
||||
**Kernel cases go to solidcore.** A case that checks a kernel operation (a boolean, a blend, a
|
||||
shell, a sweep, an import's geometry) is a new entry in solidcore's corpus,
|
||||
`crates/solidcore/tests/kernel_cases/<area>/` in
|
||||
[tangency-labs/solidcore](https://github.com/tangency-labs/solidcore), with an answer in closed
|
||||
form or exact. Draft it as a rig case here, export it with
|
||||
`cargo xtask kernel-cases --area <area> --export DIR`, add `DIR/<area>/<case>.json` and its
|
||||
`.bodies.json.gz` there, and delete the draft; or write the entry by hand as that folder's README
|
||||
describes. `tests/kernel-cases/` takes no new cases: its `CASES` list only shrinks
|
||||
(`cargo xtask lists`). App-level cases (what a command does, the UI, product rules) stay in
|
||||
SolidCraft, as tests in their area's existing test file or UI scenarios.
|
||||
|
||||
The naming corpus checks persistent names: every name a corpus design stores still finds a face or
|
||||
an edge, and two builds name its faces and edges alike
|
||||
(`cargo test --profile ci -p solidcraft-cli --test naming_corpus`).
|
||||
|
||||
@@ -465,7 +465,11 @@ fn snapshot_runs_its_script_on_the_in_design() {
|
||||
fn unknown_command_suggests_the_closest_id() {
|
||||
let output = cli().args(["exec", "file.open"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open` or `panel.open`?"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds
|
||||
|
||||
+159
-52
@@ -379,7 +379,7 @@
|
||||
"Move Type": "نوع التحريك",
|
||||
"Move down": "نقل للأسفل",
|
||||
"Move earlier": "نقل إلى الأقدم",
|
||||
"Move holes by clicking or dragging": "تحريك الثقوب بالنقر أو السحب",
|
||||
"Move holes by clicking or dragging": "التحريك بالنقر أو السحب",
|
||||
"Move later": "نقل إلى الأحدث",
|
||||
"Move up": "نقل للأعلى",
|
||||
"Name": "الاسم",
|
||||
@@ -585,7 +585,7 @@
|
||||
"Show ghost": "إظهار الطيف",
|
||||
"Show in folder": "إظهار في المجلد",
|
||||
"Show the pointer an AI agent moves (#35)": "إظهار المؤشر الذي يحرّكه وكيل الذكاء الاصطناعي (#35)",
|
||||
"Show tool at cursor": "إظهار الأداة عند المؤشر",
|
||||
"Show tool at cursor": "الأداة عند المؤشر",
|
||||
"Signed axes": "محاور بإشارة",
|
||||
"Size": "الحجم",
|
||||
"Sketch constraints": "قيود الرسم",
|
||||
@@ -596,7 +596,7 @@
|
||||
"Sketch points": "نقاط الرسم",
|
||||
"Slider": "منزلق",
|
||||
"Slot width": "عرض الشق",
|
||||
"Snap to face and edge centres": "الالتقاط إلى مراكز الأوجه والحواف",
|
||||
"Snap to face and edge centres": "الالتقاط إلى المراكز",
|
||||
"SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "لا يستطيع SolidCraft إرسال التقارير مباشرةً. أرسلها على GitHub؛ لا حاجة إلى رمز وصول في التطبيق.",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "أُغلق SolidCraft قبل حفظ هذه التصاميم. يمكن فتح آخر حفظ تلقائي لها.",
|
||||
"Sort A to Z": "فرز تصاعدي",
|
||||
@@ -739,34 +739,34 @@
|
||||
"a picked edge's body is gone": "جسم الحافة المحددة لم يعد موجودًا",
|
||||
"add a key for a joint first": "أضف أولًا مفتاحًا لمفصل",
|
||||
"ai.connected_toast": "أصبح SolidCraft متاحًا في {clients}. أعد تشغيلها لاستخدامه (التفضيلات > المساعد).",
|
||||
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال (قناة التحكم / MCP)",
|
||||
"ai.prefs.allow_control_note": "يفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
|
||||
"ai.prefs.availability": "عناصر تحكم المساعد",
|
||||
"ai.prefs.background_commands": "تشغيل أوامر الوكيل في الخلفية",
|
||||
"ai.prefs.connect_apps": "إتاحة SolidCraft لتطبيقات الذكاء الاصطناعي على هذا الحاسوب",
|
||||
"ai.prefs.connect_apps_note": "عند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_at_start": "الاتصال بآخر وكيل مستخدم عند بدء SolidCraft",
|
||||
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال",
|
||||
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
|
||||
"ai.prefs.availability": "التوفر",
|
||||
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
|
||||
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
|
||||
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجدداً.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_at_start": "توصيل آخر وكيل عند البدء",
|
||||
"ai.prefs.connect_at_start_note": "يبدأ في الخلفية الوكيل الذي اتصلت به آخر مرة (أو الوكيل الافتراضي) في كل مرة يبدأ فيها SolidCraft، ليكون جاهزًا للمحادثة. لا يحدث ذلك عند بدء SolidCraft مع --control. إذا تعذّر الاتصال، يعرض المساعد «فشل الاتصال» وزر «إعادة المحاولة».",
|
||||
"ai.prefs.follow_camera": "توجيه الكاميرا نحو الرسومات",
|
||||
"ai.prefs.follow_camera": "اتباع الكاميرا",
|
||||
"ai.prefs.follow_camera_off": "تتبّع الكاميرا التلقائي متوقف.",
|
||||
"ai.prefs.follow_camera_on": "تتجه الكاميرا نحو الرسومات التي ينشئها الوكيل.",
|
||||
"ai.prefs.intro": "الوكلاء والأذونات وطريقة ظهور عملهم.",
|
||||
"ai.prefs.make_default": "الاستخدام افتراضيًا",
|
||||
"ai.prefs.intro": "وكلاء الذكاء الاصطناعي في SolidCraft.",
|
||||
"ai.prefs.make_default": "تعيين كافتراضي",
|
||||
"ai.prefs.no_agents": "لا يوجد وكلاء بعد. أضف وكيلًا أو حدّث القائمة للعثور على الوكلاء المثبّتين.",
|
||||
"ai.prefs.no_saved_approvals": "لا توجد موافقات محفوظة.",
|
||||
"ai.prefs.open_at_start": "فتح لوحة المساعد عند بدء SolidCraft",
|
||||
"ai.prefs.open_at_start": "فتح المحادثة عند البدء",
|
||||
"ai.prefs.open_at_start_note": "يعرض لوحة المساعد (أو نافذتها المستقلة) في كل مرة يبدأ فيها SolidCraft. لا يحدث ذلك عند بدء SolidCraft مع --control.",
|
||||
"ai.prefs.override_all_warning": "قد يحفظ هذا الوكيل التصميم أو يكتب فوقه أو يصدّره أو يستبدله دون أن يسأل.",
|
||||
"ai.prefs.permission_all": "السماح بكل شيء",
|
||||
"ai.prefs.permission_all_summary": "تعمل أدوات SolidCraft المتاحة دون سؤال.",
|
||||
"ai.prefs.permission_ask": "السؤال قبل التغييرات",
|
||||
"ai.prefs.permission_ask_summary": "الموافقة على كل تغيير في التصميم وكل إجراء على الملفات.",
|
||||
"ai.prefs.permission_edits": "السماح بتعديلات النموذج",
|
||||
"ai.prefs.permission_edits_summary": "تُنفَّذ تعديلات النموذج، وتبقى إجراءات الملفات والإجراءات المدمّرة تتطلب السؤال.",
|
||||
"ai.prefs.permission_all": "فعل كل شيء",
|
||||
"ai.prefs.permission_all_summary": "لا يسأل أبداً",
|
||||
"ai.prefs.permission_ask": "السؤال أولاً",
|
||||
"ai.prefs.permission_ask_summary": "يسأل عن كل تغيير",
|
||||
"ai.prefs.permission_edits": "إجراء تعديلات",
|
||||
"ai.prefs.permission_edits_summary": "يعدّل بحرية ويسأل عن الباقي",
|
||||
"ai.prefs.permission_scope": "لأدوات SolidCraft فقط. أوامر الطرفية وتعديلات الملفات لدى الوكيل تتبع أذوناته الخاصة.",
|
||||
"ai.prefs.pointer_preview_hidden": "المؤشر مخفي",
|
||||
"ai.prefs.pointer_preview_speed": "مؤشر الوكيل · {speed}",
|
||||
"ai.prefs.pointer_speed": "سرعة الحركة",
|
||||
"ai.prefs.pointer_speed": "السرعة",
|
||||
"ai.prefs.privacy_note": "لا يستلم المحادثة إلا الوكيل أو نقطة الاتصال التي تختارها. تُخزَّن مفاتيح نقاط الاتصال في سلسلة مفاتيح النظام.",
|
||||
"ai.prefs.show_pointer": "إظهار مؤشر الوكيل",
|
||||
"ai.prefs.turn_limit_hint": "الحد الأقصى لاستدعاءات الأدوات التي تغيّر التصميم في الرسالة الواحدة.",
|
||||
@@ -997,6 +997,11 @@
|
||||
"cmd.joint_screw": "لولب",
|
||||
"cmd.joint_solve": "حل المفاصل",
|
||||
"cmd.joint_sweep": "مفصل كنس",
|
||||
"cmd.layout.apply": "تطبيق التخطيط",
|
||||
"cmd.layout.get": "فحص التخطيط",
|
||||
"cmd.layout.reset": "إعادة ضبط التخطيط",
|
||||
"cmd.layout.save": "حفظ التخطيط",
|
||||
"cmd.layout.workspace.select": "اختيار مساحة عمل التخطيط",
|
||||
"cmd.library_apply": "تطبيق عنصر من المكتبة المحلية",
|
||||
"cmd.library_export": "تصدير مكتبة التصميم",
|
||||
"cmd.library_inspect": "فحص المكتبة المحلية",
|
||||
@@ -1030,6 +1035,21 @@
|
||||
"cmd.occurrence_move": "تحريك النسخة",
|
||||
"cmd.occurrence_properties": "الخصائص الفيزيائية للنسخة",
|
||||
"cmd.occurrence_reparent": "نقل إلى مكوّن",
|
||||
"cmd.panel.activate": "تنشيط علامة تبويب اللوحة",
|
||||
"cmd.panel.close": "إغلاق اللوحة",
|
||||
"cmd.panel.collapse": "طي قسم اللوحة",
|
||||
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
|
||||
"cmd.panel.float": "تعويم اللوحة",
|
||||
"cmd.panel.focus": "تركيز اللوحة",
|
||||
"cmd.panel.list": "سرد اللوحات",
|
||||
"cmd.panel.move": "نقل اللوحة",
|
||||
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
|
||||
"cmd.panel.open": "فتح اللوحة",
|
||||
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
|
||||
"cmd.panel.popout": "نقل اللوحة إلى نافذة مستقلة",
|
||||
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
|
||||
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
|
||||
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
|
||||
"cmd.parameters_add": "إضافة معامل مستخدم",
|
||||
"cmd.parameters_change": "تغيير المعاملات",
|
||||
"cmd.parameters_comment": "التعليق على المعامل",
|
||||
@@ -1536,6 +1556,11 @@
|
||||
"help.joint.reach": "يحرّك نقطة على قطعة نحو هدف بتغيير مفاصل سلسلتها ضمن حدودها. استخدمه لضبط وضعية آلية حسب الموضع المطلوب لنهايتها، مثل طرف ذراع روبوت.",
|
||||
"help.joint.rigid_group": "يثبّت القطع المحددة معًا كما هي لتتحرك كقطعة واحدة. استخدمه للقطع التي تنتمي إلى وحدة واحدة ولا تتحرك بالنسبة إلى بعضها.",
|
||||
"help.joint.screw": "يربط الدوران والانزلاق بخطوة لولبية، بحيث تتقدم القطعة بمقدار تلك المسافة في كل دورة. استخدمه للوالب القيادة والبراغي والمحاور الملولبة.",
|
||||
"help.layout.apply": "تطبيق تخطيط محفوظ على مساحة العمل الحالية. تحتفظ أدوات التحرير القائمة بمسوداتها.",
|
||||
"help.layout.get": "قراءة التخطيط المعتمد وعرض مساحات العمل المسجلة.",
|
||||
"help.layout.reset": "استعادة التخطيط الافتراضي المسجل لمساحة العمل الحالية. تبقى إعدادات المستخدم المسبقة متاحة.",
|
||||
"help.layout.save": "حفظ التخطيط الكامل باسم محدد. يتطلب استبدال إعداد مسبق للمستخدم إذنًا صريحًا بالكتابة فوقه.",
|
||||
"help.layout.workspace.select": "اختيار مساحة عمل تخطيط مسجلة وتذكر التخطيط السابق.",
|
||||
"help.library.apply": "يطبّق مادة أو مظهرًا من ملف مكتبة محلي على أجسام أو أوجه أو مكوّنات. استخدمه لإعادة استخدام مواد حفظتها من تصميم آخر.",
|
||||
"help.library.export": "يحفظ مواد التصميم ومظاهره في ملف مكتبة. استخدمه لمشاركتها أو إعادة استخدامها في تصميمات أخرى.",
|
||||
"help.library.inspect": "يفحص ملف مكتبة ويسرد موادّه ومظاهره دون تغيير التصميم. استخدمه لمعرفة ما تحويه المكتبة قبل تطبيقها.",
|
||||
@@ -1549,6 +1574,21 @@
|
||||
"help.notice": "NOTICE",
|
||||
"help.occurrence.ground": "يثبّت قطعة في موضعها أو يحررها مجددًا، بحيث تحرّك المفاصل القطع الأخرى حولها. استخدمه للإطار أو القاعدة التي يُبنى عليها باقي التجميع.",
|
||||
"help.occurrence.move": "يحرّك جزءًا في التجميع فقط ضمن درجات الحرية التي تسمح بها مفاصله. حدد وجهًا للجزء، ثم اسحب سهمًا أو قوسًا متاحًا.",
|
||||
"help.panel.activate": "اختيار علامة تبويب لوحة مفتوحة دون طلب تركيز لوحة المفاتيح.",
|
||||
"help.panel.close": "إغلاق اللوحة بعد التحقق من دورة حياة محتواها. يُحفظ موضعها.",
|
||||
"help.panel.collapse": "طي قسم اللوحة أو توسيعه مع الاحتفاظ بأبعاده المطلوبة.",
|
||||
"help.panel.dismiss": "إغلاق العرض المؤقت للوحة مطوية.",
|
||||
"help.panel.float": "نقل لوحة إلى قسم عائم ذي موضع محدد صراحةً.",
|
||||
"help.panel.focus": "تركيز لوحة وإعادة فتح حالتها المحفوظة وتوسيع قسمها عند الحاجة.",
|
||||
"help.panel.list": "قراءة أنواع اللوحات المسجلة والمثيلات المعتمدة وعلامات التبويب النشطة والتوافر وإمكانات المضيف.",
|
||||
"help.panel.move": "نقل لوحة إلى قسم جديد أو ضمها إلى مجموعة علامات تبويب موجودة.",
|
||||
"help.panel.native_close": "إعادة قسم لوحة من نافذته المستقلة إلى موضع الإرساء المحفوظ دون إلغاء محتواه.",
|
||||
"help.panel.open": "فتح نوع لوحة بمعرّف مثيل صريح. يتطلب المثيل الجديد تحديد موضعه.",
|
||||
"help.panel.peek": "إظهار لوحة مطوية مؤقتًا دون تغيير حجمها المحفوظ أو حالة طيها.",
|
||||
"help.panel.popout": "نقل لوحة إلى نافذة مستقلة عند دعم ذلك. يستخدم مضيف المتصفح قسمًا عائمًا.",
|
||||
"help.panel.reset_pair": "استعادة نسبة الحجم الافتراضية لقسمي إرساء متجاورين ضمن حدودهما المشتركة.",
|
||||
"help.panel.resize": "تعيين العرض والارتفاع الخارجيين لقسم اللوحة ضمن حدود جميع علامات التبويب.",
|
||||
"help.panel.resize_pair": "تغيير حجم قسمي إرساء متجاورين معًا مع الحفاظ على امتدادهما الكلي المشترك.",
|
||||
"help.parameters.change": "يفتح جدول القيم المسماة والتعبيرات التي تتحكم في أبعاد التصميم. استخدمه لتغيير مقاس في مكان واحد فتتبعه كل الميزات التي تستخدمه.",
|
||||
"help.parameters.export": "يحفظ معاملات التصميم بصيغة CSV أو JSON. استخدمه لتعديلها في جدول بيانات أو نقلها إلى تصميم آخر.",
|
||||
"help.parameters.graph": "يبيّن أي المعاملات والميزات تستخدم أيًّا من غيرها، وأي حلقات أو أخطاء بينها. استخدمه لمعرفة ما سيتأثر بتغيير معامل.",
|
||||
@@ -1885,21 +1925,21 @@
|
||||
"joint.rigid_group_select_components": "اختر مكوّنين أو أكثر.",
|
||||
"joint_move.blocked_hint": "تحقق من نوع المفصل (مثل المفصل الثابت)، وتثبيت الجزء، وحدود المفصل، والقيم التي تتحكم بها المعاملات.",
|
||||
"joint_move.blocked_title": "لا يمكن تحريك هذا الجزء",
|
||||
"joint_move.coarse": "خطوة الدوران قرب المحور (°)",
|
||||
"joint_move.coarse": "الخطوة قرب المحور",
|
||||
"joint_move.conflict": "يتعارض أحد مفاصل هذا الجزء مع المفاصل الأخرى: {joint}.",
|
||||
"joint_move.conflict_hint": "عدّل المفصل المتعارض أو عطّله أو احذفه حتى تتمكن المفاصل الأخرى من وضع الجزء.",
|
||||
"joint_move.distance": "المسافة من نقطة البداية",
|
||||
"joint_move.done": "تم",
|
||||
"joint_move.fine": "خطوة الدوران بعيدًا عن المحور (°)",
|
||||
"joint_move.fine": "الخطوة بعيدًا عن المحور",
|
||||
"joint_move.fixed": "لا يملك هذا الجزء محور مفصل قابلًا للحركة.",
|
||||
"joint_move.help": "تحريك — اسحب سهمًا أو قوسًا. يلغى السحب الحالي بمفتاح Esc.",
|
||||
"joint_move.legacy_hint": "أوقف هذا الخيار لاستخدام لوحة التحريك السابقة.",
|
||||
"joint_move.precision": "اسحب خارج الدليل المتقطع لضبط الدوران بدقة.",
|
||||
"joint_move.legacy_hint": "إيقاف: لوحة التحريك السابقة.",
|
||||
"joint_move.precision": "اسحب خارج الدليل المتقطع للخطوة الدقيقة.",
|
||||
"joint_move.rotation": "الدوران من نقطة البداية",
|
||||
"joint_move.select": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
|
||||
"joint_move.settings": "تحريك التجميع",
|
||||
"joint_move.step": "الخطوة",
|
||||
"joint_move.use_handles": "استخدام مقابض حركة المفاصل لتحريك التجميع",
|
||||
"joint_move.use_handles": "استخدام مقابض حركة المفاصل للتحريك",
|
||||
"keymap.command_id": "معرّف الأمر",
|
||||
"keymap.empty": "لا توجد أوامر مطابقة",
|
||||
"keymap.use_anyway": "استخدام {key} هنا على أي حال",
|
||||
@@ -2017,6 +2057,12 @@
|
||||
"navbar.assistant": "المساعد",
|
||||
"navbar.custom.add_slot": "إضافة {slot}",
|
||||
"navbar.custom.always": "ظاهر دائمًا",
|
||||
"navbar.custom.anchor.bottom_centre": "أسفل الوسط",
|
||||
"navbar.custom.anchor.bottom_left": "أسفل اليسار",
|
||||
"navbar.custom.anchor.bottom_right": "أسفل اليمين",
|
||||
"navbar.custom.anchor.top_centre": "أعلى الوسط",
|
||||
"navbar.custom.anchor.top_left": "أعلى اليسار",
|
||||
"navbar.custom.anchor.top_right": "أعلى اليمين",
|
||||
"navbar.custom.available": "{available} · اسحبها إلى الشريط، أو اضغط إضافة",
|
||||
"navbar.custom.buttons": "الأزرار",
|
||||
"navbar.custom.compact": "مضغوط",
|
||||
@@ -2053,6 +2099,7 @@
|
||||
"navbar.custom.reset_default": "إعادة الضبط إلى الافتراضي",
|
||||
"navbar.custom.right": "نقل إلى اليمين",
|
||||
"navbar.custom.select": "لم يتم تحديد أي خانة. حدّد خانة على الشريط لنقلها أو إزالتها أو تغيير مظهرها.",
|
||||
"navbar.custom.separator_hint": "فاصل بين مجموعات الأزرار. اسحبه إلى الشريط، أو اضغط على إضافة؛ يمكنك استخدام أي عدد من الفواصل.",
|
||||
"navbar.custom.separators_full": "يحتوي الشريط بالفعل على أربعة فواصل.",
|
||||
"navbar.custom.show": "إظهار شريط التنقل",
|
||||
"navbar.custom.shows": "يعرض",
|
||||
@@ -2206,14 +2253,22 @@
|
||||
"prefs.appearance.viewport_logo": "منفذ العرض والشعار",
|
||||
"prefs.applies_on_restart": "يُطبَّق عند إعادة التشغيل",
|
||||
"prefs.assistant": "المساعد",
|
||||
"prefs.assistant_connecting": "الاتصال",
|
||||
"prefs.assistant_follow_help": "تحرّك عروض Look At للرسم المنظور مع المؤشر.",
|
||||
"prefs.assistant_limit": "الحد",
|
||||
"prefs.assistant_limited": "محدود",
|
||||
"prefs.assistant_pointer_speed": "سرعة المؤشر",
|
||||
"prefs.assistant_seconds": "ثوانٍ",
|
||||
"prefs.assistant_seconds_aria": "الثواني",
|
||||
"prefs.assistant_use_care": "استخدم بحذر",
|
||||
"prefs.auto_operation": "اختيار العملية تلقائيًا",
|
||||
"prefs.auto_operation_note": "تختار نوافذ الميزات «ضم» أو «جسم جديد» أو «قص» بحسب ما تلامسه المادة الجديدة، إلى أن تختار عملية بنفسك. عند الإيقاف: تحتفظ بالعملية التي تُفتح بها.",
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قطع، حسب ما تلامسه المادة.",
|
||||
"prefs.axes": "المحاور",
|
||||
"prefs.axis": "المحور",
|
||||
"prefs.bodies_folders": "مجلدات الأجسام",
|
||||
"prefs.bodies_folders_always": "دائمًا",
|
||||
"prefs.bodies_folders_never": "أبدًا",
|
||||
"prefs.bodies_folders_several": "عند تعدد الأجسام فقط",
|
||||
"prefs.bodies_folders_several": "عدة",
|
||||
"prefs.bottom": "أسفل",
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
@@ -2249,8 +2304,19 @@
|
||||
"prefs.dark_parts_shading_help": "تظليل العرض بإضاءة خطية مع إضاءة سماء ناعمة، لتحتفظ الأوجه الداكنة بتدرج بدل أن تصبح سوداء",
|
||||
"prefs.dead_zone_note": "الشريط المخطط هو المنطقة الميتة: تُتجاهل الحركة داخلها.",
|
||||
"prefs.default": "افتراضي",
|
||||
"prefs.design": "افتراضيات التصميم",
|
||||
"prefs.design_note": "ما تبدأ به التصاميم الجديدة. التصاميم المفتوحة تحتفظ بإعداداتها.",
|
||||
"prefs.design": "التصميم",
|
||||
"prefs.design_note": "ما تبدأ به التصاميم الجديدة.",
|
||||
"prefs.design_tools.ask": "اسأل",
|
||||
"prefs.design_tools.degrees": "درجات",
|
||||
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
|
||||
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
|
||||
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
|
||||
"prefs.design_tools.hole_snaps_help": "منتصف الوجه ومراكز حوافه الدائرية.",
|
||||
"prefs.design_tools.imported_files": "الملفات المستوردة",
|
||||
"prefs.design_tools.note": "ملاحظة",
|
||||
"prefs.design_tools.off": "إيقاف",
|
||||
"prefs.design_tools.placement_group": "مسند، نتوء، تعشيق مرن",
|
||||
"prefs.design_tools.rows": "صفوف",
|
||||
"prefs.devices": "الفأرة ثلاثية الأبعاد",
|
||||
"prefs.dialog_buttons": "أزرار مربعات الحوار",
|
||||
"prefs.dialogs": "الحوارات",
|
||||
@@ -2292,6 +2358,23 @@
|
||||
"prefs.graphics_card_restart": "يُطبَّق بعد إعادة تشغيل SolidCraft. يتجاوز WGPU_ADAPTER_NAME هذا الاختيار.",
|
||||
"prefs.graphics_card_unused": "لم تُستخدم بطاقة الرسوميات المحفوظة لهذه النافذة؛ البطاقة المستخدمة تظهر في «المساعدة › حول» وفي شريط الحالة. جرّب «تلقائي»، أو عيّن WGPU_ADAPTER_NAME إلى جزء فريد من اسم البطاقة.",
|
||||
"prefs.graphics_note": "وازن بين السرعة والنعومة. كل خيار يعرض أثره على عيّنة.",
|
||||
"prefs.graphics_page.current_view": "العرض الحالي",
|
||||
"prefs.graphics_page.fastest": "الأسرع",
|
||||
"prefs.graphics_page.fold_items": "طي العناصر",
|
||||
"prefs.graphics_page.in_view": "داخل العرض",
|
||||
"prefs.graphics_page.live_details": "تفاصيل مباشرة",
|
||||
"prefs.graphics_page.own_mix": "مزيجك الخاص",
|
||||
"prefs.graphics_page.quick_measure": "قياس سريع",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "الهندسة المرجعية عبر الأجسام",
|
||||
"prefs.graphics_page.restart": "تلزم إعادة التشغيل",
|
||||
"prefs.graphics_page.sharpest": "الأكثر وضوحًا",
|
||||
"prefs.graphics_page.single_surface_analysis": "تحليل سطح واحد",
|
||||
"prefs.graphics_page.status_chips": "شرائح الحالة",
|
||||
"prefs.graphics_page.subtitle": "وازن بين السرعة والنعومة.",
|
||||
"prefs.graphics_page.supersampling": "التنعيم الفائق",
|
||||
"prefs.graphics_page.supersampling_adaptive": "فقط أثناء سكون العرض",
|
||||
"prefs.graphics_page.surfaces_edges": "الأسطح والحواف",
|
||||
"prefs.graphics_page.view_bar": "أيضًا في شريط العرض",
|
||||
"prefs.graphics_presets_note": "الإعدادات المسبقة تضبط الخيارات أدناه معًا.",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "إخفاء رموز القيود عند التصغير",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "تختفي رموز القيود من العرض عندما يُصغَّر الرسم بقدر يجعلها تزحمه. معطّل: تبقى بحجم ثابت على الشاشة عند أي مستوى تكبير.",
|
||||
@@ -2300,7 +2383,7 @@
|
||||
"prefs.history_origin_bodies_note": "يبدأ السجل على اليسار أو تحت المستعرض بمجلد الأجسام للمكوّن النشط وبالأصل، مع أيقونات الإظهار والقوائم كما في المستعرض. لا يتغير السجل في الأسفل.",
|
||||
"prefs.history_play_button": "زر تشغيل السجل في عناصر تحكم السجل",
|
||||
"prefs.history_play_button_note": "يضيف تشغيل السجل بين الرجوع خطوة والتقدم خطوة في عناصر تحكم السجل. يمكنك أيضًا النقر بزر الماوس الأيمن على عناصر التحكم لإظهاره أو إخفائه.",
|
||||
"prefs.history_sketches": "الرسومات",
|
||||
"prefs.history_sketches": "الرسومات في السجل",
|
||||
"prefs.history_sketches_in_order": "بالترتيب",
|
||||
"prefs.history_sketches_note": "عند ضبط الرسومات على «تحت ميزتها»، يعرض السجل على اليسار كل رسم بمستوى واحد للداخل، تحت أول ميزة بُنيت منه، متى كان عريضًا بما يكفي لإظهار الأسماء. أما الخط الزمني في الأسفل فيحتفظ دائمًا بترتيب الإنشاء.",
|
||||
"prefs.history_sketches_under_feature": "تحت ميزتها",
|
||||
@@ -2325,6 +2408,22 @@
|
||||
"prefs.language_system": "لغة النظام",
|
||||
"prefs.layout": "التخطيط",
|
||||
"prefs.layout_note": "المضغوط يثبّت اللوحات على حواف النافذة. شفافية المستعرض متاحة في الوضع العائم. غيّر جهة المساعد من صفحة المساعد.",
|
||||
"prefs.layout_page.active_commands": "تمييز الأوامر النشطة",
|
||||
"prefs.layout_page.always": "دائمًا",
|
||||
"prefs.layout_page.card": "بطاقة",
|
||||
"prefs.layout_page.column": "عمود",
|
||||
"prefs.layout_page.hover": "عند مرور المؤشر",
|
||||
"prefs.layout_page.icons_labels": "الأيقونات + التسميات",
|
||||
"prefs.layout_page.ok_cancel": "موافق وإلغاء",
|
||||
"prefs.layout_page.on_hover": "عند مرور المؤشر",
|
||||
"prefs.layout_page.open": "فتح",
|
||||
"prefs.layout_page.origin_bodies": "الأصل والأجسام في الأعلى",
|
||||
"prefs.layout_page.panels": "اللوحات",
|
||||
"prefs.layout_page.pinned_menus": "استبعاد الأوامر المثبتة من القوائم",
|
||||
"prefs.layout_page.play_history": "زر تشغيل السجل",
|
||||
"prefs.layout_page.slot_count": "{count} من {max} خانة",
|
||||
"prefs.layout_page.view_corner": "أيضًا في زاوية العرض",
|
||||
"prefs.layout_page.window": "النافذة",
|
||||
"prefs.left": "يسار",
|
||||
"prefs.left_click": "نقر أيسر",
|
||||
"prefs.line_drag_draws_one": "الضغط والسحب يرسم خطًا واحدًا",
|
||||
@@ -2369,11 +2468,15 @@
|
||||
"prefs.origin_plane_names_note": "الأسماء المعروضة في المستعرض والحوارات والقوائم. {top} هو XY و{front} هو XZ و{right} هو YZ، والمحور Z للأعلى. تحتفظ الأوامر والسكربتات والتصاميم المحفوظة بـ XY وXZ وYZ.",
|
||||
"prefs.overall_speed": "السرعة العامة",
|
||||
"prefs.parameter_list_rows": "صفوف قائمة المعاملات",
|
||||
"prefs.parameter_list_rows_tip": "يعرض السهم لأسفل في حقل قيمة فارغ المعاملات، المفضلة أولًا، حتى هذا العدد. العدد الأقل يبقي القائمة خفيفة في التصميم الكبير.",
|
||||
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعلمات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
|
||||
"prefs.persistent_ribbon_pressed": "تمييز أوامر الشريط النشطة",
|
||||
"prefs.persistent_ribbon_pressed_help": "تمييز أوامر الشريط أثناء تفعيل تأثيراتها. أوقف هذا الخيار للاحتفاظ بالمظهر المحايد السابق ومساعدة الأوامر.",
|
||||
"prefs.perspective_fov": "مجال الرؤية",
|
||||
"prefs.placement": "الموضع",
|
||||
"prefs.plugins_enabled": "مفعّل",
|
||||
"prefs.plugins_installed": "المثبّتة",
|
||||
"prefs.plugins_intro": "إضافات معزولة.",
|
||||
"prefs.plugins_show_folder": "إظهار مجلد الإضافات",
|
||||
"prefs.prefer_free_sketch_points": "تفضيل نقاط الرسم القابلة للتحريك عند تداخل النقاط",
|
||||
"prefs.preview_choices": "معاينة اختياراتك",
|
||||
"prefs.preview_tint_new_faces": "تلوين السطح الجديد في المعاينة",
|
||||
@@ -2431,6 +2534,10 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
"prefs.shortcuts": "الاختصارات",
|
||||
"prefs.shortcuts_import": "استيراد",
|
||||
"prefs.shortcuts_intro": "مفاتيح كل أمر.",
|
||||
"prefs.shortcuts_not_assigned": "غير معيّن",
|
||||
"prefs.shortcuts_press": "اضغط مفتاحاً...",
|
||||
"prefs.single_surface_analysis": "استخدام تحليل سطح واحد",
|
||||
"prefs.single_surface_analysis_help": "عند إيقاف هذا الخيار، يمكن إبقاء تظليل السطح وتحليل الاستمرارية مفعّلين معًا. عند تشغيله، يبقى آخر تحليل تم بدؤه أو تغييره فقط.",
|
||||
"prefs.sketch_align_points": "المحاذاة مع نقاط الرسم الأخرى أثناء الرسم",
|
||||
@@ -2465,11 +2572,11 @@
|
||||
"prefs.source": "المصدر",
|
||||
"prefs.speed": "السرعة",
|
||||
"prefs.stand_up_note": "بعد فتح ملف STEP أو IGES ومحوره Z للأعلى، يُعرض تدويره ربع دورة حول X.",
|
||||
"prefs.stand_up_unknown": "للملفات من برامج غير معروفة",
|
||||
"prefs.stand_up_unknown": "ملفات من برامج غير معروفة",
|
||||
"prefs.stand_up_unknown.ask": "السؤال في مربع حوار",
|
||||
"prefs.stand_up_unknown.note": "عرض ملاحظة",
|
||||
"prefs.stand_up_unknown.off": "عدم العرض",
|
||||
"prefs.stand_up_unknown_note": "طريقة العرض عندما لا يذكر رأس الملف برنامجًا معروفًا بحفظ المحور Y للأعلى. تظهر الملاحظة فقط عندما يبدو أن الملف ملقى على ظهره؛ أما «السؤال في مربع حوار» فيسأل عن كل ملف كهذا، كما كان سابقًا.",
|
||||
"prefs.stand_up_unknown_note": "كيفية عرض الاقتراح عندما لا يذكر الملف برنامجًا معروفًا.",
|
||||
"prefs.status_bar": "شريط الحالة",
|
||||
"prefs.status_bar_note": "شريط رفيع على الحافة السفلية للنافذة يعرض ما يجري تحريره، وحالة الرسم المفتوح بلونها، ووحدات التصميم؛ النقر على الوحدات يغيّرها. عندها لا تظهر حالة الرسم في زاوية العرض.",
|
||||
"prefs.status_done": "تم",
|
||||
@@ -2542,8 +2649,8 @@
|
||||
"prefs.validation.label": "فحص النواة الكامل",
|
||||
"prefs.validation.off": "متوقف (تجريبي)",
|
||||
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية سحب ودمج وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
|
||||
"prefs.value_boxes_in_view": "إظهار القيم بجانب مقابضها في العرض",
|
||||
"prefs.value_boxes_in_view_tip": "قيمة الأمر (المسافة، نصف القطر…) في مربع بجانب مقبضه، فوق النموذج. عند الإيقاف: تكتبها في نافذة الأمر، التي تتلقى لوحة المفاتيح بعد التحديد، ولا شيء يغطي النموذج.",
|
||||
"prefs.value_boxes_in_view": "إظهار القيم بجانب مقابضها",
|
||||
"prefs.value_boxes_in_view_tip": "مسافة البثق ونصف قطر التدوير... في مربع داخل العرض.",
|
||||
"prefs.view": "سلوك العرض",
|
||||
"prefs.view_confirm_corner": "موافق وإلغاء في زاوية العرض",
|
||||
"prefs.view_confirm_corner_note": "أثناء فتح مربع حوار أو رسم، تؤدي علامة الصح وعلامة الإكس أعلى يمين العرض ما يؤديه زرّا موافق وإلغاء في مربع الحوار، أو تنهي الرسم وتلغيه. وينتقل متصفح العرض إلى أسفلهما.",
|
||||
@@ -3944,11 +4051,11 @@
|
||||
"select two components": "حدد مكوّنين اثنين",
|
||||
"set by the sketch's lines": "تحدده خطوط الرسم",
|
||||
"settings.display_quality_note": "المستويات الأدق ترسم الأوجه والحواف المنحنية بنعومة أكبر وتستغرق وقتًا أطول لإنشاء الشبكة. يتغير العرض فقط، لا التصميم ولا الملفات المصدّرة.",
|
||||
"settings.double_click_components": "انقر نقرًا مزدوجًا على مكوّن لتنشيطه، أو لتحرير مكوّن مرتبط في مكانه",
|
||||
"settings.double_click_components": "النقر المزدوج على مكوّن لتنشيطه",
|
||||
"settings.frame_rate_details_note": "يضيف رسمًا لأزمنة الإطارات الأخيرة مع المثلثات المرسومة فوقه، ومدرّجًا تكراريًا لأزمنة الإطارات، وما رسمته وحدة GPU. انقر معدل الإطارات في العرض لإظهارها أو إخفائها.",
|
||||
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض. أثناء تفعيله يُعاد رسم العرض باستمرار.",
|
||||
"settings.insert_lists_open_designs": "يعرض إدراج تصميم أولًا التصاميم المفتوحة في علامات التبويب الأخرى",
|
||||
"settings.measure_axis_distances": "إظهار المسافات على X/Y/Z",
|
||||
"settings.insert_lists_open_designs": "إدراج تصميم يسرد التصاميم المفتوحة أولًا",
|
||||
"settings.measure_axis_distances": "مسافات المحاور في القياس",
|
||||
"settings.measure_axis_distances_help": "إظهار مركّبات الأبعاد على محاور العالم بجانب المسافة الكلية أثناء فتح أداة القياس.",
|
||||
"settings.msaa_applies_on_restart": "يُطبّق تنعيم الحواف في المرة القادمة التي يبدأ فيها SolidCraft",
|
||||
"settings.msaa_reset": "{setting}: القيمة {requested}× غير مدعومة في جهاز الرسوميات هذا؛ أصبحت الآن {reset}× (المدعوم: {supported}).",
|
||||
@@ -3958,7 +4065,7 @@
|
||||
"settings.reset_intro": "هذه الإعدادات لا تعمل على هذا الحاسوب وكانت ستمنع SolidCraft من البدء، لذا غُيّرت:",
|
||||
"settings.reset_title": "تم تغيير الإعدادات",
|
||||
"settings.supersampling_note": "يرسم العرض بدقة أعلى ثم يصفّيه: حواف وخطوط وشبكة أنعم على الشاشات الأقل من 4K. يكلّف وقتًا من معالج الرسوميات: نحو 2.25× من البكسلات عند 1.5× و4× عند 2×.",
|
||||
"settings.update_changed_links": "تحديث التصاميم المرتبطة التي تغيّرت عند العودة إلى علامة التبويب",
|
||||
"settings.update_changed_links": "تحديث الروابط المتغيرة عند العودة إلى علامة تبويب",
|
||||
"settings.window_system_details": "التفاصيل",
|
||||
"settings.window_system_note_now": "يُطبَّق بعد إعادة التشغيل. الحالي: {now}.",
|
||||
"settings.window_system_wayland_limited": "بعض الميزات محدودة على Wayland الأصلي.",
|
||||
@@ -4360,7 +4467,7 @@
|
||||
"ui.agent_chat_send_now_tip": "إيقاف خطوة الوكيل الحالية وإرسال هذه بعدها (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "يتواصل SolidCraft مع وكيل برمجة مثبّت على هذا الحاسوب. اختر واحدًا من التفضيلات › الوكيل.",
|
||||
"ui.agent_chat_setup_title": "إعداد مساعد",
|
||||
"ui.agent_chat_side": "جانب محادثة الوكيل",
|
||||
"ui.agent_chat_side": "إرساء المحادثة",
|
||||
"ui.agent_chat_signed_in": "سجّل الدخول",
|
||||
"ui.agent_chat_state_auth": "يلزم تسجيل الدخول",
|
||||
"ui.agent_chat_state_busy": "جارٍ العمل…",
|
||||
@@ -4425,14 +4532,14 @@
|
||||
"ui.ai_key_note": "يُخزّن في سلسلة مفاتيح النظام، وليس في إعدادات SolidCraft أبدًا. اتركه فارغًا لخادم محلي.",
|
||||
"ui.ai_keychain_error": "رفضت سلسلة مفاتيح النظام: {error}",
|
||||
"ui.ai_kind": "النوع",
|
||||
"ui.ai_mode_all": "السماح بكل شيء",
|
||||
"ui.ai_mode_all": "فعل كل شيء",
|
||||
"ui.ai_mode_all_note": "لا شيء يسأل أولًا. تبقى تقارير الأعطال محظورة وتظل حدود المجلدات سارية.",
|
||||
"ui.ai_mode_all_warning": "يمكن للوكيل حفظ تصميمك والكتابة فوقه وتصديره واستبداله دون سؤال. استخدم هذا فقط مع وكيل تثق به، وعلى تصاميم حفظتها.",
|
||||
"ui.ai_mode_ask": "السؤال في كل مرة",
|
||||
"ui.ai_mode_ask": "السؤال أولاً",
|
||||
"ui.ai_mode_ask_note": "كل تغيير في التصميم وكل إجراء على الملفات ينتظر سماحك.",
|
||||
"ui.ai_mode_edits": "السماح تلقائيًا بتعديلات النموذج",
|
||||
"ui.ai_mode_edits": "إجراء تعديلات",
|
||||
"ui.ai_mode_edits_note": "أوامر النمذجة تعمل دون سؤال؛ أما الحفظ والفتح والتصدير وإجراءات الملفات أو الإجراءات الهدّامة الأخرى فتسأل.",
|
||||
"ui.ai_mode_global": "كما هو مضبوط أعلاه",
|
||||
"ui.ai_mode_global": "كما في الأعلى",
|
||||
"ui.ai_model": "النموذج",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "هذه الإعدادات تغطي أدوات SolidCraft. أدوات الوكيل الخاصة (الصدفة وتعديل الملفات) تتبع وضع الوكيل الخاص، الذي تختاره في رأس لوحة المحادثة عندما تكون للوكيل أوضاع.",
|
||||
@@ -4458,7 +4565,7 @@
|
||||
"ui.ai_save": "حفظ",
|
||||
"ui.ai_settings": "إعدادات الذكاء الاصطناعي…",
|
||||
"ui.ai_show_features": "إظهار ميزات الذكاء الاصطناعي",
|
||||
"ui.ai_show_features_note": "محادثة الوكيل: لوحة يعمل فيها وكيل برمجي تستخدمه أصلًا، أو نقطة نهاية نموذج تختارها، على التصميم المفتوح. عند الإيقاف: لا زر ولا اختصار ولا عنصر قائمة ولا لوحة للذكاء الاصطناعي، ولا يُبحث عن شيء أو يُشغّل.",
|
||||
"ui.ai_show_features_note": "إيقافه يخفي المساعد في كل مكان ولا يبدأ أي شيء. تصبح كل الخيارات أدناه باهتة.\n\nمحادثة الوكيل: لوحة يعمل فيها وكيل برمجي تستخدمه أصلًا، أو نقطة نهاية نموذج تختارها، على التصميم المفتوح. عند الإيقاف: لا زر ولا اختصار ولا عنصر قائمة ولا لوحة للذكاء الاصطناعي، ولا يُبحث عن شيء أو يُشغّل.",
|
||||
"ui.ai_switch_tip": "تغيير الوكيل لهذه المحادثة",
|
||||
"ui.ai_test": "اختبار",
|
||||
"ui.ai_test_acp_ok": "يجيب: {agent}",
|
||||
@@ -4492,7 +4599,7 @@
|
||||
"ui.ai_tool_sum_then": "{first}، ثم {next}",
|
||||
"ui.ai_tool_unfold": "انقر لعرض ما أعاده",
|
||||
"ui.ai_tool_waiting": "بالانتظار",
|
||||
"ui.ai_toolbar_button": "إظهار الزر في شريط الأدوات",
|
||||
"ui.ai_toolbar_button": "زر المساعد في شريط التطبيق",
|
||||
"ui.ai_toolbar_button_note": "زر الذكاء الاصطناعي في الطرف الأيمن من شريط الأدوات. يظل Ctrl+Shift+A وقائمة العرض يفتحان اللوحة.",
|
||||
"ui.ai_undo_tip": "التراجع عن كل ما غيّره الوكيل لهذه الرسالة، بخطوة واحدة",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -4764,7 +4871,7 @@
|
||||
"ui.extract_done": "أصبح المكوّن تصميمًا مستقلًا، مرتبطًا هنا.",
|
||||
"ui.extrude": "بثق",
|
||||
"ui.extrude_auto_to_object": "البثق إلى هدف مختار",
|
||||
"ui.extrude_auto_to_object_tip": "في البثق، مع اختيار مقطع، يؤدي اختيار وجه أو رأس أو مستوى موازٍ خارج مستواه إلى تبديل الامتداد إلى «إلى كائن»",
|
||||
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر المدى إلى «إلى كائن».",
|
||||
"ui.factor": "المعامل",
|
||||
"ui.fade": "تلاشٍ",
|
||||
"ui.fade_length": "طول التلاشي",
|
||||
@@ -4908,7 +5015,7 @@
|
||||
"ui.hidePlanes": "إخفاء المستويات",
|
||||
"ui.hideProfile": "إخفاء المقطع",
|
||||
"ui.hide_component_history": "إخفاء سجله",
|
||||
"ui.hide_origin_by_default": "إخفاء نقطة الأصل افتراضيًا",
|
||||
"ui.hide_origin_by_default": "إخفاء مجلد الأصل في التصاميم الجديدة",
|
||||
"ui.hide_origin_note": "تبدأ التصاميم الجديدة ومجلد نقطة الأصل مخفي. يحتفظ كل تصميم بإعداد نقطة الأصل الخاص به بمجرد إظهارها أو إخفائها.",
|
||||
"ui.history_back": "الرجوع خطوة",
|
||||
"ui.history_below_browser": "أسفل المستعرض",
|
||||
@@ -5068,7 +5175,7 @@
|
||||
"ui.pitch": "الخطوة",
|
||||
"ui.placement": "الموضع",
|
||||
"ui.placement_handles": "مقابض الموضع",
|
||||
"ui.placement_handles_tip": "مقابض على معاينة المسند أو النتوء أو وصلة الكبس: اسحب العلامة لتحريكها على النموذج، وأدِرها من حلقتها",
|
||||
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج وحلقة لتدويرها.",
|
||||
"ui.plane": "المستوى",
|
||||
"ui.plane_at_angle": "مستوى بزاوية",
|
||||
"ui.plugins": "الإضافات",
|
||||
@@ -5266,8 +5373,8 @@
|
||||
"ui.start": "البداية",
|
||||
"ui.start_over": "البدء من جديد",
|
||||
"ui.start_page": "صفحة البداية",
|
||||
"ui.step_values": "تغيير القيم بمفاتيح الأسهم وعجلة الفأرة",
|
||||
"ui.step_values_tip": "في حقل قيمة تكتب فيه، يغيّر السهمان لأعلى ولأسفل الرقم العادي بمقدار 1 (Shift: 10، Alt: 0.1)، وكذلك عجلة الفأرة فوق الحقل. أما التعابير فتبقى كما كُتبت.",
|
||||
"ui.step_values": "تغيير القيم بالأسهم والعجلة",
|
||||
"ui.step_values_tip": "يغيّر السهمان لأعلى ولأسفل والعجلة فوق حقل قيمة الرقم فيه.",
|
||||
"ui.stitch_tolerance_hint": "دقيقة",
|
||||
"ui.stretch": "مطّ",
|
||||
"ui.supersampling": "التنعيم الفائق (SSAA)",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"Rectangular": "Pravougaoni",
|
||||
"Rectangular Tube": "Pravougaona cijev",
|
||||
"Reference face": "Referentno lice",
|
||||
"Reference plane": "Referentna ravan",
|
||||
"Relief Depth": "Dubina rasterećenja",
|
||||
"Relief Width": "Širina rasterećenja",
|
||||
"Remove Tools": "Ukloni alate",
|
||||
@@ -556,6 +557,7 @@
|
||||
"Section": "Presjek",
|
||||
"Section (optional)": "Presjek (neobavezno)",
|
||||
"Section position": "Položaj presjeka",
|
||||
"Sections": "Presjeci",
|
||||
"Sections (one sketch each)": "Presjeci (po jedna skica)",
|
||||
"Select Other": "Izaberi drugo",
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Izaberi drugo: držite lijevo dugme mirno na modelu (ili desni klik › Izaberi drugo…) da prikažete sve ispod pokazivača; ponovo kliknite na isto mjesto da izaberete sljedeći element. Dvaput kliknite srednjim dugmetom za Početni prikaz (Postavke › Navigacija).",
|
||||
@@ -859,6 +861,7 @@
|
||||
"cmd.component_extract": "Izdvoji u datoteku",
|
||||
"cmd.component_finish_edit": "Završi uređivanje",
|
||||
"cmd.component_from_bodies": "Napravi komponente iz tijela",
|
||||
"cmd.component_hide_history": "Sakrij vremensku traku komponente",
|
||||
"cmd.component_insert_design": "Umetni dizajn",
|
||||
"cmd.component_links": "Popis veza",
|
||||
"cmd.component_list": "Popis komponenti",
|
||||
@@ -875,6 +878,7 @@
|
||||
"cmd.component_rename": "Preimenuj komponentu",
|
||||
"cmd.component_replace_with_link": "Zamijeni vezom",
|
||||
"cmd.component_revert_position": "Vrati položaj",
|
||||
"cmd.component_show_history": "Prikaži vremensku traku komponente",
|
||||
"cmd.component_unlock": "Otključaj sadržaj",
|
||||
"cmd.component_update_links": "Ažuriraj veze",
|
||||
"cmd.config_activate": "Aktiviraj konfiguraciju",
|
||||
@@ -885,6 +889,7 @@
|
||||
"cmd.config_rule": "Pravilo konfiguracije",
|
||||
"cmd.config_rules": "Pravila konfiguracije",
|
||||
"cmd.config_table": "Prikaži tabelu konfiguracija",
|
||||
"cmd.construct.plane.axis_point": "Ravan kroz osu i tačku",
|
||||
"cmd.construct_axis_edge": "Osa kroz ivicu",
|
||||
"cmd.construct_axis_normal_to_face": "Osa okomita na lice",
|
||||
"cmd.construct_axis_through_cylinder": "Osa kroz cilindar/konus/torus",
|
||||
@@ -933,6 +938,11 @@
|
||||
"cmd.explode_show": "Prikaži rastavljeni prikaz",
|
||||
"cmd.expr_evaluate": "Izračunaj izraz",
|
||||
"cmd.face_delete": "Izbriši lice",
|
||||
"cmd.file.export_3mf": "Izvezi 3MF",
|
||||
"cmd.file.export_obj": "Izvezi OBJ",
|
||||
"cmd.file.export_step": "Izvezi STEP",
|
||||
"cmd.file.export_stl": "Izvezi STL",
|
||||
"cmd.file.export_urdf": "Izvezi URDF",
|
||||
"cmd.file_export": "Izvoz",
|
||||
"cmd.file_insert_mesh": "Umetni mrežu",
|
||||
"cmd.file_insert_step": "Umetni STEP",
|
||||
@@ -950,6 +960,7 @@
|
||||
"cmd.inspect_colored_components": "Omogući boje komponenti",
|
||||
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
|
||||
"cmd.inspect_compare": "Uporedi tijela",
|
||||
"cmd.inspect_compare_picture": "Uporedi sliku",
|
||||
"cmd.inspect_continuity": "Analiza kontinuiteta",
|
||||
"cmd.inspect_curvature_comb": "Analiza češlja zakrivljenosti",
|
||||
"cmd.inspect_curvature_map": "Analiza mape zakrivljenosti",
|
||||
@@ -1040,6 +1051,9 @@
|
||||
"cmd.pointcloud_edit": "Uredi oblak tačaka",
|
||||
"cmd.pointcloud_insert": "Umetni oblak tačaka",
|
||||
"cmd.pointcloud_list": "Oblaci tačaka",
|
||||
"cmd.render.compare.divider": "Pomjeri razdjelnik poređenja renderovanja",
|
||||
"cmd.render.result.discard": "Odbaci rezultat renderovanja",
|
||||
"cmd.render.result.select": "Izaberi rezultat renderovanja",
|
||||
"cmd.render_aging_describe": "Slojevito starenje površine",
|
||||
"cmd.render_backends": "Pozadinski sistemi renderovanja",
|
||||
"cmd.render_batch": "Renderuj sve kadrove",
|
||||
@@ -1254,6 +1268,7 @@
|
||||
"cmd.timeline_rollback": "Pomjeri oznaku vraćanja",
|
||||
"cmd.timeline_stop": "Zaustavi vremensku traku",
|
||||
"cmd.timeline_suppress": "Potisni operaciju",
|
||||
"cmd.ui.assistant": "Prikaži ili sakrij Asistenta",
|
||||
"cmd.view.render_browser": "Stablo renderovanja",
|
||||
"cmd.view.render_dock": "Premjesti panel renderovanja",
|
||||
"cmd.view.render_focus": "Prikaz fokusa renderovanja",
|
||||
@@ -1288,9 +1303,11 @@
|
||||
"cmd.view_tool": "Alat za navigaciju",
|
||||
"cmd.view_top": "Pogled odozgo",
|
||||
"cmd.view_visual_style": "Vizuelni stil",
|
||||
"color.blue": "Plava",
|
||||
"color.copy": "Kliknite da kopirate vrijednosti boje",
|
||||
"color.edit": "Kliknite da uredite boju",
|
||||
"color.gamma": "Prikaz vrijednosti boje u gama prostoru 0-255",
|
||||
"color.green": "Zelena",
|
||||
"color.hex": "Hex",
|
||||
"color.hex_help": "Unesite #rrggbb ili #rrggbbaa; šest cifara zadržava neprozirnost",
|
||||
"color.hex_hint": "#rrggbb",
|
||||
@@ -1298,6 +1315,7 @@
|
||||
"color.linear": "Prikaz vrijednosti boje u linearnom prostoru 0-1",
|
||||
"color.recent": "Nedavne boje",
|
||||
"color.recent_help": "Ovdje se pojavljuju boje koje izaberete u bilo kojem polju boje",
|
||||
"color.red": "Crvena",
|
||||
"color.saturation": "Zasićenost (procenat)",
|
||||
"color.saturation_value": "Zasićenost i vrijednost",
|
||||
"color.selected": "Izabrana boja",
|
||||
@@ -1305,6 +1323,8 @@
|
||||
"color.step_up": "Povećaj za korak",
|
||||
"color.value": "Vrijednost, svjetlina (procenat)",
|
||||
"config.column_filter_empty": "Nema odgovarajućih kolona",
|
||||
"config.rule_parameter_columns": "Prikazani su samo parametri koji imaju kolonu u tabeli. Dodajte kolonu pomoću Dodaj kolonu (+) da biste parametar koristili ovdje.",
|
||||
"config.rule_suppression_columns": "Prikazane su samo operacije koje imaju kolonu Potisni u tabeli. Dodajte kolonu pomoću Dodaj kolonu (+) da biste ovdje mogli upravljati operacijom.",
|
||||
"contact.components_count": "Komponente: {count}",
|
||||
"contact.enabled_label": "omogućeno",
|
||||
"coupling.gear_1": "Zupčanik 1",
|
||||
@@ -1353,7 +1373,11 @@
|
||||
"dialog.pattern_sketch_not_object": "Izaberite tijela ili operacije: skica nije ni jedno ni drugo. Ponovite njene krive pomoću alata Niz ili Preslikaj u samoj skici ili je prvo ekstrudirajte.",
|
||||
"dialog.select": "↖ Izaberi",
|
||||
"dialog.selected_count": "↖ Izabrano: {count}",
|
||||
"dialog.selection_constraint": "Ograničenje {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · kriva {id}",
|
||||
"dialog.selection_guidance": "Izaberite ulaz da ga promijenite. Kliknite izabrane objekte da ih uklonite; Ctrl/Cmd-klik ih također uključuje i isključuje. Pogledajte pregled, a zatim pritisnite U redu.",
|
||||
"dialog.selection_midpoint": "Središnja tačka · {id}",
|
||||
"dialog.selection_profile": "{sketch} · profil {number}",
|
||||
"dialog.selection_step": "{step} od {total}: Izaberite {input}",
|
||||
"dialog.step": "Korak",
|
||||
"document.changed_on_disk": "{name} je promijenjen na disku",
|
||||
@@ -1436,6 +1460,7 @@
|
||||
"help.construct.axis.two_points": "Pravi pomoćnu osu kroz dvije tačke, vrhove ili tačke skice. Koristite je za osu koja prolazi između dva poznata položaja.",
|
||||
"help.construct.plane.along_path": "Pravi pomoćnu ravan okomitu na ivicu ili krivu, na nekom položaju duž nje. Koristite je da skicirate profil za povlačenje ili presjek putanje.",
|
||||
"help.construct.plane.at_angle": "Pravi pomoćnu ravan zakrenutu za ugao u odnosu na ravan ili lice, oko ose ili ivice. Koristite je za nagnute skice, npr. kutni nosač.",
|
||||
"help.construct.plane.axis_point": "Pravi pomoćnu ravan koja sadrži osu i tačku izvan te ose. Izaberite osu ishodišta ili pomoćnu osu, ravnu ivicu ili liniju skice, pa vrh, tačku skice ili pomoćnu tačku. Tačka na osi ne može odrediti jedinstvenu ravan.",
|
||||
"help.construct.plane.midplane": "Pravi pomoćnu ravan na pola puta između dvije ravni ili ravna lica, ili koja ih raspolavlja tamo gdje se sastaju pod uglom. Koristite je kao ravan simetrije za preslikavanje.",
|
||||
"help.construct.plane.offset": "Pravi pomoćnu ravan paralelnu s ravni ili ravnim licem, na određenoj udaljenosti ili kroz tačku. Koristite je da skicirate na zadatoj visini iznad lica.",
|
||||
"help.construct.plane.perpendicular": "Pravi pomoćnu ravan koja sadrži liniju ili ivicu i okomita je na ravan ili lice. Koristite je da skicirate uspravno na modelu duž ivice.",
|
||||
@@ -1812,6 +1837,8 @@
|
||||
"joint_move.blocked_hint": "Provjerite vrstu spoja (npr. Kruti), učvršćenje, granice spoja i vrijednosti koje pokreću parametri.",
|
||||
"joint_move.blocked_title": "Ovaj dio se ne može pomjeriti",
|
||||
"joint_move.coarse": "Korak rotacije blizu ose (°)",
|
||||
"joint_move.conflict": "Jedan spoj ovog dijela je u sukobu s ostalima: {joint}.",
|
||||
"joint_move.conflict_hint": "Uredite, potisnite ili izbrišite spoj koji je u sukobu da bi ostali spojevi mogli postaviti dio.",
|
||||
"joint_move.distance": "Udaljenost od početka",
|
||||
"joint_move.done": "Gotovo",
|
||||
"joint_move.fine": "Korak rotacije dalje od ose (°)",
|
||||
@@ -1911,6 +1938,8 @@
|
||||
"motion.track_name": "Staza {number}",
|
||||
"nav.alias.help": "Shift+Alt+povlačenje lijevim dugmetom orbitira, Shift+Alt+srednjim pomjera, Shift+Alt+desnim zumira; povlačenje srednjim dugmetom također pomjera, a desnim orbitira",
|
||||
"nav.blender.help": "Povlačenje srednjim dugmetom orbitira, Shift+srednje pomjera, Ctrl+srednje zumira; povlačenje desnim dugmetom ne dira kameru",
|
||||
"nav.creo.help": "Povlačenje srednjim dugmetom orbitira, Shift+srednje pomjera, Ctrl+srednje uz vertikalno povlačenje zumira (prema dolje za približavanje); točkić također zumira",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.free_orbit.help": "Miš i 3D miš rotiraju kroz oba pola i čuvaju nagib. Privlačenje osa tipkom Shift je isključeno.",
|
||||
"nav.free_orbit.label": "Slobodno orbitiranje",
|
||||
"nav.free_orbit.snap_help": "Miš i 3D miš rotiraju kroz oba pola i čuvaju nagib. Držite Shift tokom povlačenja za orbitiranje da privučete smjer pogleda i nagib.",
|
||||
@@ -2063,12 +2092,19 @@
|
||||
"parts.family.button_head_cap_screw": "Vijak s poluokruglom glavom i imbusom",
|
||||
"parts.family.dowel_pin": "Cilindrični zatik",
|
||||
"parts.family.hex_bolt": "Vijak sa šestougaonom glavom",
|
||||
"parts.family.hex_cap_screw_inch": "Vijak sa šestougaonom glavom (inč)",
|
||||
"parts.family.hex_nut": "Šestougaona matica",
|
||||
"parts.family.hex_nut_inch": "Šestougaona matica (inč)",
|
||||
"parts.family.set_screw": "Bezglavi vijak s imbusom, ravni vrh",
|
||||
"parts.family.socket_head_cap_screw": "Vijak s cilindričnom glavom i imbusom",
|
||||
"parts.family.washer": "Obična podloška",
|
||||
"parts.family.washer_sae": "Obična podloška, SAE (uska)",
|
||||
"parts.family.washer_uss": "Obična podloška, USS (široka)",
|
||||
"parts.holes": "Rupe (neobavezno)",
|
||||
"parts.placement_hint": "izaberite jednu ili više rupa da u svaku postavite dio, ili ga U redu postavlja u ishodište",
|
||||
"parts.system": "Sistem jedinica",
|
||||
"parts.system.inch": "Inčni",
|
||||
"parts.system.metric": "Metrički",
|
||||
"pattern.alignment_2": "Poravnanje 2",
|
||||
"pattern.alignment_hint": "Broj uključuje original. Centar ravnomjerno raspoređuje kopije; Obrni bira dodatnu stranu za parne ukupne brojeve.",
|
||||
"pattern.direction_1": "Smjer 1",
|
||||
@@ -2130,6 +2166,8 @@
|
||||
"prefs.button_left": "Povlačenje lijevim dugmetom",
|
||||
"prefs.button_middle": "Povlačenje srednjim dugmetom",
|
||||
"prefs.button_right": "Povlačenje desnim dugmetom",
|
||||
"prefs.carousel_next": "Sljedeće",
|
||||
"prefs.carousel_previous": "Prethodno",
|
||||
"prefs.choose_font": "Izaberi datoteku fonta…",
|
||||
"prefs.click": "Klik",
|
||||
"prefs.contact_shading": "Meko sjenčenje dodira",
|
||||
@@ -2142,6 +2180,8 @@
|
||||
"prefs.ctrl_cmd": "Ctrl / Cmd",
|
||||
"prefs.curved_surfaces": "Zakrivljene površi",
|
||||
"prefs.curves": "Zakrivljene površi",
|
||||
"prefs.cut_preview_tint": "Prikaži uklonjeni materijal u crvenoj boji",
|
||||
"prefs.cut_preview_tint_help": "Pregled ispunjava materijal koji operacija uklanja i oblik koji zamjenjuje crvenom prozirnom sjenom. Kada je isključeno, te ispune su potpuno prozirne i crtaju se samo njihovi obrisi.",
|
||||
"prefs.dark_parts_shading": "Zadrži oblik tamnih dijelova",
|
||||
"prefs.dark_parts_shading_help": "Sjenči prikaz u linearnom svjetlu s mekim dopunskim svjetlom neba, tako da tamna lica zadrže gradijent umjesto da pocrne",
|
||||
"prefs.dead_zone_note": "Šrafirana traka je mrtva zona: kretanje unutar nje se zanemaruje.",
|
||||
@@ -2200,6 +2240,8 @@
|
||||
"prefs.interface_language": "Jezik interfejsa",
|
||||
"prefs.interface_note": "Kako SolidCraft izgleda i gdje su njegovi dijelovi.",
|
||||
"prefs.interval_minutes": "{n} min",
|
||||
"prefs.invert_curved_roll_arrows": "Obrni zakrivljene strelice za valjanje",
|
||||
"prefs.invert_curved_roll_arrows_note": "Dvije zakrivljene strelice u uglu kocke valjaju prikaz unutar ravni ekrana. Uključeno: strelica u smjeru kazaljke na satu valja prikaz suprotno od smjera kazaljke na satu, a strelica suprotno od smjera kazaljke na satu valja ga u smjeru kazaljke na satu. Isključeno: svaka strelica valja prikaz onako kako je zakrivljena.",
|
||||
"prefs.invert_navigator_arrows": "Obrni strelice za rotaciju kocke prikaza",
|
||||
"prefs.invert_navigator_arrows_note": "Strelice oko kocke orbitiraju kameru u smjeru u kojem pokazuju, kao u programima Fusion i Inventor: strelica s lijeve strane pokazuje udesno i zakreće kameru udesno. Isključeno: strelica okreće kocku prema strani na kojoj se nalazi, pa lijeva strelica zakreće kameru ulijevo. Postavka ne utiče na zakrivljene strelice za valjanje.",
|
||||
"prefs.keep_work_on_tab_switch": "Zadrži dijaloge i alate otvorenima pri prebacivanju između dizajna",
|
||||
@@ -2259,6 +2301,8 @@
|
||||
"prefs.placement": "Postavljanje",
|
||||
"prefs.prefer_free_sketch_points": "Daj prednost pokretnim tačkama skice kada se tačke preklapaju",
|
||||
"prefs.preview_choices": "Pregled vaših izbora",
|
||||
"prefs.preview_tint_new_faces": "Oboji novu površ u pregledu",
|
||||
"prefs.preview_tint_new_faces_help": "Pregled operacije koja mijenja tijelo može nacrtati površ koju dodaje u boji pregleda. Kada je isključeno, tijelo se crta kakvo jest.",
|
||||
"prefs.print_face_highlight": "Isticanje površine za štampu",
|
||||
"prefs.print_face_highlight_tip": "Koliko se jako površina za štampu tijela boji i obrubljuje u prikazu. 0 % ne prikazuje isticanje.",
|
||||
"prefs.profile_active": "Aktivni profil",
|
||||
@@ -2556,6 +2600,10 @@
|
||||
"render.base_color": "Osnovna boja · sRGB",
|
||||
"render.base_metallic": "Metalni udio",
|
||||
"render.base_opaque_only": "Ovaj uređivač čuva semantiku neprozirnih tijela. Za optičko propuštanje koristite izričite kontrole stakla.",
|
||||
"render.batch_all_shots": "Svi kadrovi ({count})",
|
||||
"render.batch_empty": "Sačuvajte kadar prije renderovanja svih kadrova.",
|
||||
"render.batch_limit": "Renderovanje svih kadrova ograničeno je na 32 kadra.",
|
||||
"render.batch_status": "U toku: {running} · Na čekanju: {waiting}",
|
||||
"render.bloom_enable": "Sjaj prikaza",
|
||||
"render.bloom_help": "Kreativni sjaj samo na izlazu iz pozitivnih linearnih svijetlih dijelova, raširen kroz tri normalizovana prolaza kutijastog filtera. Poluprečnik je u pikselima izlaza. Dodaje se u linearnoj svjetlosti prikaza prije sRGB-a; nije objektiv, volumetrijski efekat niti poboljšanje konvergencije. Sirovi EXR ostaje nepromijenjen.",
|
||||
"render.bloom_opaque": "Sjaj zahtijeva neprozirni izlaz. Prozirni PNG se odbija, a ne spljoštava tiho.",
|
||||
@@ -2666,6 +2714,13 @@
|
||||
"render.choose_look": "Prvo izaberite izgled u Izgledima",
|
||||
"render.chromatic_offset": "Hromatski pomak ivica",
|
||||
"render.clear_stage": "Ukloni sačuvanu scenu",
|
||||
"render.compare_divider": "Razdjelnik poređenja",
|
||||
"render.compare_live": "Uživo",
|
||||
"render.compare_live_preview": "Pregled uživo",
|
||||
"render.compare_render": "Renderovanje {number}",
|
||||
"render.compare_side": "Renderovanje {number} · {shot}",
|
||||
"render.compare_summary": "{shot} · {width} × {height} · PNG · {seconds} s · {renderer} · zabilježeno u reviziji {revision}",
|
||||
"render.compare_swap": "Zamijeni strane",
|
||||
"render.copy_request": "Kopiraj JSON",
|
||||
"render.cpu_aa_help": "Prostorni uzorci: više uzoraka poboljšava ivice i produžava renderovanje.",
|
||||
"render.cpu_area_add": "Dodaj pravougaono CPU svjetlo",
|
||||
@@ -3415,6 +3470,10 @@
|
||||
"render.result_retained": "Prikazuje se posljednja završena slika; novije renderovanje je još nije zamijenilo.",
|
||||
"render.result_sequence": "Kontrolna tačka",
|
||||
"render.result_title": "Rezultat renderovanja",
|
||||
"render.results_choose": "Zadržani rezultati",
|
||||
"render.results_label": "Renderovanje {number} · {shot} · {seconds} s",
|
||||
"render.results_stale_label": "{result} [zastarjelo]",
|
||||
"render.results_unnamed": "Slika",
|
||||
"render.return_design": "Nazad na Dizajn",
|
||||
"render.review_stale": "Ovaj pregled je zastario. Riješite ga ili ponovo provjerite prije primjene.",
|
||||
"render.ribbon_aging": "Starenje",
|
||||
@@ -4072,6 +4131,7 @@
|
||||
"ui.agent_chat_auth_terminal": "Pokrenite ovo u terminalu, a zatim pritisnite Poveži ponovo:",
|
||||
"ui.agent_chat_auth_title": "Prijavi se kod agenta",
|
||||
"ui.agent_chat_cancel": "Otkaži",
|
||||
"ui.agent_chat_cancel_timeout": "Agent nije odgovorio na Zaustavi pa je veza s njim prekinuta. Povežite se ponovo da biste nastavili.",
|
||||
"ui.agent_chat_cannot_start": "Instalirano, ali se ne pokreće: {error}",
|
||||
"ui.agent_chat_changes": "Promjene: {n}, jedan korak poništavanja",
|
||||
"ui.agent_chat_changes_n": "Promjene: {n}",
|
||||
@@ -4081,11 +4141,13 @@
|
||||
"ui.agent_chat_command": "Komanda agenta",
|
||||
"ui.agent_chat_connect": "Poveži",
|
||||
"ui.agent_chat_copy": "Kopiraj",
|
||||
"ui.agent_chat_copy_message": "Kopiraj poruku",
|
||||
"ui.agent_chat_ctx_bodies": "Tijela",
|
||||
"ui.agent_chat_ctx_elements": "Elementi",
|
||||
"ui.agent_chat_ctx_features": "Operacije",
|
||||
"ui.agent_chat_ctx_more": "+još {n}",
|
||||
"ui.agent_chat_ctx_other": "Ostalo",
|
||||
"ui.agent_chat_custom_heading": "Prilagođeni model",
|
||||
"ui.agent_chat_custom_hint": "Bilo koji program koji govori Agent Client Protocol na stdin i stdout. Ne pokreće ga nikakva ljuska.",
|
||||
"ui.agent_chat_denied": "Odbijeno",
|
||||
"ui.agent_chat_deny": "Odbij",
|
||||
@@ -4129,8 +4191,16 @@
|
||||
"ui.agent_chat_mode_all": "Sve",
|
||||
"ui.agent_chat_mode_ask": "Pitaj",
|
||||
"ui.agent_chat_mode_edits": "Izmjene",
|
||||
"ui.agent_chat_model_custom": "Koristi prilagođeni: {model}",
|
||||
"ui.agent_chat_model_no_tools": "{model} ne može koristiti SolidCraft alate, pa može samo razgovarati. Izaberite model s podrškom za alate.",
|
||||
"ui.agent_chat_model_rejected": "Agent nije prihvatio model {model}: {error}",
|
||||
"ui.agent_chat_model_search": "Pretraži modele…",
|
||||
"ui.agent_chat_model_tip": "Model koji agent koristi. Vaš izbor se pamti za ovog agenta.",
|
||||
"ui.agent_chat_models_failed": "Popis modela nije moguće učitati: {error}",
|
||||
"ui.agent_chat_models_heading": "Dostupni modeli",
|
||||
"ui.agent_chat_models_listing": "Učitavanje popisa modela…",
|
||||
"ui.agent_chat_models_none": "Krajnja tačka ne navodi nijedan model",
|
||||
"ui.agent_chat_models_not_a_list": "Odgovor nije popis modela",
|
||||
"ui.agent_chat_new": "Novi razgovor",
|
||||
"ui.agent_chat_new_confirm": "Započeti novi razgovor? Trenutni će biti obrisan.",
|
||||
"ui.agent_chat_next_picture": "Sljedeća slika",
|
||||
@@ -4404,9 +4474,11 @@
|
||||
"ui.construction.contact_hint": "Tangenta: izaberite krug/luk ili otvoreni splajn/koniku, pa tačku dodira (krive slobodnog oblika: krajnje tačke)",
|
||||
"ui.construction.external_hint": "Tangenta: izaberite krug ili luk, vanjsku tačku, pa željeno rješenje",
|
||||
"ui.construction.free_hint": "Pomoćna prava: kliknite dvije tačke; desnim klikom počnite ispočetka",
|
||||
"ui.construction.normal_needs_length": "Beskonačna linija je uvijek pomoćna: upišite dužinu da biste nacrtali običnu liniju",
|
||||
"ui.construction.point_hint": "Pomoćna prava: kliknite tačku kroz koju prolazi",
|
||||
"ui.construction.reference_hint": "Pomoćna prava: izaberite pravu liniju, pa tačku; desnim klikom promijenite referencu",
|
||||
"ui.construction.ribbon_label": "Pomoćne",
|
||||
"ui.construction.side_hint": "Pomoćna prava: kliknite stranu prema kojoj linija ide; desnim klikom počnite ispočetka",
|
||||
"ui.context_menu": "Kontekstni meni",
|
||||
"ui.continuity_g0": "G0 pregib",
|
||||
"ui.continuity_g1": "G1 tangentno",
|
||||
@@ -4672,6 +4744,7 @@
|
||||
"ui.hideOrigin": "Sakrij ishodište",
|
||||
"ui.hidePlanes": "Sakrij ravni",
|
||||
"ui.hideProfile": "Sakrij profil",
|
||||
"ui.hide_component_history": "Sakrij njenu vremensku traku",
|
||||
"ui.hide_origin_by_default": "Podrazumijevano sakrij ishodište",
|
||||
"ui.hide_origin_note": "Novi dizajni počinju sa skrivenim folderom Ishodište. Dizajn zadržava vlastitu postavku Ishodišta kada ga prikažete ili sakrijete.",
|
||||
"ui.history_back": "Idi korak nazad",
|
||||
@@ -4679,6 +4752,7 @@
|
||||
"ui.history_edge_hint": "dvaput kliknite ivicu",
|
||||
"ui.history_first": "Idi na početak",
|
||||
"ui.history_forward": "Idi korak naprijed",
|
||||
"ui.history_hide_hidden": "Sakrij skrivene komponente",
|
||||
"ui.history_hide_names": "Sakrij nazive",
|
||||
"ui.history_last": "Idi na kraj",
|
||||
"ui.history_marker_tip": "Oznaka vraćanja: povucite da vratite model unazad; nove operacije dolaze ovdje",
|
||||
@@ -4688,10 +4762,12 @@
|
||||
"ui.history_play_button_show": "Prikaži dugme za reprodukciju",
|
||||
"ui.history_play_finish_action": "Završite trenutnu radnju prije reprodukcije vremenske trake",
|
||||
"ui.history_play_roll_back": "Pomjerite oznaku vraćanja unazad prije reprodukcije",
|
||||
"ui.history_show_hidden": "Prikaži skrivene komponente",
|
||||
"ui.history_show_names": "Prikaži nazive",
|
||||
"ui.history_sketch_listed_under": "Navedeno ispod {feature}",
|
||||
"ui.history_title": "Vremenska traka",
|
||||
"ui.history_toggle_names": "Prikaži ili sakrij nazive operacija",
|
||||
"ui.history_waits_for_editor": "Poništi, Ponovi i vremenska traka čekaju dok se ne otvori uređivač operacije",
|
||||
"ui.hole": "Rupa",
|
||||
"ui.hole_tap_type": "Vrsta navoja rupe",
|
||||
"ui.hole_type": "Vrsta rupe",
|
||||
@@ -4972,6 +5048,7 @@
|
||||
"ui.showDimension": "Prikaži kotu",
|
||||
"ui.showOrigin": "Prikaži ishodište",
|
||||
"ui.showPlanes": "Prikaži ravni",
|
||||
"ui.show_component_history": "Prikaži njenu vremensku traku",
|
||||
"ui.show_constraints": "Ograničenja",
|
||||
"ui.show_construction_geometries": "Pomoćna geometrija",
|
||||
"ui.show_dimensions": "Kote",
|
||||
@@ -5198,6 +5275,7 @@
|
||||
"web.open_link": "Otvoriti povezani dizajn?",
|
||||
"web.open_link_confirm": "Postoje nesačuvane promjene. Otvoriti povezani dizajn u novoj kartici?",
|
||||
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ izaberi · Enter / Tab dovrši · Esc odbaci",
|
||||
"↑ ↓ choose · Enter applies this value": "↑ ↓ izbor · Enter primjenjuje ovu vrijednost",
|
||||
"↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ izaberi · Enter primjenjuje ono što ste upisali · Esc odbaci",
|
||||
"⌘Enter continues on GitHub.": "⌘Enter nastavlja na GitHubu."
|
||||
}
|
||||
|
||||
+161
-54
@@ -379,7 +379,7 @@
|
||||
"Move Type": "Verschiebungstyp",
|
||||
"Move down": "Nach unten",
|
||||
"Move earlier": "Nach vorn verschieben",
|
||||
"Move holes by clicking or dragging": "Bohrungen durch Anklicken oder Ziehen verschieben",
|
||||
"Move holes by clicking or dragging": "Durch Klicken oder Ziehen verschieben",
|
||||
"Move later": "Nach hinten verschieben",
|
||||
"Move up": "Nach oben",
|
||||
"Name": "Name",
|
||||
@@ -585,7 +585,7 @@
|
||||
"Show ghost": "Geistermodell anzeigen",
|
||||
"Show in folder": "Im Ordner anzeigen",
|
||||
"Show the pointer an AI agent moves (#35)": "Den Zeiger anzeigen, den ein KI-Agent bewegt (#35)",
|
||||
"Show tool at cursor": "Werkzeug am Cursor anzeigen",
|
||||
"Show tool at cursor": "Werkzeug am Mauszeiger",
|
||||
"Signed axes": "Vorzeichenbehaftete Achsen",
|
||||
"Size": "Größe",
|
||||
"Sketch constraints": "Skizzenbedingungen",
|
||||
@@ -596,7 +596,7 @@
|
||||
"Sketch points": "Skizzenpunkte",
|
||||
"Slider": "Schubgelenk",
|
||||
"Slot width": "Langlochbreite",
|
||||
"Snap to face and edge centres": "An Flächen- und Kantenmittelpunkten einrasten",
|
||||
"Snap to face and edge centres": "An Mittelpunkten einrasten",
|
||||
"SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft kann Berichte nicht direkt einreichen. Reichen Sie Ihren Bericht auf GitHub ein. In der App wird dafür kein Token benötigt.",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft wurde beendet, bevor diese Designs gespeichert wurden. Ihr letzter Autosave kann geöffnet werden.",
|
||||
"Sort A to Z": "Von A bis Z sortieren",
|
||||
@@ -739,36 +739,36 @@
|
||||
"a picked edge's body is gone": "der Körper einer gewählten Kante existiert nicht mehr",
|
||||
"add a key for a joint first": "zuerst einen Schlüssel für ein Gelenk hinzufügen",
|
||||
"ai.connected_toast": "SolidCraft ist jetzt in {clients} verfügbar. Starten Sie diese neu, um es zu nutzen (Einstellungen > Assistent).",
|
||||
"ai.prefs.allow_control": "Verbindungen von KI-Agenten zulassen (Steuerkanal / MCP)",
|
||||
"ai.prefs.allow_control_note": "Öffnet einen Steuerkanal nur auf diesem Computer (127.0.0.1, Port 27878 oder der nächste freie). Ein verbindendes Programm muss zusätzlich ein geheimes Token vorweisen, das nur Ihr Benutzerkonto lesen kann und bei jedem Start neu erzeugt wird. Wirkt ab dem nächsten Start von SolidCraft. Aus: Es wartet nichts auf Verbindungen, außer Sie starten SolidCraft mit --control.",
|
||||
"ai.prefs.availability": "Assistenten-Steuerung",
|
||||
"ai.prefs.background_commands": "Agentenbefehle im Hintergrund ausführen",
|
||||
"ai.prefs.connect_apps": "SolidCraft für KI-Apps auf diesem Computer verfügbar machen",
|
||||
"ai.prefs.connect_apps_note": "Trägt SolidCraft beim Start als MCP-Server in die auf diesem Computer gefundenen KI-Apps ein (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), damit ein Agent es ohne Einrichtung nutzen kann. Nur der Eintrag „solidcraft“ wird hinzugefügt oder sein Pfad aktualisiert; ein von Ihnen geänderter Eintrag bleibt unberührt, und die Datei wird vorher gesichert. Aus: Die hinzugefügten Einträge werden entfernt. Hilfe > KI-Assistenten verbinden… erledigt das von Hand.",
|
||||
"ai.prefs.connect_at_start": "Zuletzt verwendeten Agenten beim Start von SolidCraft verbinden",
|
||||
"ai.prefs.allow_control": "KI-Agenten verbinden lassen",
|
||||
"ai.prefs.allow_control_note": "Ein lokaler Kanal (MCP). Nichts verlässt diesen Computer.\n\nÖffnet einen Steuerkanal nur auf diesem Computer (127.0.0.1, Port 27878 oder der nächste freie). Ein verbindendes Programm muss zusätzlich ein geheimes Token vorweisen, das nur Ihr Benutzerkonto lesen kann und bei jedem Start neu erzeugt wird. Wirkt ab dem nächsten Start von SolidCraft. Aus: Es wartet nichts auf Verbindungen, außer Sie starten SolidCraft mit --control.",
|
||||
"ai.prefs.availability": "Verfügbarkeit",
|
||||
"ai.prefs.background_commands": "Langsame Befehle im Hintergrund ausführen",
|
||||
"ai.prefs.connect_apps": "SolidCraft zu KI-Apps hinzufügen",
|
||||
"ai.prefs.connect_apps_note": "Aus entfernt die Einträge wieder.\n\nTrägt SolidCraft beim Start als MCP-Server in die auf diesem Computer gefundenen KI-Apps ein (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), damit ein Agent es ohne Einrichtung nutzen kann. Nur der Eintrag „solidcraft“ wird hinzugefügt oder sein Pfad aktualisiert; ein von Ihnen geänderter Eintrag bleibt unberührt, und die Datei wird vorher gesichert. Aus: Die hinzugefügten Einträge werden entfernt. Hilfe > KI-Assistenten verbinden… erledigt das von Hand.",
|
||||
"ai.prefs.connect_at_start": "Letzten Agenten beim Start verbinden",
|
||||
"ai.prefs.connect_at_start_note": "Verbindet beim Start von SolidCraft automatisch den zuletzt verwendeten Agenten (oder den Standardagenten) im Hintergrund, damit er für den Chat bereitsteht. Diese Funktion ist deaktiviert, wenn SolidCraft mit --control gestartet wird. Kann keine Verbindung hergestellt werden, zeigt der Assistent „Verbindung fehlgeschlagen“ und eine Schaltfläche zum erneuten Verbindungsversuch an.",
|
||||
"ai.prefs.follow_camera": "Kamera zu Skizzen drehen",
|
||||
"ai.prefs.follow_camera": "Der Kamera folgen",
|
||||
"ai.prefs.follow_camera_off": "Die automatische Kameraführung ist aus.",
|
||||
"ai.prefs.follow_camera_on": "Die Kamera dreht sich zu Skizzen, die der Agent erstellt.",
|
||||
"ai.prefs.intro": "Agenten, Berechtigungen und wie ihre Arbeit angezeigt wird.",
|
||||
"ai.prefs.make_default": "Standardmäßig verwenden",
|
||||
"ai.prefs.intro": "KI-Agenten in SolidCraft.",
|
||||
"ai.prefs.make_default": "Als Standard festlegen",
|
||||
"ai.prefs.no_agents": "Noch keine Agenten. Fügen Sie einen Agenten hinzu oder aktualisieren Sie, um installierte Agenten zu finden.",
|
||||
"ai.prefs.no_saved_approvals": "Keine gespeicherten Freigaben.",
|
||||
"ai.prefs.open_at_start": "Assistentenbereich beim Start von SolidCraft öffnen",
|
||||
"ai.prefs.open_at_start": "Chat beim Start öffnen",
|
||||
"ai.prefs.open_at_start_note": "Öffnet beim Start von SolidCraft automatisch den Assistentenbereich oder dessen separates Fenster. Diese Funktion ist deaktiviert, wenn SolidCraft mit --control gestartet wird.",
|
||||
"ai.prefs.override_all_warning": "Dieser Agent darf das Design ohne Rückfrage speichern, überschreiben, exportieren oder ersetzen.",
|
||||
"ai.prefs.permission_all": "Alles erlauben",
|
||||
"ai.prefs.permission_all_summary": "Verfügbare SolidCraft-Werkzeuge werden ohne Rückfrage ausgeführt.",
|
||||
"ai.prefs.permission_ask": "Vor Änderungen fragen",
|
||||
"ai.prefs.permission_ask_summary": "Jede Designänderung und Dateiaktion bestätigen.",
|
||||
"ai.prefs.permission_edits": "Modelländerungen erlauben",
|
||||
"ai.prefs.permission_edits_summary": "Modelländerungen werden ohne Rückfrage ausgeführt; Datei- und destruktive Aktionen fragen weiterhin nach.",
|
||||
"ai.prefs.permission_all": "Alles ausführen",
|
||||
"ai.prefs.permission_all_summary": "Fragt nie nach",
|
||||
"ai.prefs.permission_ask": "Zuerst fragen",
|
||||
"ai.prefs.permission_ask_summary": "Fragt bei jeder Änderung",
|
||||
"ai.prefs.permission_edits": "Änderungen vornehmen",
|
||||
"ai.prefs.permission_edits_summary": "Ändert frei, fragt beim Rest",
|
||||
"ai.prefs.permission_scope": "Gilt nur für SolidCraft-Werkzeuge. Shell und Dateiänderungen des Agenten unterliegen dessen eigenen Berechtigungen.",
|
||||
"ai.prefs.pointer_preview_hidden": "Zeiger ausgeblendet",
|
||||
"ai.prefs.pointer_preview_speed": "Agentenzeiger · {speed}",
|
||||
"ai.prefs.pointer_speed": "Bewegungsgeschwindigkeit",
|
||||
"ai.prefs.pointer_speed": "Geschwindigkeit",
|
||||
"ai.prefs.privacy_note": "Die Unterhaltung geht nur an den von Ihnen gewählten Agenten oder API-Endpunkt. API-Schlüssel werden im Schlüsselbund des Systems gespeichert.",
|
||||
"ai.prefs.show_pointer": "Agentenzeiger anzeigen",
|
||||
"ai.prefs.show_pointer": "Zeiger des Agenten anzeigen",
|
||||
"ai.prefs.turn_limit_hint": "Höchstzahl designändernder Werkzeugaufrufe, die der Assistent für eine Nachricht ausführen darf.",
|
||||
"angle, height": "Winkel, Höhe",
|
||||
"appearance.dielectric_ior": "Brechungsindex des Dielektrikums",
|
||||
@@ -992,6 +992,11 @@
|
||||
"cmd.joint_screw": "Schraube",
|
||||
"cmd.joint_solve": "Gelenke lösen",
|
||||
"cmd.joint_sweep": "Gelenk bis zum Kontakt durchfahren",
|
||||
"cmd.layout.apply": "Layout anwenden",
|
||||
"cmd.layout.get": "Layout prüfen",
|
||||
"cmd.layout.reset": "Layout zurücksetzen",
|
||||
"cmd.layout.save": "Layout speichern",
|
||||
"cmd.layout.workspace.select": "Layout-Arbeitsbereich auswählen",
|
||||
"cmd.library_apply": "Lokalen Bibliothekseintrag anwenden",
|
||||
"cmd.library_export": "Designbibliothek exportieren",
|
||||
"cmd.library_inspect": "Lokale Bibliothek prüfen",
|
||||
@@ -1025,6 +1030,21 @@
|
||||
"cmd.occurrence_move": "Instanz verschieben",
|
||||
"cmd.occurrence_properties": "Physikalische Eigenschaften der Instanz",
|
||||
"cmd.occurrence_reparent": "In andere Komponente verschieben",
|
||||
"cmd.panel.activate": "Panel-Registerkarte aktivieren",
|
||||
"cmd.panel.close": "Panel schließen",
|
||||
"cmd.panel.collapse": "Panel-Bereich einklappen",
|
||||
"cmd.panel.dismiss": "Panel-Vorschau schließen",
|
||||
"cmd.panel.float": "Panel frei platzieren",
|
||||
"cmd.panel.focus": "Panel fokussieren",
|
||||
"cmd.panel.list": "Panels auflisten",
|
||||
"cmd.panel.move": "Panel verschieben",
|
||||
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
|
||||
"cmd.panel.open": "Panel öffnen",
|
||||
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
|
||||
"cmd.panel.popout": "Panel in eigenes Fenster verschieben",
|
||||
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
|
||||
"cmd.panel.resize": "Panel-Bereich skalieren",
|
||||
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
|
||||
"cmd.parameters_add": "Benutzerparameter hinzufügen",
|
||||
"cmd.parameters_change": "Parameter ändern",
|
||||
"cmd.parameters_comment": "Parameter kommentieren",
|
||||
@@ -1531,6 +1551,11 @@
|
||||
"help.joint.reach": "Bewegt einen Punkt eines Teils auf ein Ziel zu, indem die Gelenke seiner Kette innerhalb ihrer Grenzen verstellt werden. Damit stellen Sie einen Mechanismus über die Lage seines Endes ein, etwa der Spitze eines Roboterarms.",
|
||||
"help.joint.rigid_group": "Verbindet die gewählten Teile starr in ihrer jetzigen Lage, sodass sie sich als Einheit bewegen. Für Teile, die zusammengehören und sich gegeneinander nicht bewegen.",
|
||||
"help.joint.screw": "Koppelt Drehen und Schieben über eine Steigung, sodass jede Umdrehung um diesen Weg vorrückt. Für Gewindespindeln, Schrauben und Trapezspindeln.",
|
||||
"help.layout.apply": "Ein gespeichertes Layout auf den aktuellen Arbeitsbereich anwenden. Geöffnete Editoren behalten ihre Entwürfe.",
|
||||
"help.layout.get": "Das übernommene Layout lesen und registrierte Arbeitsbereiche anzeigen.",
|
||||
"help.layout.reset": "Das registrierte Standardlayout des aktuellen Arbeitsbereichs wiederherstellen. Eigene Vorlagen bleiben erhalten.",
|
||||
"help.layout.save": "Das vollständige Layout unter einem Namen speichern. Eine vorhandene eigene Vorlage wird nur mit ausdrücklicher Erlaubnis überschrieben.",
|
||||
"help.layout.workspace.select": "Einen registrierten Layout-Arbeitsbereich auswählen und das bisherige Layout merken.",
|
||||
"help.library.apply": "Wendet ein Material oder eine Darstellung aus einer lokalen Bibliotheksdatei auf Körper, Flächen oder Komponenten an. Damit nutzen Sie Materialien aus einem anderen Design wieder.",
|
||||
"help.library.export": "Speichert die Materialien und Darstellungen des Designs in einer Bibliotheksdatei. Damit teilen Sie sie oder nutzen sie in anderen Designs wieder.",
|
||||
"help.library.inspect": "Prüft eine Bibliotheksdatei und listet ihre Materialien und Darstellungen auf, ohne das Design zu ändern. Damit sehen Sie vor dem Anwenden, was eine Bibliothek enthält.",
|
||||
@@ -1544,6 +1569,21 @@
|
||||
"help.notice": "NOTICE",
|
||||
"help.occurrence.ground": "Setzt ein Teil an seiner Stelle fest oder gibt es wieder frei, sodass Gelenke die übrigen Teile um es herum bewegen. Für den Rahmen oder Sockel, auf dem der Rest der Baugruppe aufbaut.",
|
||||
"help.occurrence.move": "Bewegt eine Baugruppeninstanz ausschließlich entlang der Freiheitsgrade, die ihre Gelenke zulassen. Wählen Sie eine Fläche des Bauteils aus und ziehen Sie anschließend einen der verfügbaren Pfeile oder Bögen.",
|
||||
"help.panel.activate": "Eine geöffnete Panel-Registerkarte auswählen, ohne den Tastaturfokus anzufordern.",
|
||||
"help.panel.close": "Ein Panel unter Beachtung seines Inhaltslebenszyklus schließen. Seine Platzierung wird gespeichert.",
|
||||
"help.panel.collapse": "Einen Panel-Bereich ein- oder ausklappen und seine gewünschten Abmessungen beibehalten.",
|
||||
"help.panel.dismiss": "Die vorübergehende Ansicht eines eingeklappten Panels schließen.",
|
||||
"help.panel.float": "Ein Panel in einen frei positionierten Bereich verschieben.",
|
||||
"help.panel.focus": "Ein Panel fokussieren, seinen gespeicherten Zustand bei Bedarf wieder öffnen und seinen Bereich ausklappen.",
|
||||
"help.panel.list": "Registrierte Panel-Arten, übernommene Instanzen, aktive Registerkarten, Verfügbarkeit und Host-Funktionen lesen.",
|
||||
"help.panel.move": "Ein Panel in einen neuen Bereich verschieben oder einer vorhandenen Registerkartengruppe hinzufügen.",
|
||||
"help.panel.native_close": "Einen nativen Panel-Bereich an seine gespeicherte Position zurückbringen, ohne seinen Inhalt abzubrechen.",
|
||||
"help.panel.open": "Eine Panel-Art mit einer ausdrücklichen Instanzkennung öffnen. Neue Instanzen benötigen eine Platzierung.",
|
||||
"help.panel.peek": "Ein eingeklapptes Panel vorübergehend anzeigen, ohne seine gespeicherte Größe oder seinen Klappzustand zu ändern.",
|
||||
"help.panel.popout": "Ein Panel bei Unterstützung in ein natives Fenster verschieben. Im Browser wird ein frei positionierter Bereich verwendet.",
|
||||
"help.panel.reset_pair": "Das Standardgrößenverhältnis zweier benachbarter Andockbereiche innerhalb ihrer gemeinsamen Grenzen wiederherstellen.",
|
||||
"help.panel.resize": "Die äußere Breite und Höhe eines Panel-Bereichs innerhalb der Grenzen aller Registerkarten festlegen.",
|
||||
"help.panel.resize_pair": "Benachbarte Andockbereiche gemeinsam skalieren und ihre gemeinsame Ausdehnung beibehalten.",
|
||||
"help.parameters.change": "Öffnet die Tabelle mit benannten Parametern und Ausdrücken, die die Abmessungen des Designs steuern. Ändern Sie einen Wert an einer zentralen Stelle, werden alle Features, die diesen Wert verwenden, entsprechend aktualisiert.",
|
||||
"help.parameters.export": "Speichert die Parameter des Designs als CSV oder JSON. Damit bearbeiten Sie sie in einer Tabellenkalkulation oder übertragen sie in ein anderes Design.",
|
||||
"help.parameters.graph": "Zeigt, welche Parameter und Elemente welche anderen nutzen, samt Schleifen oder Fehlern. Damit sehen Sie, was eine Parameteränderung beeinflusst.",
|
||||
@@ -1868,21 +1908,21 @@
|
||||
"joint.rigid_group_select_components": "Wählen Sie zwei oder mehr Komponenten aus.",
|
||||
"joint_move.blocked_hint": "Prüfen Sie den Gelenktyp (z. B. „Starr“), die Fixierung, die Gelenkgrenzen und die durch Parameter gesteuerten Werte.",
|
||||
"joint_move.blocked_title": "Dieses Bauteil kann nicht bewegt werden",
|
||||
"joint_move.coarse": "Drehschritt nahe der Achse (°)",
|
||||
"joint_move.coarse": "Schritt nahe der Achse",
|
||||
"joint_move.conflict": "Ein Gelenk dieses Bauteils steht im Konflikt mit den anderen: {joint}.",
|
||||
"joint_move.conflict_hint": "Bearbeiten, unterdrücken oder löschen Sie das widersprüchliche Gelenk, damit die anderen Gelenke das Bauteil platzieren können.",
|
||||
"joint_move.distance": "Abstand vom Ausgangspunkt",
|
||||
"joint_move.done": "Fertig",
|
||||
"joint_move.fine": "Drehschritt weit von der Achse entfernt (°)",
|
||||
"joint_move.fine": "Schritt fern der Achse",
|
||||
"joint_move.fixed": "Dieses Bauteil verfügt über keine bewegliche Gelenkachse.",
|
||||
"joint_move.help": "Verschieben: Ziehen Sie einen Pfeil oder Ring. Mit Esc brechen Sie den aktuellen Ziehvorgang ab.",
|
||||
"joint_move.legacy_hint": "Deaktivieren, um wieder den bisherigen Verschiebebereich zu verwenden.",
|
||||
"joint_move.precision": "Ziehen Sie außerhalb der gestrichelten Führung, um die Drehung fein einzustellen.",
|
||||
"joint_move.legacy_hint": "Aus: das bisherige Verschieben-Panel.",
|
||||
"joint_move.precision": "Für den feinen Schritt außerhalb der gestrichelten Führung ziehen.",
|
||||
"joint_move.rotation": "Drehwinkel vom Ausgangspunkt",
|
||||
"joint_move.select": "Wählen Sie eine Fläche oder eine Baugruppenkomponente aus und anschließend „Verschieben“.",
|
||||
"joint_move.settings": "Baugruppe verschieben",
|
||||
"joint_move.step": "Schrittweite",
|
||||
"joint_move.use_handles": "Gelenkgriffe zum Bewegen von Baugruppen verwenden",
|
||||
"joint_move.use_handles": "Gelenkbewegungsgriffe für Verschieben verwenden",
|
||||
"keymap.command_id": "Befehls-ID",
|
||||
"keymap.empty": "Keine passenden Befehle",
|
||||
"keymap.use_anyway": "{key} hier trotzdem verwenden",
|
||||
@@ -2000,6 +2040,12 @@
|
||||
"navbar.assistant": "Assistent",
|
||||
"navbar.custom.add_slot": "{slot} hinzufügen",
|
||||
"navbar.custom.always": "Immer sichtbar",
|
||||
"navbar.custom.anchor.bottom_centre": "Unten mittig",
|
||||
"navbar.custom.anchor.bottom_left": "Unten links",
|
||||
"navbar.custom.anchor.bottom_right": "Unten rechts",
|
||||
"navbar.custom.anchor.top_centre": "Oben mittig",
|
||||
"navbar.custom.anchor.top_left": "Oben links",
|
||||
"navbar.custom.anchor.top_right": "Oben rechts",
|
||||
"navbar.custom.available": "{available} · auf die Leiste ziehen oder auf Hinzufügen klicken",
|
||||
"navbar.custom.buttons": "Schaltflächen",
|
||||
"navbar.custom.compact": "Kompakt",
|
||||
@@ -2036,6 +2082,7 @@
|
||||
"navbar.custom.reset_default": "Auf Standard zurücksetzen",
|
||||
"navbar.custom.right": "Nach rechts",
|
||||
"navbar.custom.select": "Kein Feld ausgewählt. Wählen Sie ein Feld in der Leiste, um es zu verschieben, zu entfernen oder sein Aussehen zu ändern.",
|
||||
"navbar.custom.separator_hint": "Eine Trennlinie zwischen Gruppen von Schaltflächen. Auf die Leiste ziehen oder auf Hinzufügen klicken; beliebig oft verwendbar.",
|
||||
"navbar.custom.separators_full": "Die Leiste hat bereits vier Trennlinien.",
|
||||
"navbar.custom.show": "Navigationsleiste anzeigen",
|
||||
"navbar.custom.shows": "Zeigt",
|
||||
@@ -2189,14 +2236,22 @@
|
||||
"prefs.appearance.viewport_logo": "Ansichtsfenster und Logo",
|
||||
"prefs.applies_on_restart": "Wird nach einem Neustart übernommen",
|
||||
"prefs.assistant": "Assistent",
|
||||
"prefs.assistant_connecting": "Verbindung",
|
||||
"prefs.assistant_follow_help": "Look At bei Skizzen bewegt die Ansicht mit dem Zeiger.",
|
||||
"prefs.assistant_limit": "Limit",
|
||||
"prefs.assistant_limited": "Begrenzt",
|
||||
"prefs.assistant_pointer_speed": "Zeigergeschwindigkeit",
|
||||
"prefs.assistant_seconds": "Sekunden",
|
||||
"prefs.assistant_seconds_aria": "Sekunden",
|
||||
"prefs.assistant_use_care": "Mit Vorsicht verwenden",
|
||||
"prefs.auto_operation": "Operation automatisch auswählen",
|
||||
"prefs.auto_operation_note": "Die Feature-Dialoge wählen automatisch zwischen „Verbinden“, „Neuer Körper“ und „Ausschneiden“, abhängig davon, was das neue Material berührt. Sobald Sie selbst eine Operation auswählen, wird diese Einstellung nicht mehr angewendet. Deaktiviert: Die beim Öffnen des Dialogs voreingestellte Operation wird beibehalten.",
|
||||
"prefs.auto_operation_note": "Vereinigung, Neuer Körper oder Schnitt, je nachdem, was das Material berührt.",
|
||||
"prefs.axes": "Achsen",
|
||||
"prefs.axis": "Achse",
|
||||
"prefs.bodies_folders": "Körperordner",
|
||||
"prefs.bodies_folders_always": "Immer",
|
||||
"prefs.bodies_folders_never": "Nie",
|
||||
"prefs.bodies_folders_several": "Nur bei mehreren Körpern",
|
||||
"prefs.bodies_folders_several": "Mehrere",
|
||||
"prefs.bottom": "Unten",
|
||||
"prefs.bottom_centre": "Unten mittig",
|
||||
"prefs.bottom_right": "Unten rechts",
|
||||
@@ -2232,8 +2287,19 @@
|
||||
"prefs.dark_parts_shading_help": "Die Ansicht in linearem Licht mit sanftem Himmelslicht schattieren, damit dunkle Flächen einen Verlauf behalten statt schwarz zu werden",
|
||||
"prefs.dead_zone_note": "Der schraffierte Bereich kennzeichnet die Totzone. Bewegungen innerhalb dieses Bereichs werden ignoriert.",
|
||||
"prefs.default": "Standard",
|
||||
"prefs.design": "Design-Vorgaben",
|
||||
"prefs.design_note": "Legt fest, mit welchen Einstellungen neue Designs beginnen. Bereits geöffnete Designs behalten ihre eigenen Einstellungen.",
|
||||
"prefs.design": "Konstruktion",
|
||||
"prefs.design_note": "Womit neue Konstruktionen beginnen.",
|
||||
"prefs.design_tools.ask": "Fragen",
|
||||
"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_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
|
||||
"prefs.design_tools.hole_snaps_help": "Die Mitte einer Fläche und die Mittelpunkte ihrer kreisförmigen Kanten.",
|
||||
"prefs.design_tools.imported_files": "Importierte Dateien",
|
||||
"prefs.design_tools.note": "Hinweis",
|
||||
"prefs.design_tools.off": "Aus",
|
||||
"prefs.design_tools.placement_group": "Auflage, Zapfen, Schnappverbindung",
|
||||
"prefs.design_tools.rows": "Zeilen",
|
||||
"prefs.devices": "3D-Maus",
|
||||
"prefs.dialog_buttons": "Dialogschaltflächen",
|
||||
"prefs.dialogs": "Dialoge",
|
||||
@@ -2275,6 +2341,23 @@
|
||||
"prefs.graphics_card_restart": "Diese Einstellung wird nach einem Neustart von SolidCraft wirksam. Die Umgebungsvariable WGPU_ADAPTER_NAME hat Vorrang vor dieser Auswahl.",
|
||||
"prefs.graphics_card_unused": "Die gespeicherte Grafikkarte wurde für dieses Fenster nicht verwendet; die tatsächlich genutzte Karte steht unter Hilfe › Info und in der Statuszeile. Versuchen Sie Automatisch, oder setzen Sie WGPU_ADAPTER_NAME auf einen eindeutigen Teil des Kartennamens.",
|
||||
"prefs.graphics_note": "Wägen Sie Leistung und Darstellungsqualität gegeneinander ab. Jede Option veranschaulicht ihre Wirkung anhand eines Beispiels.",
|
||||
"prefs.graphics_page.current_view": "Aktuelle Ansicht",
|
||||
"prefs.graphics_page.fastest": "Am schnellsten",
|
||||
"prefs.graphics_page.fold_items": "Elemente einklappen",
|
||||
"prefs.graphics_page.in_view": "In der Ansicht",
|
||||
"prefs.graphics_page.live_details": "Live-Details",
|
||||
"prefs.graphics_page.own_mix": "Eigene Mischung",
|
||||
"prefs.graphics_page.quick_measure": "Schnellmessung",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "Referenzgeometrie durch Körper hindurch",
|
||||
"prefs.graphics_page.restart": "Neustart erforderlich",
|
||||
"prefs.graphics_page.sharpest": "Am schärfsten",
|
||||
"prefs.graphics_page.single_surface_analysis": "Einzelne Flächenanalyse",
|
||||
"prefs.graphics_page.status_chips": "Status-Chips",
|
||||
"prefs.graphics_page.subtitle": "Geschwindigkeit gegen Glätte abwägen.",
|
||||
"prefs.graphics_page.supersampling": "Supersampling",
|
||||
"prefs.graphics_page.supersampling_adaptive": "Nur solange die Ansicht stillsteht",
|
||||
"prefs.graphics_page.surfaces_edges": "Flächen und Kanten",
|
||||
"prefs.graphics_page.view_bar": "Auch in der Ansichtsleiste",
|
||||
"prefs.graphics_presets_note": "Mit den Profilen werden die unten aufgeführten Optionen gemeinsam eingestellt.",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "Bindungssymbole bei starkem Herauszoomen ausblenden",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "Bindungssymbole verschwinden, wenn die Skizze so weit herausgezoomt ist, dass sie sie überladen würden. Aus: sie behalten bei jedem Zoom eine feste Bildschirmgröße.",
|
||||
@@ -2283,7 +2366,7 @@
|
||||
"prefs.history_origin_bodies_note": "Der Verlauf links oder unter dem Browser beginnt mit dem Körper-Ordner der aktiven Komponente und dem Ursprung, mit Augen und Menüs wie im Browser. Der Verlauf unten bleibt unverändert.",
|
||||
"prefs.history_play_button": "Schaltfläche „Verlauf abspielen“ in den Verlaufssteuerelementen",
|
||||
"prefs.history_play_button_note": "Fügt „Verlauf abspielen“ zwischen „Einen Schritt zurück“ und „Einen Schritt vor“ in die Verlaufssteuerelemente ein. Mit einem Rechtsklick auf die Steuerelemente lässt sich die Schaltfläche auch ein- oder ausblenden.",
|
||||
"prefs.history_sketches": "Skizzen",
|
||||
"prefs.history_sketches": "Skizzen im Verlauf",
|
||||
"prefs.history_sketches_in_order": "In Erstellungsreihenfolge",
|
||||
"prefs.history_sketches_note": "Wenn „Skizzen“ auf „Unter dem zugehörigen Feature“ eingestellt ist, wird jede Skizze im linken Verlauf eine Ebene unter dem ersten daraus erstellten Feature angezeigt, sofern der Bereich breit genug für die Namen ist. Die Timeline unten zeigt weiterhin die Reihenfolge, in der die Elemente erstellt wurden.",
|
||||
"prefs.history_sketches_under_feature": "Unter dem zugehörigen Feature",
|
||||
@@ -2306,6 +2389,22 @@
|
||||
"prefs.language_system": "System",
|
||||
"prefs.layout": "Layout",
|
||||
"prefs.layout_note": "Im Layout „Kompakt“ werden Bereiche an den Fensterrändern angedockt. Transparenz für den Browser ist nur im Layout „Schwebend“ verfügbar. Die Position des Assistenten ändern Sie auf der Seite „Assistent“.",
|
||||
"prefs.layout_page.active_commands": "Aktive Befehle hervorheben",
|
||||
"prefs.layout_page.always": "Immer",
|
||||
"prefs.layout_page.card": "Karte",
|
||||
"prefs.layout_page.column": "Spalte",
|
||||
"prefs.layout_page.hover": "Bei Mauszeiger",
|
||||
"prefs.layout_page.icons_labels": "Symbole + Beschriftungen",
|
||||
"prefs.layout_page.ok_cancel": "OK und Abbrechen",
|
||||
"prefs.layout_page.on_hover": "Bei Mauszeiger",
|
||||
"prefs.layout_page.open": "Öffnen",
|
||||
"prefs.layout_page.origin_bodies": "Ursprung und Körper oben",
|
||||
"prefs.layout_page.panels": "Bereiche",
|
||||
"prefs.layout_page.pinned_menus": "Angeheftete Befehle aus Menüs ausblenden",
|
||||
"prefs.layout_page.play_history": "Verlaufswiedergabe",
|
||||
"prefs.layout_page.slot_count": "{count} von {max} Plätzen",
|
||||
"prefs.layout_page.view_corner": "Auch in der Ansichtsecke",
|
||||
"prefs.layout_page.window": "Fenster",
|
||||
"prefs.left": "Links",
|
||||
"prefs.left_click": "Linksklick",
|
||||
"prefs.line_drag_draws_one": "Drücken und Ziehen zeichnet eine Linie",
|
||||
@@ -2350,11 +2449,15 @@
|
||||
"prefs.origin_plane_names_note": "Namen in Browser, Dialogen und Listen. {top} ist XY, {front} ist XZ und {right} ist YZ, mit Z nach oben. Befehle, Skripte und gespeicherte Konstruktionen behalten XY, XZ und YZ.",
|
||||
"prefs.overall_speed": "Gesamtgeschwindigkeit",
|
||||
"prefs.parameter_list_rows": "Zeilen der Parameterliste",
|
||||
"prefs.parameter_list_rows_tip": "Der Pfeil nach unten in einem leeren Wertefeld listet die Parameter auf, Favoriten zuerst, bis zu dieser Anzahl. Weniger hält die Liste in einem großen Design übersichtlich.",
|
||||
"prefs.parameter_list_rows_tip": "Maximale Anzahl der Parameter, die Pfeil abwärts in einem leeren Ausdrucksfeld auflistet.",
|
||||
"prefs.persistent_ribbon_pressed": "Aktive Menübandbefehle hervorheben",
|
||||
"prefs.persistent_ribbon_pressed_help": "Hebt Befehle im Menüband hervor, solange ihre Effekte eingeschaltet sind. Deaktivieren Sie diese Option, um die bisherige neutrale Darstellung und Befehlshilfe beizubehalten.",
|
||||
"prefs.perspective_fov": "Sichtfeld",
|
||||
"prefs.placement": "Platzierung",
|
||||
"prefs.plugins_enabled": "Aktiviert",
|
||||
"prefs.plugins_installed": "Installiert",
|
||||
"prefs.plugins_intro": "Isolierte Erweiterungen.",
|
||||
"prefs.plugins_show_folder": "Plugin-Ordner anzeigen",
|
||||
"prefs.prefer_free_sketch_points": "Bei überlappenden Punkten bewegliche Skizzenpunkte bevorzugen",
|
||||
"prefs.preview_choices": "Vorschau Ihrer Einstellungen",
|
||||
"prefs.preview_tint_new_faces": "Neue Fläche in der Vorschau einfärben",
|
||||
@@ -2409,6 +2512,10 @@
|
||||
"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.",
|
||||
"prefs.shortcuts": "Tastenkürzel",
|
||||
"prefs.shortcuts_import": "Importieren",
|
||||
"prefs.shortcuts_intro": "Tasten für jeden Befehl.",
|
||||
"prefs.shortcuts_not_assigned": "Nicht zugewiesen",
|
||||
"prefs.shortcuts_press": "Taste drücken...",
|
||||
"prefs.single_surface_analysis": "Nur eine Flächenanalyse verwenden",
|
||||
"prefs.single_surface_analysis_help": "Wenn deaktiviert, können Flächenschattierung und Stetigkeitsanalyse gleichzeitig aktiv bleiben. Beim Aktivieren bleibt die zuletzt gestartete oder geänderte Analyse erhalten.",
|
||||
"prefs.sketch_align_points": "Beim Zeichnen an anderen Skizzenpunkten ausrichten",
|
||||
@@ -2443,11 +2550,11 @@
|
||||
"prefs.source": "Quelle",
|
||||
"prefs.speed": "Geschwindigkeit",
|
||||
"prefs.stand_up_note": "Wenn eine STEP- oder IGES-Datei mit der Z-Achse nach oben geöffnet wird, wird eine Vierteldrehung um die X-Achse angeboten.",
|
||||
"prefs.stand_up_unknown": "Für Dateien aus unbekannten Programmen",
|
||||
"prefs.stand_up_unknown": "Dateien aus unbekannten Programmen",
|
||||
"prefs.stand_up_unknown.ask": "In einem Dialog fragen",
|
||||
"prefs.stand_up_unknown.note": "Einen Hinweis zeigen",
|
||||
"prefs.stand_up_unknown.off": "Nicht anbieten",
|
||||
"prefs.stand_up_unknown_note": "Wie das Aufrichten angeboten wird, wenn der Dateikopf kein Programm nennt, das bekanntlich mit Y nach oben speichert. Ein Hinweis erscheint nur, wenn die Datei auf dem Rücken zu liegen scheint; „In einem Dialog fragen“ fragt wie bisher bei jeder solchen Datei.",
|
||||
"prefs.stand_up_unknown_note": "Wie der Vorschlag erscheint, wenn die Datei kein bekanntes Programm nennt.",
|
||||
"prefs.status_bar": "Statusleiste",
|
||||
"prefs.status_bar_note": "Eine schmale Leiste am unteren Fensterrand zeigt, was gerade bearbeitet wird, den Zustand der offenen Skizze in seiner Farbe und die Einheiten der Konstruktion; ein Klick auf die Einheiten ändert sie. Der Zustand der Skizze steht dann nicht mehr in der Ecke der Ansicht.",
|
||||
"prefs.status_done": "Fertig",
|
||||
@@ -2520,8 +2627,8 @@
|
||||
"prefs.validation.label": "Vollständige Kernel-Prüfung",
|
||||
"prefs.validation.off": "Aus (experimentell)",
|
||||
"prefs.validation_note": "Nach jedem Schritt: prüft jeden Sweep, jede Boolesche Operation und jede Rundung.\nEinmal je Feature: prüft das Ergebnis jedes Features einmal.\nAus (experimentell): keine Prüfung.",
|
||||
"prefs.value_boxes_in_view": "Werte in der Ansicht neben ihren Griffen zeigen",
|
||||
"prefs.value_boxes_in_view_tip": "Der Wert eines Befehls (Abstand, Radius …) in einem Feld neben seinem Griff, über dem Modell. Aus: Sie geben ihn im Befehlsfenster ein, das nach einer Auswahl die Tastatur erhält, und nichts verdeckt das Modell.",
|
||||
"prefs.value_boxes_in_view": "Werte neben ihren Griffen anzeigen",
|
||||
"prefs.value_boxes_in_view_tip": "Abstand beim Extrudieren, Radius beim Verrunden … als Feld in der Ansicht.",
|
||||
"prefs.view": "Ansichtsverhalten",
|
||||
"prefs.view_confirm_corner": "OK und Abbrechen in der Ansichtsecke",
|
||||
"prefs.view_confirm_corner_note": "Solange ein Dialog oder eine Skizze geöffnet ist, übernehmen ein Häkchen und ein Kreuz oben rechts in der Ansicht dieselben Funktionen wie „OK“ und „Abbrechen“ des Dialogs. Eine Skizze wird damit beendet oder verworfen. Der Ansichtsnavigator rückt darunter.",
|
||||
@@ -3922,11 +4029,11 @@
|
||||
"select two components": "zwei Komponenten auswählen",
|
||||
"set by the sketch's lines": "durch die Skizzenlinien festgelegt",
|
||||
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
|
||||
"settings.double_click_components": "Doppelklick auf eine Komponente aktiviert sie oder bearbeitet eine verknüpfte an Ort und Stelle",
|
||||
"settings.double_click_components": "Komponente zum Aktivieren doppelklicken",
|
||||
"settings.frame_rate_details_note": "Fügt ein Diagramm der Bildzeiten der letzten Bilder hinzu, darüber die gezeichneten Dreiecke, dazu ein Histogramm der Bildzeiten und was die GPU gezeichnet hat. Ein Klick auf die Bildrate in der Ansicht blendet sie ein oder aus.",
|
||||
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht.",
|
||||
"settings.insert_lists_open_designs": "Konstruktion einfügen zeigt zuerst die in anderen Tabs geöffneten Konstruktionen",
|
||||
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
|
||||
"settings.insert_lists_open_designs": "Konstruktion einfügen listet offene Konstruktionen zuerst",
|
||||
"settings.measure_axis_distances": "Achsabstände beim Messen",
|
||||
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
|
||||
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
|
||||
"settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).",
|
||||
@@ -3936,7 +4043,7 @@
|
||||
"settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von SolidCraft verhindern. Sie wurden daher geändert:",
|
||||
"settings.reset_title": "Einstellungen wurden geändert",
|
||||
"settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.",
|
||||
"settings.update_changed_links": "Geänderte verknüpfte Designs aktualisieren, wenn ein Tab wieder angezeigt wird",
|
||||
"settings.update_changed_links": "Geänderte Verknüpfungen bei Rückkehr zum Tab aktualisieren",
|
||||
"settings.window_system_details": "Details",
|
||||
"settings.window_system_note_now": "Wird nach einem Neustart wirksam. Aktuell: {now}.",
|
||||
"settings.window_system_wayland_limited": "Einige Funktionen sind unter nativem Wayland eingeschränkt.",
|
||||
@@ -4337,7 +4444,7 @@
|
||||
"ui.agent_chat_send_now_tip": "Den aktuellen Schritt des Agenten stoppen und dies als Nächstes senden (Strg+Eingabe)",
|
||||
"ui.agent_chat_setup_body": "SolidCraft kommuniziert mit einem auf diesem Computer installierten Programmieragenten. Wählen Sie einen unter Einstellungen › KI aus.",
|
||||
"ui.agent_chat_setup_title": "Assistenten einrichten",
|
||||
"ui.agent_chat_side": "Seite des Agenten-Chats",
|
||||
"ui.agent_chat_side": "Chat andocken",
|
||||
"ui.agent_chat_signed_in": "angemeldet",
|
||||
"ui.agent_chat_state_auth": "Anmeldung nötig",
|
||||
"ui.agent_chat_state_busy": "Arbeitet…",
|
||||
@@ -4356,7 +4463,7 @@
|
||||
"ui.agent_chat_undone": "(rückgängig gemacht)",
|
||||
"ui.agent_chat_view_attached": "Ansicht angehängt",
|
||||
"ui.agent_chat_waiting": "Wartet auf Sie",
|
||||
"ui.agent_cursor": "Agent-Cursor",
|
||||
"ui.agent_cursor": "Agentenzeiger",
|
||||
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
|
||||
"ui.agent_off_thread": "Fenster bedienbar halten, während Agenten Befehle ausführen",
|
||||
"ui.agent_off_thread_note": "Langsame Befehle eines Agenten (aus dem Assistentenbereich oder von einem MCP-Client) werden neben dem Fenster berechnet: Sie können die Ansicht weiter drehen und prüfen und einen Befehl über den Chip in der Ansicht stoppen. Aus: Jeder läuft im Thread des Fensters, und das Fenster wartet darauf.",
|
||||
@@ -4402,14 +4509,14 @@
|
||||
"ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von SolidCraft. Für einen lokalen Server leer lassen.",
|
||||
"ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}",
|
||||
"ui.ai_kind": "Art",
|
||||
"ui.ai_mode_all": "Alles erlauben",
|
||||
"ui.ai_mode_all": "Alles ausführen",
|
||||
"ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.",
|
||||
"ui.ai_mode_all_warning": "Der Agent kann Ihr Design ohne Nachfrage speichern, überschreiben, exportieren und ersetzen. Nutzen Sie das nur mit einem Agenten, dem Sie vertrauen, und mit gespeicherten Designs.",
|
||||
"ui.ai_mode_ask": "Jedes Mal fragen",
|
||||
"ui.ai_mode_ask": "Zuerst fragen",
|
||||
"ui.ai_mode_ask_note": "Jede Änderung am Design und jede Dateiaktion wartet auf Ihr Erlauben.",
|
||||
"ui.ai_mode_edits": "Modelländerungen automatisch erlauben",
|
||||
"ui.ai_mode_edits": "Änderungen vornehmen",
|
||||
"ui.ai_mode_edits_note": "Modellierbefehle laufen ohne Nachfrage; Speichern, Öffnen, Exportieren und andere Datei- oder destruktive Aktionen fragen weiterhin.",
|
||||
"ui.ai_mode_global": "Wie oben eingestellt",
|
||||
"ui.ai_mode_global": "Wie oben",
|
||||
"ui.ai_model": "Modell",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von SolidCraft. Die eigenen Werkzeuge eines Agenten (seine Shell, seine Dateiänderungen) folgen seinem eigenen Modus, den Sie im Kopf des Chat-Panels wählen, wenn der Agent Modi hat.",
|
||||
@@ -4435,7 +4542,7 @@
|
||||
"ui.ai_save": "Speichern",
|
||||
"ui.ai_settings": "KI-Einstellungen…",
|
||||
"ui.ai_show_features": "KI-Funktionen anzeigen",
|
||||
"ui.ai_show_features_note": "Der Agenten-Chat: ein Panel, in dem ein Coding-Agent, den Sie schon nutzen, oder ein Modell-Endpunkt Ihrer Wahl am geöffneten Design arbeitet. Aus: keine KI-Schaltfläche, kein Kürzel, kein Menüeintrag, kein Panel, und nichts wird gesucht oder gestartet.",
|
||||
"ui.ai_show_features_note": "Aus blendet den Assistenten überall aus und startet nichts. Alles darunter wird gedimmt.\n\nDer Agenten-Chat: ein Panel, in dem ein Coding-Agent, den Sie schon nutzen, oder ein Modell-Endpunkt Ihrer Wahl am geöffneten Design arbeitet. Aus: keine KI-Schaltfläche, kein Kürzel, kein Menüeintrag, kein Panel, und nichts wird gesucht oder gestartet.",
|
||||
"ui.ai_switch_tip": "Den Agenten für dieses Gespräch wechseln",
|
||||
"ui.ai_test": "Testen",
|
||||
"ui.ai_test_acp_ok": "Antwortet: {agent}",
|
||||
@@ -4469,7 +4576,7 @@
|
||||
"ui.ai_tool_sum_then": "{first}, dann {next}",
|
||||
"ui.ai_tool_unfold": "Klicken, um das Ergebnis zu zeigen",
|
||||
"ui.ai_tool_waiting": "wartet",
|
||||
"ui.ai_toolbar_button": "Schaltfläche in der Werkzeugleiste anzeigen",
|
||||
"ui.ai_toolbar_button": "Assistent-Schaltfläche in der Anwendungsleiste",
|
||||
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
|
||||
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -4737,7 +4844,7 @@
|
||||
"ui.extract_done": "Die Komponente ist jetzt ein eigenes Design und hier verknüpft.",
|
||||
"ui.extrude": "Extrudieren",
|
||||
"ui.extrude_auto_to_object": "Bis zu einem gewählten Ziel extrudieren",
|
||||
"ui.extrude_auto_to_object_tip": "Wenn in „Extrudieren“ ein Profil gewählt ist, schaltet das Wählen einer Fläche, eines Eckpunkts oder einer parallelen Ebene außerhalb der Profilebene die Ausdehnung auf „Bis Objekt“ um",
|
||||
"ui.extrude_auto_to_object_tip": "Eine Fläche, ein Scheitelpunkt oder eine parallele Ebene stellt die Ausdehnung auf Bis Objekt um.",
|
||||
"ui.factor": "Faktor",
|
||||
"ui.fade": "Auslauf",
|
||||
"ui.fade_length": "Auslauflänge",
|
||||
@@ -4880,7 +4987,7 @@
|
||||
"ui.hideOrigin": "Ursprung ausblenden",
|
||||
"ui.hidePlanes": "Ebenen ausblenden",
|
||||
"ui.hideProfile": "Profil ausblenden",
|
||||
"ui.hide_origin_by_default": "Ursprung standardmäßig ausblenden",
|
||||
"ui.hide_origin_by_default": "Ursprungsordner in neuen Konstruktionen ausblenden",
|
||||
"ui.hide_origin_note": "Neue Designs beginnen mit ausgeblendetem Ordner „Ursprung“. Ein Design behält seine eigene Ursprungseinstellung, sobald Sie ihn ein- oder ausblenden.",
|
||||
"ui.history_back": "Einen Schritt zurück",
|
||||
"ui.history_below_browser": "Unter dem Browser",
|
||||
@@ -5037,7 +5144,7 @@
|
||||
"ui.pitch": "Steigung",
|
||||
"ui.placement": "Platzierung",
|
||||
"ui.placement_handles": "Platzierungsgriffe",
|
||||
"ui.placement_handles_tip": "Griffe an der Vorschau einer Auflage, eines Ansatzes oder einer Rastverbindung: Ziehen Sie die Markierung, um sie über das Modell zu bewegen, und drehen Sie sie am Ring",
|
||||
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und ein Ring zum Drehen.",
|
||||
"ui.plane": "Ebene",
|
||||
"ui.plane_at_angle": "Ebene im Winkel",
|
||||
"ui.plugins": "Plugins",
|
||||
@@ -5232,8 +5339,8 @@
|
||||
"ui.start": "Start",
|
||||
"ui.start_over": "Von vorn beginnen",
|
||||
"ui.start_page": "Startseite",
|
||||
"ui.step_values": "Werte mit Pfeiltasten und Mausrad schrittweise ändern",
|
||||
"ui.step_values_tip": "In einem Wertefeld, in das Sie gerade tippen, ändern Auf/Ab eine einfache Zahl um 1 (Umschalt: 10, Alt: 0,1), ebenso das Mausrad über dem Feld. Ausdrücke bleiben wie eingegeben.",
|
||||
"ui.step_values": "Werte mit Pfeilen und Mausrad ändern",
|
||||
"ui.step_values_tip": "Pfeil auf/ab und das Mausrad über einem Wertefeld ändern dessen Zahl.",
|
||||
"ui.stitch_tolerance_hint": "eng",
|
||||
"ui.stretch": "Strecken",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"Mass Center": "Mass Centre",
|
||||
"Modeled": "Modelled",
|
||||
"Select the centre the pattern turns around first": "Select the centre the pattern turns around first",
|
||||
"Snap to face and edge centres": "Snap to face and edge centres",
|
||||
"Snap to face and edge centres": "Snap to centres",
|
||||
"Specialise": "Specialise",
|
||||
"cmd.appearance_favorite": "Favourite Look",
|
||||
"cmd.component_cycle_color": "Cycle Component Colour",
|
||||
@@ -69,6 +69,8 @@
|
||||
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.new_mm_hint": "Millimetres · opens in Design",
|
||||
"navbar.custom.anchor.bottom_centre": "Bottom centre",
|
||||
"navbar.custom.anchor.top_centre": "Top centre",
|
||||
"navbar.custom.customise": "Customise navigation bar…",
|
||||
"navbar.custom.position_centre": "Centre",
|
||||
"navbar.description.separator": "A thin line that groups neighbouring slots.",
|
||||
@@ -76,6 +78,7 @@
|
||||
"prefs.appearance.colour_vision_help": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons.",
|
||||
"prefs.appearance.viewport_colours": "3D view colours",
|
||||
"prefs.bottom_centre": "Bottom centre",
|
||||
"prefs.design_tools.hole_snaps_help": "The middle of a face and the centres of its round edges.",
|
||||
"prefs.logo_theme": "Theme's colours",
|
||||
"prefs.max_file_size": "Largest file to open",
|
||||
"prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.",
|
||||
|
||||
+162
-55
@@ -379,7 +379,7 @@
|
||||
"Move Type": "Move Type",
|
||||
"Move down": "Move down",
|
||||
"Move earlier": "Move earlier",
|
||||
"Move holes by clicking or dragging": "Move holes by clicking or dragging",
|
||||
"Move holes by clicking or dragging": "Move by click or drag",
|
||||
"Move later": "Move later",
|
||||
"Move up": "Move up",
|
||||
"Name": "Name",
|
||||
@@ -585,7 +585,7 @@
|
||||
"Show ghost": "Show ghost",
|
||||
"Show in folder": "Show in folder",
|
||||
"Show the pointer an AI agent moves (#35)": "Show the pointer an AI agent moves (#35)",
|
||||
"Show tool at cursor": "Show tool at cursor",
|
||||
"Show tool at cursor": "Tool at the cursor",
|
||||
"Signed axes": "Signed axes",
|
||||
"Size": "Size",
|
||||
"Sketch constraints": "Sketch constraints",
|
||||
@@ -596,7 +596,7 @@
|
||||
"Sketch points": "Sketch points",
|
||||
"Slider": "Slider",
|
||||
"Slot width": "Slot width",
|
||||
"Snap to face and edge centres": "Snap to face and edge centers",
|
||||
"Snap to face and edge centres": "Snap to centers",
|
||||
"SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft closed before these designs were saved. Their last autosave can be opened.",
|
||||
"Sort A to Z": "Sort A to Z",
|
||||
@@ -739,36 +739,36 @@
|
||||
"a picked edge's body is gone": "a picked edge's body is gone",
|
||||
"add a key for a joint first": "add a key for a joint first",
|
||||
"ai.connected_toast": "SolidCraft is now available in {clients}. Restart them to use it (Preferences > Assistant).",
|
||||
"ai.prefs.allow_control": "Let AI agents connect (control channel / MCP)",
|
||||
"ai.prefs.allow_control_note": "Opens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.",
|
||||
"ai.prefs.availability": "Assistant controls",
|
||||
"ai.prefs.background_commands": "Run agent commands in the background",
|
||||
"ai.prefs.connect_apps": "Make SolidCraft available to AI apps on this computer",
|
||||
"ai.prefs.connect_apps_note": "When SolidCraft starts, adds it as an MCP server to the AI apps found on this computer (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), so an agent can use it without any setup. Only the solidcraft entry is added or its path refreshed; an entry you changed is left alone, and the file is backed up first. Off: the entries it added are removed. Help > Connect AI assistants… does this by hand.",
|
||||
"ai.prefs.connect_at_start": "Connect the last-used agent when SolidCraft starts",
|
||||
"ai.prefs.allow_control": "Let AI agents connect",
|
||||
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.",
|
||||
"ai.prefs.availability": "Availability",
|
||||
"ai.prefs.background_commands": "Run slow commands in the background",
|
||||
"ai.prefs.connect_apps": "Add SolidCraft to AI apps",
|
||||
"ai.prefs.connect_apps_note": "Off takes the entries out again.\n\nWhen SolidCraft starts, adds it as an MCP server to the AI apps found on this computer (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), so an agent can use it without any setup. Only the solidcraft entry is added or its path refreshed; an entry you changed is left alone, and the file is backed up first. Off: the entries it added are removed. Help > Connect AI assistants… does this by hand.",
|
||||
"ai.prefs.connect_at_start": "Connect the last agent at start",
|
||||
"ai.prefs.connect_at_start_note": "Starts the agent you connected last (or the default one) in the background each time SolidCraft starts, so it is ready for chatting. Not when SolidCraft is started with --control. If it cannot connect, the Assistant shows Connection failed and a Retry button.",
|
||||
"ai.prefs.follow_camera": "Turn the camera to sketches",
|
||||
"ai.prefs.follow_camera": "Follow the camera",
|
||||
"ai.prefs.follow_camera_off": "Automatic camera following is off.",
|
||||
"ai.prefs.follow_camera_on": "The camera turns to face sketches the agent creates.",
|
||||
"ai.prefs.intro": "Agents, permissions and how their work appears.",
|
||||
"ai.prefs.make_default": "Use by default",
|
||||
"ai.prefs.intro": "AI agents in SolidCraft.",
|
||||
"ai.prefs.make_default": "Make default",
|
||||
"ai.prefs.no_agents": "No agents yet. Add an agent or refresh to find installed agents.",
|
||||
"ai.prefs.no_saved_approvals": "No saved approvals.",
|
||||
"ai.prefs.open_at_start": "Open the Assistant panel when SolidCraft starts",
|
||||
"ai.prefs.open_at_start": "Open the chat at start",
|
||||
"ai.prefs.open_at_start_note": "Shows the Assistant panel (or its own window) each time SolidCraft starts. Not when SolidCraft is started with --control.",
|
||||
"ai.prefs.override_all_warning": "This agent may save, overwrite, export or replace the design without asking.",
|
||||
"ai.prefs.permission_all": "Allow everything",
|
||||
"ai.prefs.permission_all_summary": "Available SolidCraft tools run without asking.",
|
||||
"ai.prefs.permission_ask": "Ask before changes",
|
||||
"ai.prefs.permission_ask_summary": "Approve every design change and file action.",
|
||||
"ai.prefs.permission_edits": "Allow model edits",
|
||||
"ai.prefs.permission_edits_summary": "Model edits run; file and destructive actions still ask.",
|
||||
"ai.prefs.permission_all": "Do everything",
|
||||
"ai.prefs.permission_all_summary": "Never asks",
|
||||
"ai.prefs.permission_ask": "Ask first",
|
||||
"ai.prefs.permission_ask_summary": "Asks for every change",
|
||||
"ai.prefs.permission_edits": "Make edits",
|
||||
"ai.prefs.permission_edits_summary": "Edits freely, asks for the rest",
|
||||
"ai.prefs.permission_scope": "For SolidCraft tools only. The agent’s shell and file edits use its own permissions.",
|
||||
"ai.prefs.pointer_preview_hidden": "Pointer hidden",
|
||||
"ai.prefs.pointer_preview_speed": "Agent pointer · {speed}",
|
||||
"ai.prefs.pointer_speed": "Motion speed",
|
||||
"ai.prefs.pointer_speed": "Speed",
|
||||
"ai.prefs.privacy_note": "Only your chosen agent or endpoint receives the conversation. Endpoint keys are stored in your system keychain.",
|
||||
"ai.prefs.show_pointer": "Show agent pointer",
|
||||
"ai.prefs.show_pointer": "Show the agent's pointer",
|
||||
"ai.prefs.turn_limit_hint": "Maximum design-changing tool calls the Assistant may make for one message.",
|
||||
"angle, height": "angle, height",
|
||||
"appearance.dielectric_ior": "Dielectric IOR",
|
||||
@@ -997,6 +997,11 @@
|
||||
"cmd.joint_screw": "Screw",
|
||||
"cmd.joint_solve": "Solve Joints",
|
||||
"cmd.joint_sweep": "Sweep Joint",
|
||||
"cmd.layout.apply": "Apply Layout",
|
||||
"cmd.layout.get": "Inspect Layout",
|
||||
"cmd.layout.reset": "Reset Layout",
|
||||
"cmd.layout.save": "Save Layout",
|
||||
"cmd.layout.workspace.select": "Select Layout Workspace",
|
||||
"cmd.library_apply": "Apply Local Library Entry",
|
||||
"cmd.library_export": "Export Design Library",
|
||||
"cmd.library_inspect": "Inspect Local Library",
|
||||
@@ -1030,6 +1035,21 @@
|
||||
"cmd.occurrence_move": "Move Occurrence",
|
||||
"cmd.occurrence_properties": "Occurrence Physical Properties",
|
||||
"cmd.occurrence_reparent": "Move to Component",
|
||||
"cmd.panel.activate": "Activate Panel Tab",
|
||||
"cmd.panel.close": "Close Panel",
|
||||
"cmd.panel.collapse": "Collapse Panel Section",
|
||||
"cmd.panel.dismiss": "Dismiss Panel Peek",
|
||||
"cmd.panel.float": "Float Panel",
|
||||
"cmd.panel.focus": "Focus Panel",
|
||||
"cmd.panel.list": "List Panels",
|
||||
"cmd.panel.move": "Move Panel",
|
||||
"cmd.panel.native_close": "Redock Native Panel Section",
|
||||
"cmd.panel.open": "Open Panel",
|
||||
"cmd.panel.peek": "Peek Collapsed Panel",
|
||||
"cmd.panel.popout": "Pop Out Panel",
|
||||
"cmd.panel.reset_pair": "Reset Panel Sash",
|
||||
"cmd.panel.resize": "Resize Panel Section",
|
||||
"cmd.panel.resize_pair": "Resize Panel Sash",
|
||||
"cmd.parameters_add": "Add User Parameter",
|
||||
"cmd.parameters_change": "Change Parameters",
|
||||
"cmd.parameters_comment": "Comment Parameter",
|
||||
@@ -1536,6 +1556,11 @@
|
||||
"help.joint.reach": "Moves a point on a part toward a target by changing the joints on its chain, within their limits. Use it to pose a mechanism by where its end should be, such as the tip of a robot arm.",
|
||||
"help.joint.rigid_group": "Locks the selected parts together as they are, so they move as one. Use it for parts that belong together and have no motion between them.",
|
||||
"help.joint.screw": "Links turning and sliding by a pitch, so each turn advances by that distance. Use it for lead screws, bolts and threaded spindles.",
|
||||
"help.layout.apply": "Apply a saved layout to the current workspace. Retained editors keep their drafts.",
|
||||
"help.layout.get": "Read the committed layout and discover registered workspaces.",
|
||||
"help.layout.reset": "Restore the current workspace's registered default layout. User presets remain available.",
|
||||
"help.layout.save": "Save the complete layout under a name. Replacing a user preset requires explicit overwrite permission.",
|
||||
"help.layout.workspace.select": "Select a registered layout workspace and remember the outgoing layout.",
|
||||
"help.library.apply": "Applies a material or appearance from a local library file to bodies, faces or components. Use it to reuse materials you saved from another design.",
|
||||
"help.library.export": "Saves the design's materials and appearances to a library file. Use it to share them or reuse them in other designs.",
|
||||
"help.library.inspect": "Checks a library file and lists its materials and appearances, without changing the design. Use it to see what a library holds before applying it.",
|
||||
@@ -1549,6 +1574,21 @@
|
||||
"help.notice": "NOTICE",
|
||||
"help.occurrence.ground": "Fixes a part in place, or frees it again, so that joints move the other parts around it. Use it for the frame or base that the rest of the assembly is built on.",
|
||||
"help.occurrence.move": "Moves an assembly part only along freedoms allowed by its joints. Select a part face, then drag an available arrow or arc.",
|
||||
"help.panel.activate": "Select an open panel tab without requesting keyboard focus.",
|
||||
"help.panel.close": "Close a panel through its content lifecycle guard. Its placement is remembered.",
|
||||
"help.panel.collapse": "Collapse or expand a panel section while retaining its requested dimensions.",
|
||||
"help.panel.dismiss": "Dismiss the temporary view of a collapsed panel.",
|
||||
"help.panel.float": "Move a panel into an explicitly positioned floating section.",
|
||||
"help.panel.focus": "Focus a panel, reopening its retained state and expanding its section when needed.",
|
||||
"help.panel.list": "Read registered panel kinds, committed instances, active tabs, availability and host capabilities.",
|
||||
"help.panel.move": "Move a panel into a new section or join an existing tab group.",
|
||||
"help.panel.native_close": "Return a native panel section to its remembered dock without cancelling its content.",
|
||||
"help.panel.open": "Open a panel kind with an explicit instance identity and placement for a new instance.",
|
||||
"help.panel.peek": "Temporarily show a collapsed panel without changing its saved size or collapse state.",
|
||||
"help.panel.popout": "Move a panel to a native host when supported. Browser hosts use a floating section.",
|
||||
"help.panel.reset_pair": "Restore an adjacent dock pair's default size ratio within its shared bounds.",
|
||||
"help.panel.resize": "Set a panel section's outer width and height within every tab's bounds.",
|
||||
"help.panel.resize_pair": "Resize adjacent dock sections together while conserving their shared extent.",
|
||||
"help.parameters.change": "Opens the table of named values and expressions that drive the dimensions of the design. Use it to change a size in one place and let every feature that uses it follow.",
|
||||
"help.parameters.export": "Saves the design's parameters as CSV or JSON. Use it to edit them in a spreadsheet or move them to another design.",
|
||||
"help.parameters.graph": "Shows which parameters and features use which others, and any loops or errors among them. Use it to see what a change to a parameter will affect.",
|
||||
@@ -1885,21 +1925,21 @@
|
||||
"joint.rigid_group_select_components": "Select two or more components.",
|
||||
"joint_move.blocked_hint": "Check the joint type (such as Rigid), grounding, joint limits, and parameter-driven values.",
|
||||
"joint_move.blocked_title": "Cannot move this part",
|
||||
"joint_move.coarse": "Near-axis rotation step (°)",
|
||||
"joint_move.coarse": "Near-axis step",
|
||||
"joint_move.conflict": "A joint of this part conflicts with the others: {joint}.",
|
||||
"joint_move.conflict_hint": "Edit, suppress or delete the conflicting joint so the other joints can place the part.",
|
||||
"joint_move.distance": "Distance from start",
|
||||
"joint_move.done": "Done",
|
||||
"joint_move.fine": "Far-axis rotation step (°)",
|
||||
"joint_move.fine": "Far-axis step",
|
||||
"joint_move.fixed": "This part has no movable joint axis.",
|
||||
"joint_move.help": "Move — drag an arrow or ring. Esc cancels the current drag.",
|
||||
"joint_move.legacy_hint": "Turn off to use the previous Move panel.",
|
||||
"joint_move.precision": "Pull outside the dashed guide for fine rotation.",
|
||||
"joint_move.legacy_hint": "Off: the previous Move panel.",
|
||||
"joint_move.precision": "Pull outside the dashed guide for the fine step.",
|
||||
"joint_move.rotation": "Rotation from start",
|
||||
"joint_move.select": "Select a face or one assembly part, then choose Move.",
|
||||
"joint_move.settings": "Assembly Move",
|
||||
"joint_move.step": "Step",
|
||||
"joint_move.use_handles": "Use joint motion handles for assembly Move",
|
||||
"joint_move.use_handles": "Use joint motion handles for Move",
|
||||
"keymap.command_id": "Command ID",
|
||||
"keymap.empty": "No matching commands",
|
||||
"keymap.use_anyway": "Use {key} here anyway",
|
||||
@@ -2017,6 +2057,12 @@
|
||||
"navbar.assistant": "Assistant",
|
||||
"navbar.custom.add_slot": "Add {slot}",
|
||||
"navbar.custom.always": "Always visible",
|
||||
"navbar.custom.anchor.bottom_centre": "Bottom center",
|
||||
"navbar.custom.anchor.bottom_left": "Bottom left",
|
||||
"navbar.custom.anchor.bottom_right": "Bottom right",
|
||||
"navbar.custom.anchor.top_centre": "Top center",
|
||||
"navbar.custom.anchor.top_left": "Top left",
|
||||
"navbar.custom.anchor.top_right": "Top right",
|
||||
"navbar.custom.available": "{available} · drag onto the bar, or press Add",
|
||||
"navbar.custom.buttons": "Buttons",
|
||||
"navbar.custom.compact": "Compact",
|
||||
@@ -2053,6 +2099,7 @@
|
||||
"navbar.custom.reset_default": "Reset to default",
|
||||
"navbar.custom.right": "Move right",
|
||||
"navbar.custom.select": "No slot selected. Select a slot on the bar to move it, remove it or change how it looks.",
|
||||
"navbar.custom.separator_hint": "A divider between groups of buttons. Drag it onto the bar, or press Add; use as many as you like.",
|
||||
"navbar.custom.separators_full": "The bar already has four separators.",
|
||||
"navbar.custom.show": "Show navigation bar",
|
||||
"navbar.custom.shows": "Shows",
|
||||
@@ -2206,21 +2253,29 @@
|
||||
"prefs.appearance.viewport_logo": "Viewport and logo",
|
||||
"prefs.applies_on_restart": "Applies on restart",
|
||||
"prefs.assistant": "Assistant",
|
||||
"prefs.assistant_connecting": "Connecting",
|
||||
"prefs.assistant_follow_help": "Look At sketches move the view with the pointer.",
|
||||
"prefs.assistant_limit": "Limit",
|
||||
"prefs.assistant_limited": "Limited",
|
||||
"prefs.assistant_pointer_speed": "Pointer speed",
|
||||
"prefs.assistant_seconds": "seconds",
|
||||
"prefs.assistant_seconds_aria": "Seconds",
|
||||
"prefs.assistant_use_care": "Use with care",
|
||||
"prefs.auto_operation": "Choose the operation automatically",
|
||||
"prefs.auto_operation_note": "Feature windows pick Join, New Body or Cut from what the new material touches, until you pick one. Off: they keep the operation they open with.",
|
||||
"prefs.auto_operation_note": "Join, New Body or Cut, from what the material touches.",
|
||||
"prefs.axes": "Axes",
|
||||
"prefs.axis": "Axis",
|
||||
"prefs.bodies_folders": "Bodies folders",
|
||||
"prefs.bodies_folders_always": "Always",
|
||||
"prefs.bodies_folders_never": "Never",
|
||||
"prefs.bodies_folders_several": "Several bodies only",
|
||||
"prefs.bodies_folders_several": "Several",
|
||||
"prefs.bottom": "Bottom",
|
||||
"prefs.bottom_centre": "Bottom center",
|
||||
"prefs.bottom_right": "Bottom right",
|
||||
"prefs.browser": "Browser",
|
||||
"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 next to the fold arrow",
|
||||
"prefs.browser_eye_left": "Eye beside the fold arrow",
|
||||
"prefs.browser_eye_left_note": "Browser rows show their show/hide eye next to the fold arrow, as before, instead of in a column at the right end of the rows.",
|
||||
"prefs.browser_reveal_on_hover": "Reveal on hover",
|
||||
"prefs.browser_show_note": "Shown keeps the browser on screen. Reveal on hover keeps it out of sight until the pointer comes over its place or the thin tab at its left edge, and it stays open while the pointer is inside it or a menu from it is open.",
|
||||
@@ -2249,8 +2304,19 @@
|
||||
"prefs.dark_parts_shading_help": "Shade the view in linear light with a soft sky fill, so dark faces keep a gradient instead of turning black",
|
||||
"prefs.dead_zone_note": "The hatched band is the dead zone: motion inside it is ignored.",
|
||||
"prefs.default": "default",
|
||||
"prefs.design": "Design defaults",
|
||||
"prefs.design_note": "What new designs start with. Open designs keep their own.",
|
||||
"prefs.design": "Design",
|
||||
"prefs.design_note": "What new designs start with.",
|
||||
"prefs.design_tools.ask": "Ask",
|
||||
"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_moves_help": "Picking the face again moves the hole instead of removing it.",
|
||||
"prefs.design_tools.hole_snaps_help": "The middle of a face and the centers of its round edges.",
|
||||
"prefs.design_tools.imported_files": "Imported files",
|
||||
"prefs.design_tools.note": "Note",
|
||||
"prefs.design_tools.off": "Off",
|
||||
"prefs.design_tools.placement_group": "Rest, Boss, Snap Fit",
|
||||
"prefs.design_tools.rows": "Rows",
|
||||
"prefs.devices": "3D mouse",
|
||||
"prefs.dialog_buttons": "Dialog buttons",
|
||||
"prefs.dialogs": "Dialogs",
|
||||
@@ -2292,6 +2358,23 @@
|
||||
"prefs.graphics_card_restart": "Takes effect after restarting SolidCraft. WGPU_ADAPTER_NAME overrides this choice.",
|
||||
"prefs.graphics_card_unused": "The saved graphics card was not used for this window; the card in use is shown in Help › About and in the status line. Try Automatic, or set WGPU_ADAPTER_NAME to a unique part of the card's name.",
|
||||
"prefs.graphics_note": "Trade speed for smoothness. Each choice shows its effect on a sample.",
|
||||
"prefs.graphics_page.current_view": "Current view",
|
||||
"prefs.graphics_page.fastest": "Fastest",
|
||||
"prefs.graphics_page.fold_items": "Fold items",
|
||||
"prefs.graphics_page.in_view": "In the view",
|
||||
"prefs.graphics_page.live_details": "Live details",
|
||||
"prefs.graphics_page.own_mix": "Your own mix",
|
||||
"prefs.graphics_page.quick_measure": "Quick measurement",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "Reference geometry through bodies",
|
||||
"prefs.graphics_page.restart": "Restart needed",
|
||||
"prefs.graphics_page.sharpest": "Sharpest",
|
||||
"prefs.graphics_page.single_surface_analysis": "Single surface analysis",
|
||||
"prefs.graphics_page.status_chips": "Status chips",
|
||||
"prefs.graphics_page.subtitle": "Trade speed for smoothness.",
|
||||
"prefs.graphics_page.supersampling": "Supersampling",
|
||||
"prefs.graphics_page.supersampling_adaptive": "Only while the view is still",
|
||||
"prefs.graphics_page.surfaces_edges": "Surfaces and edges",
|
||||
"prefs.graphics_page.view_bar": "Also in the view bar",
|
||||
"prefs.graphics_presets_note": "Presets set the choices below together.",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "Hide constraint glyphs when zoomed out",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "Constraint glyphs leave the view when the sketch is zoomed out far enough that they would clutter it. Off: they stay at a fixed screen size at every zoom.",
|
||||
@@ -2300,7 +2383,7 @@
|
||||
"prefs.history_origin_bodies_note": "The History on the left or under the browser starts with the active component's Bodies folder and the Origin, with their eyes and menus as in the browser. The History at the bottom is unchanged.",
|
||||
"prefs.history_play_button": "Play History button in the History controls",
|
||||
"prefs.history_play_button_note": "Puts Play History between Go back one step and Go forward one step in the History controls. You can also right-click the controls to show or hide it.",
|
||||
"prefs.history_sketches": "Sketches",
|
||||
"prefs.history_sketches": "Sketches in History",
|
||||
"prefs.history_sketches_in_order": "In order",
|
||||
"prefs.history_sketches_note": "With Sketches set to Under their feature, the History at the left lists each sketch one level in, under the first feature built from it, whenever it is wide enough for names. The timeline at the bottom always keeps the creation order.",
|
||||
"prefs.history_sketches_under_feature": "Under their feature",
|
||||
@@ -2325,6 +2408,22 @@
|
||||
"prefs.language_system": "System",
|
||||
"prefs.layout": "Layout",
|
||||
"prefs.layout_note": "Compact docks panels to the window edges. Browser translucency is available in Floating. Change the Assistant side on the Assistant page.",
|
||||
"prefs.layout_page.active_commands": "Highlight active commands",
|
||||
"prefs.layout_page.always": "Always",
|
||||
"prefs.layout_page.card": "Card",
|
||||
"prefs.layout_page.column": "Column",
|
||||
"prefs.layout_page.hover": "Hover",
|
||||
"prefs.layout_page.icons_labels": "Icons + labels",
|
||||
"prefs.layout_page.ok_cancel": "OK and Cancel",
|
||||
"prefs.layout_page.on_hover": "On hover",
|
||||
"prefs.layout_page.open": "Open",
|
||||
"prefs.layout_page.origin_bodies": "Origin and bodies on top",
|
||||
"prefs.layout_page.panels": "Panels",
|
||||
"prefs.layout_page.pinned_menus": "Pinned commands out of menus",
|
||||
"prefs.layout_page.play_history": "Play History button",
|
||||
"prefs.layout_page.slot_count": "{count} of {max} slots",
|
||||
"prefs.layout_page.view_corner": "Also in the view's corner",
|
||||
"prefs.layout_page.window": "Window",
|
||||
"prefs.left": "Left",
|
||||
"prefs.left_click": "Left click",
|
||||
"prefs.line_drag_draws_one": "Press and drag draws one line",
|
||||
@@ -2369,11 +2468,15 @@
|
||||
"prefs.origin_plane_names_note": "Names shown in the browser, dialogs and lists. {top} is XY, {front} is XZ and {right} is YZ, with Z up. Commands, scripts and saved designs keep XY, XZ and YZ.",
|
||||
"prefs.overall_speed": "Overall speed",
|
||||
"prefs.parameter_list_rows": "Parameter list rows",
|
||||
"prefs.parameter_list_rows_tip": "Down arrow in an empty value field lists the parameters, favourites first, up to this many. Fewer keeps the list light in a large design.",
|
||||
"prefs.parameter_list_rows_tip": "The most parameters Down arrow lists in an empty expression box.",
|
||||
"prefs.persistent_ribbon_pressed": "Highlight active ribbon commands",
|
||||
"prefs.persistent_ribbon_pressed_help": "Highlight ribbon commands while their effects are on. Turn off to keep the previous neutral appearance and command help.",
|
||||
"prefs.perspective_fov": "Field of view",
|
||||
"prefs.placement": "Placement",
|
||||
"prefs.plugins_enabled": "Enabled",
|
||||
"prefs.plugins_installed": "Installed",
|
||||
"prefs.plugins_intro": "Sandboxed add-ons.",
|
||||
"prefs.plugins_show_folder": "Show plugins folder",
|
||||
"prefs.prefer_free_sketch_points": "Prefer movable sketch points when points overlap",
|
||||
"prefs.preview_choices": "Preview of your choices",
|
||||
"prefs.preview_tint_new_faces": "Tint new surface in a preview",
|
||||
@@ -2431,6 +2534,10 @@
|
||||
"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.",
|
||||
"prefs.shortcuts": "Shortcuts",
|
||||
"prefs.shortcuts_import": "Import",
|
||||
"prefs.shortcuts_intro": "Keys for each command.",
|
||||
"prefs.shortcuts_not_assigned": "Not assigned",
|
||||
"prefs.shortcuts_press": "Press a key...",
|
||||
"prefs.single_surface_analysis": "Use a single surface analysis",
|
||||
"prefs.single_surface_analysis_help": "When off, surface shading and Continuity can stay on together. Turning this on keeps the last analysis started or changed.",
|
||||
"prefs.sketch_align_points": "Line up with other sketch points while drawing",
|
||||
@@ -2465,11 +2572,11 @@
|
||||
"prefs.source": "Source",
|
||||
"prefs.speed": "Speed",
|
||||
"prefs.stand_up_note": "After a STEP or IGES file opens with Z up, offer a quarter turn about X.",
|
||||
"prefs.stand_up_unknown": "For files from unknown programs",
|
||||
"prefs.stand_up_unknown": "Files from unknown programs",
|
||||
"prefs.stand_up_unknown.ask": "Ask in a dialog",
|
||||
"prefs.stand_up_unknown.note": "Show a note",
|
||||
"prefs.stand_up_unknown.off": "Don't offer",
|
||||
"prefs.stand_up_unknown_note": "How the offer is made when the file's header names no program known to save Y up. A note is shown only when the file seems to lie on its back; Ask in a dialog asks about every such file, as before.",
|
||||
"prefs.stand_up_unknown_note": "How the offer is made when the file names no known program.",
|
||||
"prefs.status_bar": "Status bar",
|
||||
"prefs.status_bar_note": "A thin bar along the window's bottom edge shows what is being edited, the open sketch's state in its color and the design's units; a click on the units changes them. The sketch's state then no longer shows in the view's corner.",
|
||||
"prefs.status_done": "Done",
|
||||
@@ -2542,8 +2649,8 @@
|
||||
"prefs.validation.label": "Full kernel check",
|
||||
"prefs.validation.off": "Off (experimental)",
|
||||
"prefs.validation_note": "After every step: checks each sweep, boolean and blend.\nOnce per feature: checks each feature's result once.\nOff (experimental): no check.",
|
||||
"prefs.value_boxes_in_view": "Show values beside their handles in the view",
|
||||
"prefs.value_boxes_in_view_tip": "A command's value (Distance, Radius…) in a box next to its handle, over the model. Off: you type it in the command window, which takes the keyboard after a pick, and nothing covers the model.",
|
||||
"prefs.value_boxes_in_view": "Show values beside their handles",
|
||||
"prefs.value_boxes_in_view_tip": "Extrude's Distance, Fillet's Radius... as a box in the view.",
|
||||
"prefs.view": "View behavior",
|
||||
"prefs.view_confirm_corner": "OK and Cancel in the view's corner",
|
||||
"prefs.view_confirm_corner_note": "While a dialog or a sketch is open, a check and a cross at the top right of the view do what the dialog's OK and Cancel do, or finish and cancel the sketch. The view navigator moves down below them.",
|
||||
@@ -3944,11 +4051,11 @@
|
||||
"select two components": "select two components",
|
||||
"set by the sketch's lines": "set by the sketch's lines",
|
||||
"settings.display_quality_note": "Only the display. Designs and exports are not affected.",
|
||||
"settings.double_click_components": "Double-click a component to activate it, or to edit a linked one in place",
|
||||
"settings.double_click_components": "Double-click a component to activate it",
|
||||
"settings.frame_rate_details_note": "Adds a graph of the last frames' times with the triangles drawn over it, a histogram of frame times and what the GPU drew. Click the frame rate in the view to show or hide them.",
|
||||
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner.",
|
||||
"settings.insert_lists_open_designs": "Insert Design lists the designs open in other tabs first",
|
||||
"settings.measure_axis_distances": "Show X/Y/Z distances",
|
||||
"settings.insert_lists_open_designs": "Insert Design lists open designs first",
|
||||
"settings.measure_axis_distances": "Axis distances in Measure",
|
||||
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
|
||||
"settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).",
|
||||
@@ -3958,7 +4065,7 @@
|
||||
"settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:",
|
||||
"settings.reset_title": "Settings were changed",
|
||||
"settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.",
|
||||
"settings.update_changed_links": "Update changed linked designs when a tab comes back",
|
||||
"settings.update_changed_links": "Update changed links when a tab comes back",
|
||||
"settings.window_system_details": "Details",
|
||||
"settings.window_system_note_now": "Applies after a restart. Now: {now}.",
|
||||
"settings.window_system_wayland_limited": "Some features are limited on native Wayland.",
|
||||
@@ -4360,7 +4467,7 @@
|
||||
"ui.agent_chat_send_now_tip": "Stop the agent's current step and send this next (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "SolidCraft talks to a coding agent installed on this computer. Pick one in Preferences › AI.",
|
||||
"ui.agent_chat_setup_title": "Set up an assistant",
|
||||
"ui.agent_chat_side": "Agent chat side",
|
||||
"ui.agent_chat_side": "Dock the chat",
|
||||
"ui.agent_chat_signed_in": "signed in",
|
||||
"ui.agent_chat_state_auth": "Sign-in needed",
|
||||
"ui.agent_chat_state_busy": "Working…",
|
||||
@@ -4379,7 +4486,7 @@
|
||||
"ui.agent_chat_undone": "(undone)",
|
||||
"ui.agent_chat_view_attached": "View attached",
|
||||
"ui.agent_chat_waiting": "Waiting for you",
|
||||
"ui.agent_cursor": "Agent cursor",
|
||||
"ui.agent_cursor": "Agent pointer",
|
||||
"ui.agent_cursor_speed": "Agent cursor speed",
|
||||
"ui.agent_off_thread": "Keep the window responsive while agents run commands",
|
||||
"ui.agent_off_thread_note": "An agent's slow commands (from the AI panel or an MCP client) compute beside the window: you can keep turning and inspecting the view, and stop one from the chip in the view. Off: each runs on the window's thread and the window waits for it.",
|
||||
@@ -4425,14 +4532,14 @@
|
||||
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
|
||||
"ui.ai_keychain_error": "The system keychain refused: {error}",
|
||||
"ui.ai_kind": "Kind",
|
||||
"ui.ai_mode_all": "Allow everything",
|
||||
"ui.ai_mode_all": "Do everything",
|
||||
"ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.",
|
||||
"ui.ai_mode_all_warning": "The agent can save, overwrite, export and replace your design without asking. Use this only with an agent you trust, on designs you have saved.",
|
||||
"ui.ai_mode_ask": "Ask every time",
|
||||
"ui.ai_mode_ask": "Ask first",
|
||||
"ui.ai_mode_ask_note": "Every change to the design, and every file action, waits for your Allow.",
|
||||
"ui.ai_mode_edits": "Auto-allow model edits",
|
||||
"ui.ai_mode_edits": "Make edits",
|
||||
"ui.ai_mode_edits_note": "Modeling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
|
||||
"ui.ai_mode_global": "As set above",
|
||||
"ui.ai_mode_global": "Same as above",
|
||||
"ui.ai_model": "Model",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "These settings cover SolidCraft's tools. An agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the chat panel's header when the agent has modes.",
|
||||
@@ -4458,7 +4565,7 @@
|
||||
"ui.ai_save": "Save",
|
||||
"ui.ai_settings": "AI settings…",
|
||||
"ui.ai_show_features": "Show AI features",
|
||||
"ui.ai_show_features_note": "The agent chat: a panel where a coding agent you already use, or a model endpoint you choose, works on the open design. Off: no AI button, shortcut, menu entry or panel, and nothing is looked for or started.",
|
||||
"ui.ai_show_features_note": "Off hides the Assistant everywhere and starts nothing. Everything below dims.\n\nThe agent chat: a panel where a coding agent you already use, or a model endpoint you choose, works on the open design. Off: no AI button, shortcut, menu entry or panel, and nothing is looked for or started.",
|
||||
"ui.ai_switch_tip": "Change the agent for this conversation",
|
||||
"ui.ai_test": "Test",
|
||||
"ui.ai_test_acp_ok": "Answers: {agent}",
|
||||
@@ -4492,7 +4599,7 @@
|
||||
"ui.ai_tool_sum_then": "{first}, then {next}",
|
||||
"ui.ai_tool_unfold": "Click to show what it returned",
|
||||
"ui.ai_tool_waiting": "waiting",
|
||||
"ui.ai_toolbar_button": "Show Assistant button in application bar",
|
||||
"ui.ai_toolbar_button": "Assistant button in the application bar",
|
||||
"ui.ai_toolbar_button_note": "The Assistant button in the application bar. Ctrl+Shift+A and the View menu still open the panel.",
|
||||
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -4764,7 +4871,7 @@
|
||||
"ui.extract_done": "The component is now its own design, linked here.",
|
||||
"ui.extrude": "Extrude",
|
||||
"ui.extrude_auto_to_object": "Extrude to a picked target",
|
||||
"ui.extrude_auto_to_object_tip": "In Extrude, with a profile picked, picking a face, vertex or parallel plane off its plane switches the extent to To Object",
|
||||
"ui.extrude_auto_to_object_tip": "A face, vertex or parallel plane switches the extent to To Object.",
|
||||
"ui.factor": "Factor",
|
||||
"ui.fade": "Fade",
|
||||
"ui.fade_length": "Fade Length",
|
||||
@@ -4908,7 +5015,7 @@
|
||||
"ui.hidePlanes": "Hide Planes",
|
||||
"ui.hideProfile": "Hide Profile",
|
||||
"ui.hide_component_history": "Hide its History",
|
||||
"ui.hide_origin_by_default": "Hide origin by default",
|
||||
"ui.hide_origin_by_default": "Hide the Origin folder in new designs",
|
||||
"ui.hide_origin_note": "New designs start with the Origin folder hidden. A design keeps its own Origin setting once you show or hide it.",
|
||||
"ui.history_back": "Go back one step",
|
||||
"ui.history_below_browser": "Below browser",
|
||||
@@ -5068,7 +5175,7 @@
|
||||
"ui.pitch": "Pitch",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Placement handles",
|
||||
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
|
||||
"ui.placement_handles_tip": "A marker to drag the feature across the model and a ring to turn it.",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.plugins": "Plugins",
|
||||
@@ -5266,8 +5373,8 @@
|
||||
"ui.start": "Start",
|
||||
"ui.start_over": "Start over",
|
||||
"ui.start_page": "Start page",
|
||||
"ui.step_values": "Step values with the arrow keys and the wheel",
|
||||
"ui.step_values_tip": "In a value field you are typing in, Up/Down change a plain number by 1 (Shift: 10, Alt: 0.1), and so does the mouse wheel over the field. Expressions are left as typed.",
|
||||
"ui.step_values": "Step values with arrows and wheel",
|
||||
"ui.step_values_tip": "Up/Down and the wheel over a value field change its number.",
|
||||
"ui.stitch_tolerance_hint": "tight",
|
||||
"ui.stretch": "Stretch",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
|
||||
+161
-54
@@ -379,7 +379,7 @@
|
||||
"Move Type": "סוג הזזה",
|
||||
"Move down": "הזזה למטה",
|
||||
"Move earlier": "הקדמה",
|
||||
"Move holes by clicking or dragging": "הזזת חורים בלחיצה או בגרירה",
|
||||
"Move holes by clicking or dragging": "הזזה בלחיצה או בגרירה",
|
||||
"Move later": "דחייה",
|
||||
"Move up": "הזזה למעלה",
|
||||
"Name": "שם",
|
||||
@@ -585,7 +585,7 @@
|
||||
"Show ghost": "הצגת צללית",
|
||||
"Show in folder": "הצגה בתיקייה",
|
||||
"Show the pointer an AI agent moves (#35)": "הצגת הסמן שסוכן הבינה המלאכותית מזיז (#35)",
|
||||
"Show tool at cursor": "הצגת הכלי ליד הסמן",
|
||||
"Show tool at cursor": "כלי ליד הסמן",
|
||||
"Signed axes": "צירים עם סימן",
|
||||
"Size": "גודל",
|
||||
"Sketch constraints": "אילוצי סקיצה",
|
||||
@@ -596,7 +596,7 @@
|
||||
"Sketch points": "נקודות סקיצה",
|
||||
"Slider": "הזזה",
|
||||
"Slot width": "רוחב חריץ",
|
||||
"Snap to face and edge centres": "הצמדה למרכזי פאות ומקצועות",
|
||||
"Snap to face and edge centres": "הצמדה למרכזים",
|
||||
"SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "אין אפשרות לשלוח דיווחים ישירות מתוך SolidCraft. יש לשלוח אותם ב-GitHub; אין צורך באסימון גישה באפליקציה.",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft נסגרה לפני שתכנים אלה נשמרו. אפשר לפתוח את השמירה האוטומטית האחרונה שלהם.",
|
||||
"Sort A to Z": "מיון בסדר עולה",
|
||||
@@ -739,36 +739,36 @@
|
||||
"a picked edge's body is gone": "הגוף של המקצוע שנבחר כבר לא קיים",
|
||||
"add a key for a joint first": "יש להוסיף תחילה מפתח למפרק",
|
||||
"ai.connected_toast": "SolidCraft זמין עכשיו ב־{clients}. יש להפעיל אותם מחדש כדי להשתמש בו (הגדרות > עוזר).",
|
||||
"ai.prefs.allow_control": "התרת חיבור לסוכני בינה מלאכותית (ערוץ בקרה / MCP)",
|
||||
"ai.prefs.allow_control_note": "פותח ערוץ בקרה במחשב זה בלבד (127.0.0.1, פורט 27878 או הפנוי הבא). תוכנה שמתחברת צריכה גם להציג אסימון סודי שרק חשבון המשתמש שלך יכול לקרוא, ונוצר מחדש בכל הפעלה. ייכנס לתוקף בהפעלה הבאה של SolidCraft. כבוי: שום דבר אינו מאזין, אלא אם SolidCraft מופעל עם --control.",
|
||||
"ai.prefs.availability": "פקדי העוזר",
|
||||
"ai.prefs.background_commands": "הרצת פקודות הסוכן ברקע",
|
||||
"ai.prefs.connect_apps": "הנגשת SolidCraft ליישומי בינה מלאכותית במחשב זה",
|
||||
"ai.prefs.connect_apps_note": "בהפעלת SolidCraft הוא מתווסף כשרת MCP ליישומי הבינה המלאכותית שנמצאו במחשב זה (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), כך שסוכן יכול להשתמש בו בלי שום הגדרה. רק הרשומה solidcraft נוספת או שהנתיב שלה מתעדכן; רשומה ששינית נשארת כפי שהיא, ולפני כן נשמר גיבוי של הקובץ. כבוי: הרשומות שנוספו מוסרות. הפקודה עזרה > חיבור עוזרי בינה מלאכותית… עושה זאת ידנית.",
|
||||
"ai.prefs.connect_at_start": "התחברות לסוכן האחרון שבו השתמשת בהפעלת SolidCraft",
|
||||
"ai.prefs.allow_control": "לאפשר לסוכני AI להתחבר",
|
||||
"ai.prefs.allow_control_note": "ערוץ מקומי (MCP). שום דבר לא יוצא מהמחשב הזה.\n\nפותח ערוץ בקרה במחשב זה בלבד (127.0.0.1, פורט 27878 או הפנוי הבא). תוכנה שמתחברת צריכה גם להציג אסימון סודי שרק חשבון המשתמש שלך יכול לקרוא, ונוצר מחדש בכל הפעלה. ייכנס לתוקף בהפעלה הבאה של SolidCraft. כבוי: שום דבר אינו מאזין, אלא אם SolidCraft מופעל עם --control.",
|
||||
"ai.prefs.availability": "זמינות",
|
||||
"ai.prefs.background_commands": "להפעיל פקודות איטיות ברקע",
|
||||
"ai.prefs.connect_apps": "להוסיף את SolidCraft ליישומי AI",
|
||||
"ai.prefs.connect_apps_note": "כיבוי מסיר שוב את הרשומות.\n\nבהפעלת SolidCraft הוא מתווסף כשרת MCP ליישומי הבינה המלאכותית שנמצאו במחשב זה (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), כך שסוכן יכול להשתמש בו בלי שום הגדרה. רק הרשומה solidcraft נוספת או שהנתיב שלה מתעדכן; רשומה ששינית נשארת כפי שהיא, ולפני כן נשמר גיבוי של הקובץ. כבוי: הרשומות שנוספו מוסרות. הפקודה עזרה > חיבור עוזרי בינה מלאכותית… עושה זאת ידנית.",
|
||||
"ai.prefs.connect_at_start": "לחבר את הסוכן האחרון בהפעלה",
|
||||
"ai.prefs.connect_at_start_note": "מפעיל ברקע את הסוכן שאליו התחברת לאחרונה (או את סוכן ברירת המחדל) בכל הפעלה של SolidCraft, כך שהוא מוכן לשיחה. לא כש-SolidCraft מופעל עם --control. אם ההתחברות נכשלת, העוזר מציג „ההתחברות נכשלה” ולחצן „ניסיון חוזר”.",
|
||||
"ai.prefs.follow_camera": "הפניית המצלמה אל סקיצות",
|
||||
"ai.prefs.follow_camera": "לעקוב אחר המצלמה",
|
||||
"ai.prefs.follow_camera_off": "מעקב המצלמה האוטומטי כבוי.",
|
||||
"ai.prefs.follow_camera_on": "המצלמה פונה אל הסקיצות שהסוכן יוצר.",
|
||||
"ai.prefs.intro": "סוכנים, הרשאות ואופן הצגת העבודה שלהם.",
|
||||
"ai.prefs.make_default": "שימוש כברירת מחדל",
|
||||
"ai.prefs.intro": "סוכני AI ב־SolidCraft.",
|
||||
"ai.prefs.make_default": "לקבוע כברירת מחדל",
|
||||
"ai.prefs.no_agents": "אין עדיין סוכנים. אפשר להוסיף סוכן או לרענן כדי למצוא סוכנים מותקנים.",
|
||||
"ai.prefs.no_saved_approvals": "אין אישורים שמורים.",
|
||||
"ai.prefs.open_at_start": "פתיחת חלונית העוזר בהפעלת SolidCraft",
|
||||
"ai.prefs.open_at_start": "לפתוח את הצ'אט בהפעלה",
|
||||
"ai.prefs.open_at_start_note": "מציג את חלונית העוזר (או את החלון הנפרד שלה) בכל הפעלה של SolidCraft. לא כש-SolidCraft מופעל עם --control.",
|
||||
"ai.prefs.override_all_warning": "הסוכן הזה עשוי לשמור, לדרוס, לייצא או להחליף את התכן בלי לשאול.",
|
||||
"ai.prefs.permission_all": "התרת הכול",
|
||||
"ai.prefs.permission_all_summary": "כלי SolidCraft הזמינים פועלים בלי לשאול.",
|
||||
"ai.prefs.permission_ask": "שאלה לפני שינויים",
|
||||
"ai.prefs.permission_ask_summary": "אישור כל שינוי בתכן וכל פעולה על קבצים.",
|
||||
"ai.prefs.permission_edits": "התרת עריכות מודל",
|
||||
"ai.prefs.permission_edits_summary": "עריכות מודל מתבצעות; פעולות קבצים ופעולות הרסניות עדיין דורשות אישור.",
|
||||
"ai.prefs.permission_all": "לעשות הכול",
|
||||
"ai.prefs.permission_all_summary": "אף פעם לא שואל",
|
||||
"ai.prefs.permission_ask": "לשאול קודם",
|
||||
"ai.prefs.permission_ask_summary": "שואל על כל שינוי",
|
||||
"ai.prefs.permission_edits": "לבצע עריכות",
|
||||
"ai.prefs.permission_edits_summary": "עורך בחופשיות, שואל על השאר",
|
||||
"ai.prefs.permission_scope": "לכלי SolidCraft בלבד. פקודות המעטפת ועריכות הקבצים של הסוכן כפופות להרשאות שלו.",
|
||||
"ai.prefs.pointer_preview_hidden": "הסמן מוסתר",
|
||||
"ai.prefs.pointer_preview_speed": "סמן הסוכן · {speed}",
|
||||
"ai.prefs.pointer_speed": "מהירות התנועה",
|
||||
"ai.prefs.pointer_speed": "מהירות",
|
||||
"ai.prefs.privacy_note": "רק הסוכן או נקודת הקצה שבחרת מקבלים את השיחה. מפתחות נקודות הקצה נשמרים במחזיק המפתחות של המערכת.",
|
||||
"ai.prefs.show_pointer": "הצגת סמן הסוכן",
|
||||
"ai.prefs.show_pointer": "להציג את מצביע הסוכן",
|
||||
"ai.prefs.turn_limit_hint": "מספר מרבי של קריאות לכלים שמשנים את התכן שהעוזר רשאי לבצע להודעה אחת.",
|
||||
"angle, height": "זווית, גובה",
|
||||
"appearance.dielectric_ior": "מקדם שבירה דיאלקטרי",
|
||||
@@ -997,6 +997,11 @@
|
||||
"cmd.joint_screw": "בורג",
|
||||
"cmd.joint_solve": "פתרון מפרקים",
|
||||
"cmd.joint_sweep": "סריקת טווח מפרק",
|
||||
"cmd.layout.apply": "החלת פריסה",
|
||||
"cmd.layout.get": "בדיקת פריסה",
|
||||
"cmd.layout.reset": "איפוס פריסה",
|
||||
"cmd.layout.save": "שמירת פריסה",
|
||||
"cmd.layout.workspace.select": "בחירת סביבת עבודה לפריסה",
|
||||
"cmd.library_apply": "החלת פריט מהספרייה המקומית",
|
||||
"cmd.library_export": "ייצוא ספריית התכן",
|
||||
"cmd.library_inspect": "בדיקת הספרייה המקומית",
|
||||
@@ -1030,6 +1035,21 @@
|
||||
"cmd.occurrence_move": "הזזת מופע",
|
||||
"cmd.occurrence_properties": "תכונות פיזיקליות של מופע",
|
||||
"cmd.occurrence_reparent": "העברה לרכיב",
|
||||
"cmd.panel.activate": "הפעלת לשונית חלונית",
|
||||
"cmd.panel.close": "סגירת חלונית",
|
||||
"cmd.panel.collapse": "כיווץ מקטע חלונית",
|
||||
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
|
||||
"cmd.panel.float": "הצפת חלונית",
|
||||
"cmd.panel.focus": "מיקוד חלונית",
|
||||
"cmd.panel.list": "הצגת רשימת חלוניות",
|
||||
"cmd.panel.move": "העברת חלונית",
|
||||
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
|
||||
"cmd.panel.open": "פתיחת חלונית",
|
||||
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
|
||||
"cmd.panel.popout": "העברת חלונית לחלון נפרד",
|
||||
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
|
||||
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
|
||||
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
|
||||
"cmd.parameters_add": "הוספת פרמטר משתמש",
|
||||
"cmd.parameters_change": "שינוי פרמטרים",
|
||||
"cmd.parameters_comment": "הערה לפרמטר",
|
||||
@@ -1536,6 +1556,11 @@
|
||||
"help.joint.reach": "הזזת נקודה על חלק לעבר יעד על ידי שינוי המפרקים בשרשרת שלה, בתוך גבולותיהם. כך קובעים תנוחה של מנגנון לפי המיקום הרצוי של קצהו, כגון קצה זרוע רובוט.",
|
||||
"help.joint.rigid_group": "נעילת החלקים שנבחרו יחד כפי שהם, כך שינועו כיחידה אחת. מתאים לחלקים השייכים יחד ואינם נעים זה ביחס לזה.",
|
||||
"help.joint.screw": "קישור סיבוב והחלקה לפי פסיעה, כך שכל סיבוב מקדם באותו מרחק. מתאים לבורגי הנעה, לברגים ולכושים בעלי תבריג.",
|
||||
"help.layout.apply": "החלת פריסה שמורה על סביבת העבודה הנוכחית. העורכים הקיימים שומרים על הטיוטות שלהם.",
|
||||
"help.layout.get": "קריאת הפריסה שאושרה והצגת סביבות העבודה הרשומות.",
|
||||
"help.layout.reset": "שחזור פריסת ברירת המחדל הרשומה של סביבת העבודה הנוכחית. הגדרות המשתמש השמורות נשארות זמינות.",
|
||||
"help.layout.save": "שמירת הפריסה המלאה בשם. החלפת הגדרה שמורה של משתמש מחייבת הרשאה מפורשת לדריסה.",
|
||||
"help.layout.workspace.select": "בחירת סביבת עבודה רשומה לפריסה ושמירת הפריסה שממנה יוצאים.",
|
||||
"help.library.apply": "החלת חומר או מראה מקובץ ספרייה מקומי על גופים, פאות או רכיבים. מתאים לשימוש חוזר בחומרים ששמרת מתכן אחר.",
|
||||
"help.library.export": "שמירת החומרים והמראות של התכן בקובץ ספרייה. מתאים לשיתוף או לשימוש חוזר בתכן אחר.",
|
||||
"help.library.inspect": "בדיקת קובץ ספרייה והצגת החומרים והמראות שבו, בלי לשנות את התכן. מתאים כדי לראות מה הספרייה מכילה לפני החלתה.",
|
||||
@@ -1549,6 +1574,21 @@
|
||||
"help.notice": "NOTICE",
|
||||
"help.occurrence.ground": "עיגון חלק במקומו או שחרורו מחדש, כך שהמפרקים מניעים את החלקים האחרים סביבו. מתאים למסגרת או לבסיס שעליהם נבנית שאר ההרכבה.",
|
||||
"help.occurrence.move": "מזיז חלק בהרכבה רק בכיוונים שהמפרקים שלו מאפשרים. בחרו פאה של החלק, ואז גררו חץ או קשת זמינים.",
|
||||
"help.panel.activate": "בחירת לשונית חלונית פתוחה בלי לבקש מיקוד מקלדת.",
|
||||
"help.panel.close": "סגירת חלונית דרך בדיקת מחזור החיים של התוכן שלה. המיקום שלה נשמר.",
|
||||
"help.panel.collapse": "כיווץ או הרחבה של מקטע חלונית תוך שמירת המידות המבוקשות.",
|
||||
"help.panel.dismiss": "סגירת התצוגה הזמנית של חלונית מכווצת.",
|
||||
"help.panel.float": "העברת חלונית למקטע צף שמיקומו מוגדר במפורש.",
|
||||
"help.panel.focus": "מיקוד חלונית, פתיחה מחדש של מצבה השמור והרחבת המקטע שלה לפי הצורך.",
|
||||
"help.panel.list": "קריאת סוגי החלוניות הרשומים, המופעים שאושרו, הלשוניות הפעילות, הזמינות ויכולות המארח.",
|
||||
"help.panel.move": "העברת חלונית למקטע חדש או צירופה לקבוצת לשוניות קיימת.",
|
||||
"help.panel.native_close": "החזרת מקטע חלונית מחלון נפרד למיקום העיגון השמור בלי לבטל את התוכן שלו.",
|
||||
"help.panel.open": "פתיחת סוג חלונית עם מזהה מופע מפורש. מופע חדש דורש מיקום.",
|
||||
"help.panel.peek": "הצגה זמנית של חלונית מכווצת בלי לשנות את הגודל השמור או את מצב הכיווץ שלה.",
|
||||
"help.panel.popout": "העברת חלונית לחלון נפרד כאשר יש תמיכה בכך. במארח דפדפן נעשה שימוש במקטע צף.",
|
||||
"help.panel.reset_pair": "שחזור יחס הגדלים המוגדר כברירת מחדל לשני מקטעי עיגון סמוכים, במסגרת הגבולות המשותפים שלהם.",
|
||||
"help.panel.resize": "הגדרת הרוחב והגובה החיצוניים של מקטע חלונית במסגרת המגבלות של כל הלשוניות.",
|
||||
"help.panel.resize_pair": "שינוי גודלם של מקטעי עיגון סמוכים יחד תוך שמירה על האורך הכולל המשותף שלהם.",
|
||||
"help.parameters.change": "פתיחת הטבלה של ערכים בעלי שם וביטויים שקובעים את מידות התכנון. כך משנים מידה במקום אחד וכל הפיצ'רים שמשתמשים בה מתעדכנים.",
|
||||
"help.parameters.export": "שמירת הפרמטרים של התכן כ־CSV או JSON. מתאים לעריכתם בגיליון אלקטרוני או להעברתם לתכן אחר.",
|
||||
"help.parameters.graph": "הצגת אילו פרמטרים ופיצ'רים משתמשים באילו אחרים, ואילו לולאות או שגיאות יש ביניהם. מתאים כדי לראות על מה ישפיע שינוי בפרמטר.",
|
||||
@@ -1885,21 +1925,21 @@
|
||||
"joint.rigid_group_select_components": "יש לבחור שני רכיבים או יותר.",
|
||||
"joint_move.blocked_hint": "בדקו את סוג המפרק (למשל מפרק קשיח), את קיבוע החלק, את מגבלות המפרק ואת הערכים הנשלטים על ידי פרמטרים.",
|
||||
"joint_move.blocked_title": "אי אפשר להזיז את החלק הזה",
|
||||
"joint_move.coarse": "צעד סיבוב קרוב לציר (°)",
|
||||
"joint_move.coarse": "צעד בקרבת הציר",
|
||||
"joint_move.conflict": "אחד המפרקים של החלק הזה מתנגש עם האחרים: {joint}.",
|
||||
"joint_move.conflict_hint": "ערכו, השביתו או מחקו את המפרק המתנגש כדי שהמפרקים האחרים יוכלו למקם את החלק.",
|
||||
"joint_move.distance": "מרחק מנקודת ההתחלה",
|
||||
"joint_move.done": "סיום",
|
||||
"joint_move.fine": "צעד סיבוב רחוק מהציר (°)",
|
||||
"joint_move.fine": "צעד הרחק מהציר",
|
||||
"joint_move.fixed": "לחלק הזה אין ציר מפרק שניתן להזיז.",
|
||||
"joint_move.help": "הזזה — גררו חץ או קשת. Esc מבטל את הגרירה הנוכחית.",
|
||||
"joint_move.legacy_hint": "כבו כדי להשתמש בחלונית ההזזה הקודמת.",
|
||||
"joint_move.precision": "משכו אל מחוץ למנחה המקווקו כדי לכוון את הסיבוב בעדינות.",
|
||||
"joint_move.legacy_hint": "כבוי: חלונית ההזזה הקודמת.",
|
||||
"joint_move.precision": "לצעד העדין יש למשוך מחוץ לקו העזר המקווקו.",
|
||||
"joint_move.rotation": "סיבוב מנקודת ההתחלה",
|
||||
"joint_move.select": "בחרו פאה או חלק אחד בהרכבה, ואז בחרו הזזה.",
|
||||
"joint_move.settings": "הזזה בהרכבה",
|
||||
"joint_move.step": "צעד",
|
||||
"joint_move.use_handles": "שימוש בידיות תנועת מפרקים להזזה בהרכבה",
|
||||
"joint_move.use_handles": "שימוש בידיות תנועת מפרקים להזזה",
|
||||
"keymap.command_id": "מזהה פקודה",
|
||||
"keymap.empty": "אין פקודות תואמות",
|
||||
"keymap.use_anyway": "שימוש ב־{key} כאן בכל זאת",
|
||||
@@ -2017,6 +2057,12 @@
|
||||
"navbar.assistant": "עוזר",
|
||||
"navbar.custom.add_slot": "הוספת {slot}",
|
||||
"navbar.custom.always": "תמיד מוצג",
|
||||
"navbar.custom.anchor.bottom_centre": "למטה במרכז",
|
||||
"navbar.custom.anchor.bottom_left": "למטה משמאל",
|
||||
"navbar.custom.anchor.bottom_right": "למטה מימין",
|
||||
"navbar.custom.anchor.top_centre": "למעלה במרכז",
|
||||
"navbar.custom.anchor.top_left": "למעלה משמאל",
|
||||
"navbar.custom.anchor.top_right": "למעלה מימין",
|
||||
"navbar.custom.available": "{available} · גררו אל הסרגל או לחצו על הוספה",
|
||||
"navbar.custom.buttons": "לחצנים",
|
||||
"navbar.custom.compact": "דחוס",
|
||||
@@ -2053,6 +2099,7 @@
|
||||
"navbar.custom.reset_default": "איפוס לברירת מחדל",
|
||||
"navbar.custom.right": "הזזה ימינה",
|
||||
"navbar.custom.select": "לא נבחרה משבצת. בחרו משבצת בסרגל כדי להזיז אותה, להסיר אותה או לשנות את המראה שלה.",
|
||||
"navbar.custom.separator_hint": "מפריד בין קבוצות כפתורים. גררו אותו לסרגל או לחצו על הוספה; אפשר להשתמש בכמה מפרידים שתרצו.",
|
||||
"navbar.custom.separators_full": "בסרגל כבר יש ארבעה מפרידים.",
|
||||
"navbar.custom.show": "הצגת סרגל הניווט",
|
||||
"navbar.custom.shows": "מציג",
|
||||
@@ -2206,14 +2253,22 @@
|
||||
"prefs.appearance.viewport_logo": "חלון התצוגה והלוגו",
|
||||
"prefs.applies_on_restart": "ייכנס לתוקף בהפעלה מחדש",
|
||||
"prefs.assistant": "עוזר",
|
||||
"prefs.assistant_connecting": "חיבור",
|
||||
"prefs.assistant_follow_help": "פעולות Look At בסקיצות מזיזות את התצוגה עם המצביע.",
|
||||
"prefs.assistant_limit": "מגבלה",
|
||||
"prefs.assistant_limited": "מוגבל",
|
||||
"prefs.assistant_pointer_speed": "מהירות המצביע",
|
||||
"prefs.assistant_seconds": "שניות",
|
||||
"prefs.assistant_seconds_aria": "שניות",
|
||||
"prefs.assistant_use_care": "להשתמש בזהירות",
|
||||
"prefs.auto_operation": "בחירת הפעולה באופן אוטומטי",
|
||||
"prefs.auto_operation_note": "חלונות הפיצ'רים בוחרים „איחוד”, „גוף חדש” או „הסרה” לפי מה שהחומר החדש נוגע בו, עד שתבחרו פעולה בעצמכם. כבוי: הם שומרים על הפעולה שאיתה נפתחו.",
|
||||
"prefs.auto_operation_note": "איחוד, גוף חדש או חיתוך, לפי מה שהחומר נוגע בו.",
|
||||
"prefs.axes": "צירים",
|
||||
"prefs.axis": "ציר",
|
||||
"prefs.bodies_folders": "תיקיות גופים",
|
||||
"prefs.bodies_folders_always": "תמיד",
|
||||
"prefs.bodies_folders_never": "אף פעם",
|
||||
"prefs.bodies_folders_several": "רק כשיש כמה גופים",
|
||||
"prefs.bodies_folders_several": "כמה",
|
||||
"prefs.bottom": "למטה",
|
||||
"prefs.bottom_centre": "למטה במרכז",
|
||||
"prefs.bottom_right": "למטה מימין",
|
||||
@@ -2249,8 +2304,19 @@
|
||||
"prefs.dark_parts_shading_help": "הצללת התצוגה באור ליניארי עם תאורת שמיים רכה, כך שפאות כהות שומרות על מעבר גוונים במקום להפוך לשחורות",
|
||||
"prefs.dead_zone_note": "הפס המקווקו הוא האזור המת: תנועה בתוכו לא נלקחת בחשבון.",
|
||||
"prefs.default": "ברירת מחדל",
|
||||
"prefs.design": "ברירות מחדל לתכן",
|
||||
"prefs.design_note": "ההגדרות שתכנים חדשים מתחילים איתן. תכנים פתוחים שומרים על ההגדרות שלהם.",
|
||||
"prefs.design": "תכנון",
|
||||
"prefs.design_note": "במה מתחילים תכנונים חדשים.",
|
||||
"prefs.design_tools.ask": "שאלה",
|
||||
"prefs.design_tools.degrees": "מעלות",
|
||||
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
|
||||
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
|
||||
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
|
||||
"prefs.design_tools.hole_snaps_help": "אמצע הפאה ומרכזי הקצוות העגולים שלה.",
|
||||
"prefs.design_tools.imported_files": "קבצים מיובאים",
|
||||
"prefs.design_tools.note": "הערה",
|
||||
"prefs.design_tools.off": "כבוי",
|
||||
"prefs.design_tools.placement_group": "משען, בליטה, חיבור נצמד",
|
||||
"prefs.design_tools.rows": "שורות",
|
||||
"prefs.devices": "עכבר תלת־ממדי",
|
||||
"prefs.dialog_buttons": "לחצני תיבות דו־שיח",
|
||||
"prefs.dialogs": "תיבות דו־שיח",
|
||||
@@ -2292,6 +2358,23 @@
|
||||
"prefs.graphics_card_restart": "ייכנס לתוקף אחרי הפעלה מחדש של SolidCraft. WGPU_ADAPTER_NAME גובר על בחירה זו.",
|
||||
"prefs.graphics_card_unused": "כרטיס המסך השמור לא שימש לחלון זה; הכרטיס שבשימוש מוצג בעזרה › אודות ובשורת המצב. נסו אוטומטי, או הגדירו את WGPU_ADAPTER_NAME לחלק ייחודי משם הכרטיס.",
|
||||
"prefs.graphics_note": "איזון בין מהירות לחלקות. כל בחירה מציגה את השפעתה על דוגמה.",
|
||||
"prefs.graphics_page.current_view": "התצוגה הנוכחית",
|
||||
"prefs.graphics_page.fastest": "המהיר ביותר",
|
||||
"prefs.graphics_page.fold_items": "קיפול פריטים",
|
||||
"prefs.graphics_page.in_view": "בתצוגה",
|
||||
"prefs.graphics_page.live_details": "פרטים בזמן אמת",
|
||||
"prefs.graphics_page.own_mix": "התמהיל שלך",
|
||||
"prefs.graphics_page.quick_measure": "מדידה מהירה",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "גאומטריית ייחוס דרך גופים",
|
||||
"prefs.graphics_page.restart": "נדרשת הפעלה מחדש",
|
||||
"prefs.graphics_page.sharpest": "החד ביותר",
|
||||
"prefs.graphics_page.single_surface_analysis": "ניתוח משטח יחיד",
|
||||
"prefs.graphics_page.status_chips": "תגיות מצב",
|
||||
"prefs.graphics_page.subtitle": "איזון בין מהירות לחלקות.",
|
||||
"prefs.graphics_page.supersampling": "דגימת־על",
|
||||
"prefs.graphics_page.supersampling_adaptive": "רק כל עוד התצוגה נייחת",
|
||||
"prefs.graphics_page.surfaces_edges": "משטחים וקצוות",
|
||||
"prefs.graphics_page.view_bar": "גם בסרגל התצוגה",
|
||||
"prefs.graphics_presets_note": "הגדרות קבועות מראש קובעות יחד את הבחירות שלמטה.",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "הסתרת סמלי אילוצים בהקטנה",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "סמלי האילוצים נעלמים כשהשרטוט מוקטן מספיק כדי שלא יעמיסו על התצוגה. כבוי: הם נשארים בגודל מסך קבוע בכל זום.",
|
||||
@@ -2300,7 +2383,7 @@
|
||||
"prefs.history_origin_bodies_note": "ההיסטוריה משמאל או מתחת לדפדפן מתחילה בתיקיית הגופים של הרכיב הפעיל ובראשית, עם סמלי העין והתפריטים כמו בדפדפן. ההיסטוריה למטה לא משתנה.",
|
||||
"prefs.history_play_button": "כפתור הפעלת ציר הזמן בפקדי ציר הזמן",
|
||||
"prefs.history_play_button_note": "מוסיף את הפעלת ציר הזמן בין צעד אחד אחורה לצעד אחד קדימה בפקדי ציר הזמן. אפשר גם ללחוץ לחיצה ימנית על הפקדים כדי להציג או להסתיר אותו.",
|
||||
"prefs.history_sketches": "סקיצות",
|
||||
"prefs.history_sketches": "סקיצות בציר הזמן",
|
||||
"prefs.history_sketches_in_order": "לפי הסדר",
|
||||
"prefs.history_sketches_note": "כשהסקיצות מוגדרות ל„מתחת לפיצ'ר שלהן”, ציר הזמן משמאל מציג כל סקיצה רמה אחת פנימה, מתחת לפיצ'ר הראשון שנבנה ממנה, בכל פעם שהוא רחב מספיק להצגת שמות. ציר הזמן בתחתית שומר תמיד על סדר היצירה.",
|
||||
"prefs.history_sketches_under_feature": "מתחת לפיצ'ר שלהן",
|
||||
@@ -2325,6 +2408,22 @@
|
||||
"prefs.language_system": "מערכת",
|
||||
"prefs.layout": "פריסה",
|
||||
"prefs.layout_note": "הפריסה הדחוסה מצמידה חלוניות לשולי החלון. שקיפות עץ התכן זמינה בפריסה הצפה. את צד העוזר אפשר לשנות בעמוד העוזר.",
|
||||
"prefs.layout_page.active_commands": "הדגשת פקודות פעילות",
|
||||
"prefs.layout_page.always": "תמיד",
|
||||
"prefs.layout_page.card": "כרטיס",
|
||||
"prefs.layout_page.column": "עמודה",
|
||||
"prefs.layout_page.hover": "מעבר עכבר",
|
||||
"prefs.layout_page.icons_labels": "סמלים + תוויות",
|
||||
"prefs.layout_page.ok_cancel": "אישור וביטול",
|
||||
"prefs.layout_page.on_hover": "בעת מעבר עכבר",
|
||||
"prefs.layout_page.open": "פתיחה",
|
||||
"prefs.layout_page.origin_bodies": "הראשית והגופים למעלה",
|
||||
"prefs.layout_page.panels": "חלוניות",
|
||||
"prefs.layout_page.pinned_menus": "השמטת פקודות מוצמדות מהתפריטים",
|
||||
"prefs.layout_page.play_history": "כפתור הפעלת ציר הזמן",
|
||||
"prefs.layout_page.slot_count": "{count} מתוך {max} משבצות",
|
||||
"prefs.layout_page.view_corner": "גם בפינת התצוגה",
|
||||
"prefs.layout_page.window": "חלון",
|
||||
"prefs.left": "שמאל",
|
||||
"prefs.left_click": "לחיצה שמאלית",
|
||||
"prefs.line_drag_draws_one": "לחיצה וגרירה משרטטות קו אחד",
|
||||
@@ -2369,11 +2468,15 @@
|
||||
"prefs.origin_plane_names_note": "השמות המוצגים בדפדפן, בתיבות הדו־שיח וברשימות. {top} הוא XY, {front} הוא XZ ו־{right} הוא YZ, כש־Z למעלה. פקודות, סקריפטים ותכנונים שמורים שומרים על XY, XZ ו־YZ.",
|
||||
"prefs.overall_speed": "מהירות כללית",
|
||||
"prefs.parameter_list_rows": "שורות ברשימת הפרמטרים",
|
||||
"prefs.parameter_list_rows_tip": "חץ למטה בשדה ערך ריק מציג את הפרמטרים, המועדפים תחילה, עד מספר זה. מספר קטן יותר שומר על רשימה קלה בתכנון גדול.",
|
||||
"prefs.parameter_list_rows_tip": "מספר הפרמטרים המרבי שמקש חץ למטה מציג בתיבת ביטוי ריקה.",
|
||||
"prefs.persistent_ribbon_pressed": "הדגשת פקודות פעילות ברצועת הכלים",
|
||||
"prefs.persistent_ribbon_pressed_help": "הדגשת פקודות ברצועת הכלים כל עוד ההשפעות שלהן פעילות. כבו אפשרות זו כדי לשמור על המראה הניטרלי הקודם ועל עזרת הפקודות.",
|
||||
"prefs.perspective_fov": "שדה ראייה",
|
||||
"prefs.placement": "מיקום",
|
||||
"prefs.plugins_enabled": "מופעל",
|
||||
"prefs.plugins_installed": "מותקנים",
|
||||
"prefs.plugins_intro": "תוספים מבודדים.",
|
||||
"prefs.plugins_show_folder": "להציג את תיקיית התוספים",
|
||||
"prefs.prefer_free_sketch_points": "העדפת נקודות סקיצה ניתנות להזזה כשנקודות חופפות",
|
||||
"prefs.preview_choices": "תצוגה מקדימה של הבחירות שלך",
|
||||
"prefs.preview_tint_new_faces": "צביעת משטח חדש בתצוגה מקדימה",
|
||||
@@ -2431,6 +2534,10 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
|
||||
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
|
||||
"prefs.shortcuts": "קיצורים",
|
||||
"prefs.shortcuts_import": "ייבוא",
|
||||
"prefs.shortcuts_intro": "מקשים לכל פקודה.",
|
||||
"prefs.shortcuts_not_assigned": "לא הוקצה",
|
||||
"prefs.shortcuts_press": "יש ללחוץ על מקש...",
|
||||
"prefs.single_surface_analysis": "שימוש בניתוח משטח יחיד",
|
||||
"prefs.single_surface_analysis_help": "כשהאפשרות כבויה, הצללת המשטח וניתוח הרציפות יכולים להישאר פעילים יחד. הפעלתה משאירה רק את הניתוח האחרון שהופעל או שונה.",
|
||||
"prefs.sketch_align_points": "יישור לנקודות סקיצה אחרות בזמן השרטוט",
|
||||
@@ -2465,11 +2572,11 @@
|
||||
"prefs.source": "מקור",
|
||||
"prefs.speed": "מהירות",
|
||||
"prefs.stand_up_note": "אחרי שקובץ STEP או IGES נפתח כש־Z למעלה, להציע רבע סיבוב סביב X.",
|
||||
"prefs.stand_up_unknown": "לקבצים מתוכנות לא מוכרות",
|
||||
"prefs.stand_up_unknown": "קבצים מתוכנות לא מוכרות",
|
||||
"prefs.stand_up_unknown.ask": "לשאול בתיבת דו־שיח",
|
||||
"prefs.stand_up_unknown.note": "להציג הערה",
|
||||
"prefs.stand_up_unknown.off": "לא להציע",
|
||||
"prefs.stand_up_unknown_note": "איך ההצעה מוצגת כשכותרת הקובץ לא מציינת תוכנה שידוע שהיא שומרת עם Y למעלה. הערה מוצגת רק כשנראה שהקובץ שוכב על גבו; ״לשאול בתיבת דו־שיח״ שואל על כל קובץ כזה, כמו קודם.",
|
||||
"prefs.stand_up_unknown_note": "כיצד מוצעת האפשרות כשהקובץ אינו מציין תוכנה מוכרת.",
|
||||
"prefs.status_bar": "שורת מצב",
|
||||
"prefs.status_bar_note": "פס דק לאורך הקצה התחתון של החלון מציג מה נערך, את מצב הסקיצה הפתוחה בצבע שלו ואת יחידות התכנון; לחיצה על היחידות משנה אותן. מצב הסקיצה לא מוצג אז עוד בפינת התצוגה.",
|
||||
"prefs.status_done": "הושלם",
|
||||
@@ -2542,8 +2649,8 @@
|
||||
"prefs.validation.label": "הבדיקה המלאה של הקרנל",
|
||||
"prefs.validation.off": "כבוי (ניסיוני)",
|
||||
"prefs.validation_note": "אחרי כל שלב: בודק כל סחיפה, פעולה בוליאנית ועיגול.\nפעם אחת לכל תכונה: בודק את התוצאה של כל תכונה פעם אחת.\nכבוי (ניסיוני): אין בדיקה.",
|
||||
"prefs.value_boxes_in_view": "הצגת ערכים ליד הידיות שלהם בתצוגה",
|
||||
"prefs.value_boxes_in_view_tip": "ערך הפקודה (מרחק, רדיוס…) בתיבה ליד הידית שלו, מעל המודל. כבוי: מקלידים אותו בחלון הפקודה, שמקבל את המקלדת אחרי בחירה, ושום דבר לא מסתיר את המודל.",
|
||||
"prefs.value_boxes_in_view": "הצגת ערכים לצד הידיות שלהם",
|
||||
"prefs.value_boxes_in_view_tip": "מרחק ההבלטה, רדיוס העיגול... בתיבה בתצוגה.",
|
||||
"prefs.view": "התנהגות המבט",
|
||||
"prefs.view_confirm_corner": "אישור וביטול בפינת התצוגה",
|
||||
"prefs.view_confirm_corner_note": "כשתיבת דו־שיח או סקיצה פתוחות, סימן וי וסימן איקס בפינה הימנית העליונה של התצוגה עושים את מה שעושים אישור וביטול של תיבת הדו־שיח, או מסיימים ומבטלים את הסקיצה. ניווט התצוגה זז אל מתחתיהם.",
|
||||
@@ -3944,11 +4051,11 @@
|
||||
"select two components": "יש לבחור שני רכיבים",
|
||||
"set by the sketch's lines": "נקבע לפי קווי הסקיצה",
|
||||
"settings.display_quality_note": "ברמות עדינות יותר, פאות ומקצועות מעוקלים מוצגים חלקים יותר ויצירת הרשת אורכת זמן רב יותר. רק אזור התצוגה משתנה, לא התכן ולא קובצי הייצוא.",
|
||||
"settings.double_click_components": "לחיצה כפולה על רכיב מפעילה אותו, או עורכת רכיב מקושר במקומו",
|
||||
"settings.double_click_components": "לחיצה כפולה על רכיב להפעלתו",
|
||||
"settings.frame_rate_details_note": "מוסיף גרף של זמני התמונות האחרונות עם המשולשים שצוירו מעליו, היסטוגרמה של זמני התמונות ואת מה שה־GPU צייר. לחיצה על קצב התמונות בתצוגה מציגה או מסתירה אותם.",
|
||||
"settings.frame_rate_note": "הצגת מספר הפריימים לשנייה בפינה הימנית התחתונה של התצוגה. כל עוד האפשרות פעילה, התצוגה מצוירת מחדש ברציפות.",
|
||||
"settings.insert_lists_open_designs": "הוספת תכן מציגה קודם את התכנים הפתוחים בלשוניות אחרות",
|
||||
"settings.measure_axis_distances": "הצגת מרחקי X/Y/Z",
|
||||
"settings.insert_lists_open_designs": "הוספת תכנון מציגה תחילה תכנונים פתוחים",
|
||||
"settings.measure_axis_distances": "מרחקי צירים במדידה",
|
||||
"settings.measure_axis_distances_help": "הצגת המרחקים לאורך צירי העולם לצד המרחק הכולל כשכלי המדידה פתוח.",
|
||||
"settings.msaa_applies_on_restart": "החלקת הקצוות תחול בהפעלה הבאה של SolidCraft",
|
||||
"settings.msaa_reset": "{setting}: {requested}× אינו נתמך בהתקן הגרפי הזה; הערך הוא כעת {reset}× (נתמכים: {supported}).",
|
||||
@@ -3958,7 +4065,7 @@
|
||||
"settings.reset_intro": "ההגדרות האלה לא פועלות במחשב הזה והיו מונעות מ־SolidCraft לעלות, ולכן הן שונו:",
|
||||
"settings.reset_title": "ההגדרות שונו",
|
||||
"settings.supersampling_note": "התצוגה מצוירת ברזולוציה גבוהה יותר ומסוננת כלפי מטה: מקצוות, קווים ורשת חלקים יותר במסכים שמתחת ל־4K. הדבר צורך זמן GPU: בערך 2.25× פיקסלים ב־1.5×, ו־4× ב־2×.",
|
||||
"settings.update_changed_links": "עדכון תכנים מקושרים שהשתנו כשלשונית מוצגת שוב",
|
||||
"settings.update_changed_links": "עדכון קישורים שהשתנו בחזרה ללשונית",
|
||||
"settings.window_system_details": "פרטים",
|
||||
"settings.window_system_note_now": "ייכנס לתוקף אחרי הפעלה מחדש. כעת: {now}.",
|
||||
"settings.window_system_wayland_limited": "חלק מהתכונות מוגבלות ב־Wayland מקורי.",
|
||||
@@ -4360,7 +4467,7 @@
|
||||
"ui.agent_chat_send_now_tip": "עצירת הצעד הנוכחי של הסוכן ושליחת הודעה זו כבאה בתור (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "SolidCraft מדבר עם סוכן קוד שמותקן במחשב הזה. אפשר לבחור אחד בהעדפות › AI.",
|
||||
"ui.agent_chat_setup_title": "הגדרת עוזר",
|
||||
"ui.agent_chat_side": "צד הצ'אט עם הסוכן",
|
||||
"ui.agent_chat_side": "לעגן את הצ'אט",
|
||||
"ui.agent_chat_signed_in": "מחובר",
|
||||
"ui.agent_chat_state_auth": "נדרשת התחברות",
|
||||
"ui.agent_chat_state_busy": "עובד…",
|
||||
@@ -4379,7 +4486,7 @@
|
||||
"ui.agent_chat_undone": "(בוטל)",
|
||||
"ui.agent_chat_view_attached": "התצוגה צורפה",
|
||||
"ui.agent_chat_waiting": "ממתין לך",
|
||||
"ui.agent_cursor": "סמן הסוכן",
|
||||
"ui.agent_cursor": "מצביע הסוכן",
|
||||
"ui.agent_cursor_speed": "מהירות סמן הסוכן",
|
||||
"ui.agent_off_thread": "שמירה על תגובתיות החלון בזמן שסוכנים מריצים פקודות",
|
||||
"ui.agent_off_thread_note": "פקודות איטיות של סוכן (מחלונית ה־AI או מלקוח MCP) מחושבות לצד החלון: אפשר להמשיך לסובב ולבחון את התצוגה, ולעצור פקודה מהתג שבתצוגה. כבוי: כל פקודה רצה בתהליכון של החלון, והחלון ממתין לה.",
|
||||
@@ -4425,14 +4532,14 @@
|
||||
"ui.ai_key_note": "נשמר במחזיק המפתחות של המערכת, לעולם לא בהגדרות של SolidCraft. יש להשאיר ריק עבור שרת מקומי.",
|
||||
"ui.ai_keychain_error": "מחזיק המפתחות של המערכת סירב: {error}",
|
||||
"ui.ai_kind": "סוג",
|
||||
"ui.ai_mode_all": "התרת הכול",
|
||||
"ui.ai_mode_all": "לעשות הכול",
|
||||
"ui.ai_mode_all_note": "שום דבר לא שואל קודם. דוחות קריסה נשארים חסומים ומגבלות התיקיות עדיין חלות.",
|
||||
"ui.ai_mode_all_warning": "הסוכן יכול לשמור, לדרוס, לייצא ולהחליף את התכן בלי לשאול. יש להשתמש בזה רק עם סוכן אמין, ועל תכנים שנשמרו.",
|
||||
"ui.ai_mode_ask": "שאלה בכל פעם",
|
||||
"ui.ai_mode_ask": "לשאול קודם",
|
||||
"ui.ai_mode_ask_note": "כל שינוי בתכן, וכל פעולת קובץ, ממתינים לאישור התרה.",
|
||||
"ui.ai_mode_edits": "התרה אוטומטית של עריכות מודל",
|
||||
"ui.ai_mode_edits": "לבצע עריכות",
|
||||
"ui.ai_mode_edits_note": "פקודות מידול רצות בלי לשאול; שמירה, פתיחה, ייצוא ופעולות קובץ או פעולות הרסניות אחרות עדיין שואלות.",
|
||||
"ui.ai_mode_global": "כפי שהוגדר למעלה",
|
||||
"ui.ai_mode_global": "כמו למעלה",
|
||||
"ui.ai_model": "מודל",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "ההגדרות האלה חלות על הכלים של SolidCraft. הכלים של הסוכן עצמו (המעטפת שלו, עריכת הקבצים שלו) כפופים למצב של הסוכן, שנבחר בכותרת חלונית הצ'אט כשלסוכן יש מצבים.",
|
||||
@@ -4458,7 +4565,7 @@
|
||||
"ui.ai_save": "שמירה",
|
||||
"ui.ai_settings": "הגדרות AI…",
|
||||
"ui.ai_show_features": "הצגת תכונות AI",
|
||||
"ui.ai_show_features_note": "הצ'אט עם הסוכן: חלונית שבה סוכן קוד שכבר נמצא בשימוש, או נקודת קצה של מודל לבחירתך, עובדים על התכן הפתוח. כבוי: אין לחצן AI, קיצור דרך, פריט תפריט או חלונית, ושום דבר לא מחופש או מופעל.",
|
||||
"ui.ai_show_features_note": "כיבוי מסתיר את העוזר בכל מקום ולא מפעיל דבר. כל מה שמתחת מעומעם.\n\nהצ'אט עם הסוכן: חלונית שבה סוכן קוד שכבר נמצא בשימוש, או נקודת קצה של מודל לבחירתך, עובדים על התכן הפתוח. כבוי: אין לחצן AI, קיצור דרך, פריט תפריט או חלונית, ושום דבר לא מחופש או מופעל.",
|
||||
"ui.ai_switch_tip": "החלפת הסוכן לשיחה הזו",
|
||||
"ui.ai_test": "בדיקה",
|
||||
"ui.ai_test_acp_ok": "עונה: {agent}",
|
||||
@@ -4492,7 +4599,7 @@
|
||||
"ui.ai_tool_sum_then": "{first}, ואז {next}",
|
||||
"ui.ai_tool_unfold": "יש ללחוץ כדי להציג מה הוחזר",
|
||||
"ui.ai_tool_waiting": "ממתין",
|
||||
"ui.ai_toolbar_button": "הצגת לחצן העוזר בסרגל היישום",
|
||||
"ui.ai_toolbar_button": "כפתור העוזר בסרגל היישום",
|
||||
"ui.ai_toolbar_button_note": "לחצן העוזר בסרגל היישום. Ctrl+Shift+A ותפריט התצוגה עדיין פותחים את החלונית.",
|
||||
"ui.ai_undo_tip": "ביטול כל מה שהסוכן שינה בהודעה הזו, כצעד אחד",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -4764,7 +4871,7 @@
|
||||
"ui.extract_done": "הרכיב הוא עכשיו תכן נפרד, מקושר לכאן.",
|
||||
"ui.extrude": "שליפה",
|
||||
"ui.extrude_auto_to_object": "שליפה עד יעד שנבחר",
|
||||
"ui.extrude_auto_to_object_tip": "בשליפה, כשנבחר פרופיל, בחירת פאה, קודקוד או מישור מקביל מחוץ למישור שלו מעבירה את התחום ל\"עד אובייקט\"",
|
||||
"ui.extrude_auto_to_object_tip": "פאה, קודקוד או מישור מקביל מעבירים את ההיקף למצב עד אובייקט.",
|
||||
"ui.factor": "מקדם",
|
||||
"ui.fade": "דעיכה",
|
||||
"ui.fade_length": "אורך דעיכה",
|
||||
@@ -4908,7 +5015,7 @@
|
||||
"ui.hidePlanes": "הסתרת מישורים",
|
||||
"ui.hideProfile": "הסתרת פרופיל",
|
||||
"ui.hide_component_history": "הסתר את ההיסטוריה שלו",
|
||||
"ui.hide_origin_by_default": "הסתרת הראשית כברירת מחדל",
|
||||
"ui.hide_origin_by_default": "הסתרת תיקיית הראשית בתכנונים חדשים",
|
||||
"ui.hide_origin_note": "תכנים חדשים מתחילים כשתיקיית הראשית מוסתרת. כל תכן שומר על הגדרת הראשית שלו אחרי שמציגים או מסתירים אותה.",
|
||||
"ui.history_back": "צעד אחד אחורה",
|
||||
"ui.history_below_browser": "מתחת לעץ התכן",
|
||||
@@ -5068,7 +5175,7 @@
|
||||
"ui.pitch": "פסיעה",
|
||||
"ui.placement": "מיקום",
|
||||
"ui.placement_handles": "ידיות מיקום",
|
||||
"ui.placement_handles_tip": "ידיות בתצוגה המקדימה של משען, בליטה או חיבור הצמדה: יש לגרור את הסמן כדי להזיז על פני המודל, ולסובב בעזרת הטבעת",
|
||||
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל וטבעת לסיבובה.",
|
||||
"ui.plane": "מישור",
|
||||
"ui.plane_at_angle": "מישור בזווית",
|
||||
"ui.plugins": "תוספים",
|
||||
@@ -5266,8 +5373,8 @@
|
||||
"ui.start": "התחלה",
|
||||
"ui.start_over": "התחלה מחדש",
|
||||
"ui.start_page": "דף פתיחה",
|
||||
"ui.step_values": "שינוי ערכים בעזרת מקשי החצים והגלגלת",
|
||||
"ui.step_values_tip": "בשדה ערך שמקלידים בו, החצים למעלה ולמטה משנים מספר רגיל ב־1 (Shift: 10, Alt: 0.1), וכך גם גלגלת העכבר מעל השדה. ביטויים נשארים כפי שהוקלדו.",
|
||||
"ui.step_values": "שינוי ערכים בחצים ובגלגלת",
|
||||
"ui.step_values_tip": "מקשי מעלה/מטה והגלגלת מעל שדה ערך משנים את המספר בו.",
|
||||
"ui.stitch_tolerance_hint": "הדוקה",
|
||||
"ui.stretch": "מתיחה",
|
||||
"ui.supersampling": "דגימת־על (SSAA)",
|
||||
|
||||
+159
-52
@@ -379,7 +379,7 @@
|
||||
"Move Type": "移動タイプ",
|
||||
"Move down": "下へ移動",
|
||||
"Move earlier": "前へ移動",
|
||||
"Move holes by clicking or dragging": "クリックやドラッグで穴を移動",
|
||||
"Move holes by clicking or dragging": "クリックまたはドラッグで移動",
|
||||
"Move later": "後へ移動",
|
||||
"Move up": "上へ移動",
|
||||
"Name": "名前",
|
||||
@@ -585,7 +585,7 @@
|
||||
"Show ghost": "ゴーストを表示",
|
||||
"Show in folder": "フォルダに表示",
|
||||
"Show the pointer an AI agent moves (#35)": "エージェントが操作するポインターを表示 (#35)",
|
||||
"Show tool at cursor": "カーソルの横にツールを表示",
|
||||
"Show tool at cursor": "カーソル位置にツールを表示",
|
||||
"Signed axes": "符号付き座標軸",
|
||||
"Size": "サイズ",
|
||||
"Sketch constraints": "スケッチ拘束",
|
||||
@@ -596,7 +596,7 @@
|
||||
"Sketch points": "スケッチ点",
|
||||
"Slider": "スライダ",
|
||||
"Slot width": "長穴の幅",
|
||||
"Snap to face and edge centres": "面とエッジの中心にスナップ",
|
||||
"Snap to face and edge centres": "中心にスナップ",
|
||||
"SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraftからレポートを直接送信することはできません。GitHubで送信してください。アプリにトークンは必要ありません。",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "これらのデザインが保存される前にSolidCraftが終了しました。最後の自動保存ファイルを開くことができます。",
|
||||
"Sort A to Z": "昇順で並べ替え",
|
||||
@@ -739,34 +739,34 @@
|
||||
"a picked edge's body is gone": "選択したエッジのボディが存在しなくなりました",
|
||||
"add a key for a joint first": "先にジョイントのキーを追加してください",
|
||||
"ai.connected_toast": "SolidCraft が {clients} で使えるようになりました。使うには再起動してください(設定 > アシスタント)。",
|
||||
"ai.prefs.allow_control": "AI エージェントの接続を許可(コントロールチャンネル / MCP)",
|
||||
"ai.prefs.allow_control_note": "このコンピューター内だけのコントロールチャンネル(127.0.0.1、ポート 27878 または次に空いているポート)を開きます。接続するプログラムは、起動のたびに作られ、あなたのユーザーアカウントだけが読める秘密のトークンも提示する必要があります。次に SolidCraft を起動したときから有効です。オフ:--control を付けて起動しない限り、何も待ち受けません。",
|
||||
"ai.prefs.availability": "アシスタントの操作",
|
||||
"ai.prefs.background_commands": "エージェントのコマンドをバックグラウンドで実行",
|
||||
"ai.prefs.connect_apps": "このコンピューターの AI アプリで SolidCraft を使えるようにする",
|
||||
"ai.prefs.connect_apps_note": "SolidCraft の起動時に、このコンピューターで見つかった AI アプリ(OpenCode、Claude Desktop、Claude Code、Cursor、Codex、Gemini CLI、Copilot CLI、Windsurf、Zed、VS Code)へ MCP サーバーとして追加します。追加または更新するのは solidcraft のエントリのみで、変更済みのエントリには触れず、ファイルは先にバックアップされます。オフにすると追加したエントリを削除します。",
|
||||
"ai.prefs.connect_at_start": "SolidCraft の起動時に前回使用したエージェントに接続",
|
||||
"ai.prefs.allow_control": "AIエージェントの接続を許可",
|
||||
"ai.prefs.allow_control_note": "ローカルチャネル(MCP)です。このコンピューターから外には何も送信しません。\n\nこのコンピューター内だけのコントロールチャンネル(127.0.0.1、ポート 27878 または次に空いているポート)を開きます。接続するプログラムは、起動のたびに作られ、あなたのユーザーアカウントだけが読める秘密のトークンも提示する必要があります。次に SolidCraft を起動したときから有効です。オフ:--control を付けて起動しない限り、何も待ち受けません。",
|
||||
"ai.prefs.availability": "利用設定",
|
||||
"ai.prefs.background_commands": "時間のかかるコマンドをバックグラウンドで実行",
|
||||
"ai.prefs.connect_apps": "SolidCraftをAIアプリに追加",
|
||||
"ai.prefs.connect_apps_note": "オフにすると追加した項目を削除します。\n\nSolidCraft の起動時に、このコンピューターで見つかった AI アプリ(OpenCode、Claude Desktop、Claude Code、Cursor、Codex、Gemini CLI、Copilot CLI、Windsurf、Zed、VS Code)へ MCP サーバーとして追加します。追加または更新するのは solidcraft のエントリのみで、変更済みのエントリには触れず、ファイルは先にバックアップされます。オフにすると追加したエントリを削除します。",
|
||||
"ai.prefs.connect_at_start": "起動時に前回のエージェントに接続",
|
||||
"ai.prefs.connect_at_start_note": "SolidCraft を起動するたびに、前回接続したエージェント (またはデフォルトのエージェント) をバックグラウンドで起動し、すぐにチャットできるようにします。--control を付けて起動した場合は行いません。接続できない場合、アシスタントに「接続に失敗しました」と「再試行」ボタンが表示されます。",
|
||||
"ai.prefs.follow_camera": "カメラをスケッチに向ける",
|
||||
"ai.prefs.follow_camera": "カメラを追従",
|
||||
"ai.prefs.follow_camera_off": "カメラの自動追従はオフです。",
|
||||
"ai.prefs.follow_camera_on": "エージェントが作成するスケッチにカメラを向けます。",
|
||||
"ai.prefs.intro": "エージェント、権限、作業の表示方法を設定します。",
|
||||
"ai.prefs.intro": "SolidCraftのAIエージェント。",
|
||||
"ai.prefs.make_default": "既定にする",
|
||||
"ai.prefs.no_agents": "エージェントはまだありません。追加するか、更新してインストール済みのエージェントを検索してください。",
|
||||
"ai.prefs.no_saved_approvals": "保存された許可はありません。",
|
||||
"ai.prefs.open_at_start": "SolidCraft の起動時にアシスタントパネルを開く",
|
||||
"ai.prefs.open_at_start": "起動時にチャットを開く",
|
||||
"ai.prefs.open_at_start_note": "SolidCraft を起動するたびにアシスタントパネル (または独立したウィンドウ) を表示します。--control を付けて起動した場合は行いません。",
|
||||
"ai.prefs.override_all_warning": "このエージェントは確認せずに設計を保存、上書き、書き出し、置換できます。",
|
||||
"ai.prefs.permission_all": "すべて許可",
|
||||
"ai.prefs.permission_all_summary": "利用可能なSolidCraftツールを確認せず実行します。",
|
||||
"ai.prefs.permission_ask": "変更前に確認",
|
||||
"ai.prefs.permission_ask_summary": "設計の変更とファイル操作を毎回確認します。",
|
||||
"ai.prefs.permission_edits": "モデル編集を許可",
|
||||
"ai.prefs.permission_edits_summary": "モデル編集は自動実行。ファイル操作や破壊的な操作は確認します。",
|
||||
"ai.prefs.permission_all": "すべて実行",
|
||||
"ai.prefs.permission_all_summary": "確認しない",
|
||||
"ai.prefs.permission_ask": "先に確認",
|
||||
"ai.prefs.permission_ask_summary": "変更ごとに確認",
|
||||
"ai.prefs.permission_edits": "編集を許可",
|
||||
"ai.prefs.permission_edits_summary": "自由に編集し、その他は確認",
|
||||
"ai.prefs.permission_scope": "SolidCraftツールのみに適用されます。エージェントのシェルやファイル編集には、そのエージェント自身の権限設定が適用されます。",
|
||||
"ai.prefs.pointer_preview_hidden": "ポインターは非表示",
|
||||
"ai.prefs.pointer_preview_speed": "エージェントのポインター · {speed}",
|
||||
"ai.prefs.pointer_speed": "移動速度",
|
||||
"ai.prefs.pointer_speed": "速度",
|
||||
"ai.prefs.privacy_note": "会話は選択したエージェントまたはエンドポイントにのみ送信されます。エンドポイントのキーはシステムのキーチェーンに保存されます。",
|
||||
"ai.prefs.show_pointer": "エージェントのポインターを表示",
|
||||
"ai.prefs.turn_limit_hint": "アシスタントが1件のメッセージで行える、設計を変更するツール呼び出しの上限です。",
|
||||
@@ -997,6 +997,11 @@
|
||||
"cmd.joint_screw": "ねじ",
|
||||
"cmd.joint_solve": "ジョイントを解決",
|
||||
"cmd.joint_sweep": "ジョイントをスイープ",
|
||||
"cmd.layout.apply": "レイアウトを適用",
|
||||
"cmd.layout.get": "レイアウトを確認",
|
||||
"cmd.layout.reset": "レイアウトをリセット",
|
||||
"cmd.layout.save": "レイアウトを保存",
|
||||
"cmd.layout.workspace.select": "レイアウトのワークスペースを選択",
|
||||
"cmd.library_apply": "ローカルライブラリ項目を適用",
|
||||
"cmd.library_export": "デザインライブラリを書き出す",
|
||||
"cmd.library_inspect": "ローカルライブラリを確認",
|
||||
@@ -1030,6 +1035,21 @@
|
||||
"cmd.occurrence_move": "オカレンスを移動",
|
||||
"cmd.occurrence_properties": "オカレンスの物理的プロパティ",
|
||||
"cmd.occurrence_reparent": "コンポーネントへ移動",
|
||||
"cmd.panel.activate": "パネルのタブをアクティブ化",
|
||||
"cmd.panel.close": "パネルを閉じる",
|
||||
"cmd.panel.collapse": "パネルのセクションを折りたたむ",
|
||||
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
|
||||
"cmd.panel.float": "パネルをフロート表示",
|
||||
"cmd.panel.focus": "パネルにフォーカス",
|
||||
"cmd.panel.list": "パネルを一覧表示",
|
||||
"cmd.panel.move": "パネルを移動",
|
||||
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
|
||||
"cmd.panel.open": "パネルを開く",
|
||||
"cmd.panel.peek": "折りたたんだパネルを一時表示",
|
||||
"cmd.panel.popout": "パネルを別ウィンドウに移動",
|
||||
"cmd.panel.reset_pair": "パネルの区切りをリセット",
|
||||
"cmd.panel.resize": "パネルのセクションのサイズを変更",
|
||||
"cmd.panel.resize_pair": "パネルの区切りを移動",
|
||||
"cmd.parameters_add": "ユーザー パラメータを追加",
|
||||
"cmd.parameters_change": "パラメータを変更",
|
||||
"cmd.parameters_comment": "パラメータにコメントを付与",
|
||||
@@ -1536,6 +1556,11 @@
|
||||
"help.joint.reach": "部品上の点を目標に向けて動かし、そのチェーン上のジョイントを制限の範囲内で変化させます。ロボット アームの先端のように、先端の位置から機構の姿勢を決めるときに使います。",
|
||||
"help.joint.rigid_group": "選択した部品を現在の配置のまま固定し、一体として動くようにします。互いに動きのない、ひとまとまりの部品に使います。",
|
||||
"help.joint.screw": "回転とスライドをピッチで連動させ、1回転ごとにその距離だけ進むようにします。送りねじ、ボルト、ねじ軸に使います。",
|
||||
"help.layout.apply": "保存したレイアウトを現在のワークスペースに適用します。保持されているエディターの下書きは維持されます。",
|
||||
"help.layout.get": "確定したレイアウトと登録済みのワークスペースを確認します。",
|
||||
"help.layout.reset": "現在のワークスペースに登録された既定のレイアウトを復元します。ユーザープリセットは保持されます。",
|
||||
"help.layout.save": "レイアウト全体に名前を付けて保存します。ユーザープリセットの置き換えには明示的な上書き許可が必要です。",
|
||||
"help.layout.workspace.select": "登録済みのレイアウト用ワークスペースを選択し、移動前のレイアウトを記憶します。",
|
||||
"help.library.apply": "ローカルのライブラリファイルから材料または外観を、ボディ、面、コンポーネントに適用します。別のデザインで保存した材料を再利用するときに使います。",
|
||||
"help.library.export": "デザインの材料と外観をライブラリファイルに保存します。共有したり、ほかのデザインで再利用したりするときに使います。",
|
||||
"help.library.inspect": "デザインを変更せずに、ライブラリファイルを確認して材料と外観を一覧にします。適用する前にライブラリの中身を確認するときに使います。",
|
||||
@@ -1549,6 +1574,21 @@
|
||||
"help.notice": "NOTICE",
|
||||
"help.occurrence.ground": "部品をその場に固定するか、固定を解除します。ジョイントはほかの部品をこの部品の周りで動かします。アセンブリの残りが組み付けられるフレームやベースに使います。",
|
||||
"help.occurrence.move": "アセンブリ部品をジョイントが許す自由度だけで動かします。部品の面を選択してから、操作可能な矢印または円弧をドラッグしてください。",
|
||||
"help.panel.activate": "キーボードフォーカスを要求せずに、開いているパネルのタブを選択します。",
|
||||
"help.panel.close": "内容のライフサイクルの確認を経てパネルを閉じます。配置は記憶されます。",
|
||||
"help.panel.collapse": "要求された寸法を保持したまま、パネルのセクションを折りたたむか展開します。",
|
||||
"help.panel.dismiss": "折りたたんだパネルの一時表示を閉じます。",
|
||||
"help.panel.float": "パネルを明示的に指定した位置のフローティングセクションに移動します。",
|
||||
"help.panel.focus": "パネルにフォーカスします。必要に応じて保持された状態を再度開き、セクションを展開します。",
|
||||
"help.panel.list": "登録済みのパネル種類、確定したインスタンス、アクティブなタブ、利用可否、ホストの機能を確認します。",
|
||||
"help.panel.move": "パネルを新しいセクションに移動するか、既存のタブグループに追加します。",
|
||||
"help.panel.native_close": "別ウィンドウのパネルセクションを、内容をキャンセルせずに記憶されたドックに戻します。",
|
||||
"help.panel.open": "パネルの種類と明示的なインスタンス識別子を指定して開きます。新しいインスタンスには配置を指定します。",
|
||||
"help.panel.peek": "保存されたサイズや折りたたみ状態を変更せずに、折りたたんだパネルを一時表示します。",
|
||||
"help.panel.popout": "対応している場合はパネルを別ウィンドウに移動します。ブラウザーではフローティングセクションを使用します。",
|
||||
"help.panel.reset_pair": "隣接するドックのペアを、共通の制約内で既定のサイズ比率に戻します。",
|
||||
"help.panel.resize": "すべてのタブの制約内で、パネルセクションの外側の幅と高さを設定します。",
|
||||
"help.panel.resize_pair": "共有する全体の長さを維持しながら、隣接するドックセクションのサイズを同時に変更します。",
|
||||
"help.parameters.change": "デザインの寸法を決める名前付きの値と式の表を開きます。1か所で寸法を変え、それを使うすべてのフィーチャに反映させるときに使います。",
|
||||
"help.parameters.export": "デザインのパラメータをCSVまたはJSONで保存します。表計算ソフトで編集したり、別のデザインに移したりするときに使います。",
|
||||
"help.parameters.graph": "どのパラメータとフィーチャーがほかのどれを使っているか、その間のループやエラーも表示します。パラメータを変更すると何に影響するかを確認するときに使います。",
|
||||
@@ -1885,21 +1925,21 @@
|
||||
"joint.rigid_group_select_components": "2 つ以上のコンポーネントを選択してください。",
|
||||
"joint_move.blocked_hint": "Rigid合致、部品の固定状態、ジョイントの制限値、パラメーターでの拘束を確認してください。",
|
||||
"joint_move.blocked_title": "この部品は移動できません",
|
||||
"joint_move.coarse": "軸に近い位置の回転刻み(°)",
|
||||
"joint_move.coarse": "軸の近くのステップ",
|
||||
"joint_move.conflict": "この部品のジョイントが他のジョイントと競合しています: {joint}。",
|
||||
"joint_move.conflict_hint": "競合しているジョイントを編集、抑制、または削除して、他のジョイントで部品を配置できるようにしてください。",
|
||||
"joint_move.distance": "開始位置からの移動距離",
|
||||
"joint_move.done": "完了",
|
||||
"joint_move.fine": "軸から遠い位置の回転刻み(°)",
|
||||
"joint_move.fine": "軸から離れたステップ",
|
||||
"joint_move.fixed": "この部品には操作できるジョイントの自由度がありません。",
|
||||
"joint_move.help": "移動 — 矢印または円弧をドラッグ。Escでドラッグを取消。",
|
||||
"joint_move.legacy_hint": "オフにすると従来の移動パネルを使います。",
|
||||
"joint_move.precision": "破線ガイドの外側へ引くと回転を微調整できます。",
|
||||
"joint_move.legacy_hint": "オフ:従来の移動パネル。",
|
||||
"joint_move.precision": "破線のガイドの外へ引くと細かいステップになります。",
|
||||
"joint_move.rotation": "開始位置からの回転角",
|
||||
"joint_move.select": "面またはアセンブリ部品を1つ選択してから移動を実行してください。",
|
||||
"joint_move.settings": "アセンブリの移動",
|
||||
"joint_move.step": "刻み",
|
||||
"joint_move.use_handles": "アセンブリの移動にジョイントの操作ハンドルを使う",
|
||||
"joint_move.use_handles": "移動にジョイントのモーションハンドルを使用",
|
||||
"keymap.command_id": "コマンドID",
|
||||
"keymap.empty": "一致するコマンドはありません",
|
||||
"keymap.use_anyway": "ここでは{key}をそのまま使用",
|
||||
@@ -2017,6 +2057,12 @@
|
||||
"navbar.assistant": "アシスタント",
|
||||
"navbar.custom.add_slot": "{slot} を追加",
|
||||
"navbar.custom.always": "常に表示",
|
||||
"navbar.custom.anchor.bottom_centre": "下中央",
|
||||
"navbar.custom.anchor.bottom_left": "左下",
|
||||
"navbar.custom.anchor.bottom_right": "右下",
|
||||
"navbar.custom.anchor.top_centre": "上中央",
|
||||
"navbar.custom.anchor.top_left": "左上",
|
||||
"navbar.custom.anchor.top_right": "右上",
|
||||
"navbar.custom.available": "{available} · バーへドラッグ、または「追加」",
|
||||
"navbar.custom.buttons": "ボタン",
|
||||
"navbar.custom.compact": "コンパクト",
|
||||
@@ -2053,6 +2099,7 @@
|
||||
"navbar.custom.reset_default": "既定値に戻す",
|
||||
"navbar.custom.right": "右へ移動",
|
||||
"navbar.custom.select": "スロットが選択されていません。バーのスロットを選択して移動、削除、表示の変更ができます。",
|
||||
"navbar.custom.separator_hint": "ボタンのグループ間の区切りです。バーにドラッグするか、「追加」を押してください。いくつでも使用できます。",
|
||||
"navbar.custom.separators_full": "区切りは既に4個あります。",
|
||||
"navbar.custom.show": "ナビゲーションバーを表示",
|
||||
"navbar.custom.shows": "表示内容",
|
||||
@@ -2206,21 +2253,29 @@
|
||||
"prefs.appearance.viewport_logo": "ビューポートとロゴ",
|
||||
"prefs.applies_on_restart": "再起動後に適用",
|
||||
"prefs.assistant": "アシスタント",
|
||||
"prefs.assistant_connecting": "接続",
|
||||
"prefs.assistant_follow_help": "スケッチのLook At操作で、ポインターに合わせてビューが移動します。",
|
||||
"prefs.assistant_limit": "上限",
|
||||
"prefs.assistant_limited": "制限あり",
|
||||
"prefs.assistant_pointer_speed": "ポインターの速度",
|
||||
"prefs.assistant_seconds": "秒",
|
||||
"prefs.assistant_seconds_aria": "秒",
|
||||
"prefs.assistant_use_care": "注意して使用",
|
||||
"prefs.auto_operation": "操作を自動で選択",
|
||||
"prefs.auto_operation_note": "フィーチャーのウィンドウは、新しい形状が何に接するかから結合・新規ボディ・切り取りを選びます(手動で選ぶまで)。オフ:開いたときの操作のままです。",
|
||||
"prefs.auto_operation_note": "材料が接触する対象に応じて結合、新規ボディ、切り取りを選びます。",
|
||||
"prefs.axes": "軸",
|
||||
"prefs.axis": "軸",
|
||||
"prefs.bodies_folders": "ボディフォルダー",
|
||||
"prefs.bodies_folders_always": "常に表示",
|
||||
"prefs.bodies_folders_never": "表示しない",
|
||||
"prefs.bodies_folders_several": "複数ボディのみ",
|
||||
"prefs.bodies_folders_several": "複数",
|
||||
"prefs.bottom": "下",
|
||||
"prefs.bottom_centre": "下中央",
|
||||
"prefs.bottom_right": "右下",
|
||||
"prefs.browser": "ブラウザー",
|
||||
"prefs.browser_components_open": "コンポーネントを最初から展開",
|
||||
"prefs.browser_components_open_note": "ブラウザーのすべてのコンポーネントを以前と同じく最初から展開して表示します。オフの場合は展開するまで折りたたまれています。",
|
||||
"prefs.browser_eye_left": "表示切り替えを折りたたみ矢印の隣に表示",
|
||||
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
|
||||
"prefs.browser_eye_left_note": "ブラウザーの各行の表示切り替えを、行の右端の列ではなく、以前のように折りたたみ矢印の隣に表示します。",
|
||||
"prefs.browser_reveal_on_hover": "ホバーで表示",
|
||||
"prefs.browser_show_note": "「表示」はブラウザーを常に画面に表示します。「ホバーで表示」はポインターがその位置か左端の細いタブに来るまで隠し、ポインターが中にある間やそのメニューが開いている間は表示したままにします。",
|
||||
@@ -2249,8 +2304,19 @@
|
||||
"prefs.dark_parts_shading_help": "リニア光と柔らかいスカイライトでビューをシェーディングし、暗い面が黒くつぶれずにグラデーションを保つようにします",
|
||||
"prefs.dead_zone_note": "斜線の帯はデッドゾーンです。この範囲内の動きは無視されます。",
|
||||
"prefs.default": "既定",
|
||||
"prefs.design": "設計の既定値",
|
||||
"prefs.design_note": "新しいデザインの初期設定です。開いているデザインはそれぞれの設定を保持します。",
|
||||
"prefs.design": "デザイン",
|
||||
"prefs.design_note": "新規デザインの初期設定。",
|
||||
"prefs.design_tools.ask": "確認",
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
|
||||
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
|
||||
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
|
||||
"prefs.design_tools.hole_snaps_help": "面の中央と円形エッジの中心。",
|
||||
"prefs.design_tools.imported_files": "インポートしたファイル",
|
||||
"prefs.design_tools.note": "通知",
|
||||
"prefs.design_tools.off": "オフ",
|
||||
"prefs.design_tools.placement_group": "レスト、ボス、スナップフィット",
|
||||
"prefs.design_tools.rows": "行数",
|
||||
"prefs.devices": "3D マウス",
|
||||
"prefs.dialog_buttons": "ダイアログのボタン",
|
||||
"prefs.dialogs": "ダイアログ",
|
||||
@@ -2292,6 +2358,23 @@
|
||||
"prefs.graphics_card_restart": "SolidCraft の再起動後に有効になります。WGPU_ADAPTER_NAME がこの選択より優先されます。",
|
||||
"prefs.graphics_card_unused": "保存したグラフィックスカードはこのウィンドウでは使われていません。使用中のカードは「ヘルプ › SolidCraft について」とステータス行に表示されます。自動を試すか、カード名の一意な部分を WGPU_ADAPTER_NAME に設定してください。",
|
||||
"prefs.graphics_note": "速度と滑らかさのバランスを調整します。各選択肢の効果をサンプルで確認できます。",
|
||||
"prefs.graphics_page.current_view": "現在のビュー",
|
||||
"prefs.graphics_page.fastest": "最速",
|
||||
"prefs.graphics_page.fold_items": "項目を折りたたむ",
|
||||
"prefs.graphics_page.in_view": "ビュー内",
|
||||
"prefs.graphics_page.live_details": "リアルタイムの詳細",
|
||||
"prefs.graphics_page.own_mix": "独自の組み合わせ",
|
||||
"prefs.graphics_page.quick_measure": "クイック測定",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "ボディ越しの参照ジオメトリ",
|
||||
"prefs.graphics_page.restart": "再起動が必要",
|
||||
"prefs.graphics_page.sharpest": "最も鮮明",
|
||||
"prefs.graphics_page.single_surface_analysis": "単一のサーフェス解析",
|
||||
"prefs.graphics_page.status_chips": "ステータスチップ",
|
||||
"prefs.graphics_page.subtitle": "速度と滑らかさのバランスを調整します。",
|
||||
"prefs.graphics_page.supersampling": "スーパーサンプリング",
|
||||
"prefs.graphics_page.supersampling_adaptive": "ビューが静止している間のみ",
|
||||
"prefs.graphics_page.surfaces_edges": "サーフェスとエッジ",
|
||||
"prefs.graphics_page.view_bar": "ビューバーにも表示",
|
||||
"prefs.graphics_presets_note": "プリセットは以下の設定をまとめて変更します。",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "ズームアウト時に拘束グリフを隠す",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "スケッチを十分にズームアウトすると、拘束グリフは表示されず画面を散らかしません。オフ: ズームに関係なく固定の画面サイズのままです。",
|
||||
@@ -2300,7 +2383,7 @@
|
||||
"prefs.history_origin_bodies_note": "左側またはブラウザー下の履歴の先頭に、アクティブなコンポーネントのボディフォルダーと原点を、ブラウザーと同じ表示切り替えとメニュー付きで表示します。下部の履歴は変わりません。",
|
||||
"prefs.history_play_button": "履歴コントロールに「履歴を再生」ボタンを表示",
|
||||
"prefs.history_play_button_note": "履歴コントロールの「1ステップ戻る」と「1ステップ進む」の間に「履歴を再生」を追加します。コントロールを右クリックして表示/非表示を切り替えることもできます。",
|
||||
"prefs.history_sketches": "スケッチ",
|
||||
"prefs.history_sketches": "履歴のスケッチ",
|
||||
"prefs.history_sketches_in_order": "作成順",
|
||||
"prefs.history_sketches_note": "スケッチ:「フィーチャーの下」では、左側の履歴に名前が表示される幅があるとき、スケッチをそれから作成した最初のフィーチャーの下に 1 段字下げして表示します。下部のタイムラインは常に作成順です。",
|
||||
"prefs.history_sketches_under_feature": "フィーチャーの下",
|
||||
@@ -2325,6 +2408,22 @@
|
||||
"prefs.language_system": "システム",
|
||||
"prefs.layout": "レイアウト",
|
||||
"prefs.layout_note": "コンパクトではパネルをウィンドウの端に固定します。ブラウザーの半透明表示はフローティングで使用できます。アシスタントの配置はアシスタントページで変更します。",
|
||||
"prefs.layout_page.active_commands": "アクティブなコマンドを強調",
|
||||
"prefs.layout_page.always": "常に",
|
||||
"prefs.layout_page.card": "カード",
|
||||
"prefs.layout_page.column": "列",
|
||||
"prefs.layout_page.hover": "ホバー時",
|
||||
"prefs.layout_page.icons_labels": "アイコン+ラベル",
|
||||
"prefs.layout_page.ok_cancel": "OK とキャンセル",
|
||||
"prefs.layout_page.on_hover": "ホバー時",
|
||||
"prefs.layout_page.open": "開く位置",
|
||||
"prefs.layout_page.origin_bodies": "原点とボディを上に表示",
|
||||
"prefs.layout_page.panels": "パネル",
|
||||
"prefs.layout_page.pinned_menus": "固定コマンドをメニューから除外",
|
||||
"prefs.layout_page.play_history": "履歴の再生ボタン",
|
||||
"prefs.layout_page.slot_count": "{max} スロット中 {count}",
|
||||
"prefs.layout_page.view_corner": "ビューの隅にも表示",
|
||||
"prefs.layout_page.window": "ウィンドウ",
|
||||
"prefs.left": "左",
|
||||
"prefs.left_click": "左クリック",
|
||||
"prefs.line_drag_draws_one": "押してドラッグすると線を 1 本描く",
|
||||
@@ -2369,11 +2468,15 @@
|
||||
"prefs.origin_plane_names_note": "ブラウザー、ダイアログ、一覧に表示される名前です。{top} は XY、{front} は XZ、{right} は YZ です (Z が上)。コマンド、スクリプト、保存されたデザインでは XY、XZ、YZ のままです。",
|
||||
"prefs.overall_speed": "全体の速度",
|
||||
"prefs.parameter_list_rows": "パラメーター一覧の行数",
|
||||
"prefs.parameter_list_rows_tip": "空の値フィールドで下矢印を押すと、お気に入りを先頭にパラメーターをこの数まで一覧表示します。少なくすると大きな設計でも一覧が軽くなります。",
|
||||
"prefs.parameter_list_rows_tip": "空の式入力欄で下矢印キーを押したときに表示するパラメータの最大数。",
|
||||
"prefs.persistent_ribbon_pressed": "有効なリボンコマンドを強調表示",
|
||||
"prefs.persistent_ribbon_pressed_help": "効果が有効な間、リボンのコマンドを強調表示します。無効にすると、従来の中立的な表示とコマンドのヘルプを保持します。",
|
||||
"prefs.perspective_fov": "画角",
|
||||
"prefs.placement": "配置",
|
||||
"prefs.plugins_enabled": "有効",
|
||||
"prefs.plugins_installed": "インストール済み",
|
||||
"prefs.plugins_intro": "サンドボックス化されたアドオン。",
|
||||
"prefs.plugins_show_folder": "プラグインフォルダーを表示",
|
||||
"prefs.prefer_free_sketch_points": "スケッチ点が重なったときは移動可能な点を優先する",
|
||||
"prefs.preview_choices": "選択した設定のプレビュー",
|
||||
"prefs.preview_tint_new_faces": "プレビューで新しい面に色を付ける",
|
||||
@@ -2431,6 +2534,10 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
|
||||
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
|
||||
"prefs.shortcuts": "ショートカット",
|
||||
"prefs.shortcuts_import": "インポート",
|
||||
"prefs.shortcuts_intro": "各コマンドのキー。",
|
||||
"prefs.shortcuts_not_assigned": "未割り当て",
|
||||
"prefs.shortcuts_press": "キーを押してください...",
|
||||
"prefs.single_surface_analysis": "サーフェス解析を1つだけ使用",
|
||||
"prefs.single_surface_analysis_help": "オフの場合、サーフェスのシェーディングと連続性解析を同時に有効にできます。オンにすると、最後に開始または変更した解析が残ります。",
|
||||
"prefs.sketch_align_points": "描画中にほかのスケッチ点に揃える",
|
||||
@@ -2469,7 +2576,7 @@
|
||||
"prefs.stand_up_unknown.ask": "ダイアログで確認",
|
||||
"prefs.stand_up_unknown.note": "メモを表示",
|
||||
"prefs.stand_up_unknown.off": "確認しない",
|
||||
"prefs.stand_up_unknown_note": "ファイルのヘッダーに Y 上向きで保存することが知られているプログラム名がない場合の確認方法です。メモはファイルが横倒しに見える場合にのみ表示されます。「ダイアログで確認」は従来どおり、このようなファイルすべてについて確認します。",
|
||||
"prefs.stand_up_unknown_note": "ファイルに既知のプログラム名がない場合の提案方法。",
|
||||
"prefs.status_bar": "ステータスバー",
|
||||
"prefs.status_bar_note": "ウィンドウの下端の細いバーに、編集中のもの、開いているスケッチの状態(その色で)、デザインの単位を表示します。単位をクリックすると変更できます。スケッチの状態はビューの隅には表示されなくなります。",
|
||||
"prefs.status_done": "完了",
|
||||
@@ -2542,8 +2649,8 @@
|
||||
"prefs.validation.label": "カーネルの完全チェック",
|
||||
"prefs.validation.off": "オフ (試験的)",
|
||||
"prefs.validation_note": "各ステップの後: スイープ、ブーリアン、ブレンドのたびにチェックします。\nフィーチャーごとに 1 回: 各フィーチャーの結果を 1 回チェックします。\nオフ (試験的): チェックしません。",
|
||||
"prefs.value_boxes_in_view": "ビューでハンドルの横に値を表示",
|
||||
"prefs.value_boxes_in_view_tip": "コマンドの値(距離、半径など)をモデル上のハンドルの横のボックスに表示します。オフにすると、値はコマンドウィンドウに入力します。選択後はそのウィンドウにキーボード入力が入り、モデルを覆うものはありません。",
|
||||
"prefs.value_boxes_in_view": "ハンドルの横に値を表示",
|
||||
"prefs.value_boxes_in_view_tip": "押し出しの距離、フィレットの半径などをビュー内のボックスに表示します。",
|
||||
"prefs.view": "ビューの動作",
|
||||
"prefs.view_confirm_corner": "ビューの隅に OK とキャンセル",
|
||||
"prefs.view_confirm_corner_note": "ダイアログまたはスケッチを開いている間、ビュー右上のチェックとバツが、ダイアログの OK とキャンセルと同じ操作を行うか、スケッチを終了またはキャンセルします。ビューナビゲーターはその下に移動します。",
|
||||
@@ -3944,11 +4051,11 @@
|
||||
"select two components": "2つのコンポーネントを選択してください",
|
||||
"set by the sketch's lines": "スケッチの線で決まります",
|
||||
"settings.display_quality_note": "表示だけに影響します。設計や書き出しは変わりません。",
|
||||
"settings.double_click_components": "コンポーネントをダブルクリックしてアクティブにする、またはリンクされたものをその場で編集する",
|
||||
"settings.double_click_components": "コンポーネントをダブルクリックしてアクティブ化",
|
||||
"settings.frame_rate_details_note": "直近のフレーム時間のグラフ(描画した三角形数を重ねて表示)、フレーム時間のヒストグラム、GPU が描画した内容を追加します。ビューのフレームレートをクリックすると表示/非表示を切り替えます。",
|
||||
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。",
|
||||
"settings.insert_lists_open_designs": "デザインを挿入で、ほかのタブで開いているデザインを先に表示する",
|
||||
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
|
||||
"settings.insert_lists_open_designs": "デザインを挿入で開いているデザインを先に表示",
|
||||
"settings.measure_axis_distances": "計測で軸方向の距離を表示",
|
||||
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
|
||||
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
|
||||
"settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。",
|
||||
@@ -3958,7 +4065,7 @@
|
||||
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:",
|
||||
"settings.reset_title": "設定が変更されました",
|
||||
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
|
||||
"settings.update_changed_links": "タブに戻ったときに変更されたリンク先デザインを更新",
|
||||
"settings.update_changed_links": "タブに戻ったときに変更されたリンクを更新",
|
||||
"settings.window_system_details": "詳細",
|
||||
"settings.window_system_note_now": "再起動後に適用されます。現在: {now}。",
|
||||
"settings.window_system_wayland_limited": "ネイティブ Wayland では一部の機能が制限されます。",
|
||||
@@ -4360,7 +4467,7 @@
|
||||
"ui.agent_chat_send_now_tip": "エージェントの現在のステップを止めて、これを次に送信します(Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "SolidCraft は、このコンピューターにインストールされたコーディングエージェントと連携します。「環境設定 › AI」で選択してください。",
|
||||
"ui.agent_chat_setup_title": "アシスタントを設定",
|
||||
"ui.agent_chat_side": "エージェントチャットの位置",
|
||||
"ui.agent_chat_side": "チャットをドッキング",
|
||||
"ui.agent_chat_signed_in": "サインイン済み",
|
||||
"ui.agent_chat_state_auth": "サインインが必要",
|
||||
"ui.agent_chat_state_busy": "作業中…",
|
||||
@@ -4379,7 +4486,7 @@
|
||||
"ui.agent_chat_undone": "(元に戻しました)",
|
||||
"ui.agent_chat_view_attached": "ビューを添付済み",
|
||||
"ui.agent_chat_waiting": "確認待ち",
|
||||
"ui.agent_cursor": "エージェントカーソル",
|
||||
"ui.agent_cursor": "エージェントのポインター",
|
||||
"ui.agent_cursor_speed": "エージェントカーソル速度",
|
||||
"ui.agent_off_thread": "エージェントのコマンド実行中もウィンドウを操作可能にする",
|
||||
"ui.agent_off_thread_note": "エージェント(AI パネルまたは MCP クライアント)の時間のかかるコマンドをウィンドウとは別に計算します。実行中もビューの回転や確認ができ、ビュー内の表示から停止できます。オフ:各コマンドはウィンドウのスレッドで実行され、終わるまでウィンドウは待ちます。",
|
||||
@@ -4425,14 +4532,14 @@
|
||||
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
|
||||
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
|
||||
"ui.ai_kind": "種類",
|
||||
"ui.ai_mode_all": "すべて許可",
|
||||
"ui.ai_mode_all": "すべて実行",
|
||||
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
|
||||
"ui.ai_mode_all_warning": "エージェントは確認なしでデザインの保存・上書き・エクスポート・置き換えができます。信頼できるエージェントで、保存済みのデザインに対してのみ使ってください。",
|
||||
"ui.ai_mode_ask": "毎回確認",
|
||||
"ui.ai_mode_ask": "先に確認",
|
||||
"ui.ai_mode_ask_note": "デザインへのすべての変更とファイル操作が、あなたの許可を待ちます。",
|
||||
"ui.ai_mode_edits": "モデル編集を自動許可",
|
||||
"ui.ai_mode_edits": "編集を許可",
|
||||
"ui.ai_mode_edits_note": "モデリングコマンドは確認なしで実行されます。保存・開く・エクスポートなどのファイル操作や破壊的な操作は引き続き確認します。",
|
||||
"ui.ai_mode_global": "上の設定に従う",
|
||||
"ui.ai_mode_global": "上の設定と同じ",
|
||||
"ui.ai_model": "モデル",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "これらの設定は SolidCraft のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
|
||||
@@ -4458,7 +4565,7 @@
|
||||
"ui.ai_save": "保存",
|
||||
"ui.ai_settings": "AI 設定…",
|
||||
"ui.ai_show_features": "AI 機能を表示",
|
||||
"ui.ai_show_features_note": "エージェントチャット: 普段使っているコーディングエージェント、または選んだモデルのエンドポイントが、開いているデザインを操作するパネルです。オフ: AI ボタン、ショートカット、メニュー項目、パネルは表示されず、検索も起動も行いません。",
|
||||
"ui.ai_show_features_note": "オフにするとアシスタントをすべて非表示にし、何も起動しません。以下の項目はすべて無効表示になります。\n\nエージェントチャット: 普段使っているコーディングエージェント、または選んだモデルのエンドポイントが、開いているデザインを操作するパネルです。オフ: AI ボタン、ショートカット、メニュー項目、パネルは表示されず、検索も起動も行いません。",
|
||||
"ui.ai_switch_tip": "この会話のエージェントを切り替え",
|
||||
"ui.ai_test": "テスト",
|
||||
"ui.ai_test_acp_ok": "応答あり: {agent}",
|
||||
@@ -4492,7 +4599,7 @@
|
||||
"ui.ai_tool_sum_then": "{first}、続いて {next}",
|
||||
"ui.ai_tool_unfold": "クリックして結果を表示",
|
||||
"ui.ai_tool_waiting": "待機中",
|
||||
"ui.ai_toolbar_button": "アプリケーションバーにアシスタントボタンを表示",
|
||||
"ui.ai_toolbar_button": "アプリケーションバーのアシスタントボタン",
|
||||
"ui.ai_toolbar_button_note": "アプリケーションバーのアシスタントボタン。Ctrl+Shift+A と表示メニューからもパネルを開けます。",
|
||||
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -4764,7 +4871,7 @@
|
||||
"ui.extract_done": "コンポーネントは独立したデザインになり、ここにリンクされました。",
|
||||
"ui.extrude": "押し出し",
|
||||
"ui.extrude_auto_to_object": "選択した対象まで押し出し",
|
||||
"ui.extrude_auto_to_object_tip": "押し出しでプロファイルを選択した後、その平面外の面・頂点・平行な平面を選ぶと、範囲タイプが「オブジェクトまで」に切り替わります",
|
||||
"ui.extrude_auto_to_object_tip": "面、頂点、平行な平面を選ぶと範囲が「オブジェクトまで」に切り替わります。",
|
||||
"ui.factor": "倍率",
|
||||
"ui.fade": "切り上げ",
|
||||
"ui.fade_length": "切り上げ長さ",
|
||||
@@ -4908,7 +5015,7 @@
|
||||
"ui.hidePlanes": "平面を非表示",
|
||||
"ui.hideProfile": "プロファイルを非表示",
|
||||
"ui.hide_component_history": "このコンポーネントの履歴を非表示",
|
||||
"ui.hide_origin_by_default": "原点を既定で非表示",
|
||||
"ui.hide_origin_by_default": "新規デザインで原点フォルダーを非表示",
|
||||
"ui.hide_origin_note": "新しいデザインは原点フォルダーを非表示にして始まります。原点の表示/非表示を切り替えたデザインは、その設定を保持します。",
|
||||
"ui.history_back": "1ステップ戻る",
|
||||
"ui.history_below_browser": "ブラウザーの下",
|
||||
@@ -5068,7 +5175,7 @@
|
||||
"ui.pitch": "ピッチ",
|
||||
"ui.placement": "配置",
|
||||
"ui.placement_handles": "配置ハンドル",
|
||||
"ui.placement_handles_tip": "レスト、ボス、スナップ フィットのプレビューに表示されるハンドル: マーカーをドラッグしてモデル上を移動し、リングで回転します",
|
||||
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、回転させるリング。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "傾斜平面",
|
||||
"ui.plugins": "プラグイン",
|
||||
@@ -5266,8 +5373,8 @@
|
||||
"ui.start": "開始",
|
||||
"ui.start_over": "最初からやり直す",
|
||||
"ui.start_page": "スタートページ",
|
||||
"ui.step_values": "矢印キーとホイールで数値を増減",
|
||||
"ui.step_values_tip": "入力中の値フィールドでは、上下矢印キーとフィールド上のマウスホイールで数値を 1 ずつ増減します(Shift: 10、Alt: 0.1)。式はそのまま残ります。",
|
||||
"ui.step_values": "矢印キーとホイールで値を変更",
|
||||
"ui.step_values_tip": "値入力欄の上で上下矢印キーやホイールを使うと数値が変わります。",
|
||||
"ui.stitch_tolerance_hint": "厳密",
|
||||
"ui.stretch": "伸縮",
|
||||
"ui.supersampling": "スーパーサンプリング (SSAA)",
|
||||
|
||||
+161
-30
@@ -325,6 +325,7 @@
|
||||
"Move Type": "이동 유형",
|
||||
"Move down": "아래로 이동",
|
||||
"Move earlier": "앞으로 이동",
|
||||
"Move holes by clicking or dragging": "클릭 또는 드래그로 이동",
|
||||
"Move later": "뒤로 이동",
|
||||
"Move up": "위로 이동",
|
||||
"Name": "Name",
|
||||
@@ -505,7 +506,7 @@
|
||||
"Show Agent Cursor": "에이전트 커서 표시",
|
||||
"Show in folder": "폴더에서 보기",
|
||||
"Show the pointer an AI agent moves (#35)": "AI 에이전트가 움직이는 포인터 표시 (#35)",
|
||||
"Show tool at cursor": "커서 옆에 도구 표시",
|
||||
"Show tool at cursor": "커서에 도구 표시",
|
||||
"Signed axes": "부호 있는 축",
|
||||
"Size": "크기",
|
||||
"Sketch constraints": "스케치 구속 조건",
|
||||
@@ -516,6 +517,7 @@
|
||||
"Sketch points": "스케치 점",
|
||||
"Slider": "슬라이더",
|
||||
"Slot width": "슬롯 폭",
|
||||
"Snap to face and edge centres": "중심에 스냅",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "이 디자인들이 저장되기 전에 SolidCraft가 종료되었습니다. 마지막 자동 저장본을 열 수 있습니다.",
|
||||
"Spacing 2 must be a non-zero number": "간격 2는 0이 아닌 숫자여야 합니다",
|
||||
"Spacing must be a non-zero number": "간격은 0이 아닌 숫자여야 합니다",
|
||||
@@ -641,26 +643,32 @@
|
||||
"a picked edge is gone": "선택한 모서리가 더 이상 없습니다",
|
||||
"a picked edge's body is gone": "선택한 모서리의 바디가 더 이상 없습니다",
|
||||
"add a key for a joint first": "먼저 관절의 키를 추가하세요",
|
||||
"ai.prefs.availability": "어시스턴트 제어",
|
||||
"ai.prefs.background_commands": "에이전트 명령을 백그라운드에서 실행",
|
||||
"ai.prefs.follow_camera": "카메라를 스케치로 돌리기",
|
||||
"ai.prefs.allow_control": "AI 에이전트 연결 허용",
|
||||
"ai.prefs.allow_control_note": "로컬 채널(MCP)입니다. 이 컴퓨터 밖으로 아무것도 나가지 않습니다.\n\n이 컴퓨터에서만 제어 채널을 엽니다(127.0.0.1, 포트 27878 또는 다음 빈 포트). 연결하는 프로그램은 시작할 때마다 새로 생성되며 현재 사용자 계정만 읽을 수 있는 비밀 토큰을 제시해야 합니다. SolidCraft를 다음에 시작할 때 적용됩니다. 끄면 --control로 실행하지 않는 한 아무것도 수신하지 않습니다.",
|
||||
"ai.prefs.availability": "사용 설정",
|
||||
"ai.prefs.background_commands": "느린 명령을 백그라운드에서 실행",
|
||||
"ai.prefs.connect_apps": "AI 앱에 SolidCraft 추가",
|
||||
"ai.prefs.connect_apps_note": "끄면 추가한 항목이 다시 제거됩니다.\n\nSolidCraft 시작 시 이 컴퓨터에서 찾은 AI 앱(OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code)에 MCP 서버로 추가합니다. solidcraft 항목만 추가하거나 경로를 갱신하며, 사용자가 변경한 항목은 그대로 두고 파일을 먼저 백업합니다. 끄면 추가했던 항목을 제거합니다. 도움말 > AI 어시스턴트 연결…에서 수동으로 실행할 수도 있습니다.",
|
||||
"ai.prefs.connect_at_start": "시작할 때 마지막 에이전트 연결",
|
||||
"ai.prefs.follow_camera": "카메라 따라가기",
|
||||
"ai.prefs.follow_camera_off": "카메라 자동 따라가기가 꺼져 있습니다.",
|
||||
"ai.prefs.follow_camera_on": "에이전트가 만드는 스케치를 향해 카메라가 회전합니다.",
|
||||
"ai.prefs.intro": "에이전트, 권한, 그리고 작업이 표시되는 방식.",
|
||||
"ai.prefs.make_default": "기본값으로 사용",
|
||||
"ai.prefs.intro": "SolidCraft의 AI 에이전트.",
|
||||
"ai.prefs.make_default": "기본값으로 설정",
|
||||
"ai.prefs.no_agents": "아직 에이전트가 없습니다. 에이전트를 추가하거나 새로 고쳐서 설치된 에이전트를 찾으세요.",
|
||||
"ai.prefs.no_saved_approvals": "저장된 승인이 없습니다.",
|
||||
"ai.prefs.open_at_start": "시작할 때 채팅 열기",
|
||||
"ai.prefs.override_all_warning": "이 에이전트는 묻지 않고 설계를 저장, 덮어쓰기, 내보내기 또는 교체할 수 있습니다.",
|
||||
"ai.prefs.permission_all": "모두 허용",
|
||||
"ai.prefs.permission_all_summary": "사용 가능한 SolidCraft 도구가 묻지 않고 실행됩니다.",
|
||||
"ai.prefs.permission_ask": "변경 전에 묻기",
|
||||
"ai.prefs.permission_ask_summary": "모든 설계 변경과 파일 작업을 승인합니다.",
|
||||
"ai.prefs.permission_edits": "모델 편집 허용",
|
||||
"ai.prefs.permission_edits_summary": "모델 편집은 실행되고, 파일 및 파괴적인 작업은 계속 묻습니다.",
|
||||
"ai.prefs.permission_all": "모두 실행",
|
||||
"ai.prefs.permission_all_summary": "묻지 않음",
|
||||
"ai.prefs.permission_ask": "먼저 묻기",
|
||||
"ai.prefs.permission_ask_summary": "모든 변경 전에 확인",
|
||||
"ai.prefs.permission_edits": "편집 허용",
|
||||
"ai.prefs.permission_edits_summary": "자유롭게 편집하고 나머지는 확인",
|
||||
"ai.prefs.permission_scope": "SolidCraft 도구에만 적용됩니다. 에이전트의 셸과 파일 편집은 자체 권한을 사용합니다.",
|
||||
"ai.prefs.pointer_preview_hidden": "포인터 숨김",
|
||||
"ai.prefs.pointer_preview_speed": "에이전트 포인터 · {speed}",
|
||||
"ai.prefs.pointer_speed": "동작 속도",
|
||||
"ai.prefs.pointer_speed": "속도",
|
||||
"ai.prefs.privacy_note": "선택한 에이전트 또는 엔드포인트만 대화를 받습니다. 엔드포인트 키는 시스템 키체인에 저장됩니다.",
|
||||
"ai.prefs.show_pointer": "에이전트 포인터 표시",
|
||||
"ai.prefs.turn_limit_hint": "메시지 하나에 어시스턴트가 수행할 수 있는 설계 변경 도구 호출의 최대 횟수입니다.",
|
||||
@@ -856,6 +864,11 @@
|
||||
"cmd.joint_screw": "나사",
|
||||
"cmd.joint_solve": "관절 해석",
|
||||
"cmd.joint_sweep": "관절 스윕",
|
||||
"cmd.layout.apply": "레이아웃 적용",
|
||||
"cmd.layout.get": "레이아웃 확인",
|
||||
"cmd.layout.reset": "레이아웃 초기화",
|
||||
"cmd.layout.save": "레이아웃 저장",
|
||||
"cmd.layout.workspace.select": "레이아웃 작업 공간 선택",
|
||||
"cmd.library_apply": "로컬 라이브러리 항목 적용",
|
||||
"cmd.library_export": "설계 라이브러리 내보내기",
|
||||
"cmd.library_inspect": "로컬 라이브러리 확인",
|
||||
@@ -885,6 +898,21 @@
|
||||
"cmd.occurrence_ground": "고정",
|
||||
"cmd.occurrence_move": "인스턴스 이동",
|
||||
"cmd.occurrence_properties": "인스턴스 물리 속성",
|
||||
"cmd.panel.activate": "패널 탭 활성화",
|
||||
"cmd.panel.close": "패널 닫기",
|
||||
"cmd.panel.collapse": "패널 구역 접기",
|
||||
"cmd.panel.dismiss": "패널 임시 보기 닫기",
|
||||
"cmd.panel.float": "패널 띄우기",
|
||||
"cmd.panel.focus": "패널에 초점 맞추기",
|
||||
"cmd.panel.list": "패널 목록",
|
||||
"cmd.panel.move": "패널 이동",
|
||||
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
|
||||
"cmd.panel.open": "패널 열기",
|
||||
"cmd.panel.peek": "접힌 패널 임시 보기",
|
||||
"cmd.panel.popout": "패널을 별도 창으로 이동",
|
||||
"cmd.panel.reset_pair": "패널 구분선 초기화",
|
||||
"cmd.panel.resize": "패널 구역 크기 조정",
|
||||
"cmd.panel.resize_pair": "패널 구분선 이동",
|
||||
"cmd.parameters_add": "사용자 매개 변수 추가",
|
||||
"cmd.parameters_change": "매개 변수 변경",
|
||||
"cmd.parameters_comment": "매개 변수에 설명 추가",
|
||||
@@ -1239,9 +1267,29 @@
|
||||
"help.construct.plane.axis_point": "축과 축 위에 있지 않은 점을 포함하는 구성 평면을 만듭니다. 원점 축, 구성 축, 직선 모서리 또는 스케치 직선을 선택한 다음 꼭짓점, 스케치 점 또는 구성점을 선택합니다. 축 위의 점으로는 평면을 하나로 결정할 수 없습니다.",
|
||||
"help.font_details": "인터페이스는 egui의 내장 글꼴을 사용합니다(Ubuntu Font Licence 및 MIT/Bitstream Vera 라이선스의 Ubuntu Light와 Hack, OFL 및 MIT의 Noto Emoji와 이모지 아이콘 글꼴). 스케치 텍스트는 Liberation Sans(SIL OFL 1.1)를 사용합니다. The Crafting Apps의 선택적 글꼴 팩 craft-fonts(BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1)는 SolidCraft 빌드에 포함되지 않습니다.",
|
||||
"help.fonts": "글꼴",
|
||||
"help.layout.apply": "저장된 레이아웃을 현재 작업 공간에 적용합니다. 유지 중인 편집기의 초안은 보존됩니다.",
|
||||
"help.layout.get": "확정된 레이아웃과 등록된 작업 공간을 확인합니다.",
|
||||
"help.layout.reset": "현재 작업 공간에 등록된 기본 레이아웃을 복원합니다. 사용자 사전 설정은 유지됩니다.",
|
||||
"help.layout.save": "전체 레이아웃에 이름을 지정해 저장합니다. 사용자 사전 설정을 바꾸려면 명시적인 덮어쓰기 허용이 필요합니다.",
|
||||
"help.layout.workspace.select": "등록된 레이아웃 작업 공간을 선택하고 이전 레이아웃을 기억합니다.",
|
||||
"help.licences": "라이선스",
|
||||
"help.mit": "MIT 라이선스",
|
||||
"help.notice": "NOTICE",
|
||||
"help.panel.activate": "키보드 초점을 요청하지 않고 열린 패널 탭을 선택합니다.",
|
||||
"help.panel.close": "콘텐츠 수명 주기 검사를 거쳐 패널을 닫습니다. 배치는 기억됩니다.",
|
||||
"help.panel.collapse": "요청한 치수를 유지하면서 패널 구역을 접거나 펼칩니다.",
|
||||
"help.panel.dismiss": "접힌 패널의 임시 보기를 닫습니다.",
|
||||
"help.panel.float": "패널을 위치가 명시된 부동 구역으로 이동합니다.",
|
||||
"help.panel.focus": "패널에 초점을 맞춥니다. 필요하면 유지된 상태를 다시 열고 구역을 펼칩니다.",
|
||||
"help.panel.list": "등록된 패널 종류, 확정된 인스턴스, 활성 탭, 사용 가능 여부와 호스트 기능을 확인합니다.",
|
||||
"help.panel.move": "패널을 새 구역으로 이동하거나 기존 탭 그룹에 추가합니다.",
|
||||
"help.panel.native_close": "콘텐츠를 취소하지 않고 별도 창의 패널 구역을 기억된 도킹 위치로 되돌립니다.",
|
||||
"help.panel.open": "패널 종류와 명시적인 인스턴스 식별자로 패널을 엽니다. 새 인스턴스에는 배치가 필요합니다.",
|
||||
"help.panel.peek": "저장된 크기나 접힘 상태를 바꾸지 않고 접힌 패널을 임시로 표시합니다.",
|
||||
"help.panel.popout": "지원되는 경우 패널을 별도 창으로 이동합니다. 브라우저에서는 부동 구역을 사용합니다.",
|
||||
"help.panel.reset_pair": "인접한 도킹 구역 쌍을 공통 제약 안에서 기본 크기 비율로 복원합니다.",
|
||||
"help.panel.resize": "모든 탭의 제약 안에서 패널 구역의 바깥 너비와 높이를 설정합니다.",
|
||||
"help.panel.resize_pair": "공유하는 전체 길이를 유지하면서 인접한 도킹 구역의 크기를 함께 조정합니다.",
|
||||
"help.pointcloud.insert": "PLY, XYZ 또는 PTS 스캔을 모델 옆에 포인트 클라우드로 삽입합니다. 측정 데이터를 참고하여 모델링할 때 사용합니다.",
|
||||
"help.sketch.construction_line": "두 점을 지나는 무한 보조 직선을 만듭니다. 스케치 프로파일을 나누지 않고 작도할 수 있습니다.",
|
||||
"help.sketch.construction_line.angle": "스케치 X축을 기준으로 지정한 각도의 무한 보조 직선을 만듭니다. 점을 선택한 뒤 각도를 입력하거나 방향을 선택합니다.",
|
||||
@@ -1360,8 +1408,13 @@
|
||||
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.",
|
||||
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
|
||||
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
|
||||
"joint_move.coarse": "축 근처 단계",
|
||||
"joint_move.conflict": "이 부품의 조인트가 다른 조인트와 충돌합니다: {joint}.",
|
||||
"joint_move.conflict_hint": "충돌하는 조인트를 편집, 비활성화 또는 삭제하여 다른 조인트가 부품을 배치할 수 있게 하세요.",
|
||||
"joint_move.fine": "축에서 먼 단계",
|
||||
"joint_move.legacy_hint": "끄면 이전 이동 패널을 사용합니다.",
|
||||
"joint_move.precision": "미세 단계를 사용하려면 점선 가이드 바깥으로 당기세요.",
|
||||
"joint_move.use_handles": "이동에 조인트 모션 핸들 사용",
|
||||
"keymap.command_id": "명령 ID",
|
||||
"keymap.empty": "일치하는 명령이 없습니다",
|
||||
"keymap.use_anyway": "그래도 여기서 {key} 사용",
|
||||
@@ -1431,6 +1484,12 @@
|
||||
"navbar.assistant": "어시스턴트",
|
||||
"navbar.custom.add_slot": "{slot} 추가",
|
||||
"navbar.custom.always": "항상 표시",
|
||||
"navbar.custom.anchor.bottom_centre": "하단 가운데",
|
||||
"navbar.custom.anchor.bottom_left": "왼쪽 아래",
|
||||
"navbar.custom.anchor.bottom_right": "오른쪽 아래",
|
||||
"navbar.custom.anchor.top_centre": "상단 가운데",
|
||||
"navbar.custom.anchor.top_left": "왼쪽 위",
|
||||
"navbar.custom.anchor.top_right": "오른쪽 위",
|
||||
"navbar.custom.available": "{available} · 막대로 끌어 놓거나 추가를 누르세요",
|
||||
"navbar.custom.buttons": "버튼",
|
||||
"navbar.custom.compact": "컴팩트",
|
||||
@@ -1464,6 +1523,7 @@
|
||||
"navbar.custom.reset_default": "기본값으로 재설정",
|
||||
"navbar.custom.right": "오른쪽으로 이동",
|
||||
"navbar.custom.select": "선택한 칸이 없습니다. 막대에서 칸을 선택하면 이동하거나 제거하거나 모양을 바꿀 수 있습니다.",
|
||||
"navbar.custom.separator_hint": "버튼 그룹 사이의 구분선입니다. 막대로 끌거나 추가를 누르세요. 원하는 만큼 사용할 수 있습니다.",
|
||||
"navbar.custom.separators_full": "막대에 구분선이 이미 네 개 있습니다.",
|
||||
"navbar.custom.show": "탐색 막대 표시",
|
||||
"navbar.custom.shows": "표시 내용",
|
||||
@@ -1586,19 +1646,28 @@
|
||||
"prefs.appearance.viewport_logo": "뷰포트 및 로고",
|
||||
"prefs.applies_on_restart": "다시 시작하면 적용됩니다",
|
||||
"prefs.assistant": "어시스턴트",
|
||||
"prefs.assistant_connecting": "연결",
|
||||
"prefs.assistant_follow_help": "스케치의 Look At 동작이 포인터와 함께 보기를 이동합니다.",
|
||||
"prefs.assistant_limit": "제한",
|
||||
"prefs.assistant_limited": "제한됨",
|
||||
"prefs.assistant_pointer_speed": "포인터 속도",
|
||||
"prefs.assistant_seconds": "초",
|
||||
"prefs.assistant_seconds_aria": "초",
|
||||
"prefs.assistant_use_care": "주의해서 사용",
|
||||
"prefs.auto_operation_note": "재료가 닿는 대상에 따라 결합, 새 바디 또는 자르기를 선택합니다.",
|
||||
"prefs.axes": "축",
|
||||
"prefs.axis": "축",
|
||||
"prefs.bodies_folders": "바디 폴더",
|
||||
"prefs.bodies_folders_always": "항상 표시",
|
||||
"prefs.bodies_folders_never": "표시 안 함",
|
||||
"prefs.bodies_folders_several": "바디가 여러 개일 때만",
|
||||
"prefs.bodies_folders_several": "여러 개",
|
||||
"prefs.bottom": "아래쪽",
|
||||
"prefs.bottom_centre": "하단 가운데",
|
||||
"prefs.bottom_right": "오른쪽 아래",
|
||||
"prefs.browser": "브라우저",
|
||||
"prefs.browser_components_open": "구성요소를 펼친 상태로 시작",
|
||||
"prefs.browser_components_open_note": "브라우저의 모든 구성요소가 이전처럼 펼쳐진 상태로 시작합니다. 끄면 직접 펼칠 때까지 접혀 있습니다.",
|
||||
"prefs.browser_eye_left": "표시 아이콘을 접기 화살표 옆에 표시",
|
||||
"prefs.browser_eye_left": "접기 화살표 옆의 눈 아이콘",
|
||||
"prefs.browser_eye_left_note": "브라우저 행의 표시 아이콘을 행 오른쪽 끝의 열이 아니라 이전처럼 접기 화살표 옆에 표시합니다.",
|
||||
"prefs.built_in": "기본 제공",
|
||||
"prefs.button_left": "왼쪽 드래그",
|
||||
@@ -1624,8 +1693,19 @@
|
||||
"prefs.dark_parts_shading_help": "선형 조명과 부드러운 하늘광으로 뷰를 음영 처리하여 어두운 면이 검게 뭉개지지 않고 그라데이션을 유지합니다",
|
||||
"prefs.dead_zone_note": "빗금 친 영역이 데드존입니다. 그 안의 움직임은 무시됩니다.",
|
||||
"prefs.default": "기본값",
|
||||
"prefs.design": "설계 기본값",
|
||||
"prefs.design_note": "새 설계가 시작할 때의 설정입니다. 열려 있는 설계는 자체 설정을 유지합니다.",
|
||||
"prefs.design": "디자인",
|
||||
"prefs.design_note": "새 디자인의 시작 설정입니다.",
|
||||
"prefs.design_tools.ask": "묻기",
|
||||
"prefs.design_tools.degrees": "도",
|
||||
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
|
||||
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
|
||||
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
|
||||
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
"prefs.design_tools.note": "알림",
|
||||
"prefs.design_tools.off": "끄기",
|
||||
"prefs.design_tools.placement_group": "레스트, 보스, 스냅 핏",
|
||||
"prefs.design_tools.rows": "행",
|
||||
"prefs.devices": "3D 마우스",
|
||||
"prefs.dialog_buttons": "대화 상자 버튼",
|
||||
"prefs.dialogs": "대화 상자",
|
||||
@@ -1660,6 +1740,23 @@
|
||||
"prefs.gesture_zoom": "확대/축소",
|
||||
"prefs.graphics": "그래픽",
|
||||
"prefs.graphics_note": "속도와 부드러움 중에서 선택합니다. 각 선택의 효과가 샘플에 표시됩니다.",
|
||||
"prefs.graphics_page.current_view": "현재 뷰",
|
||||
"prefs.graphics_page.fastest": "가장 빠르게",
|
||||
"prefs.graphics_page.fold_items": "항목 접기",
|
||||
"prefs.graphics_page.in_view": "뷰 내부",
|
||||
"prefs.graphics_page.live_details": "실시간 세부 정보",
|
||||
"prefs.graphics_page.own_mix": "사용자 조합",
|
||||
"prefs.graphics_page.quick_measure": "빠른 측정",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "바디를 통한 참조 형상",
|
||||
"prefs.graphics_page.restart": "다시 시작 필요",
|
||||
"prefs.graphics_page.sharpest": "가장 선명하게",
|
||||
"prefs.graphics_page.single_surface_analysis": "단일 표면 분석",
|
||||
"prefs.graphics_page.status_chips": "상태 칩",
|
||||
"prefs.graphics_page.subtitle": "속도와 부드러움의 균형을 조절합니다.",
|
||||
"prefs.graphics_page.supersampling": "슈퍼샘플링",
|
||||
"prefs.graphics_page.supersampling_adaptive": "뷰가 멈춰 있는 동안만",
|
||||
"prefs.graphics_page.surfaces_edges": "곡면과 모서리",
|
||||
"prefs.graphics_page.view_bar": "뷰 표시줄에서도 사용",
|
||||
"prefs.graphics_presets_note": "프리셋은 아래의 선택 항목을 한 번에 설정합니다.",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "축소 시 구속 조건 기호 숨기기",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "스케치를 충분히 축소하면 구속 조건 기호가 가려지지 않도록 숨깁니다. 끄기: 확대/축소와 관계없이 고정된 화면 크기를 유지합니다.",
|
||||
@@ -1668,6 +1765,7 @@
|
||||
"prefs.history_origin_bodies_note": "왼쪽 또는 브라우저 아래의 히스토리가 활성 구성 요소의 바디 폴더와 원점으로 시작하며, 브라우저처럼 표시 아이콘과 메뉴가 있습니다. 아래쪽 히스토리는 바뀌지 않습니다.",
|
||||
"prefs.history_play_button": "기록 컨트롤에 기록 재생 버튼 표시",
|
||||
"prefs.history_play_button_note": "기록 컨트롤의 한 단계 뒤로와 한 단계 앞으로 사이에 기록 재생을 추가합니다. 컨트롤을 마우스 오른쪽 버튼으로 클릭해 표시하거나 숨길 수도 있습니다.",
|
||||
"prefs.history_sketches": "히스토리의 스케치",
|
||||
"prefs.hold_stop_snapping": "누르고 있으면 스냅 중지",
|
||||
"prefs.home_fit_size": "홈·맞춤 크기",
|
||||
"prefs.home_fit_size_note": "홈, 맞춤, 연 디자인 또는 뷰 탐색기의 뷰 변경으로 모델을 화면에 맞출 때 모델이 뷰에서 차지하는 비율. 아래 설정과 관계없이 적용됩니다. 100 %: 패널, 나침반, 내비게이션 바 사이의 뷰를 가득 채웁니다.",
|
||||
@@ -1682,6 +1780,22 @@
|
||||
"prefs.language_system": "시스템",
|
||||
"prefs.layout": "레이아웃",
|
||||
"prefs.layout_note": "컴팩트는 패널을 창 가장자리에 고정합니다. 브라우저 반투명은 플로팅에서 사용할 수 있습니다. 어시스턴트 위치는 어시스턴트 페이지에서 바꿀 수 있습니다.",
|
||||
"prefs.layout_page.active_commands": "활성 명령 강조",
|
||||
"prefs.layout_page.always": "항상",
|
||||
"prefs.layout_page.card": "카드",
|
||||
"prefs.layout_page.column": "열",
|
||||
"prefs.layout_page.hover": "마우스를 올릴 때",
|
||||
"prefs.layout_page.icons_labels": "아이콘 + 레이블",
|
||||
"prefs.layout_page.ok_cancel": "확인과 취소",
|
||||
"prefs.layout_page.on_hover": "마우스를 올릴 때",
|
||||
"prefs.layout_page.open": "열기",
|
||||
"prefs.layout_page.origin_bodies": "원점과 바디를 위에 표시",
|
||||
"prefs.layout_page.panels": "패널",
|
||||
"prefs.layout_page.pinned_menus": "고정 명령을 메뉴에서 제외",
|
||||
"prefs.layout_page.play_history": "히스토리 재생 버튼",
|
||||
"prefs.layout_page.slot_count": "슬롯 {count} / {max}개",
|
||||
"prefs.layout_page.view_corner": "뷰 모서리에도 표시",
|
||||
"prefs.layout_page.window": "창",
|
||||
"prefs.left": "왼쪽",
|
||||
"prefs.left_click": "왼쪽 클릭",
|
||||
"prefs.line_drag_draws_one": "누른 채 드래그하면 선 하나 그리기",
|
||||
@@ -1715,9 +1829,14 @@
|
||||
"prefs.origin_plane_names": "원점 평면 이름",
|
||||
"prefs.origin_plane_names_note": "브라우저, 대화 상자, 목록에 표시되는 이름입니다. {top}은 XY, {front}는 XZ, {right}는 YZ이며 Z가 위쪽입니다. 명령, 스크립트, 저장된 디자인은 XY, XZ, YZ를 유지합니다.",
|
||||
"prefs.overall_speed": "전체 속도",
|
||||
"prefs.parameter_list_rows_tip": "빈 수식 상자에서 아래쪽 화살표로 표시할 매개변수의 최대 개수입니다.",
|
||||
"prefs.persistent_ribbon_pressed": "활성 리본 명령 강조 표시",
|
||||
"prefs.persistent_ribbon_pressed_help": "효과가 켜져 있는 동안 리본 명령을 강조 표시합니다. 끄면 이전의 중립적인 모양과 명령 도움말을 유지합니다.",
|
||||
"prefs.perspective_fov": "시야각",
|
||||
"prefs.plugins_enabled": "사용",
|
||||
"prefs.plugins_installed": "설치됨",
|
||||
"prefs.plugins_intro": "샌드박스 추가 기능.",
|
||||
"prefs.plugins_show_folder": "플러그인 폴더 표시",
|
||||
"prefs.preview_choices": "선택 항목 미리보기",
|
||||
"prefs.preview_tint_new_faces": "미리 보기에서 새 면에 색 입히기",
|
||||
"prefs.preview_tint_new_faces_help": "바디를 변경하는 피처의 미리 보기에서 추가되는 면을 미리 보기 색으로 그릴 수 있습니다. 끄면 바디가 원래 색으로 그려집니다.",
|
||||
@@ -1752,6 +1871,10 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
|
||||
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
|
||||
"prefs.shortcuts": "단축키",
|
||||
"prefs.shortcuts_import": "가져오기",
|
||||
"prefs.shortcuts_intro": "각 명령의 키.",
|
||||
"prefs.shortcuts_not_assigned": "할당되지 않음",
|
||||
"prefs.shortcuts_press": "키를 누르세요...",
|
||||
"prefs.single_surface_analysis": "표면 분석 하나만 사용",
|
||||
"prefs.single_surface_analysis_help": "끄면 표면 음영 분석과 연속성 분석을 함께 유지할 수 있습니다. 켜면 마지막으로 시작하거나 변경한 분석만 유지됩니다.",
|
||||
"prefs.sketch_align_points": "그리는 동안 다른 스케치 점에 맞추기",
|
||||
@@ -1779,6 +1902,8 @@
|
||||
"prefs.source": "소스",
|
||||
"prefs.speed": "속도",
|
||||
"prefs.stand_up_note": "STEP 또는 IGES 파일이 Z가 위쪽인 상태로 열린 후 X축 기준 90도 회전을 제안합니다.",
|
||||
"prefs.stand_up_unknown": "알 수 없는 프로그램의 파일",
|
||||
"prefs.stand_up_unknown_note": "파일에 알려진 프로그램 이름이 없을 때 제안하는 방법입니다.",
|
||||
"prefs.status_bar": "상태 표시줄",
|
||||
"prefs.status_bar_note": "창 아래쪽 가장자리의 얇은 표시줄에 편집 중인 항목, 열려 있는 스케치의 상태(해당 색상으로), 설계 단위가 표시됩니다. 단위를 클릭하면 변경할 수 있습니다. 스케치 상태는 더 이상 뷰 모서리에 표시되지 않습니다.",
|
||||
"prefs.status_done": "완료",
|
||||
@@ -1847,8 +1972,8 @@
|
||||
"prefs.validation.label": "커널 전체 검사",
|
||||
"prefs.validation.off": "끄기 (실험적)",
|
||||
"prefs.validation_note": "모든 단계 후: 스윕, 불리언, 블렌드마다 검사합니다.\n피처마다 한 번: 각 피처의 결과를 한 번 검사합니다.\n끄기 (실험적): 검사하지 않습니다.",
|
||||
"prefs.value_boxes_in_view": "뷰에서 핸들 옆에 값 표시",
|
||||
"prefs.value_boxes_in_view_tip": "명령의 값(거리, 반경 등)을 모델 위 핸들 옆 상자에 표시합니다. 끄면 값은 명령 창에 입력하며, 선택 후 키보드 입력이 그 창으로 가고 모델을 가리는 것이 없습니다.",
|
||||
"prefs.value_boxes_in_view": "핸들 옆에 값 표시",
|
||||
"prefs.value_boxes_in_view_tip": "돌출의 거리, 필렛의 반지름 등을 뷰 안의 상자로 표시합니다.",
|
||||
"prefs.view": "뷰 동작",
|
||||
"prefs.view_confirm_corner": "뷰 모서리에 확인 및 취소",
|
||||
"prefs.view_confirm_corner_note": "대화 상자나 스케치가 열려 있는 동안 뷰 오른쪽 위의 체크와 X 표시가 대화 상자의 확인과 취소와 같은 동작을 하거나 스케치를 마치고 취소합니다. 뷰 탐색기는 그 아래로 내려갑니다.",
|
||||
@@ -2791,10 +2916,10 @@
|
||||
"select the sheet edge first": "판금 모서리를 먼저 선택하세요",
|
||||
"select two components": "구성요소 두 개를 선택하세요",
|
||||
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
|
||||
"settings.double_click_components": "구성 요소를 두 번 클릭하여 활성화하거나 연결된 구성 요소를 제자리에서 편집",
|
||||
"settings.double_click_components": "구성요소를 두 번 클릭하여 활성화",
|
||||
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다.",
|
||||
"settings.insert_lists_open_designs": "설계 삽입에서 다른 탭에 열린 설계를 먼저 표시",
|
||||
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
|
||||
"settings.insert_lists_open_designs": "디자인 삽입에서 열린 디자인을 먼저 표시",
|
||||
"settings.measure_axis_distances": "측정에서 축 거리 표시",
|
||||
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
|
||||
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
|
||||
"settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).",
|
||||
@@ -2804,6 +2929,7 @@
|
||||
"settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 SolidCraft를 시작할 수 없게 하므로 변경되었습니다:",
|
||||
"settings.reset_title": "설정이 변경되었습니다",
|
||||
"settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.",
|
||||
"settings.update_changed_links": "탭으로 돌아올 때 변경된 링크 업데이트",
|
||||
"sheet.flange_type_base": "베이스",
|
||||
"sheet.flange_type_contour": "윤곽",
|
||||
"sheet.flange_type_edge": "모서리",
|
||||
@@ -3159,7 +3285,7 @@
|
||||
"ui.agent_chat_send_now_tip": "에이전트의 현재 단계를 중지하고 이것을 다음으로 보냅니다 (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "SolidCraft는 이 컴퓨터에 설치된 코딩 에이전트와 통신합니다. 환경 설정 › AI에서 에이전트를 선택하세요.",
|
||||
"ui.agent_chat_setup_title": "어시스턴트 설정",
|
||||
"ui.agent_chat_side": "에이전트 채팅 위치",
|
||||
"ui.agent_chat_side": "채팅 도킹",
|
||||
"ui.agent_chat_signed_in": "로그인됨",
|
||||
"ui.agent_chat_state_auth": "로그인 필요",
|
||||
"ui.agent_chat_state_busy": "작업 중…",
|
||||
@@ -3176,7 +3302,7 @@
|
||||
"ui.agent_chat_undone": "(취소됨)",
|
||||
"ui.agent_chat_view_attached": "뷰 첨부됨",
|
||||
"ui.agent_chat_waiting": "확인 대기 중",
|
||||
"ui.agent_cursor": "에이전트 커서",
|
||||
"ui.agent_cursor": "에이전트 포인터",
|
||||
"ui.agent_cursor_speed": "에이전트 커서 속도",
|
||||
"ui.agent_off_thread": "에이전트가 명령을 실행하는 동안 창 반응성 유지",
|
||||
"ui.agent_off_thread_note": "에이전트의 느린 명령(AI 패널 또는 MCP 클라이언트에서 실행)은 창과 별도로 계산됩니다. 뷰를 계속 회전하며 살펴볼 수 있고, 뷰의 칩에서 하나씩 중지할 수 있습니다. 끄면 각 명령이 창의 스레드에서 실행되며, 창은 명령이 끝날 때까지 기다립니다.",
|
||||
@@ -3219,14 +3345,14 @@
|
||||
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
|
||||
"ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
|
||||
"ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}",
|
||||
"ui.ai_mode_all": "모두 허용",
|
||||
"ui.ai_mode_all": "모두 실행",
|
||||
"ui.ai_mode_all_note": "묻지 않고 실행합니다. 충돌 보고서는 계속 제외되고 폴더 제한도 그대로 적용됩니다.",
|
||||
"ui.ai_mode_all_warning": "에이전트가 묻지 않고 디자인을 저장, 덮어쓰기, 내보내기, 교체할 수 있습니다. 신뢰하는 에이전트와 저장된 디자인에서만 사용하세요.",
|
||||
"ui.ai_mode_ask": "매번 묻기",
|
||||
"ui.ai_mode_ask": "먼저 묻기",
|
||||
"ui.ai_mode_ask_note": "디자인의 모든 변경과 파일 작업이 사용자의 허용을 기다립니다.",
|
||||
"ui.ai_mode_edits": "모델 편집 자동 허용",
|
||||
"ui.ai_mode_edits": "편집 허용",
|
||||
"ui.ai_mode_edits_note": "모델링 명령은 묻지 않고 실행되며, 저장·열기·내보내기 등 파일 작업이나 파괴적인 작업은 계속 묻습니다.",
|
||||
"ui.ai_mode_global": "위 설정 따름",
|
||||
"ui.ai_mode_global": "위와 동일",
|
||||
"ui.ai_model": "모델",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "이 설정은 SolidCraft 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
|
||||
@@ -3245,7 +3371,7 @@
|
||||
"ui.ai_save": "저장",
|
||||
"ui.ai_settings": "AI 설정…",
|
||||
"ui.ai_show_features": "AI 기능 표시",
|
||||
"ui.ai_show_features_note": "에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.",
|
||||
"ui.ai_show_features_note": "끄면 모든 곳에서 어시스턴트를 숨기고 아무것도 시작하지 않습니다. 아래 항목이 모두 흐려집니다.\n\n에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.",
|
||||
"ui.ai_switch_tip": "이 대화의 에이전트 변경",
|
||||
"ui.ai_test": "테스트",
|
||||
"ui.ai_test_acp_ok": "응답: {agent}",
|
||||
@@ -3259,7 +3385,7 @@
|
||||
"ui.ai_tool_running": "실행 중",
|
||||
"ui.ai_tool_unfold": "클릭하면 결과를 표시",
|
||||
"ui.ai_tool_waiting": "대기 중",
|
||||
"ui.ai_toolbar_button": "도구 모음에 버튼 표시",
|
||||
"ui.ai_toolbar_button": "애플리케이션 바의 어시스턴트 버튼",
|
||||
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
|
||||
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -3515,6 +3641,7 @@
|
||||
"ui.extend": "연장",
|
||||
"ui.extent_type": "범위 유형",
|
||||
"ui.extrude": "돌출",
|
||||
"ui.extrude_auto_to_object_tip": "면, 꼭짓점 또는 평행 평면을 선택하면 범위가 객체까지로 전환됩니다.",
|
||||
"ui.factor": "배율",
|
||||
"ui.fade": "페이드",
|
||||
"ui.fade_length": "페이드 길이",
|
||||
@@ -3655,6 +3782,7 @@
|
||||
"ui.hideOrigin": "원점 숨기기",
|
||||
"ui.hidePlanes": "평면 숨기기",
|
||||
"ui.hideProfile": "윤곽 숨기기",
|
||||
"ui.hide_origin_by_default": "새 디자인에서 원점 폴더 숨기기",
|
||||
"ui.history_back": "한 단계 뒤로",
|
||||
"ui.history_below_browser": "브라우저 아래",
|
||||
"ui.history_edge_hint": "가장자리를 더블클릭",
|
||||
@@ -3792,6 +3920,7 @@
|
||||
"ui.pipe": "파이프",
|
||||
"ui.pitch": "피치",
|
||||
"ui.placement": "배치",
|
||||
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와 회전하는 링입니다.",
|
||||
"ui.plane": "평면",
|
||||
"ui.plane_at_angle": "각도 평면",
|
||||
"ui.plugins": "플러그인",
|
||||
@@ -3956,6 +4085,8 @@
|
||||
"ui.start": "시작",
|
||||
"ui.start_over": "처음부터 다시",
|
||||
"ui.start_page": "시작 페이지",
|
||||
"ui.step_values": "화살표와 휠로 값 변경",
|
||||
"ui.step_values_tip": "값 필드에서 위/아래 화살표와 휠로 숫자를 변경합니다.",
|
||||
"ui.stitch_tolerance_hint": "엄격",
|
||||
"ui.stretch": "늘이기",
|
||||
"ui.supersampling": "슈퍼샘플링 (SSAA)",
|
||||
|
||||
+160
-53
@@ -379,7 +379,7 @@
|
||||
"Move Type": "Kiểu di chuyển",
|
||||
"Move down": "Chuyển xuống",
|
||||
"Move earlier": "Di chuyển lên trước",
|
||||
"Move holes by clicking or dragging": "Di chuyển lỗ bằng cách bấm hoặc kéo rê",
|
||||
"Move holes by clicking or dragging": "Di chuyển bằng nhấp hoặc kéo",
|
||||
"Move later": "Di chuyển xuống sau",
|
||||
"Move up": "Chuyển lên",
|
||||
"Name": "Tên",
|
||||
@@ -585,7 +585,7 @@
|
||||
"Show ghost": "Hiển thị bóng mờ",
|
||||
"Show in folder": "Hiển thị trong thư mục",
|
||||
"Show the pointer an AI agent moves (#35)": "Hiển thị con trỏ do AI Agent điều khiển (#35)",
|
||||
"Show tool at cursor": "Hiển thị công cụ cạnh con trỏ",
|
||||
"Show tool at cursor": "Công cụ tại con trỏ",
|
||||
"Signed axes": "Các trục có dấu (+/-)",
|
||||
"Size": "Kích thước",
|
||||
"Sketch constraints": "Ràng buộc phác thảo",
|
||||
@@ -596,7 +596,7 @@
|
||||
"Sketch points": "Điểm phác thảo",
|
||||
"Slider": "Trượt",
|
||||
"Slot width": "Bề rộng rãnh then",
|
||||
"Snap to face and edge centres": "Bắt vào tâm mặt và tâm cạnh",
|
||||
"Snap to face and edge centres": "Bắt dính vào tâm",
|
||||
"SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft không thể gửi báo cáo trực tiếp. Hãy gửi trên GitHub; ứng dụng không cần mã truy cập.",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft đã đóng trước khi các thiết kế này được lưu. Bạn có thể mở bản tự động lưu gần nhất của chúng.",
|
||||
"Sort A to Z": "Sắp xếp từ A đến Z",
|
||||
@@ -739,36 +739,36 @@
|
||||
"a picked edge's body is gone": "thân của một cạnh đã chọn không còn nữa",
|
||||
"add a key for a joint first": "hãy thêm một khóa cho khớp trước",
|
||||
"ai.connected_toast": "SolidCraft hiện đã sẵn sàng trong {clients}. Khởi động lại ứng dụng để sử dụng (Cài đặt > Trợ lý ảo).",
|
||||
"ai.prefs.allow_control": "Cho phép AI agent kết nối (kênh điều khiển / MCP)",
|
||||
"ai.prefs.allow_control_note": "Mở kênh điều khiển chỉ trên máy tính này (127.0.0.1, cổng 27878 hoặc cổng trống tiếp theo). Chương trình kết nối phải xuất trình mã token bảo mật chỉ tài khoản người dùng của bạn đọc được, được tạo mới mỗi lần khởi động. Có hiệu lực vào lần khởi động tiếp theo của SolidCraft. Khi tắt: không lắng nghe trừ khi bạn bật.",
|
||||
"ai.prefs.availability": "Điều khiển trợ lý",
|
||||
"ai.prefs.background_commands": "Chạy các lệnh của agent dưới nền",
|
||||
"ai.prefs.connect_apps": "Cho phép các ứng dụng AI trên máy tính này kết nối với SolidCraft",
|
||||
"ai.prefs.connect_apps_note": "Khi SolidCraft khởi động, tự động thêm phần mềm dưới dạng MCP server vào các ứng dụng AI tìm thấy trên máy tính này (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code) để AI agent có thể sử dụng ngay mà không cần cấu hình thủ công. Chỉ mục cấu hình solidcraft sẽ được thêm mới hoặc làm mới đường dẫn; các mục bạn tự tùy chỉnh sẽ được giữ nguyên và file được sao lưu .bak trước khi sửa. Tắt: các mục đã thêm sẽ được gỡ bỏ. Trợ giúp > Kết nối trợ lý ảo AI… để thực hiện thủ công.",
|
||||
"ai.prefs.connect_at_start": "Kết nối tác tử dùng gần nhất khi SolidCraft khởi động",
|
||||
"ai.prefs.allow_control": "Cho phép tác nhân AI kết nối",
|
||||
"ai.prefs.allow_control_note": "Kênh cục bộ (MCP). Không có gì rời khỏi máy tính này.\n\nMở kênh điều khiển chỉ trên máy tính này (127.0.0.1, cổng 27878 hoặc cổng trống tiếp theo). Chương trình kết nối phải xuất trình mã token bảo mật chỉ tài khoản người dùng của bạn đọc được, được tạo mới mỗi lần khởi động. Có hiệu lực vào lần khởi động tiếp theo của SolidCraft. Khi tắt: không lắng nghe trừ khi bạn bật.",
|
||||
"ai.prefs.availability": "Khả dụng",
|
||||
"ai.prefs.background_commands": "Chạy lệnh chậm trong nền",
|
||||
"ai.prefs.connect_apps": "Thêm SolidCraft vào ứng dụng AI",
|
||||
"ai.prefs.connect_apps_note": "Tắt sẽ xóa các mục đã thêm.\n\nKhi SolidCraft khởi động, tự động thêm phần mềm dưới dạng MCP server vào các ứng dụng AI tìm thấy trên máy tính này (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code) để AI agent có thể sử dụng ngay mà không cần cấu hình thủ công. Chỉ mục cấu hình solidcraft sẽ được thêm mới hoặc làm mới đường dẫn; các mục bạn tự tùy chỉnh sẽ được giữ nguyên và file được sao lưu .bak trước khi sửa. Tắt: các mục đã thêm sẽ được gỡ bỏ. Trợ giúp > Kết nối trợ lý ảo AI… để thực hiện thủ công.",
|
||||
"ai.prefs.connect_at_start": "Kết nối tác nhân gần nhất khi khởi động",
|
||||
"ai.prefs.connect_at_start_note": "Mỗi lần SolidCraft khởi động, chạy ngầm tác tử bạn kết nối gần nhất (hoặc tác tử mặc định) để sẵn sàng trò chuyện. Không áp dụng khi SolidCraft được khởi động với --control. Nếu không kết nối được, Trợ lý hiển thị “Kết nối thất bại” và nút “Thử lại”.",
|
||||
"ai.prefs.follow_camera": "Xoay camera theo phác thảo",
|
||||
"ai.prefs.follow_camera": "Theo camera",
|
||||
"ai.prefs.follow_camera_off": "Tự động xoay camera theo phác thảo đang tắt.",
|
||||
"ai.prefs.follow_camera_on": "Camera sẽ tự động xoay vuông góc với phác thảo do agent tạo.",
|
||||
"ai.prefs.intro": "Thiết lập agent, quyền hạn và cách hiển thị kết quả.",
|
||||
"ai.prefs.intro": "Tác nhân AI trong SolidCraft.",
|
||||
"ai.prefs.make_default": "Đặt làm mặc định",
|
||||
"ai.prefs.no_agents": "Chưa có agent nào. Thêm một agent hoặc làm mới để tìm các agent đã cài đặt.",
|
||||
"ai.prefs.no_saved_approvals": "Chưa có phê duyệt nào được lưu.",
|
||||
"ai.prefs.open_at_start": "Mở bảng Trợ lý khi SolidCraft khởi động",
|
||||
"ai.prefs.open_at_start": "Mở trò chuyện khi khởi động",
|
||||
"ai.prefs.open_at_start_note": "Hiển thị bảng Trợ lý (hoặc cửa sổ riêng của nó) mỗi lần SolidCraft khởi động. Không áp dụng khi SolidCraft được khởi động với --control.",
|
||||
"ai.prefs.override_all_warning": "Agent này có thể lưu, ghi đè, xuất hoặc thay thế thiết kế mà không cần hỏi trước.",
|
||||
"ai.prefs.permission_all": "Cho phép tất cả",
|
||||
"ai.prefs.permission_all_summary": "Các công cụ SolidCraft khả dụng sẽ chạy mà không cần hỏi trước.",
|
||||
"ai.prefs.permission_ask": "Hỏi trước khi thay đổi",
|
||||
"ai.prefs.permission_ask_summary": "Phê duyệt từng thay đổi thiết kế và thao tác với tệp.",
|
||||
"ai.prefs.permission_edits": "Cho phép chỉnh sửa mô hình",
|
||||
"ai.prefs.permission_edits_summary": "Các thao tác chỉnh sửa mô hình tự động chạy; thao tác với tệp và tác vụ phá hủy vẫn cần hỏi.",
|
||||
"ai.prefs.permission_all": "Làm mọi việc",
|
||||
"ai.prefs.permission_all_summary": "Không bao giờ hỏi",
|
||||
"ai.prefs.permission_ask": "Hỏi trước",
|
||||
"ai.prefs.permission_ask_summary": "Hỏi trước mọi thay đổi",
|
||||
"ai.prefs.permission_edits": "Cho phép chỉnh sửa",
|
||||
"ai.prefs.permission_edits_summary": "Tự do chỉnh sửa, hỏi về việc còn lại",
|
||||
"ai.prefs.permission_scope": "Chỉ áp dụng cho các công cụ SolidCraft. Thao tác dòng lệnh và tệp của agent sử dụng quyền riêng của nó.",
|
||||
"ai.prefs.pointer_preview_hidden": "Con trỏ đang ẩn",
|
||||
"ai.prefs.pointer_preview_speed": "Con trỏ agent • {speed}",
|
||||
"ai.prefs.pointer_speed": "Tốc độ di chuyển",
|
||||
"ai.prefs.pointer_speed": "Tốc độ",
|
||||
"ai.prefs.privacy_note": "Chỉ agent hoặc endpoint bạn đã chọn mới nhận được nội dung trò chuyện. Khóa API endpoint được lưu trong chuỗi khóa (keychain) hệ thống.",
|
||||
"ai.prefs.show_pointer": "Hiển thị con trỏ agent",
|
||||
"ai.prefs.show_pointer": "Hiện con trỏ của tác nhân",
|
||||
"ai.prefs.turn_limit_hint": "Số lượt gọi công cụ thay đổi thiết kế tối đa mà Trợ lý có thể thực hiện cho một tin nhắn.",
|
||||
"angle, height": "góc, chiều cao",
|
||||
"appearance.dielectric_ior": "Chỉ số khúc xạ điện môi (IOR)",
|
||||
@@ -997,6 +997,11 @@
|
||||
"cmd.joint_screw": "Khớp trục vít",
|
||||
"cmd.joint_solve": "Giải hệ thống khớp nối",
|
||||
"cmd.joint_sweep": "Quét khớp nối",
|
||||
"cmd.layout.apply": "Áp dụng bố cục",
|
||||
"cmd.layout.get": "Kiểm tra bố cục",
|
||||
"cmd.layout.reset": "Đặt lại bố cục",
|
||||
"cmd.layout.save": "Lưu bố cục",
|
||||
"cmd.layout.workspace.select": "Chọn không gian làm việc của bố cục",
|
||||
"cmd.library_apply": "Áp dụng mục thư viện cục bộ",
|
||||
"cmd.library_export": "Xuất thư viện thiết kế",
|
||||
"cmd.library_inspect": "Kiểm tra thư viện cục bộ",
|
||||
@@ -1030,6 +1035,21 @@
|
||||
"cmd.occurrence_move": "Di chuyển đối tượng",
|
||||
"cmd.occurrence_properties": "Thuộc tính cơ lý của bản thể (Occurrence)",
|
||||
"cmd.occurrence_reparent": "Chuyển sang Cụm chi tiết",
|
||||
"cmd.panel.activate": "Kích hoạt thẻ bảng",
|
||||
"cmd.panel.close": "Đóng bảng",
|
||||
"cmd.panel.collapse": "Thu gọn vùng bảng",
|
||||
"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.list": "Liệt kê các bảng",
|
||||
"cmd.panel.move": "Di chuyển bảng",
|
||||
"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",
|
||||
"cmd.panel.popout": "Chuyển bảng sang cửa sổ riêng",
|
||||
"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.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ố",
|
||||
@@ -1536,6 +1556,11 @@
|
||||
"help.joint.reach": "Di chuyển một điểm trên chi tiết về phía đích bằng cách thay đổi các khớp trong chuỗi của nó, trong giới hạn cho phép. Dùng để đặt tư thế cơ cấu theo vị trí mong muốn của đầu mút, như đầu cánh tay rô bốt.",
|
||||
"help.joint.rigid_group": "Khóa các chi tiết đã chọn với nhau ở vị trí hiện tại để chúng chuyển động như một khối. Dùng cho các chi tiết thuộc cùng một nhóm và không chuyển động tương đối với nhau.",
|
||||
"help.joint.screw": "Liên kết chuyển động quay và trượt theo bước vít để mỗi vòng quay tiến được khoảng cách đó. Dùng cho vít me, bu lông và trục có ren.",
|
||||
"help.layout.apply": "Áp dụng bố cục đã lưu cho không gian làm việc hiện tại. Các trình chỉnh sửa được giữ lại vẫn giữ bản nháp.",
|
||||
"help.layout.get": "Đọc bố cục đã xác nhận và xem các không gian làm việc đã đăng ký.",
|
||||
"help.layout.reset": "Khôi phục bố cục mặc định đã đăng ký của không gian làm việc hiện tại. Các thiết lập sẵn của người dùng vẫn được giữ lại.",
|
||||
"help.layout.save": "Lưu toàn bộ bố cục với một tên. Việc thay thế thiết lập sẵn của người dùng cần được cho phép ghi đè rõ ràng.",
|
||||
"help.layout.workspace.select": "Chọn không gian làm việc của bố cục đã đăng ký và ghi nhớ bố cục trước đó.",
|
||||
"help.library.apply": "Áp dụng vật liệu hoặc ngoại quan từ tệp thư viện cục bộ lên thân khối, mặt hoặc cụm chi tiết. Dùng để tái sử dụng vật liệu đã lưu từ thiết kế khác.",
|
||||
"help.library.export": "Lưu vật liệu và ngoại quan của thiết kế vào tệp thư viện. Dùng để chia sẻ hoặc tái sử dụng trong các thiết kế khác.",
|
||||
"help.library.inspect": "Kiểm tra một tệp thư viện và liệt kê các vật liệu, ngoại quan trong đó, không thay đổi thiết kế. Dùng để xem thư viện có gì trước khi áp dụng.",
|
||||
@@ -1549,6 +1574,21 @@
|
||||
"help.notice": "NOTICE",
|
||||
"help.occurrence.ground": "Cố định chi tiết tại chỗ hoặc giải phóng nó trở lại để các khớp nối di chuyển những chi tiết khác quanh nó. Dùng cho khung hoặc đế làm nền cho phần còn lại của cụm lắp ráp.",
|
||||
"help.occurrence.move": "Chỉ di chuyển chi tiết lắp ráp theo các bậc tự do mà khớp nối cho phép. Chọn một mặt của chi tiết, rồi kéo mũi tên hoặc cung tròn có thể sử dụng.",
|
||||
"help.panel.activate": "Chọn một thẻ bảng đang mở mà không yêu cầu tiêu điểm bàn phím.",
|
||||
"help.panel.close": "Đóng bảng sau khi kiểm tra vòng đời nội dung. Vị trí của bảng được ghi nhớ.",
|
||||
"help.panel.collapse": "Thu gọn hoặc mở rộng vùng bảng trong khi giữ nguyên các kích thước đã yêu cầu.",
|
||||
"help.panel.dismiss": "Đóng chế độ xem tạm thời của bảng đã thu gọn.",
|
||||
"help.panel.float": "Di chuyển bảng vào một vùng thả nổi có vị trí được chỉ định rõ ràng.",
|
||||
"help.panel.focus": "Đặt tiêu điểm vào bảng, mở lại trạng thái được giữ và mở rộng vùng của bảng khi cần.",
|
||||
"help.panel.list": "Đọc các loại bảng đã đăng ký, các phiên bản đã xác nhận, thẻ đang hoạt động, tính khả dụng và khả năng của môi trường chứa.",
|
||||
"help.panel.move": "Di chuyển bảng vào vùng mới hoặc thêm vào nhóm thẻ hiện có.",
|
||||
"help.panel.native_close": "Đưa vùng bảng từ cửa sổ riêng về vị trí gắn đã ghi nhớ mà không hủy nội dung.",
|
||||
"help.panel.open": "Mở một loại bảng với mã phiên bản rõ ràng. Phiên bản mới cần được chỉ định vị trí.",
|
||||
"help.panel.peek": "Tạm hiển thị bảng đã thu gọn mà không thay đổi kích thước đã lưu hoặc trạng thái thu gọn.",
|
||||
"help.panel.popout": "Di chuyển bảng sang cửa sổ riêng khi được hỗ trợ. Trình duyệt sử dụng vùng thả nổi.",
|
||||
"help.panel.reset_pair": "Khôi phục tỷ lệ kích thước mặc định của hai vùng gắn liền kề trong giới hạn chung.",
|
||||
"help.panel.resize": "Đặt chiều rộng và chiều cao bên ngoài của vùng bảng trong giới hạn của mọi thẻ.",
|
||||
"help.panel.resize_pair": "Đổi kích thước các vùng gắn liền kề cùng nhau trong khi giữ nguyên tổng chiều dài chung.",
|
||||
"help.parameters.change": "Mở bảng các giá trị có tên và biểu thức điều khiển kích thước của thiết kế. Dùng để sửa một kích thước ở một chỗ và mọi tính năng dùng nó sẽ cập nhật theo.",
|
||||
"help.parameters.export": "Lưu các tham số của thiết kế dạng CSV hoặc JSON. Dùng để chỉnh sửa trong bảng tính hoặc chuyển sang thiết kế khác.",
|
||||
"help.parameters.graph": "Hiển thị tham số và tính năng nào dùng tham số, tính năng nào khác, cùng các vòng lặp hoặc lỗi giữa chúng. Dùng để biết thay đổi một tham số sẽ ảnh hưởng đến những gì.",
|
||||
@@ -1885,21 +1925,21 @@
|
||||
"joint.rigid_group_select_components": "Chọn từ hai cụm chi tiết trở lên.",
|
||||
"joint_move.blocked_hint": "Kiểm tra loại khớp nối (chẳng hạn khớp nối cố định), trạng thái cố định của chi tiết, giới hạn khớp nối và các giá trị do tham số điều khiển.",
|
||||
"joint_move.blocked_title": "Không thể di chuyển chi tiết này",
|
||||
"joint_move.coarse": "Bước xoay khi ở gần trục (°)",
|
||||
"joint_move.coarse": "Bước gần trục",
|
||||
"joint_move.conflict": "Một khớp nối của chi tiết này xung đột với các khớp nối khác: {joint}.",
|
||||
"joint_move.conflict_hint": "Chỉnh sửa, tạm nén hoặc xóa khớp nối xung đột để các khớp nối khác có thể định vị chi tiết.",
|
||||
"joint_move.distance": "Quãng đường từ vị trí bắt đầu",
|
||||
"joint_move.done": "Xong",
|
||||
"joint_move.fine": "Bước xoay khi ở xa trục (°)",
|
||||
"joint_move.fine": "Bước xa trục",
|
||||
"joint_move.fixed": "Chi tiết này không có trục khớp nối nào có thể di chuyển.",
|
||||
"joint_move.help": "Di chuyển — kéo mũi tên hoặc cung tròn. Nhấn Esc để hủy thao tác kéo hiện tại.",
|
||||
"joint_move.legacy_hint": "Tắt để sử dụng bảng Di chuyển trước đây.",
|
||||
"joint_move.precision": "Kéo ra ngoài đường dẫn nét đứt để tinh chỉnh góc xoay.",
|
||||
"joint_move.legacy_hint": "Tắt: bảng Di chuyển trước đây.",
|
||||
"joint_move.precision": "Kéo ra ngoài đường dẫn nét đứt để dùng bước nhỏ.",
|
||||
"joint_move.rotation": "Góc xoay từ vị trí bắt đầu",
|
||||
"joint_move.select": "Chọn một mặt hoặc một chi tiết trong cụm lắp ráp, rồi chọn Di chuyển.",
|
||||
"joint_move.settings": "Di chuyển trong cụm lắp ráp",
|
||||
"joint_move.step": "Bước",
|
||||
"joint_move.use_handles": "Dùng tay nắm chuyển động khớp nối khi di chuyển trong cụm lắp ráp",
|
||||
"joint_move.use_handles": "Dùng tay nắm chuyển động khớp cho Di chuyển",
|
||||
"keymap.command_id": "Mã lệnh",
|
||||
"keymap.empty": "Không có lệnh phù hợp",
|
||||
"keymap.use_anyway": "Vẫn dùng {key} tại đây",
|
||||
@@ -2017,6 +2057,12 @@
|
||||
"navbar.assistant": "Trợ lý",
|
||||
"navbar.custom.add_slot": "Thêm {slot}",
|
||||
"navbar.custom.always": "Luôn hiện",
|
||||
"navbar.custom.anchor.bottom_centre": "Dưới giữa",
|
||||
"navbar.custom.anchor.bottom_left": "Dưới trái",
|
||||
"navbar.custom.anchor.bottom_right": "Dưới phải",
|
||||
"navbar.custom.anchor.top_centre": "Trên giữa",
|
||||
"navbar.custom.anchor.top_left": "Trên trái",
|
||||
"navbar.custom.anchor.top_right": "Trên phải",
|
||||
"navbar.custom.available": "{available} · kéo vào thanh hoặc nhấn Thêm",
|
||||
"navbar.custom.buttons": "Nút",
|
||||
"navbar.custom.compact": "Gọn",
|
||||
@@ -2053,6 +2099,7 @@
|
||||
"navbar.custom.reset_default": "Đặt lại mặc định",
|
||||
"navbar.custom.right": "Chuyển sang phải",
|
||||
"navbar.custom.select": "Chưa chọn ô nào. Hãy chọn một ô trên thanh để di chuyển, xóa hoặc đổi cách hiển thị.",
|
||||
"navbar.custom.separator_hint": "Đường phân cách giữa các nhóm nút. Kéo vào thanh hoặc nhấn Thêm; bạn có thể dùng bao nhiêu đường phân cách tùy ý.",
|
||||
"navbar.custom.separators_full": "Thanh đã có bốn dải phân cách.",
|
||||
"navbar.custom.show": "Hiện thanh điều hướng",
|
||||
"navbar.custom.shows": "Hiển thị",
|
||||
@@ -2206,14 +2253,22 @@
|
||||
"prefs.appearance.viewport_logo": "Khung nhìn và logo",
|
||||
"prefs.applies_on_restart": "Áp dụng sau khi khởi động lại",
|
||||
"prefs.assistant": "Trợ lý",
|
||||
"prefs.assistant_connecting": "Kết nối",
|
||||
"prefs.assistant_follow_help": "Thao tác Look At trên bản phác di chuyển góc nhìn theo con trỏ.",
|
||||
"prefs.assistant_limit": "Giới hạn",
|
||||
"prefs.assistant_limited": "Có giới hạn",
|
||||
"prefs.assistant_pointer_speed": "Tốc độ con trỏ",
|
||||
"prefs.assistant_seconds": "giây",
|
||||
"prefs.assistant_seconds_aria": "Giây",
|
||||
"prefs.assistant_use_care": "Sử dụng thận trọng",
|
||||
"prefs.auto_operation": "Tự động chọn thao tác",
|
||||
"prefs.auto_operation_note": "Cửa sổ tính năng chọn “Nối liền”, “Tạo mới Thân khối” hoặc “Cắt” theo những gì vật liệu mới chạm vào, cho đến khi bạn tự chọn. Tắt: cửa sổ giữ nguyên thao tác lúc mở.",
|
||||
"prefs.auto_operation_note": "Nối, Vật thể mới hoặc Cắt, tùy vào phần vật liệu tiếp xúc.",
|
||||
"prefs.axes": "Các trục tọa độ",
|
||||
"prefs.axis": "Trục tọa độ",
|
||||
"prefs.bodies_folders": "Thư mục thân khối",
|
||||
"prefs.bodies_folders_always": "Luôn luôn",
|
||||
"prefs.bodies_folders_never": "Không bao giờ",
|
||||
"prefs.bodies_folders_several": "Chỉ khi có nhiều thân khối",
|
||||
"prefs.bodies_folders_several": "Nhiều",
|
||||
"prefs.bottom": "Dưới cùng",
|
||||
"prefs.bottom_centre": "Giữa phía dưới",
|
||||
"prefs.bottom_right": "Góc dưới bên phải",
|
||||
@@ -2249,8 +2304,19 @@
|
||||
"prefs.dark_parts_shading_help": "Tô bóng khung nhìn bằng ánh sáng tuyến tính với ánh sáng bầu trời dịu, để các mặt tối giữ được dải chuyển màu thay vì thành màu đen",
|
||||
"prefs.dead_zone_note": "Dải sọc chéo là vùng chết: các chuyển động bên trong vùng này sẽ bị bỏ qua.",
|
||||
"prefs.default": "mặc định",
|
||||
"prefs.design": "Mặc định thiết kế",
|
||||
"prefs.design_note": "Cài đặt khởi đầu cho các tệp thiết kế mới. Các thiết kế đang mở giữ nguyên cài đặt riêng của chúng.",
|
||||
"prefs.design": "Thiết kế",
|
||||
"prefs.design_note": "Thiết lập ban đầu cho thiết kế mới.",
|
||||
"prefs.design_tools.ask": "Hỏi",
|
||||
"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_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
|
||||
"prefs.design_tools.hole_snaps_help": "Điểm giữa của mặt và tâm các cạnh tròn của mặt đó.",
|
||||
"prefs.design_tools.imported_files": "Tệp đã nhập",
|
||||
"prefs.design_tools.note": "Thông báo",
|
||||
"prefs.design_tools.off": "Tắt",
|
||||
"prefs.design_tools.placement_group": "Gối đỡ, Trụ, Khớp gài",
|
||||
"prefs.design_tools.rows": "Hàng",
|
||||
"prefs.devices": "Chuột 3D",
|
||||
"prefs.dialog_buttons": "Nút hộp thoại",
|
||||
"prefs.dialogs": "Hộp thoại",
|
||||
@@ -2292,6 +2358,23 @@
|
||||
"prefs.graphics_card_restart": "Có hiệu lực sau khi khởi động lại SolidCraft. Biến môi trường WGPU_ADAPTER_NAME sẽ ghi đè lựa chọn này.",
|
||||
"prefs.graphics_card_unused": "Card đồ họa đã lưu không được dùng cho cửa sổ này; card đang dùng hiện trong Trợ giúp › Giới thiệu và trên dòng trạng thái. Thử Tự động, hoặc đặt WGPU_ADAPTER_NAME thành phần tên card mang tính duy nhất.",
|
||||
"prefs.graphics_note": "Cân đối giữa tốc độ và độ mượt mà. Mỗi tùy chọn đều hiển thị hiệu ứng trực quan trên mẫu thử.",
|
||||
"prefs.graphics_page.current_view": "Khung nhìn hiện tại",
|
||||
"prefs.graphics_page.fastest": "Nhanh nhất",
|
||||
"prefs.graphics_page.fold_items": "Thu gọn mục",
|
||||
"prefs.graphics_page.in_view": "Trong khung nhìn",
|
||||
"prefs.graphics_page.live_details": "Chi tiết trực tiếp",
|
||||
"prefs.graphics_page.own_mix": "Tùy chỉnh riêng",
|
||||
"prefs.graphics_page.quick_measure": "Đo nhanh",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "Hình học tham chiếu xuyên qua các khối",
|
||||
"prefs.graphics_page.restart": "Cần khởi động lại",
|
||||
"prefs.graphics_page.sharpest": "Sắc nét nhất",
|
||||
"prefs.graphics_page.single_surface_analysis": "Phân tích bề mặt duy nhất",
|
||||
"prefs.graphics_page.status_chips": "Nút trạng thái",
|
||||
"prefs.graphics_page.subtitle": "Cân bằng tốc độ và độ mượt.",
|
||||
"prefs.graphics_page.supersampling": "Siêu lấy mẫu",
|
||||
"prefs.graphics_page.supersampling_adaptive": "Chỉ trong khi góc nhìn đứng yên",
|
||||
"prefs.graphics_page.surfaces_edges": "Bề mặt và cạnh",
|
||||
"prefs.graphics_page.view_bar": "Cũng có trên thanh khung nhìn",
|
||||
"prefs.graphics_presets_note": "Các cấu hình định sẵn sẽ thiết lập đồng thời các tùy chọn bên dưới.",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "Ẩn biểu tượng ràng buộc khi thu nhỏ",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "Biểu tượng ràng buộc biến mất khi bản phác được thu nhỏ đủ xa để không làm rối khung nhìn. Tắt: chúng giữ kích thước màn hình cố định ở mọi mức thu phóng.",
|
||||
@@ -2300,7 +2383,7 @@
|
||||
"prefs.history_origin_bodies_note": "Cây Lịch sử ở bên trái hoặc dưới cây thư mục sẽ bắt đầu với thư mục Thân khối và Gốc tọa độ của cụm chi tiết đang kích hoạt, cùng biểu tượng mắt bật/tắt và trình đơn tương tự như trong cây thư mục. Dòng Lịch sử ở cạnh dưới không thay đổi.",
|
||||
"prefs.history_play_button": "Nút Phát lịch sử trong các điều khiển Lịch sử",
|
||||
"prefs.history_play_button_note": "Thêm Phát lịch sử vào giữa Lùi một bước và Tiến một bước trong các điều khiển Lịch sử. Bạn cũng có thể nhấp chuột phải vào các điều khiển để hiện hoặc ẩn nút này.",
|
||||
"prefs.history_sketches": "Phác thảo",
|
||||
"prefs.history_sketches": "Các bản phác trong Lịch sử",
|
||||
"prefs.history_sketches_in_order": "Theo thứ tự tạo",
|
||||
"prefs.history_sketches_note": "Khi tùy chọn Phác thảo được đặt thành Dưới tính năng tương ứng, cây Lịch sử ở bên trái sẽ liệt kê mỗi phác thảo thụt vào một cấp dưới tính năng đầu tiên được tạo từ nó (khi khung đủ rộng để hiển thị tên). Dòng thời gian ở cạnh dưới luôn giữ nguyên thứ tự tạo.",
|
||||
"prefs.history_sketches_under_feature": "Dưới tính năng tương ứng",
|
||||
@@ -2325,6 +2408,22 @@
|
||||
"prefs.language_system": "Hệ thống",
|
||||
"prefs.layout": "Bố cục",
|
||||
"prefs.layout_note": "Chế độ Gọn (Compact) gắn các bảng vào mép cửa sổ. Độ trong suốt của Cây thư mục chỉ khả dụng ở chế độ Nổi (Floating). Thay đổi vị trí của Trợ lý trong trang Trợ lý.",
|
||||
"prefs.layout_page.active_commands": "Tô sáng lệnh đang bật",
|
||||
"prefs.layout_page.always": "Luôn luôn",
|
||||
"prefs.layout_page.card": "Thẻ",
|
||||
"prefs.layout_page.column": "Cột",
|
||||
"prefs.layout_page.hover": "Rê chuột",
|
||||
"prefs.layout_page.icons_labels": "Biểu tượng + nhãn",
|
||||
"prefs.layout_page.ok_cancel": "Đồng ý và Hủy bỏ",
|
||||
"prefs.layout_page.on_hover": "Khi rê chuột",
|
||||
"prefs.layout_page.open": "Mở",
|
||||
"prefs.layout_page.origin_bodies": "Gốc tọa độ và thân khối ở trên cùng",
|
||||
"prefs.layout_page.panels": "Bảng điều khiển",
|
||||
"prefs.layout_page.pinned_menus": "Loại lệnh đã ghim khỏi menu",
|
||||
"prefs.layout_page.play_history": "Nút Phát lịch sử",
|
||||
"prefs.layout_page.slot_count": "{count}/{max} ô",
|
||||
"prefs.layout_page.view_corner": "Cũng ở góc khung nhìn",
|
||||
"prefs.layout_page.window": "Cửa sổ",
|
||||
"prefs.left": "Bên trái",
|
||||
"prefs.left_click": "Nhấp chuột trái",
|
||||
"prefs.line_drag_draws_one": "Nhấn và kéo vẽ một đường",
|
||||
@@ -2369,11 +2468,15 @@
|
||||
"prefs.origin_plane_names_note": "Tên hiển thị trong trình duyệt, hộp thoại và danh sách. {top} là XY, {front} là XZ và {right} là YZ, với Z hướng lên. Lệnh, script và thiết kế đã lưu vẫn dùng XY, XZ và YZ.",
|
||||
"prefs.overall_speed": "Tốc độ tổng thể",
|
||||
"prefs.parameter_list_rows": "Số dòng danh sách tham số",
|
||||
"prefs.parameter_list_rows_tip": "Phím mũi tên xuống trong ô giá trị trống liệt kê các tham số, mục yêu thích trước, tối đa số này. Ít hơn giúp danh sách nhẹ trong thiết kế lớn.",
|
||||
"prefs.parameter_list_rows_tip": "Số tham số tối đa được liệt kê khi nhấn mũi tên xuống trong ô biểu thức trống.",
|
||||
"prefs.persistent_ribbon_pressed": "Tô sáng lệnh đang bật trên dải băng",
|
||||
"prefs.persistent_ribbon_pressed_help": "Tô sáng các lệnh trên dải băng khi hiệu ứng của chúng đang bật. Tắt tùy chọn này để giữ giao diện trung tính và trợ giúp lệnh như trước.",
|
||||
"prefs.perspective_fov": "Trường nhìn",
|
||||
"prefs.placement": "Vị trí đặt",
|
||||
"prefs.plugins_enabled": "Đã bật",
|
||||
"prefs.plugins_installed": "Đã cài đặt",
|
||||
"prefs.plugins_intro": "Tiện ích bổ sung trong vùng cách ly.",
|
||||
"prefs.plugins_show_folder": "Hiện thư mục plugin",
|
||||
"prefs.prefer_free_sketch_points": "Ưu tiên điểm phác thảo có thể di chuyển khi các điểm chồng lên nhau",
|
||||
"prefs.preview_choices": "Xem trước các lựa chọn của bạn",
|
||||
"prefs.preview_tint_new_faces": "Tô màu bề mặt mới trong bản xem trước",
|
||||
@@ -2431,6 +2534,10 @@
|
||||
"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.",
|
||||
"prefs.shortcuts": "Phím tắt",
|
||||
"prefs.shortcuts_import": "Nhập",
|
||||
"prefs.shortcuts_intro": "Phím cho từng lệnh.",
|
||||
"prefs.shortcuts_not_assigned": "Chưa gán",
|
||||
"prefs.shortcuts_press": "Nhấn một phím...",
|
||||
"prefs.single_surface_analysis": "Chỉ dùng một phân tích bề mặt",
|
||||
"prefs.single_surface_analysis_help": "Khi tắt, phân tích tô bóng bề mặt và phân tích tính liên tục có thể cùng hoạt động. Khi bật, chỉ giữ phân tích được bắt đầu hoặc thay đổi gần nhất.",
|
||||
"prefs.sketch_align_points": "Căn theo các điểm phác thảo khác khi vẽ",
|
||||
@@ -2465,11 +2572,11 @@
|
||||
"prefs.source": "Nguồn",
|
||||
"prefs.speed": "Tốc độ",
|
||||
"prefs.stand_up_note": "Sau khi mở tệp STEP hoặc IGES với trục Z hướng lên, hỏi xem có muốn xoay một phần tư vòng quanh trục X không.",
|
||||
"prefs.stand_up_unknown": "Với tệp từ chương trình không xác định",
|
||||
"prefs.stand_up_unknown": "Tệp từ chương trình không xác định",
|
||||
"prefs.stand_up_unknown.ask": "Hỏi trong hộp thoại",
|
||||
"prefs.stand_up_unknown.note": "Hiện ghi chú",
|
||||
"prefs.stand_up_unknown.off": "Không gợi ý",
|
||||
"prefs.stand_up_unknown_note": "Cách gợi ý khi phần đầu tệp không nêu chương trình nào được biết là lưu với trục Y hướng lên. Ghi chú chỉ hiện khi tệp có vẻ đang nằm ngửa; “Hỏi trong hộp thoại” sẽ hỏi với mọi tệp như vậy, như trước đây.",
|
||||
"prefs.stand_up_unknown_note": "Cách đưa ra đề nghị khi tệp không ghi tên chương trình đã biết.",
|
||||
"prefs.status_bar": "Thanh trạng thái",
|
||||
"prefs.status_bar_note": "Một thanh mảnh dọc cạnh dưới cửa sổ hiển thị thứ đang được chỉnh sửa, trạng thái của bản phác đang mở theo màu của nó và đơn vị của thiết kế; nhấp vào đơn vị để đổi. Khi đó trạng thái bản phác không còn hiện ở góc khung nhìn.",
|
||||
"prefs.status_done": "Hoàn thành",
|
||||
@@ -2542,8 +2649,8 @@
|
||||
"prefs.validation.label": "Kiểm tra kernel đầy đủ",
|
||||
"prefs.validation.off": "Tắt (thử nghiệm)",
|
||||
"prefs.validation_note": "Sau mỗi bước: kiểm tra từng lần quét, phép boolean và đường bo.\nMột lần mỗi đặc trưng: kiểm tra kết quả của từng đặc trưng một lần.\nTắt (thử nghiệm): không kiểm tra.",
|
||||
"prefs.value_boxes_in_view": "Hiện giá trị cạnh tay nắm trong khung nhìn",
|
||||
"prefs.value_boxes_in_view_tip": "Giá trị của lệnh (Khoảng cách, Bán kính…) trong một ô cạnh tay nắm, phía trên mô hình. Tắt: bạn nhập giá trị trong cửa sổ lệnh, cửa sổ này nhận bàn phím sau khi chọn, và không có gì che mô hình.",
|
||||
"prefs.value_boxes_in_view": "Hiện giá trị bên cạnh tay nắm",
|
||||
"prefs.value_boxes_in_view_tip": "Khoảng cách Đùn, Bán kính Bo góc... dưới dạng ô trong khung nhìn.",
|
||||
"prefs.view": "Hành vi góc nhìn",
|
||||
"prefs.view_confirm_corner": "Nút Đồng ý và Hủy bỏ ở góc khung nhìn",
|
||||
"prefs.view_confirm_corner_note": "Khi hộp thoại hoặc môi trường phác thảo đang mở, dấu tích và dấu chéo ở góc trên bên phải khung nhìn có chức năng tương tự nút Đồng ý và Hủy bỏ của hộp thoại, hoặc để hoàn tất và hủy phác thảo. Khối điều hướng góc nhìn sẽ dời xuống bên dưới chúng.",
|
||||
@@ -3944,11 +4051,11 @@
|
||||
"select two components": "chọn hai thành phần",
|
||||
"set by the sketch's lines": "xác định bởi các đường của phác thảo",
|
||||
"settings.display_quality_note": "Mức độ chi tiết cao hơn giúp hiển thị các mặt cong và cạnh mượt mà hơn nhưng tốn nhiều thời gian tạo lưới hơn. Thiết lập này chỉ thay đổi vùng nhìn, không ảnh hưởng đến thiết kế hay dữ liệu xuất tệp.",
|
||||
"settings.double_click_components": "Nhấp đúp vào một thành phần để kích hoạt nó, hoặc chỉnh sửa tại chỗ một thành phần liên kết",
|
||||
"settings.double_click_components": "Nhấp đúp vào thành phần để kích hoạt",
|
||||
"settings.frame_rate_details_note": "Bổ sung biểu đồ thời gian các khung hình gần nhất xếp chồng số tam giác đã vẽ, biểu đồ phân bố thời gian khung hình và nội dung GPU đã vẽ. Bấm vào tốc độ khung hình trên khung nhìn để ẩn/hiện.",
|
||||
"settings.frame_rate_note": "Hiển thị số khung hình trên giây (FPS) ở góc dưới bên phải khung nhìn.",
|
||||
"settings.insert_lists_open_designs": "Chèn thiết kế vào cụm liệt kê trước các thiết kế đang mở ở các thẻ khác",
|
||||
"settings.measure_axis_distances": "Hiển thị khoảng cách theo trục X/Y/Z",
|
||||
"settings.insert_lists_open_designs": "Chèn thiết kế liệt kê thiết kế đang mở trước",
|
||||
"settings.measure_axis_distances": "Khoảng cách theo trục trong Đo",
|
||||
"settings.measure_axis_distances_help": "Hiển thị kích thước thành phần theo các trục tọa độ cùng với khoảng cách tổng thể khi mở công cụ Đo lường.",
|
||||
"settings.msaa_applies_on_restart": "Khử răng cưa sẽ áp dụng vào lần khởi động tiếp theo của SolidCraft",
|
||||
"settings.msaa_reset": "{setting}: Mức {requested}× không được hỗ trợ bởi thiết bị đồ họa này; đã đặt về {reset}× (hỗ trợ: {supported}).",
|
||||
@@ -3958,7 +4065,7 @@
|
||||
"settings.reset_intro": "Các cài đặt sau không hoạt động trên máy tính này và có thể khiến SolidCraft không thể khởi động, vì vậy chúng đã được tự động điều chỉnh:",
|
||||
"settings.reset_title": "Cài đặt đã được thay đổi",
|
||||
"settings.supersampling_note": "Vẽ khung nhìn ở độ phân giải cao hơn rồi nén lại: giúp các cạnh, đường nét và lưới mượt mà hơn trên màn hình dưới 4K. Tính năng này tiêu tốn tài nguyên GPU: gấp khoảng 2,25 lần số điểm ảnh ở mức 1,5× và 4 lần ở mức 2×.",
|
||||
"settings.update_changed_links": "Cập nhật các thiết kế liên kết đã thay đổi khi quay lại một thẻ",
|
||||
"settings.update_changed_links": "Cập nhật liên kết đã thay đổi khi quay lại thẻ",
|
||||
"settings.window_system_details": "Chi tiết",
|
||||
"settings.window_system_note_now": "Áp dụng sau khi khởi động lại. Hiện tại: {now}.",
|
||||
"settings.window_system_wayland_limited": "Một số tính năng bị giới hạn trên Wayland gốc.",
|
||||
@@ -4360,7 +4467,7 @@
|
||||
"ui.agent_chat_send_now_tip": "Dừng bước hiện tại của tác tử và gửi tin nhắn này ngay (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "SolidCraft giao tiếp với một tác nhân lập trình được cài đặt trên máy tính này. Chọn một tác nhân trong Tùy chọn cài đặt › AI.",
|
||||
"ui.agent_chat_setup_title": "Thiết lập trợ lý",
|
||||
"ui.agent_chat_side": "Vị trí bảng trò chuyện AI",
|
||||
"ui.agent_chat_side": "Gắn khung trò chuyện",
|
||||
"ui.agent_chat_signed_in": "đã đăng nhập",
|
||||
"ui.agent_chat_state_auth": "Cần đăng nhập",
|
||||
"ui.agent_chat_state_busy": "Đang xử lý…",
|
||||
@@ -4379,7 +4486,7 @@
|
||||
"ui.agent_chat_undone": "(hoàn tácne)",
|
||||
"ui.agent_chat_view_attached": "Đã đính kèm góc nhìn",
|
||||
"ui.agent_chat_waiting": "Đang chờ bạn",
|
||||
"ui.agent_cursor": "Con trỏ AI",
|
||||
"ui.agent_cursor": "Con trỏ tác nhân",
|
||||
"ui.agent_cursor_speed": "Tốc độ con trỏ AI",
|
||||
"ui.agent_off_thread": "Giữ giao diện phản hồi mượt mà khi agent AI thực thi lệnh",
|
||||
"ui.agent_off_thread_note": "Các lệnh tốn thời gian của agent (từ bảng AI hoặc MCP client) được tính toán song song ngoài luồng cửa sổ: bạn vẫn có thể xoay và kiểm tra vùng nhìn, và có thể dừng lệnh bất cứ lúc nào từ thẻ trạng thái trong khung nhìn. Khi tắt: mỗi lệnh sẽ chạy trên luồng cửa sổ và cửa sổ sẽ phải chờ cho đến khi hoàn thành.",
|
||||
@@ -4425,14 +4532,14 @@
|
||||
"ui.ai_key_note": "Được lưu an toàn trong Keychain hệ thống, không lưu trong cài đặt của SolidCraft. Để trống nếu dùng máy chủ cục bộ.",
|
||||
"ui.ai_keychain_error": "Chuỗi khóa hệ thống từ chối: {error}",
|
||||
"ui.ai_kind": "Loại",
|
||||
"ui.ai_mode_all": "Cho phép tất cả",
|
||||
"ui.ai_mode_all": "Làm mọi việc",
|
||||
"ui.ai_mode_all_note": "Không yêu cầu xác nhận trước. Báo cáo sự cố và các thư mục giới hạn vẫn được bảo vệ.",
|
||||
"ui.ai_mode_all_warning": "Tác tử có thể lưu, ghi đè, xuất tệp và thay thế thiết kế của bạn mà không cần hỏi. Chỉ sử dụng chế độ này với tác tử bạn tin tưởng và trên các thiết kế đã được lưu.",
|
||||
"ui.ai_mode_ask": "Hỏi mỗi lần",
|
||||
"ui.ai_mode_ask": "Hỏi trước",
|
||||
"ui.ai_mode_ask_note": "Mọi thay đổi đối với thiết kế và mọi thao tác tệp đều cần bạn xác nhận Cho phép.",
|
||||
"ui.ai_mode_edits": "Tự động cho phép chỉnh sửa mô hình",
|
||||
"ui.ai_mode_edits": "Cho phép chỉnh sửa",
|
||||
"ui.ai_mode_edits_note": "Các lệnh tạo mô hình sẽ chạy mà không cần hỏi; các hành động lưu, mở, xuất và các thao tác tệp hoặc hủy hoại khác vẫn sẽ hỏi ý kiến bạn.",
|
||||
"ui.ai_mode_global": "Theo cài đặt bên trên",
|
||||
"ui.ai_mode_global": "Giống bên trên",
|
||||
"ui.ai_model": "Mô hình AI",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "Các cài đặt này áp dụng cho các công cụ của SolidCraft. Công cụ riêng của tác tử (shell hệ thống, chỉnh sửa tệp) tuân theo chế độ riêng của tác tử, được chọn trên thanh tiêu đề bảng điều khiển trò chuyện.",
|
||||
@@ -4458,7 +4565,7 @@
|
||||
"ui.ai_save": "Lưu",
|
||||
"ui.ai_settings": "AI cài đặt…",
|
||||
"ui.ai_show_features": "Hiển thị các tính năng AI",
|
||||
"ui.ai_show_features_note": "Trò chuyện với tác tử: bảng điều khiển nơi tác tử lập trình hoặc điểm cuối mô hình thao tác trên thiết kế đang mở. Tắt: ẩn nút AI, phím tắt, menu và bảng điều khiển, không tìm kiếm hay khởi chạy tác tử.",
|
||||
"ui.ai_show_features_note": "Tắt sẽ ẩn Trợ lý ở mọi nơi và không khởi chạy gì. Mọi mục bên dưới đều mờ đi.\n\nTrò chuyện với tác tử: bảng điều khiển nơi tác tử lập trình hoặc điểm cuối mô hình thao tác trên thiết kế đang mở. Tắt: ẩn nút AI, phím tắt, menu và bảng điều khiển, không tìm kiếm hay khởi chạy tác tử.",
|
||||
"ui.ai_switch_tip": "Chuyển đổi AI Agent cho cuộc hội thoại này",
|
||||
"ui.ai_test": "Kiểm tra",
|
||||
"ui.ai_test_acp_ok": "Phản hồi: {agent}",
|
||||
@@ -4492,7 +4599,7 @@
|
||||
"ui.ai_tool_sum_then": "{first}, sau đó {next}",
|
||||
"ui.ai_tool_unfold": "Nhấp để xem nội dung trả về",
|
||||
"ui.ai_tool_waiting": "đang chờ",
|
||||
"ui.ai_toolbar_button": "Hiển thị nút trên thanh công cụ",
|
||||
"ui.ai_toolbar_button": "Nút Trợ lý trên thanh ứng dụng",
|
||||
"ui.ai_toolbar_button_note": "Nút AI ở góc phải thanh công cụ. Phím tắt Ctrl+Shift+A và menu Khung nhìn vẫn có thể mở bảng điều khiển.",
|
||||
"ui.ai_undo_tip": "Hoàn tác mọi thay đổi do tác tử thực hiện cho tin nhắn này, như một bước duy nhất",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -4764,7 +4871,7 @@
|
||||
"ui.extract_done": "Thành phần này hiện đã là một thiết kế độc lập, được liên kết tại đây.",
|
||||
"ui.extrude": "Đùn khối",
|
||||
"ui.extrude_auto_to_object": "Đùn khối đến đối tượng được chọn",
|
||||
"ui.extrude_auto_to_object_tip": "Trong lệnh Đùn, khi đã chọn biên dạng, nhấp chọn một mặt, đỉnh hoặc mặt phẳng song song ngoài mặt phẳng của nó sẽ tự động chuyển phạm vi đùn thành 'Đến đối tượng' (To Object)",
|
||||
"ui.extrude_auto_to_object_tip": "Một mặt, đỉnh hoặc mặt phẳng song song chuyển phạm vi sang Đến đối tượng.",
|
||||
"ui.factor": "Hệ số",
|
||||
"ui.fade": "Thoát ren",
|
||||
"ui.fade_length": "Chiều dài thoát ren",
|
||||
@@ -4908,7 +5015,7 @@
|
||||
"ui.hidePlanes": "Ẩn các mặt phẳng",
|
||||
"ui.hideProfile": "Ẩn biên dạng",
|
||||
"ui.hide_component_history": "Ẩn lịch sử của nó",
|
||||
"ui.hide_origin_by_default": "Mặc định ẩn gốc tọa độ",
|
||||
"ui.hide_origin_by_default": "Ẩn thư mục Gốc tọa độ trong thiết kế mới",
|
||||
"ui.hide_origin_note": "Bản vẽ mới bắt đầu với thư mục Gốc tọa độ được ẩn. Thiết kế sẽ giữ lại cài đặt Gốc tọa độ riêng sau khi bạn ẩn hoặc hiện nó.",
|
||||
"ui.history_back": "Lùi một bước",
|
||||
"ui.history_below_browser": "Bên dưới trình duyệt",
|
||||
@@ -5068,7 +5175,7 @@
|
||||
"ui.pitch": "Bước xoắn",
|
||||
"ui.placement": "Vị trí đặt",
|
||||
"ui.placement_handles": "Tay nắm định vị",
|
||||
"ui.placement_handles_tip": "Tay nắm trên hình xem trước của Gờ đỡ, Vấu lồi hoặc Khớp cài: kéo điểm đánh dấu để di chuyển trên mô hình, xoay bằng vòng xoay",
|
||||
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và vòng để xoay.",
|
||||
"ui.plane": "Mặt phẳng",
|
||||
"ui.plane_at_angle": "Mặt phẳng theo góc",
|
||||
"ui.plugins": "Plugin",
|
||||
@@ -5266,8 +5373,8 @@
|
||||
"ui.start": "Bắt đầu",
|
||||
"ui.start_over": "Bắt đầu lại",
|
||||
"ui.start_page": "Trang bắt đầu",
|
||||
"ui.step_values": "Tăng giảm giá trị bằng phím mũi tên và con lăn chuột",
|
||||
"ui.step_values_tip": "Trong ô nhập giá trị đang gõ, phím Lên/Xuống và con lăn chuột trên ô sẽ tăng giảm số thuần túy 1 đơn vị (Shift: 10, Alt: 0.1). Biểu thức toán học được giữ nguyên như đã nhập.",
|
||||
"ui.step_values": "Thay đổi giá trị bằng mũi tên và con lăn",
|
||||
"ui.step_values_tip": "Phím lên/xuống và con lăn trên trường giá trị sẽ thay đổi số trong đó.",
|
||||
"ui.stitch_tolerance_hint": "chặt",
|
||||
"ui.stretch": "Kéo giãn",
|
||||
"ui.supersampling": "Khử răng cưa SSAA",
|
||||
|
||||
+159
-52
@@ -379,7 +379,7 @@
|
||||
"Move Type": "移动类型",
|
||||
"Move down": "下移",
|
||||
"Move earlier": "向前移动",
|
||||
"Move holes by clicking or dragging": "通过单击或拖动移动孔",
|
||||
"Move holes by clicking or dragging": "通过单击或拖动移动",
|
||||
"Move later": "向后移动",
|
||||
"Move up": "上移",
|
||||
"Name": "名称",
|
||||
@@ -585,7 +585,7 @@
|
||||
"Show ghost": "显示虚影",
|
||||
"Show in folder": "在文件夹中显示",
|
||||
"Show the pointer an AI agent moves (#35)": "显示 AI 代理移动的指针(#35)",
|
||||
"Show tool at cursor": "在光标旁显示工具",
|
||||
"Show tool at cursor": "光标处显示工具",
|
||||
"Signed axes": "带符号轴",
|
||||
"Size": "大小",
|
||||
"Sketch constraints": "草图约束",
|
||||
@@ -596,7 +596,7 @@
|
||||
"Sketch points": "草图点",
|
||||
"Slider": "滑块",
|
||||
"Slot width": "槽宽",
|
||||
"Snap to face and edge centres": "捕捉到面和边的中心",
|
||||
"Snap to face and edge centres": "捕捉到中心",
|
||||
"SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft 无法直接提交报告。请在 GitHub 上提交;应用中不需要令牌。",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft 在这些设计保存前已关闭。可以打开它们的上次自动保存。",
|
||||
"Sort A to Z": "升序排序",
|
||||
@@ -739,34 +739,34 @@
|
||||
"a picked edge's body is gone": "所选边所属的实体已不存在",
|
||||
"add a key for a joint first": "请先为接头添加关键帧",
|
||||
"ai.connected_toast": "SolidCraft 现已可在 {clients} 中使用。请重启它们(设置 > 助手)。",
|
||||
"ai.prefs.allow_control": "允许 AI 代理连接(控制通道 / MCP)",
|
||||
"ai.prefs.allow_control_note": "仅在本机开放控制通道(127.0.0.1,端口 27878 或下一个空闲端口)。连接的程序还必须出示一个每次启动时新生成、只有你的用户账户能读取的秘密令牌。下次启动 SolidCraft 时生效。关闭:除非用 --control 启动,否则不监听任何端口。",
|
||||
"ai.prefs.availability": "助手控制",
|
||||
"ai.prefs.background_commands": "在后台执行代理命令",
|
||||
"ai.prefs.connect_apps": "让此电脑上的 AI 应用可以使用 SolidCraft",
|
||||
"ai.prefs.connect_apps_note": "SolidCraft 启动时,会把自己作为 MCP 服务器添加到此电脑上找到的 AI 应用(OpenCode、Claude Desktop、Claude Code、Cursor、Codex、Gemini CLI、Copilot CLI、Windsurf、Zed、VS Code)。只添加或更新 solidcraft 条目;你修改过的条目不会被改动,文件会先备份。关闭后,会删除它添加的条目。",
|
||||
"ai.prefs.connect_at_start": "SolidCraft 启动时连接上次使用的智能体",
|
||||
"ai.prefs.allow_control": "允许AI代理连接",
|
||||
"ai.prefs.allow_control_note": "本地通道(MCP)。任何内容都不会离开此计算机。\n\n仅在本机开放控制通道(127.0.0.1,端口 27878 或下一个空闲端口)。连接的程序还必须出示一个每次启动时新生成、只有你的用户账户能读取的秘密令牌。下次启动 SolidCraft 时生效。关闭:除非用 --control 启动,否则不监听任何端口。",
|
||||
"ai.prefs.availability": "可用性",
|
||||
"ai.prefs.background_commands": "在后台运行耗时命令",
|
||||
"ai.prefs.connect_apps": "将SolidCraft添加到AI应用",
|
||||
"ai.prefs.connect_apps_note": "关闭后会移除已添加的条目。\n\nSolidCraft 启动时,会把自己作为 MCP 服务器添加到此电脑上找到的 AI 应用(OpenCode、Claude Desktop、Claude Code、Cursor、Codex、Gemini CLI、Copilot CLI、Windsurf、Zed、VS Code)。只添加或更新 solidcraft 条目;你修改过的条目不会被改动,文件会先备份。关闭后,会删除它添加的条目。",
|
||||
"ai.prefs.connect_at_start": "启动时连接上次使用的代理",
|
||||
"ai.prefs.connect_at_start_note": "每次启动 SolidCraft 时,在后台启动上次连接的智能体(或默认智能体),以便随时开始聊天。使用 --control 启动 SolidCraft 时不会这样做。如果无法连接,助手会显示“连接失败”和“重试”按钮。",
|
||||
"ai.prefs.follow_camera": "将相机转向草图",
|
||||
"ai.prefs.follow_camera": "跟随相机",
|
||||
"ai.prefs.follow_camera_off": "相机自动跟随已关闭。",
|
||||
"ai.prefs.follow_camera_on": "相机将转向代理创建的草图。",
|
||||
"ai.prefs.intro": "设置代理、权限及其工作方式。",
|
||||
"ai.prefs.intro": "SolidCraft中的AI代理。",
|
||||
"ai.prefs.make_default": "设为默认",
|
||||
"ai.prefs.no_agents": "尚无代理。添加代理或刷新以查找已安装的代理。",
|
||||
"ai.prefs.no_saved_approvals": "没有已保存的批准。",
|
||||
"ai.prefs.open_at_start": "SolidCraft 启动时打开助手面板",
|
||||
"ai.prefs.open_at_start": "启动时打开聊天",
|
||||
"ai.prefs.open_at_start_note": "每次启动 SolidCraft 时显示助手面板(或其独立窗口)。使用 --control 启动 SolidCraft 时不会这样做。",
|
||||
"ai.prefs.override_all_warning": "此代理可在不询问的情况下保存、覆盖、导出或替换设计。",
|
||||
"ai.prefs.permission_all": "全部允许",
|
||||
"ai.prefs.permission_all_summary": "可用的 SolidCraft 工具无需询问即可运行。",
|
||||
"ai.prefs.permission_ask": "更改前询问",
|
||||
"ai.prefs.permission_ask_summary": "每次设计更改和文件操作均需批准。",
|
||||
"ai.prefs.permission_edits": "允许模型编辑",
|
||||
"ai.prefs.permission_edits_summary": "模型编辑自动执行;文件及破坏性操作仍需询问。",
|
||||
"ai.prefs.permission_all": "执行所有操作",
|
||||
"ai.prefs.permission_all_summary": "从不询问",
|
||||
"ai.prefs.permission_ask": "先询问",
|
||||
"ai.prefs.permission_ask_summary": "每次更改前询问",
|
||||
"ai.prefs.permission_edits": "允许编辑",
|
||||
"ai.prefs.permission_edits_summary": "自由编辑,其余操作先询问",
|
||||
"ai.prefs.permission_scope": "仅适用于 SolidCraft 工具。代理的终端和文件编辑使用其自身权限。",
|
||||
"ai.prefs.pointer_preview_hidden": "指针已隐藏",
|
||||
"ai.prefs.pointer_preview_speed": "代理指针 · {speed}",
|
||||
"ai.prefs.pointer_speed": "移动速度",
|
||||
"ai.prefs.pointer_speed": "速度",
|
||||
"ai.prefs.privacy_note": "对话仅发送给所选代理或端点。端点密钥保存在系统钥匙串中。",
|
||||
"ai.prefs.show_pointer": "显示代理指针",
|
||||
"ai.prefs.turn_limit_hint": "助手针对一条消息可调用的设计更改工具次数上限。",
|
||||
@@ -997,6 +997,11 @@
|
||||
"cmd.joint_screw": "螺旋",
|
||||
"cmd.joint_solve": "解算接头",
|
||||
"cmd.joint_sweep": "扫掠接头",
|
||||
"cmd.layout.apply": "应用布局",
|
||||
"cmd.layout.get": "检查布局",
|
||||
"cmd.layout.reset": "重置布局",
|
||||
"cmd.layout.save": "保存布局",
|
||||
"cmd.layout.workspace.select": "选择布局工作区",
|
||||
"cmd.library_apply": "应用本地库条目",
|
||||
"cmd.library_export": "导出设计库",
|
||||
"cmd.library_inspect": "检查本地库",
|
||||
@@ -1030,6 +1035,21 @@
|
||||
"cmd.occurrence_move": "移动实例",
|
||||
"cmd.occurrence_properties": "实例物理属性",
|
||||
"cmd.occurrence_reparent": "移动到组件",
|
||||
"cmd.panel.activate": "激活面板选项卡",
|
||||
"cmd.panel.close": "关闭面板",
|
||||
"cmd.panel.collapse": "折叠面板分区",
|
||||
"cmd.panel.dismiss": "关闭面板临时预览",
|
||||
"cmd.panel.float": "浮动面板",
|
||||
"cmd.panel.focus": "聚焦面板",
|
||||
"cmd.panel.list": "列出面板",
|
||||
"cmd.panel.move": "移动面板",
|
||||
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
|
||||
"cmd.panel.open": "打开面板",
|
||||
"cmd.panel.peek": "临时预览折叠面板",
|
||||
"cmd.panel.popout": "将面板移至独立窗口",
|
||||
"cmd.panel.reset_pair": "重置面板分隔条",
|
||||
"cmd.panel.resize": "调整面板分区大小",
|
||||
"cmd.panel.resize_pair": "移动面板分隔条",
|
||||
"cmd.parameters_add": "添加用户参数",
|
||||
"cmd.parameters_change": "更改参数",
|
||||
"cmd.parameters_comment": "注释参数",
|
||||
@@ -1536,6 +1556,11 @@
|
||||
"help.joint.reach": "通过在限制范围内改变链上的接头,将零件上的某一点移向目标。用于按末端应到达的位置摆放机构,例如机械臂的末端。",
|
||||
"help.joint.rigid_group": "将选中的零件按当前位置锁定在一起,使其作为一个整体运动。用于彼此之间没有相对运动的成组零件。",
|
||||
"help.joint.screw": "以螺距关联转动与滑动,每转一圈前进相应距离。用于丝杠、螺栓和螺纹轴。",
|
||||
"help.layout.apply": "将保存的布局应用到当前工作区。保留的编辑器会继续保留其草稿。",
|
||||
"help.layout.get": "读取已提交的布局并查看已注册的工作区。",
|
||||
"help.layout.reset": "恢复当前工作区注册的默认布局。用户预设仍会保留。",
|
||||
"help.layout.save": "为完整布局命名并保存。替换用户预设需要明确允许覆盖。",
|
||||
"help.layout.workspace.select": "选择已注册的布局工作区,并记住离开前的布局。",
|
||||
"help.library.apply": "将本地库文件中的材料或外观应用到实体、面或组件。用于重复使用在其他设计中保存的材料。",
|
||||
"help.library.export": "将设计的材料和外观保存到库文件。用于共享,或在其他设计中重复使用。",
|
||||
"help.library.inspect": "检查库文件并列出其中的材料和外观,不改变设计。用于在应用之前查看库里有什么。",
|
||||
@@ -1549,6 +1574,21 @@
|
||||
"help.notice": "NOTICE",
|
||||
"help.occurrence.ground": "将零件固定在原位,或重新解除固定,接头会围绕它移动其他零件。用于装配中其余零件所依托的框架或底座。",
|
||||
"help.occurrence.move": "仅沿关节允许的自由度移动装配零部件。选中零部件的面后,拖动可用的箭头或圆弧。",
|
||||
"help.panel.activate": "选择已打开的面板选项卡,而不请求键盘焦点。",
|
||||
"help.panel.close": "通过内容生命周期检查后关闭面板。其位置会被记住。",
|
||||
"help.panel.collapse": "折叠或展开面板分区,同时保留所需尺寸。",
|
||||
"help.panel.dismiss": "关闭折叠面板的临时视图。",
|
||||
"help.panel.float": "将面板移至位置明确指定的浮动分区。",
|
||||
"help.panel.focus": "聚焦面板,必要时重新打开其保留状态并展开分区。",
|
||||
"help.panel.list": "读取已注册的面板种类、已提交的实例、活动选项卡、可用状态和宿主能力。",
|
||||
"help.panel.move": "将面板移至新分区,或加入现有的选项卡组。",
|
||||
"help.panel.native_close": "将独立窗口中的面板分区返回记住的停靠位置,而不取消其内容。",
|
||||
"help.panel.open": "使用明确的实例标识打开指定种类的面板。新实例需要指定位置。",
|
||||
"help.panel.peek": "临时显示折叠的面板,而不更改保存的尺寸或折叠状态。",
|
||||
"help.panel.popout": "支持时将面板移至独立窗口。浏览器宿主会使用浮动分区。",
|
||||
"help.panel.reset_pair": "在共同约束内恢复相邻停靠分区对的默认尺寸比例。",
|
||||
"help.panel.resize": "在所有选项卡的约束内设置面板分区的外部宽度和高度。",
|
||||
"help.panel.resize_pair": "同时调整相邻停靠分区的尺寸,并保持其共同总长度。",
|
||||
"help.parameters.change": "打开驱动设计尺寸的命名数值和表达式表。用于在一处修改尺寸,让所有引用它的特征随之更新。",
|
||||
"help.parameters.export": "将设计的参数保存为 CSV 或 JSON。用于在电子表格中编辑,或转移到其他设计。",
|
||||
"help.parameters.graph": "显示哪些参数和特征使用了哪些其他参数和特征,以及其中的循环或错误。用于了解改动某个参数会影响什么。",
|
||||
@@ -1885,21 +1925,21 @@
|
||||
"joint.rigid_group_select_components": "请选择两个或更多组件。",
|
||||
"joint_move.blocked_hint": "请检查关节类型(例如 Rigid)、固定状态、关节限位和参数驱动值。",
|
||||
"joint_move.blocked_title": "无法移动此零部件",
|
||||
"joint_move.coarse": "靠近轴线时的旋转步长(°)",
|
||||
"joint_move.coarse": "近轴步长",
|
||||
"joint_move.conflict": "此零部件的一个关节与其他关节冲突:{joint}。",
|
||||
"joint_move.conflict_hint": "请编辑、抑制或删除冲突的关节,以便其他关节可以放置该零部件。",
|
||||
"joint_move.distance": "相对起点的移动距离",
|
||||
"joint_move.done": "完成",
|
||||
"joint_move.fine": "远离轴线时的旋转步长(°)",
|
||||
"joint_move.fine": "远轴步长",
|
||||
"joint_move.fixed": "此零部件没有可操作的关节自由度。",
|
||||
"joint_move.help": "移动 — 拖动箭头或圆弧。按 Esc 取消当前拖动。",
|
||||
"joint_move.legacy_hint": "关闭后使用原有的移动面板。",
|
||||
"joint_move.precision": "将光标移到虚线引导圈外侧可微调旋转。",
|
||||
"joint_move.legacy_hint": "关闭:使用原来的移动面板。",
|
||||
"joint_move.precision": "拉到虚线引导线外可使用精细步长。",
|
||||
"joint_move.rotation": "相对起点的旋转角度",
|
||||
"joint_move.select": "请选择一个面或一个装配零部件,然后执行移动。",
|
||||
"joint_move.settings": "装配移动",
|
||||
"joint_move.step": "步长",
|
||||
"joint_move.use_handles": "装配移动使用关节运动手柄",
|
||||
"joint_move.use_handles": "移动时使用关节运动控制柄",
|
||||
"keymap.command_id": "命令 ID",
|
||||
"keymap.empty": "没有匹配的命令",
|
||||
"keymap.use_anyway": "仍在此处使用 {key}",
|
||||
@@ -2017,6 +2057,12 @@
|
||||
"navbar.assistant": "助手",
|
||||
"navbar.custom.add_slot": "添加{slot}",
|
||||
"navbar.custom.always": "始终显示",
|
||||
"navbar.custom.anchor.bottom_centre": "底部居中",
|
||||
"navbar.custom.anchor.bottom_left": "左下角",
|
||||
"navbar.custom.anchor.bottom_right": "右下角",
|
||||
"navbar.custom.anchor.top_centre": "顶部居中",
|
||||
"navbar.custom.anchor.top_left": "左上角",
|
||||
"navbar.custom.anchor.top_right": "右上角",
|
||||
"navbar.custom.available": "{available} · 拖到栏上,或点击添加",
|
||||
"navbar.custom.buttons": "按钮",
|
||||
"navbar.custom.compact": "紧凑",
|
||||
@@ -2053,6 +2099,7 @@
|
||||
"navbar.custom.reset_default": "恢复默认",
|
||||
"navbar.custom.right": "右移",
|
||||
"navbar.custom.select": "未选择槽位。选择栏上的槽位即可移动、移除或更改其外观。",
|
||||
"navbar.custom.separator_hint": "按钮组之间的分隔线。将其拖到栏上,或按“添加”;可以按需使用任意数量。",
|
||||
"navbar.custom.separators_full": "导航栏已有4个分隔线。",
|
||||
"navbar.custom.show": "显示导航栏",
|
||||
"navbar.custom.shows": "显示内容",
|
||||
@@ -2206,21 +2253,29 @@
|
||||
"prefs.appearance.viewport_logo": "视口和徽标",
|
||||
"prefs.applies_on_restart": "重启后生效",
|
||||
"prefs.assistant": "助手",
|
||||
"prefs.assistant_connecting": "连接",
|
||||
"prefs.assistant_follow_help": "草图的Look At操作会使视图随指针移动。",
|
||||
"prefs.assistant_limit": "上限",
|
||||
"prefs.assistant_limited": "有限制",
|
||||
"prefs.assistant_pointer_speed": "指针速度",
|
||||
"prefs.assistant_seconds": "秒",
|
||||
"prefs.assistant_seconds_aria": "秒",
|
||||
"prefs.assistant_use_care": "谨慎使用",
|
||||
"prefs.auto_operation": "自动选择操作",
|
||||
"prefs.auto_operation_note": "特征窗口根据新材料接触的对象选择连接、新建实体或剪切,直到您手动选择。关闭:保持窗口打开时的操作。",
|
||||
"prefs.auto_operation_note": "根据材料接触的对象选择合并、新建实体或切割。",
|
||||
"prefs.axes": "坐标轴",
|
||||
"prefs.axis": "轴",
|
||||
"prefs.bodies_folders": "实体文件夹",
|
||||
"prefs.bodies_folders_always": "始终显示",
|
||||
"prefs.bodies_folders_never": "不显示",
|
||||
"prefs.bodies_folders_several": "仅限多个实体",
|
||||
"prefs.bodies_folders_several": "多个",
|
||||
"prefs.bottom": "底部",
|
||||
"prefs.bottom_centre": "底部居中",
|
||||
"prefs.bottom_right": "右下角",
|
||||
"prefs.browser": "浏览器",
|
||||
"prefs.browser_components_open": "组件初始展开",
|
||||
"prefs.browser_components_open_note": "浏览器中的每个组件都像以前一样初始展开,而不是在您展开前保持折叠。",
|
||||
"prefs.browser_eye_left": "显示切换位于折叠箭头旁",
|
||||
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
|
||||
"prefs.browser_eye_left_note": "浏览器各行的显示切换像以前一样位于折叠箭头旁,而不是在行右端的一列中。",
|
||||
"prefs.browser_reveal_on_hover": "悬停时显示",
|
||||
"prefs.browser_show_note": "“显示”让浏览器始终留在屏幕上。“悬停时显示”会将其隐藏,直到指针移到它的位置或其左边缘的细标签上;指针在其中或其菜单打开时,它保持打开。",
|
||||
@@ -2249,8 +2304,19 @@
|
||||
"prefs.dark_parts_shading_help": "以线性光照和柔和的天空补光着色视图,使深色面保留渐变而不变成一片黑色",
|
||||
"prefs.dead_zone_note": "斜线区域为死区:此范围内的移动将被忽略。",
|
||||
"prefs.default": "默认",
|
||||
"prefs.design": "设计默认值",
|
||||
"prefs.design_note": "新建设计的初始设置。已打开的设计保留各自的设置。",
|
||||
"prefs.design": "设计",
|
||||
"prefs.design_note": "新设计的初始设置。",
|
||||
"prefs.design_tools.ask": "询问",
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
|
||||
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
|
||||
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
|
||||
"prefs.design_tools.hole_snaps_help": "面的中点及其圆形边的圆心。",
|
||||
"prefs.design_tools.imported_files": "导入的文件",
|
||||
"prefs.design_tools.note": "提示",
|
||||
"prefs.design_tools.off": "关闭",
|
||||
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
|
||||
"prefs.design_tools.rows": "行数",
|
||||
"prefs.devices": "3D 鼠标",
|
||||
"prefs.dialog_buttons": "对话框按钮",
|
||||
"prefs.dialogs": "对话框",
|
||||
@@ -2292,6 +2358,23 @@
|
||||
"prefs.graphics_card_restart": "重启 SolidCraft 后生效。WGPU_ADAPTER_NAME 优先于此选择。",
|
||||
"prefs.graphics_card_unused": "已保存的显卡未用于此窗口;实际使用的显卡显示在“帮助 › 关于”和状态栏中。请尝试“自动”,或将 WGPU_ADAPTER_NAME 设为显卡名称中的唯一部分。",
|
||||
"prefs.graphics_note": "在速度与平滑度之间取舍。每个选项均以示例展示效果。",
|
||||
"prefs.graphics_page.current_view": "当前视图",
|
||||
"prefs.graphics_page.fastest": "最快",
|
||||
"prefs.graphics_page.fold_items": "折叠项目",
|
||||
"prefs.graphics_page.in_view": "视图内",
|
||||
"prefs.graphics_page.live_details": "实时详情",
|
||||
"prefs.graphics_page.own_mix": "自定义组合",
|
||||
"prefs.graphics_page.quick_measure": "快速测量",
|
||||
"prefs.graphics_page.reference_geometry_through_bodies": "透过实体的参考几何体",
|
||||
"prefs.graphics_page.restart": "需要重启",
|
||||
"prefs.graphics_page.sharpest": "最清晰",
|
||||
"prefs.graphics_page.single_surface_analysis": "单一曲面分析",
|
||||
"prefs.graphics_page.status_chips": "状态标签",
|
||||
"prefs.graphics_page.subtitle": "在速度与平滑度之间权衡。",
|
||||
"prefs.graphics_page.supersampling": "超级采样",
|
||||
"prefs.graphics_page.supersampling_adaptive": "仅在视图保持静止时",
|
||||
"prefs.graphics_page.surfaces_edges": "曲面与边",
|
||||
"prefs.graphics_page.view_bar": "也可在视图栏中设置",
|
||||
"prefs.graphics_presets_note": "预设会同时设置以下选项。",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "缩小时隐藏约束符号",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "草图缩小到一定程度时隐藏约束符号,以免遮挡草图。关闭:任意缩放比例下符号仍保持固定屏幕大小。",
|
||||
@@ -2300,7 +2383,7 @@
|
||||
"prefs.history_origin_bodies_note": "左侧或浏览器下方的历史以活动组件的实体文件夹和原点开头,并带有与浏览器相同的显示切换和菜单。底部的历史保持不变。",
|
||||
"prefs.history_play_button": "在历史控件中显示“播放历史”按钮",
|
||||
"prefs.history_play_button_note": "在历史控件的“后退一步”和“前进一步”之间添加“播放历史”。也可以右键单击这些控件来显示或隐藏它。",
|
||||
"prefs.history_sketches": "草图",
|
||||
"prefs.history_sketches": "历史中的草图",
|
||||
"prefs.history_sketches_in_order": "按创建顺序",
|
||||
"prefs.history_sketches_note": "草图:选择“位于其特征下”后,左侧历史在宽度足以显示名称时,会将草图缩进一级,列在由其创建的第一个特征下。底部时间轴始终保持创建顺序。",
|
||||
"prefs.history_sketches_under_feature": "位于其特征下",
|
||||
@@ -2325,6 +2408,22 @@
|
||||
"prefs.language_system": "系统",
|
||||
"prefs.layout": "布局",
|
||||
"prefs.layout_note": "紧凑模式将面板停靠在窗口边缘。浏览器半透明仅适用于浮动模式。助手面板的位置在助手页面中设置。",
|
||||
"prefs.layout_page.active_commands": "突出显示活动命令",
|
||||
"prefs.layout_page.always": "始终",
|
||||
"prefs.layout_page.card": "卡片",
|
||||
"prefs.layout_page.column": "列",
|
||||
"prefs.layout_page.hover": "悬停",
|
||||
"prefs.layout_page.icons_labels": "图标+标签",
|
||||
"prefs.layout_page.ok_cancel": "确定和取消",
|
||||
"prefs.layout_page.on_hover": "悬停时",
|
||||
"prefs.layout_page.open": "打开位置",
|
||||
"prefs.layout_page.origin_bodies": "原点和实体置顶",
|
||||
"prefs.layout_page.panels": "面板",
|
||||
"prefs.layout_page.pinned_menus": "从菜单中排除固定命令",
|
||||
"prefs.layout_page.play_history": "播放历史按钮",
|
||||
"prefs.layout_page.slot_count": "{count} / {max} 个槽位",
|
||||
"prefs.layout_page.view_corner": "同时在视图角落显示",
|
||||
"prefs.layout_page.window": "窗口",
|
||||
"prefs.left": "左侧",
|
||||
"prefs.left_click": "左键单击",
|
||||
"prefs.line_drag_draws_one": "按下并拖动绘制一条直线",
|
||||
@@ -2369,11 +2468,15 @@
|
||||
"prefs.origin_plane_names_note": "浏览器、对话框和列表中显示的名称。{top} 即 XY,{front} 即 XZ,{right} 即 YZ(Z 轴朝上)。命令、脚本和已保存的设计仍使用 XY、XZ 和 YZ。",
|
||||
"prefs.overall_speed": "整体速度",
|
||||
"prefs.parameter_list_rows": "参数列表行数",
|
||||
"prefs.parameter_list_rows_tip": "在空的数值框中按下箭头会列出参数(收藏的在前),最多列出此数量。数量越少,大型设计中的列表越轻。",
|
||||
"prefs.parameter_list_rows_tip": "在空表达式框中按向下箭头时列出的参数上限。",
|
||||
"prefs.persistent_ribbon_pressed": "突出显示已启用的功能区命令",
|
||||
"prefs.persistent_ribbon_pressed_help": "在效果启用时突出显示相应的功能区命令。关闭此选项可保留原来的中性外观和命令帮助。",
|
||||
"prefs.perspective_fov": "视场角",
|
||||
"prefs.placement": "放置",
|
||||
"prefs.plugins_enabled": "已启用",
|
||||
"prefs.plugins_installed": "已安装",
|
||||
"prefs.plugins_intro": "沙箱化扩展。",
|
||||
"prefs.plugins_show_folder": "显示插件文件夹",
|
||||
"prefs.prefer_free_sketch_points": "草图点重合时优先选择可移动的点",
|
||||
"prefs.preview_choices": "所选设置预览",
|
||||
"prefs.preview_tint_new_faces": "在预览中为新表面着色",
|
||||
@@ -2431,6 +2534,10 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
|
||||
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
|
||||
"prefs.shortcuts": "快捷键",
|
||||
"prefs.shortcuts_import": "导入",
|
||||
"prefs.shortcuts_intro": "每个命令的按键。",
|
||||
"prefs.shortcuts_not_assigned": "未分配",
|
||||
"prefs.shortcuts_press": "请按键...",
|
||||
"prefs.single_surface_analysis": "仅使用一个曲面分析",
|
||||
"prefs.single_surface_analysis_help": "关闭时,曲面着色分析和连续性分析可以同时保持启用。开启时,仅保留最后启动或修改的分析。",
|
||||
"prefs.sketch_align_points": "绘制时与其他草图点对齐",
|
||||
@@ -2469,7 +2576,7 @@
|
||||
"prefs.stand_up_unknown.ask": "在对话框中询问",
|
||||
"prefs.stand_up_unknown.note": "显示提示",
|
||||
"prefs.stand_up_unknown.off": "不提供",
|
||||
"prefs.stand_up_unknown_note": "当文件头未注明已知以 Y 轴向上保存的程序时,如何提供立起选项。仅当文件看起来平躺时才显示提示;“在对话框中询问”会像以前一样对每个此类文件进行询问。",
|
||||
"prefs.stand_up_unknown_note": "文件未注明已知程序时如何提出建议。",
|
||||
"prefs.status_bar": "状态栏",
|
||||
"prefs.status_bar_note": "窗口底边的一条细栏显示正在编辑的对象、打开的草图的状态(以其颜色显示)以及设计的单位;单击单位即可更改。草图的状态随后不再显示在视图的角落。",
|
||||
"prefs.status_done": "完成",
|
||||
@@ -2542,8 +2649,8 @@
|
||||
"prefs.validation.label": "内核完整检查",
|
||||
"prefs.validation.off": "关闭(实验性)",
|
||||
"prefs.validation_note": "每一步之后:检查每次扫掠、布尔运算和倒圆。\n每个特征一次:每个特征的结果检查一次。\n关闭(实验性):不检查。",
|
||||
"prefs.value_boxes_in_view": "在视图中于手柄旁显示数值",
|
||||
"prefs.value_boxes_in_view_tip": "命令的数值(距离、半径等)显示在模型上方手柄旁的框中。关闭后,在命令窗口中输入数值,选取后键盘输入会进入该窗口,模型不会被遮挡。",
|
||||
"prefs.value_boxes_in_view": "在控制柄旁显示数值",
|
||||
"prefs.value_boxes_in_view_tip": "将拉伸距离、圆角半径等显示为视图中的输入框。",
|
||||
"prefs.view": "视图行为",
|
||||
"prefs.view_confirm_corner": "在视图角落显示确定和取消",
|
||||
"prefs.view_confirm_corner_note": "打开对话框或草图时,视图右上角的勾号和叉号与对话框的确定和取消作用相同,或完成和取消草图。视图导航器会下移到它们下方。",
|
||||
@@ -3944,11 +4051,11 @@
|
||||
"select two components": "请选择两个组件",
|
||||
"set by the sketch's lines": "由草图的线条决定",
|
||||
"settings.display_quality_note": "仅影响显示,不影响设计和导出。",
|
||||
"settings.double_click_components": "双击组件以激活它,或在原位编辑链接组件",
|
||||
"settings.double_click_components": "双击组件以激活",
|
||||
"settings.frame_rate_details_note": "添加最近各帧耗时的图表(叠加绘制的三角形数)、帧耗时直方图以及 GPU 绘制的内容。点击视图中的帧率可显示或隐藏。",
|
||||
"settings.frame_rate_note": "在视图右下角显示每秒帧数。",
|
||||
"settings.insert_lists_open_designs": "插入设计时先列出其他标签页中打开的设计",
|
||||
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
|
||||
"settings.insert_lists_open_designs": "插入设计时优先列出已打开的设计",
|
||||
"settings.measure_axis_distances": "测量中的轴向距离",
|
||||
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
|
||||
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
|
||||
"settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。",
|
||||
@@ -3958,7 +4065,7 @@
|
||||
"settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 SolidCraft 无法启动,因此已更改:",
|
||||
"settings.reset_title": "设置已更改",
|
||||
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
|
||||
"settings.update_changed_links": "返回标签页时更新已更改的链接设计",
|
||||
"settings.update_changed_links": "返回选项卡时更新已更改的链接",
|
||||
"settings.window_system_details": "详情",
|
||||
"settings.window_system_note_now": "重启后生效。当前:{now}。",
|
||||
"settings.window_system_wayland_limited": "在原生 Wayland 下部分功能受限。",
|
||||
@@ -4360,7 +4467,7 @@
|
||||
"ui.agent_chat_send_now_tip": "停止智能体当前的步骤,并接着发送这条消息(Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "SolidCraft 会与安装在此计算机上的编程代理通信。请在“首选项 › AI”中选择一个。",
|
||||
"ui.agent_chat_setup_title": "设置助手",
|
||||
"ui.agent_chat_side": "智能体聊天位置",
|
||||
"ui.agent_chat_side": "停靠聊天",
|
||||
"ui.agent_chat_signed_in": "已登录",
|
||||
"ui.agent_chat_state_auth": "需要登录",
|
||||
"ui.agent_chat_state_busy": "工作中…",
|
||||
@@ -4379,7 +4486,7 @@
|
||||
"ui.agent_chat_undone": "(已撤销)",
|
||||
"ui.agent_chat_view_attached": "已附加视图",
|
||||
"ui.agent_chat_waiting": "等待您的回复",
|
||||
"ui.agent_cursor": "代理光标",
|
||||
"ui.agent_cursor": "代理指针",
|
||||
"ui.agent_cursor_speed": "代理光标速度",
|
||||
"ui.agent_off_thread": "代理运行命令时保持窗口响应",
|
||||
"ui.agent_off_thread_note": "代理(来自 AI 面板或 MCP 客户端)的耗时命令在窗口之外计算:运行时仍可旋转和查看视图,并可在视图中的提示上停止它。关闭:每条命令在窗口线程上运行,窗口会等待其完成。",
|
||||
@@ -4425,14 +4532,14 @@
|
||||
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
|
||||
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
|
||||
"ui.ai_kind": "类型",
|
||||
"ui.ai_mode_all": "全部允许",
|
||||
"ui.ai_mode_all": "执行所有操作",
|
||||
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
|
||||
"ui.ai_mode_all_warning": "智能体可以不经询问保存、覆盖、导出和替换你的设计。只在你信任的智能体和已保存的设计上使用。",
|
||||
"ui.ai_mode_ask": "每次询问",
|
||||
"ui.ai_mode_ask": "先询问",
|
||||
"ui.ai_mode_ask_note": "对设计的每项更改和每个文件操作都要等待你的允许。",
|
||||
"ui.ai_mode_edits": "自动允许模型编辑",
|
||||
"ui.ai_mode_edits": "允许编辑",
|
||||
"ui.ai_mode_edits_note": "建模命令无需询问即可运行;保存、打开、导出等文件操作或破坏性操作仍会询问。",
|
||||
"ui.ai_mode_global": "按上方设置",
|
||||
"ui.ai_mode_global": "与上方相同",
|
||||
"ui.ai_model": "模型",
|
||||
"ui.ai_model_hint": "qwen3:14b",
|
||||
"ui.ai_modes_scope": "这些设置适用于 SolidCraft 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
|
||||
@@ -4458,7 +4565,7 @@
|
||||
"ui.ai_save": "保存",
|
||||
"ui.ai_settings": "AI 设置…",
|
||||
"ui.ai_show_features": "显示 AI 功能",
|
||||
"ui.ai_show_features_note": "智能体聊天:一个面板,由你已在使用的编程智能体或你选择的模型端点处理打开的设计。关闭后:没有 AI 按钮、快捷键、菜单项或面板,也不会查找或启动任何程序。",
|
||||
"ui.ai_show_features_note": "关闭后将隐藏所有助手功能,且不会启动任何内容。下方所有选项都会变灰。\n\n智能体聊天:一个面板,由你已在使用的编程智能体或你选择的模型端点处理打开的设计。关闭后:没有 AI 按钮、快捷键、菜单项或面板,也不会查找或启动任何程序。",
|
||||
"ui.ai_switch_tip": "更改此对话的智能体",
|
||||
"ui.ai_test": "测试",
|
||||
"ui.ai_test_acp_ok": "已响应:{agent}",
|
||||
@@ -4492,7 +4599,7 @@
|
||||
"ui.ai_tool_sum_then": "{first},然后 {next}",
|
||||
"ui.ai_tool_unfold": "点击显示返回内容",
|
||||
"ui.ai_tool_waiting": "等待中",
|
||||
"ui.ai_toolbar_button": "在工具栏显示按钮",
|
||||
"ui.ai_toolbar_button": "应用栏中的助手按钮",
|
||||
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
|
||||
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
|
||||
"ui.ai_url_hint": "https://example.com/v1",
|
||||
@@ -4764,7 +4871,7 @@
|
||||
"ui.extract_done": "该组件现在是独立的设计,并已在此处链接。",
|
||||
"ui.extrude": "拉伸",
|
||||
"ui.extrude_auto_to_object": "拉伸到所选目标",
|
||||
"ui.extrude_auto_to_object_tip": "在拉伸中选择轮廓后,选取其平面外的面、顶点或平行平面会将延伸类型切换为“到对象”",
|
||||
"ui.extrude_auto_to_object_tip": "选择面、顶点或平行平面会将范围切换为到对象。",
|
||||
"ui.factor": "系数",
|
||||
"ui.fade": "收尾",
|
||||
"ui.fade_length": "收尾长度",
|
||||
@@ -4908,7 +5015,7 @@
|
||||
"ui.hidePlanes": "隐藏平面",
|
||||
"ui.hideProfile": "隐藏轮廓",
|
||||
"ui.hide_component_history": "隐藏其历史记录",
|
||||
"ui.hide_origin_by_default": "默认隐藏原点",
|
||||
"ui.hide_origin_by_default": "在新设计中隐藏原点文件夹",
|
||||
"ui.hide_origin_note": "新设计开始时原点文件夹处于隐藏状态。显示或隐藏过原点的设计会保留自己的原点设置。",
|
||||
"ui.history_back": "后退一步",
|
||||
"ui.history_below_browser": "浏览器下方",
|
||||
@@ -5068,7 +5175,7 @@
|
||||
"ui.pitch": "螺距",
|
||||
"ui.placement": "放置",
|
||||
"ui.placement_handles": "放置手柄",
|
||||
"ui.placement_handles_tip": "垫块、凸台或卡扣预览上的手柄:拖动标记在模型上移动,用转环旋转",
|
||||
"ui.placement_handles_tip": "用于在模型上拖动特征的标记和用于旋转的圆环。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
@@ -5266,8 +5373,8 @@
|
||||
"ui.start": "开始",
|
||||
"ui.start_over": "重新开始",
|
||||
"ui.start_page": "起始页",
|
||||
"ui.step_values": "使用方向键和滚轮调整数值",
|
||||
"ui.step_values_tip": "在正在输入的数值框中,上下方向键和框上的鼠标滚轮可将数字增减 1(Shift:10,Alt:0.1)。表达式保持不变。",
|
||||
"ui.step_values": "使用箭头和滚轮调整数值",
|
||||
"ui.step_values_tip": "在数值字段上使用上/下箭头或滚轮可更改数值。",
|
||||
"ui.stitch_tolerance_hint": "严格",
|
||||
"ui.stretch": "伸展",
|
||||
"ui.supersampling": "超级采样 (SSAA)",
|
||||
|
||||
@@ -291,6 +291,17 @@ impl crate::Feature {
|
||||
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => remap_geo(geo, ids, bodies, 0)?,
|
||||
// An Align frame's joint origin finds its faces and edges by name, as the feature's
|
||||
// own face names are found.
|
||||
K::Align { from_frame, to_frame, .. } => {
|
||||
for fr in [from_frame, to_frame].into_iter().flatten() {
|
||||
if let crate::AlignFrame::Origin { origin } = fr {
|
||||
for n in origin.snap.face_names_mut() {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -610,6 +610,51 @@ impl<'de> Deserialize<'de> for FlangeEdge {
|
||||
}
|
||||
}
|
||||
|
||||
/// One side of an Align by frames (#6257): an origin and a z direction, found again from the
|
||||
/// geometry each time the design is evaluated, so the bodies follow it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AlignFrame {
|
||||
/// A joint origin on the model (its component's coordinates): its origin and z.
|
||||
Origin { origin: Box<crate::joints::JointOrigin> },
|
||||
/// A sketch point; z along the sketch's normal.
|
||||
SketchPoint { sketch: SketchId, point: String },
|
||||
/// The centre of a sketch circle, arc or ellipse; z along the sketch's normal.
|
||||
SketchCurve { sketch: SketchId, curve: String },
|
||||
}
|
||||
|
||||
impl AlignFrame {
|
||||
/// The frame's origin and unit z in the model state `st` (its component's coordinates).
|
||||
pub fn resolve(&self, doc: &Document, st: &crate::ModelState) -> Result<(Vec3, Vec3)> {
|
||||
let sketch = |id: &SketchId| st.sketch(id.0).ok_or_else(|| DocError::Unknown(format!("sketch {id}")));
|
||||
let (o, z) = match self {
|
||||
AlignFrame::Origin { origin } => {
|
||||
let m = crate::joints::resolve_origin(doc, st, origin)?;
|
||||
(Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[2][0], m[2][1], m[2][2]))
|
||||
}
|
||||
AlignFrame::SketchPoint { sketch: id, point } => {
|
||||
let ss = sketch(id)?;
|
||||
let q = ss.sketch.point_index(point).and_then(|i| ss.sketch.point(i));
|
||||
(ss.plane.to_world(q.ok_or_else(|| DocError::Unknown(format!("sketch point `{point}`")))?), ss.plane.normal())
|
||||
}
|
||||
AlignFrame::SketchCurve { sketch: id, curve } => {
|
||||
use solidcraft_sketch::CurveKind;
|
||||
let ss = sketch(id)?;
|
||||
let kind = ss.sketch.curve_index(curve).and_then(|i| ss.sketch.curves.get(i)).map(|c| &c.kind);
|
||||
let centre = match kind.ok_or_else(|| DocError::Unknown(format!("sketch curve `{curve}`")))? {
|
||||
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } | CurveKind::Ellipse { c, .. } => ss.sketch.point(*c),
|
||||
_ => return Err(DocError::Invalid(format!("sketch curve `{curve}` is not a circle, arc or ellipse"))),
|
||||
};
|
||||
(ss.plane.to_world(centre.ok_or_else(|| DocError::Invalid(format!("sketch curve `{curve}` has no centre")))?), ss.plane.normal())
|
||||
}
|
||||
};
|
||||
match z.normalized() {
|
||||
Some(z) if o.is_finite() => Ok((o, z)),
|
||||
_ => Err(DocError::Invalid("the frame has no finite origin and direction".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -914,6 +959,10 @@ pub enum FeatureKind {
|
||||
/// Buttress forms: which way the steep flank of the screw's teeth faces.
|
||||
#[serde(default, skip_serializing_if = "crate::thread_forms::is_default")]
|
||||
steep_flank: crate::thread_forms::SteepFlank,
|
||||
/// Set on threads saved before holes were sized by their nominal diameter, whose size
|
||||
/// was left to the tap drill: a 12 mm hole stays M14 there. New threads leave it off.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
old_auto_size: bool,
|
||||
},
|
||||
/// Ruled loft through profiles of several sketches, in order.
|
||||
Loft {
|
||||
@@ -1172,6 +1221,19 @@ pub enum FeatureKind {
|
||||
/// offset in full.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
axes: Option<Vec<Dir>>,
|
||||
/// A frame on the bodies (#6257), found again on every evaluation: its origin and z are
|
||||
/// the `from` point and normal. Replaces `from`, `from_normal` and `from_plane`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
from_frame: Option<AlignFrame>,
|
||||
/// A frame the bodies move onto, found again on every evaluation, so they follow it when
|
||||
/// it moves. Replaces `to`, `to_normal` and `to_plane`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to_frame: Option<AlignFrame>,
|
||||
/// A turn about the target's direction (its frame's z, face normal or plane normal),
|
||||
/// through where `from` lands, after the move.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Angle")]
|
||||
angle: Option<Expr>,
|
||||
},
|
||||
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
|
||||
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
|
||||
|
||||
@@ -7018,7 +7018,7 @@ fn eval_feature(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Thread { .. } => thread::eval(doc, vals, f, st),
|
||||
FeatureKind::Thread { .. } => thread::eval(doc, vals, f, st, warning),
|
||||
FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => {
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
// No face opened (a closed hollow body) picks no body: the one there is, or the named one.
|
||||
|
||||
@@ -1279,6 +1279,7 @@ fn marking(p: Vec3) -> FeatureKind {
|
||||
chamfer_angle: None,
|
||||
chamfer_depth: None,
|
||||
steep_flank: Default::default(),
|
||||
old_auto_size: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,17 +89,26 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane, axes } => {
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane, axes, from_frame, to_frame, angle } => {
|
||||
// A plane on either side is worked out now, so the bodies follow it when it moves.
|
||||
let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized()));
|
||||
if let Some(p) = from_plane {
|
||||
let pl = doc.resolve_plane_in(vals, p, 0, Some(&*st))?;
|
||||
(from, from_normal) = (pl.origin, Some(pl.normal()));
|
||||
}
|
||||
let target = to_plane.as_ref().map(|p| doc.resolve_plane_in(vals, p, 0, Some(&*st))).transpose()?;
|
||||
let (to, to_normal) = match &target {
|
||||
Some(pl) => (pl.origin, Some(pl.normal())),
|
||||
None => (to.0, to_normal.and_then(|n| n.normalized())),
|
||||
// So is a frame (#6257): its origin and z, found again from the geometry.
|
||||
if let Some(fr) = from_frame {
|
||||
let (o, z) = fr.resolve(doc, st)?;
|
||||
(from, from_normal) = (o, Some(z));
|
||||
}
|
||||
let target = match to_frame {
|
||||
Some(_) => None,
|
||||
None => to_plane.as_ref().map(|p| doc.resolve_plane_in(vals, p, 0, Some(&*st))).transpose()?,
|
||||
};
|
||||
let (to, to_normal) = match (to_frame, &target) {
|
||||
(Some(fr), _) => fr.resolve(doc, st).map(|(o, z)| (o, Some(z)))?,
|
||||
(None, Some(pl)) => (pl.origin, Some(pl.normal())),
|
||||
(None, None) => (to.0, to_normal.and_then(|n| n.normalized())),
|
||||
};
|
||||
let mut m = translation(Vec3::ZERO);
|
||||
if let (Some(a), Some(b)) = (from_normal, to_normal) {
|
||||
@@ -143,6 +152,19 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
m[3][0] += d.x;
|
||||
m[3][1] += d.y;
|
||||
m[3][2] += d.z;
|
||||
// Then the turn about the target's direction, through where `from` landed.
|
||||
if let Some(e) = angle {
|
||||
let a = val(vals, e, Kind::Angle)?;
|
||||
if !a.is_finite() {
|
||||
return Err(DocError::Invalid("the Align angle is not a finite number".into()));
|
||||
}
|
||||
let n = to_normal.ok_or_else(|| {
|
||||
DocError::Invalid("an Align angle turns about the target's direction: move onto a frame, a face or a plane".into())
|
||||
})?;
|
||||
if a.abs() > 1e-12 {
|
||||
m = crate::mat_mul(&rotation(apply_point(&m, from), n, a), &m);
|
||||
}
|
||||
}
|
||||
if bodies.is_empty() {
|
||||
return Err(DocError::Invalid("no bodies to align".into()));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ pub(super) struct ThreadAsk<'a> {
|
||||
/// A tapped hole's thread: `designation` is a catalogue size (ISO metric, or a catalogued
|
||||
/// UNC/UNF, G, Rp, Rc or NPT size), and a size that is not metric fits the hole drilled for it.
|
||||
pub tapped: bool,
|
||||
/// A design saved before holes were sized by their nominal diameter: with no designation, a
|
||||
/// hole is matched by its tap drill alone ([`ThreadForm::fit_with`]), so it keeps its size.
|
||||
pub old_auto_size: bool,
|
||||
}
|
||||
|
||||
/// How one end of a thread's length meets its face.
|
||||
@@ -115,6 +118,9 @@ pub(super) struct ThreadOn {
|
||||
ease: [f64; 2],
|
||||
/// The end of the axis (0: its start, 1: its end) the thread starts from.
|
||||
from: usize,
|
||||
/// A modelled internal thread in a hole wider than it needs: the radius the bore is narrowed
|
||||
/// to (the thread's minor radius) before the groove is cut, with the hole's own radius.
|
||||
narrow: Option<f64>,
|
||||
}
|
||||
|
||||
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
|
||||
@@ -197,7 +203,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
(Some(t), _) => crate::thread_forms::ThreadSize { form, major: t.major_diameter, pitch: t.pitch, lead: t.pitch },
|
||||
(None, Some(s)) => form.parse(s, starts).map_err(DocError::Invalid)?,
|
||||
(None, None) => {
|
||||
let (d, p) = form.fit(dia, cyl.internal).ok_or_else(|| {
|
||||
let (d, p) = form.fit_with(dia, cyl.internal, ask.old_auto_size).ok_or_else(|| {
|
||||
DocError::Invalid(if form.sizes().is_empty() {
|
||||
format!("{} threads have no size table: give the designation ({})", form.name(), form.format(50.8, 25.4 / 8.0, 25.4 / 8.0))
|
||||
} else {
|
||||
@@ -243,12 +249,19 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
// The hole drilled for it, without borrowing an ISO minor-diameter rule.
|
||||
cyl.internal && (t.tap_drill - dia).abs() < 0.15 * major
|
||||
} else if cyl.internal {
|
||||
(minor - dia).abs() < 0.2 * major || (form.drill(major, pitch) - dia).abs() < 0.15 * major
|
||||
(minor - dia).abs() < 0.2 * major || (form.drill(major, pitch) - dia).abs() < 0.15 * major || (major - dia).abs() < 0.15 * major
|
||||
} else {
|
||||
(major - dia).abs() < 0.15 * major
|
||||
};
|
||||
if !fits {
|
||||
return Err(DocError::Invalid(format!("an {designation} thread does not fit a {dia:.2} mm {}", if cyl.internal { "hole" } else { "shaft" })));
|
||||
let kind = if cyl.internal { "hole" } else { "shaft" };
|
||||
let try_instead = form
|
||||
.fit(dia, cyl.internal)
|
||||
.map(|(n, q)| form.format(n, q, q))
|
||||
.filter(|a| *a != designation)
|
||||
.map(|a| format!("; this {kind} takes {a}"))
|
||||
.unwrap_or_default();
|
||||
return Err(DocError::Invalid(format!("an {designation} thread does not fit a {dia:.2} mm {kind}{try_instead}")));
|
||||
}
|
||||
// The radii the groove cuts: from the external thread's minor diameter (a nut's: its minor)
|
||||
// to the major (a nut's root), and to the face itself where it lies outside that.
|
||||
@@ -354,7 +367,13 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
hand: ask.hand,
|
||||
starts,
|
||||
};
|
||||
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease, from: side })
|
||||
// A hole already as wide as the thread's major diameter (a 12 mm hole for M12) has no wall to
|
||||
// cut a groove into: the bore is narrowed to the thread's minor diameter first.
|
||||
let narrow = (ask.modeled && cyl.internal && tapped.is_none() && !form.is_taper())
|
||||
.then(|| form.groove(major, pitch, true))
|
||||
.filter(|g| !(g.root_r - cyl.radius >= g.basic_depth / 4.0))
|
||||
.map(|g| g.crest_r);
|
||||
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease, from: side, narrow })
|
||||
}
|
||||
|
||||
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
|
||||
@@ -681,7 +700,7 @@ fn taper_envelope(t: &ThreadInfo, radius: f64, ends: [End; 2], (d0, from): (f64,
|
||||
|
||||
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
|
||||
/// one cut into its body.
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
let FeatureKind::Thread {
|
||||
face,
|
||||
designation,
|
||||
@@ -697,6 +716,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
chamfer_angle,
|
||||
chamfer_depth,
|
||||
steep_flank,
|
||||
old_auto_size,
|
||||
} = &f.kind
|
||||
else {
|
||||
return Err(DocError::Invalid("not a thread".into()));
|
||||
@@ -723,6 +743,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
modeled: *modeled,
|
||||
ease_out: (0.0, 0.0),
|
||||
tapped: false,
|
||||
old_auto_size: *old_auto_size,
|
||||
};
|
||||
if ease_out.is_some() && !*modeled {
|
||||
return Err(DocError::Invalid("a runout is cut only on a modelled thread".into()));
|
||||
@@ -735,6 +756,18 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
let angle = chamfer_angle.as_deref().map(|e| val(vals, e, Kind::Angle)).transpose()?;
|
||||
let depth = chamfer_depth.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?;
|
||||
if let Some(r) = on.narrow {
|
||||
let note = format!(
|
||||
"narrowed the {} mm hole to {} mm (the {} thread's minor diameter) so the thread has material to cut into",
|
||||
show_mm(2.0 * on.cyl.radius),
|
||||
show_mm(2.0 * r),
|
||||
on.info.designation
|
||||
);
|
||||
*warning = Some(match warning.take() {
|
||||
Some(w) => format!("{w}; {note}"),
|
||||
None => note,
|
||||
});
|
||||
}
|
||||
cut(st, on, (*tip, angle, depth))
|
||||
}
|
||||
|
||||
@@ -752,10 +785,33 @@ pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRun
|
||||
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
|
||||
/// (`tip`, with its angle and depth when given).
|
||||
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<()> {
|
||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side } = on;
|
||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow } = on;
|
||||
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let (input, radius) = (mb.traced(), cyl.radius);
|
||||
let (mut input, radius) = (mb.traced(), cyl.radius);
|
||||
// A hole wider than the thread needs is filled in to the thread's minor radius over the
|
||||
// threaded length first, so the groove has the wall to cut into.
|
||||
let mut groove_radius = radius;
|
||||
if let Some(r_min) = narrow {
|
||||
let (lo, hi) = ((t.start - ease[0]).max(cyl.start), (t.end + ease[1]).min(cyl.end));
|
||||
let k = *t.axis;
|
||||
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
|
||||
let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?;
|
||||
let plane = Plane { origin: t.axis_point + k * lo, x: u, y: k };
|
||||
let r_out = radius + 0.1 * t.pitch;
|
||||
let pts = [Vec2::new(r_min, 0.0), Vec2::new(r_out, 0.0), Vec2::new(r_out, hi - lo), Vec2::new(r_min, hi - lo)];
|
||||
let region = Region2 { outer: solidcore_record::Loop2::polygon(&pts), holes: Vec::new() };
|
||||
let sleeve = kernel::revolve(&plane, &[region], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), std::f64::consts::TAU)?
|
||||
.pop()
|
||||
.ok_or_else(|| DocError::Invalid("the narrowed bore".into()))?;
|
||||
let faces = sleeve.face_count();
|
||||
let filled =
|
||||
kernel::boolean(&input.body, &sleeve, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("narrowing the hole removed everything".into()))?;
|
||||
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&filled, &[&l, &unnamed]));
|
||||
input = Traced { body: filled, lineage };
|
||||
groove_radius = r_min;
|
||||
}
|
||||
// The exact grooves: SMLib's screw sweeps, cut by one B-rep boolean.
|
||||
// A taper thread starts at its free end at the major diameter its standard puts there: an
|
||||
// internal one's gauge (reference) plane at its face, an external one's that far in.
|
||||
@@ -767,7 +823,7 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
||||
false => None,
|
||||
};
|
||||
let mut grooves = Vec::new();
|
||||
for g in thread_grooves(&t, radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! An Align feature (`FeatureKind::Align`) saves optional `from_frame` and `to_frame` (a joint
|
||||
//! origin on the model, `{"type": "origin", "origin": {...}}`, or a sketch point or the centre of a
|
||||
//! sketch circle, arc or ellipse, `{"type": "sketch_point", "sketch": id, "point": "<id>"}` /
|
||||
//! `{"type": "sketch_curve", "sketch": id, "curve": "<id>"}`), found again from the geometry on
|
||||
//! every evaluation so the bodies follow them, and an optional `angle` expression (a turn about the
|
||||
//! target's direction). Older designs have none of them and evaluate as before. The last release
|
||||
//! refuses a design with this change by its newer format number.
|
||||
@@ -0,0 +1,45 @@
|
||||
//! A thread with no `designation` (its size left automatic) now takes, for a hole, the size
|
||||
//! whose nominal diameter the hole has (a 12 mm hole is M12); before, it took the size whose tap
|
||||
//! drill the hole matches (a 12 mm hole was M14, the drill M14 is tapped at). A design saved
|
||||
//! before keeps its size: each such thread is saved with `old_auto_size`, which an automatic
|
||||
//! thread made now does not have. A modelled thread in a hole wider than it needs also narrows
|
||||
//! the bore now, rather than being refused. The last release refuses a design saved with this
|
||||
//! (the newer format number).
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn step(v: &mut Value) -> crate::Result<()> {
|
||||
let Some(Value::Array(features)) = v.get_mut("features") else {
|
||||
return Ok(());
|
||||
};
|
||||
for feature in features {
|
||||
let Some(object) = feature.as_object_mut() else { continue };
|
||||
if object.get("type").and_then(Value::as_str) != Some("thread") {
|
||||
continue;
|
||||
}
|
||||
if object.get("designation").is_none_or(Value::is_null) {
|
||||
object.insert("old_auto_size".into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn an_automatic_thread_keeps_the_old_rule_and_a_named_one_is_left_alone() {
|
||||
let mut v = json!({"features": [
|
||||
{"type": "thread", "face": [6, 0, 5]},
|
||||
{"type": "thread", "designation": "M12"},
|
||||
{"type": "hole", "thread": "M12"}
|
||||
]});
|
||||
super::step(&mut v).unwrap();
|
||||
assert_eq!(v["features"][0]["old_auto_size"], json!(true));
|
||||
assert!(v["features"][1].get("old_auto_size").is_none());
|
||||
assert!(v["features"][2].get("old_auto_size").is_none());
|
||||
let mut empty = json!({"name": "Untitled"});
|
||||
super::step(&mut empty).unwrap();
|
||||
assert_eq!(empty, json!({"name": "Untitled"}));
|
||||
}
|
||||
}
|
||||
@@ -554,8 +554,22 @@ impl ThreadForm {
|
||||
}
|
||||
|
||||
/// The standard size a cylinder of this diameter takes: a shaft is the major diameter, a hole
|
||||
/// the drill. None when no size is within 15% (or the form has no size table).
|
||||
/// its nominal diameter, else the drill ([`fit_with`](Self::fit_with)). None when no size is within 15% (or the form has no size table).
|
||||
pub fn fit(self, diameter: f64, internal: bool) -> Option<(f64, f64)> {
|
||||
self.fit_with(diameter, internal, false)
|
||||
}
|
||||
|
||||
/// [`fit`](Self::fit) as designs saved before the nominal rule had it (`old_rule`: a hole is
|
||||
/// matched by the tap drill alone, so a 12 mm hole is M14's) or as it is now: a hole as wide
|
||||
/// as a size's nominal diameter (within 1% and 0.03 mm) is that size, a 12 mm hole is M12,
|
||||
/// and only a hole near no nominal diameter is matched by tap drill.
|
||||
pub fn fit_with(self, diameter: f64, internal: bool, old_rule: bool) -> Option<(f64, f64)> {
|
||||
if internal && !old_rule {
|
||||
let near = |n: f64| (n - diameter).abs() <= 0.01 * diameter + 0.03;
|
||||
if let Some((n, p)) = self.sizes().into_iter().find(|(n, _)| near(*n)) {
|
||||
return Some((n, p));
|
||||
}
|
||||
}
|
||||
self.sizes()
|
||||
.into_iter()
|
||||
.map(|(n, p)| (n, p, if internal { self.drill(n, p) } else { n }))
|
||||
|
||||
@@ -67,6 +67,8 @@ impl Walk for crate::thread_forms::ThreadForm {}
|
||||
impl Walk for crate::thread_forms::ThreadHand {}
|
||||
impl Walk for crate::thread_forms::ThreadTip {}
|
||||
impl Walk for crate::thread_forms::SteepFlank {}
|
||||
// An Align frame's joint origin: picks in its component's coordinates, found by their names.
|
||||
impl Walk for Box<crate::joints::JointOrigin> {}
|
||||
|
||||
/// A reference walks itself: a sketch, a feature or a body.
|
||||
macro_rules! reference {
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Refusal {
|
||||
/// The batch reply for a batch refused before its first step: as a failed step's reply,
|
||||
/// with nothing completed, nothing undone and every step not run.
|
||||
pub fn reply(&self, steps: usize) -> Value {
|
||||
json!({
|
||||
let mut reply = json!({
|
||||
"completed": 0,
|
||||
"results": [],
|
||||
"failed": {"index": self.index, "command": self.command, "error": self.error},
|
||||
@@ -41,7 +41,11 @@ impl Refusal {
|
||||
"rolled_back": false,
|
||||
"not_run": (0..steps).collect::<Vec<_>>(),
|
||||
"note": "nothing was run: the batch was checked before its first step",
|
||||
})
|
||||
});
|
||||
if let Some(reason) = crate::panels::commands::structured_error(&self.error) {
|
||||
reply["failed"]["panel_error"] = reason;
|
||||
}
|
||||
reply
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +86,15 @@ pub fn contracts(steps: &[Value]) -> Option<Refusal> {
|
||||
|
||||
/// Step `index`'s refusal when its parameters break the typed contract of its command `id`.
|
||||
fn contract_refusal(index: usize, id: &str, step: &Value) -> Option<Refusal> {
|
||||
if crate::panels::commands::is_command(id) {
|
||||
let empty = json!({});
|
||||
let params = step.get("params").filter(|p| !p.is_null()).unwrap_or(&empty);
|
||||
return crate::panels::commands::parse_command(id, params).err().map(|reason| Refusal {
|
||||
index,
|
||||
command: Some(id.into()),
|
||||
error: crate::panels::commands::error_text(reason),
|
||||
});
|
||||
}
|
||||
let first = crate::schema::issues(id, step.get("params").unwrap_or(&Value::Null)).into_iter().next()?;
|
||||
Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() })
|
||||
}
|
||||
|
||||
@@ -16,11 +16,12 @@ use solidcraft_doc::{BrowserEntry, FeatureKind, MAX_GROUP_NESTING};
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr};
|
||||
use crate::params::{bad, bool_, expr, str_};
|
||||
use crate::schema::browser::{RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn};
|
||||
use crate::schema::browser::{DocumentUnitsParams, DocumentUnitsResult, RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const REPAIR_CONTRACT: TypedContract<RepairParams> = TypedContract::new();
|
||||
const UNITS_CONTRACT: TypedContract<DocumentUnitsParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("body.rename", "Rename Body", rename_body).icon("rename").params("body: its name, name: the new name (features that use the body follow)"),
|
||||
@@ -42,8 +43,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("browser.rename_group", "Rename Group", rename_group).icon("rename").params("group: id, name"),
|
||||
CommandSpec::new("browser.move", "Move Browser Items", move_items).icon("reorder")
|
||||
.params("folder, component?, items: [keys (strings) and group ids (numbers)]; group?: id (into it; omit for the folder's top level); before?: key or group id (position in it; default the end). A group cannot go into itself or past 8 levels"),
|
||||
CommandSpec::new("document.units", "Change Design Units", document_units)
|
||||
CommandSpec::new("document.units", "Change Design Units", crate::checked!("document.units", document_units))
|
||||
.icon("settings")
|
||||
.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)
|
||||
.icon("camera")
|
||||
@@ -844,8 +847,11 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`units` must be mm, cm, m, in or ft"));
|
||||
}
|
||||
let old = s.doc.units.clone();
|
||||
let reply = |kept: Vec<String>| {
|
||||
serde_json::to_value(DocumentUnitsResult { units: u.to_string(), kept, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
};
|
||||
if old == u {
|
||||
return Ok(json!({ "units": u, "kept": [] }));
|
||||
return reply(Vec::new());
|
||||
}
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
||||
@@ -886,7 +892,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
reply(kept)
|
||||
}
|
||||
|
||||
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1750,6 +1750,15 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
// A thread saved before holes were sized by their nominal diameter keeps the size it had
|
||||
// when it is edited without naming one.
|
||||
if let FeatureKind::Thread { old_auto_size: true, .. } = &old.kind
|
||||
&& cid == "solid.thread"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
&& parameters.get("designation").is_none_or(|d| d.is_null() || d.as_str().is_some_and(|d| d.trim().is_empty()))
|
||||
{
|
||||
parameters.entry("old_auto_size").or_insert_with(|| json!(true));
|
||||
}
|
||||
// Members keep their names through an edit, unless it gives them another name.
|
||||
if let FeatureKind::FrameMember { names, body_name, .. } = &old.kind
|
||||
&& cid == "frame.member"
|
||||
|
||||
@@ -132,7 +132,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.thread", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its drill); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a shaft thicker than the thread is refused with the size to make it, never turned down"),
|
||||
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its nominal diameter (a 12 mm hole is M12) or else the size whose tap drill it is); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); old_auto_size?: bool (an automatic size keeps the rule of designs saved before a hole was sized by its nominal diameter: a 12 mm hole is M14; set on those threads, keeps them as they were when edited); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a hole as wide as the thread or wider is narrowed to the thread's minor diameter first, and the reply's warning says so; a shaft thicker than the thread is refused with the size to make it, never turned down"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -3316,6 +3316,7 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(cmd, msg));
|
||||
}
|
||||
let designation_is_none = designation.is_none();
|
||||
let kind = FeatureKind::Thread {
|
||||
face: FacePick(face),
|
||||
designation,
|
||||
@@ -3331,11 +3332,18 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chamfer_angle,
|
||||
chamfer_depth,
|
||||
steep_flank,
|
||||
// Only a thread saved before holes were sized by nominal diameter, edited by `timeline.redefine`.
|
||||
old_auto_size: designation_is_none && bool_(p, "old_auto_size").unwrap_or(false),
|
||||
};
|
||||
let v = add_feature(s, p, kind)?;
|
||||
let st = s.model.state();
|
||||
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
|
||||
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
|
||||
let mut reply = json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t});
|
||||
// A hole narrowed to the thread's minor diameter says so.
|
||||
if let Some(w) = v.get("warning").filter(|w| !w.is_null()) {
|
||||
reply["warning"] = w.clone();
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::{AlignFrame, FeatureKind};
|
||||
use solidcraft_doc::{BodyName, SketchId};
|
||||
use solidcraft_doc::{Dir, FacePick, Position};
|
||||
use solidcraft_geom::prelude::*;
|
||||
@@ -44,7 +44,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.align", "Align", align)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("align")
|
||||
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool; axes?: [x, y, z] (point-to-point only: which world axes the offset moves along; default all three, at least one true)"),
|
||||
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); from_frame / to_frame: a frame found again whenever the design changes, so the bodies follow it, in place of that side's point, face or plane: a joint origin as joint.create takes one ({face|circle|edge|point: [x,y,z]}, a face's name, a named joint origin) or {sketch, point|curve} (a sketch point, or a circle's, arc's or ellipse's centre, z along the sketch's normal), on the bodies' own component; faces, planes and frames also turn to meet; flip?: bool; axes?: [x, y, z] (point-to-point only: which world axes the offset moves along; default all three, at least one true); angle?: expr (a turn about the target's direction after the move)"),
|
||||
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
|
||||
];
|
||||
|
||||
@@ -444,12 +444,26 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
|
||||
};
|
||||
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
|
||||
let world_pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
|
||||
if plane.is_some() {
|
||||
// Or a frame (#6257), found again when the feature is evaluated.
|
||||
let (from_frame, to_frame) = (align_frame(s, p, "from", &bodies)?, align_frame(s, p, "to", &bodies)?);
|
||||
// A target on the bodies that move would move away with them: it must be found the same
|
||||
// without them.
|
||||
if let Some(fr @ AlignFrame::Origin { .. }) = &to_frame {
|
||||
let st = s.model.state();
|
||||
let mut rest = (*st).clone();
|
||||
rest.bodies.retain(|b| !bodies.contains(&b.name));
|
||||
let same = |(a, n): (Vec3, Vec3), (b, m): (Vec3, Vec3)| a.dist(b) <= 1e-9 * (1.0 + a.len()) && n.dot(m) > 1.0 - 1e-12;
|
||||
if !matches!((fr.resolve(&s.doc, &st), fr.resolve(&s.doc, &rest)), (Ok(a), Ok(b)) if same(a, b)) {
|
||||
return Err(bad(cmd, "`to_frame` is on a body that moves: pick the target on another body or on a sketch"));
|
||||
}
|
||||
}
|
||||
// Worked out from the plane or the frame when the feature is evaluated.
|
||||
let held = |plane: &Option<solidcraft_doc::PlaneRef>, frame: &Option<AlignFrame>| plane.is_some() || frame.is_some();
|
||||
let world_pick = |s: &Session, pk: &str, fk: &str, held: bool| -> Result<(Vec3, Option<Vec3>)> {
|
||||
if held {
|
||||
if p.get(pk).is_some() || p.get(fk).is_some() {
|
||||
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
|
||||
}
|
||||
// Worked out from the plane when the feature is evaluated.
|
||||
return Ok((Vec3::ZERO, None));
|
||||
}
|
||||
if let Some(fp) = p.get(fk).and_then(vec3) {
|
||||
@@ -471,24 +485,36 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|v| (v, None))
|
||||
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
|
||||
};
|
||||
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
|
||||
let (at, n) = world_pick(s, pk, fk, plane)?;
|
||||
if plane.is_some() {
|
||||
let pick = |s: &Session, pk: &str, fk: &str, held: bool| -> Result<(Vec3, Option<Vec3>)> {
|
||||
let (at, n) = world_pick(s, pk, fk, held)?;
|
||||
if held {
|
||||
return Ok((at, n));
|
||||
}
|
||||
Ok((solidcraft_doc::apply_point(&inverse, at), n.and_then(|n| solidcraft_doc::apply_vector(&inverse, n).normalized())))
|
||||
};
|
||||
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
|
||||
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
|
||||
let (from, from_normal) = pick(s, "from", "from_face", held(&from_plane, &from_frame))?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face", held(&to_plane, &to_frame))?;
|
||||
// A turn about the target's direction after the move.
|
||||
let angle = expr(p, "angle");
|
||||
if let Some(a) = &angle {
|
||||
check_expr(s, a, Kind::Angle, cmd, "angle")?;
|
||||
if to_normal.is_none() && !held(&to_plane, &to_frame) {
|
||||
return Err(bad(cmd, "`angle` turns about the target's direction: give `to_frame`, `to_face` or `to_plane`"));
|
||||
}
|
||||
}
|
||||
// Faces turn only to meet another face, a plane or a frame; a plane's normal and a frame's z
|
||||
// are read when evaluated. A target face's normal is also the axis of an `angle`.
|
||||
let (from_normal, to_normal) = match (from_normal, to_normal) {
|
||||
(Some(a), Some(b)) => (Some(a), Some(b)),
|
||||
(a, b) if from_plane.is_some() || to_plane.is_some() => (a, b),
|
||||
(a, b) if held(&from_plane, &from_frame) || held(&to_plane, &to_frame) || angle.is_some() => (a, b),
|
||||
_ => (None, None),
|
||||
};
|
||||
let axes = axes_param(p, cmd)?;
|
||||
if axes.is_some() && (from_plane.is_some() || to_plane.is_some() || from_normal.is_some() || to_normal.is_some()) {
|
||||
return Err(bad(cmd, "`axes` applies to point-to-point alignment: leave out `from_face`, `to_face`, `from_plane` and `to_plane`"));
|
||||
if axes.is_some() && (held(&from_plane, &from_frame) || held(&to_plane, &to_frame) || from_normal.is_some() || to_normal.is_some()) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"`axes` applies to point-to-point alignment: leave out `from_face`, `to_face`, `from_plane`, `to_plane`, `from_frame` and `to_frame`",
|
||||
));
|
||||
}
|
||||
// The directions are stored in the same frame as `from`/`to` (the first moved body's frame).
|
||||
let axes = axes.map(|mask| {
|
||||
@@ -512,10 +538,57 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
from_plane,
|
||||
to_plane,
|
||||
axes,
|
||||
from_frame,
|
||||
to_frame,
|
||||
angle,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The longest sketch point or curve id an Align frame takes.
|
||||
const MAX_SKETCH_ID: usize = 256;
|
||||
|
||||
/// An Align side's frame (#6257), `<key>_frame`: a joint origin as `joint.create` takes one, or a
|
||||
/// sketch point or the centre of a sketch circle, arc or ellipse (`{sketch, point|curve}`), on the
|
||||
/// moved bodies' component (all of them on one, placed once). It takes the place of `<key>`,
|
||||
/// `<key>_face` and `<key>_plane`, and is checked to resolve now.
|
||||
fn align_frame(s: &Session, p: &Value, key: &str, bodies: &[String]) -> Result<Option<AlignFrame>> {
|
||||
let cmd = "solid.align";
|
||||
let fk = format!("{key}_frame");
|
||||
let Some(v) = p.get(&fk).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
if let Some(other) = [key.to_string(), format!("{key}_face"), format!("{key}_plane")].into_iter().find(|k| p.get(k).is_some()) {
|
||||
return Err(bad(cmd, format!("give `{fk}` or `{other}`, not both")));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let (frame, on) = match v.get("sketch") {
|
||||
Some(sk) => {
|
||||
let sketch = super::features::sketch_id(s, Some(sk), cmd, &fk)?;
|
||||
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
|
||||
let frame = match (id("point"), id("curve")) {
|
||||
(Some(point), None) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
|
||||
(None, Some(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
|
||||
_ => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
|
||||
};
|
||||
(frame, s.doc.feature(sketch).map_or(0, |f| f.component))
|
||||
}
|
||||
None => {
|
||||
let origin = super::joints::origin_param(s, Some(v), cmd)?;
|
||||
let on = super::joints::component_of(s, origin.occurrence);
|
||||
(AlignFrame::Origin { origin: Box::new(origin) }, on)
|
||||
}
|
||||
};
|
||||
// The move is worked out in that component's coordinates: every body must be in it, and a
|
||||
// component placed twice would not say which placement the picks were made in.
|
||||
if bodies.iter().any(|b| st.body(b).map_or(0, |b| s.doc.model_body_component(b)) != on) {
|
||||
return Err(bad(cmd, format!("not supported yet: `{fk}` on another component than the bodies")));
|
||||
}
|
||||
if on != 0 && s.doc.occurrences.iter().filter(|o| o.component == on).count() != 1 {
|
||||
return Err(bad(cmd, format!("not supported yet: `{fk}` on a component placed more than once")));
|
||||
}
|
||||
frame.resolve(&s.doc, &st).map_err(|e| bad(cmd, format!("`{fk}`: {e}")))?;
|
||||
Ok(Some(frame))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.remove";
|
||||
let bodies = string_list(cmd, p, "bodies")?;
|
||||
|
||||
@@ -951,6 +951,120 @@ fn align_takes_planes_and_the_origin() {
|
||||
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from": [0, 0, 0], "to_plane": "Nowhere"})).is_err());
|
||||
}
|
||||
|
||||
/// The bounding box of body `name`: min and max.
|
||||
fn bounds(s: &mut Session, name: &str) -> ([f64; 3], [f64; 3]) {
|
||||
let m = measure(s);
|
||||
let b = m["bodies"].as_array().and_then(|bs| bs.iter().find(|b| b["name"] == name)).cloned().unwrap_or_else(|| panic!("no body {name}: {m}"));
|
||||
let g = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
|
||||
([g("min", 0), g("min", 1), g("min", 2)], [g("max", 0), g("max", 1), g("max", 2)])
|
||||
}
|
||||
|
||||
fn assert_bounds(s: &mut Session, name: &str, lo: [f64; 3], hi: [f64; 3]) {
|
||||
let (a, b) = bounds(s, name);
|
||||
let near = |x: [f64; 3], y: [f64; 3]| x.iter().zip(y).all(|(p, q)| (p - q).abs() < 1e-6);
|
||||
assert!(near(a, lo) && near(b, hi), "{name}: {a:?}..{b:?}, want {lo:?}..{hi:?}");
|
||||
}
|
||||
|
||||
/// Align by frames (#6257): a body's face centre lands on a sketch circle's centre and follows it
|
||||
/// when the parameter that places the circle changes; a turn about the target's z, a sketch point
|
||||
/// as the target with a flip, and a frame on the body that follows the body's own edits.
|
||||
#[test]
|
||||
fn align_by_frames_follows_its_target() {
|
||||
// B is 10 x 20 x 10 at (50, 0, 20); sketch S on XY has a circle at (X, 5) and a point at (20, X).
|
||||
let target = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "X", "expression": "0 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "H", "expression": "10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": "H", "corner": [50, 0, 20], "body_name": "B"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": ["X", 5], "radius": 3, "id": "ring"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [20, "X"], "id": "p"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 30], [10, 30]], "ids": ["edge"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
};
|
||||
let bottom = json!({"face": [55, 10, 20]});
|
||||
let ring = json!({"sketch": "S", "curve": "ring"});
|
||||
// The bottom face (z down) onto the circle (z up): no turn, B sits on XY centred on it.
|
||||
let mut s = target();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_frame": bottom, "to_frame": ring}));
|
||||
assert_bounds(&mut s, "B", [-5., -5., 0.], [5., 15., 10.]);
|
||||
// The circle moves: B follows it, and still does in the saved design.
|
||||
run(&mut s, "parameters.change", json!({"name": "X", "expression": "30 mm"}));
|
||||
assert_bounds(&mut s, "B", [25., -5., 0.], [35., 15., 10.]);
|
||||
let mut r = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_bounds(&mut r, "B", [25., -5., 0.], [35., 15., 10.]);
|
||||
// A quarter turn about the circle's z through its centre.
|
||||
let mut s = target();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_frame": bottom, "to_frame": ring, "angle": 90}));
|
||||
assert_bounds(&mut s, "B", [-10., 0., 0.], [10., 10., 10.]);
|
||||
// The top face onto the sketch point, flipped (z along z): B hangs under XY. The frame on B
|
||||
// follows B's own height.
|
||||
let mut s = target();
|
||||
run(
|
||||
&mut s,
|
||||
"solid.align",
|
||||
json!({"bodies": ["B"], "from_frame": {"face": [55, 10, 30]}, "to_frame": {"sketch": "S", "point": "p"}, "flip": true}),
|
||||
);
|
||||
assert_bounds(&mut s, "B", [15., -10., -10.], [25., 10., 0.]);
|
||||
run(&mut s, "parameters.change", json!({"name": "H", "expression": "15 mm"}));
|
||||
assert_bounds(&mut s, "B", [15., -10., -15.], [25., 10., 0.]);
|
||||
// A face pick with an angle about the target face's normal; a sketch frame onto a fixed point.
|
||||
let mut s = target();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["B"], "from": [50, 0, 20], "to_face": [55, 10, 30], "angle": 0}));
|
||||
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5]}));
|
||||
// Refused: a side given twice, an angle with no target direction, a frame on the bodies that
|
||||
// move, unknown or wrong sketch geometry, ids out of range, a degenerate frame.
|
||||
let mut s = target();
|
||||
for bad in [
|
||||
json!({"from": [0, 0, 0], "from_frame": bottom, "to_frame": ring}),
|
||||
json!({"from_frame": bottom, "to_frame": ring, "to_plane": "XY"}),
|
||||
json!({"from_frame": bottom, "to": [0, 0, 0], "angle": 30}),
|
||||
json!({"from_frame": bottom, "to_frame": ring, "angle": "3 mm"}),
|
||||
json!({"from_frame": bottom, "to_frame": {"face": [55, 10, 30]}}),
|
||||
json!({"from_frame": bottom, "to_frame": {"sketch": "Nope", "point": "p"}}),
|
||||
json!({"from_frame": bottom, "to_frame": {"sketch": "S", "point": "zz"}}),
|
||||
json!({"from_frame": bottom, "to_frame": {"sketch": "S", "curve": "edge"}}),
|
||||
json!({"from_frame": bottom, "to_frame": {"sketch": "S", "point": "p", "curve": "ring"}}),
|
||||
json!({"from_frame": bottom, "to_frame": {"sketch": "S", "point": ""}}),
|
||||
json!({"from_frame": bottom, "to_frame": {"sketch": "S", "point": "q".repeat(300)}}),
|
||||
json!({"from_frame": bottom, "to_frame": {"frame": {"origin": [0, 0, 0], "z": [0, 0, 0]}}}),
|
||||
json!({"from_frame": {"face": "no such face"}, "to_frame": ring}),
|
||||
] {
|
||||
let mut p = bad.clone();
|
||||
p["bodies"] = json!(["B"]);
|
||||
assert!(s.execute("solid.align", &p).is_err(), "{bad}");
|
||||
}
|
||||
// One frame does not move bodies of two components.
|
||||
run(&mut s, "component.create", json!({"name": "Part"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [80, 0, 0], "body_name": "P"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
assert!(s.execute("solid.align", &json!({"bodies": ["B", "P"], "from_frame": bottom, "to_frame": ring})).is_err());
|
||||
assert!(!s.doc.features.iter().any(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Align { .. })), "nothing refused was added");
|
||||
// A design file whose frame no longer resolves (here a degenerate one) fails the feature
|
||||
// with a message; it never panics.
|
||||
let mut s = target();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_frame": bottom, "to_frame": ring}));
|
||||
let mut v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
fn spoil(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) if o.get("type").and_then(Value::as_str) == Some("align") => {
|
||||
o.insert(
|
||||
"to_frame".into(),
|
||||
json!({"type": "origin", "origin": {"occurrence": 0, "snap": {"type": "frame", "origin": [0, 0, 0], "z": [0, 0, 0]}}}),
|
||||
);
|
||||
}
|
||||
Value::Object(o) => o.values_mut().for_each(spoil),
|
||||
Value::Array(a) => a.iter_mut().for_each(spoil),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
spoil(&mut v);
|
||||
let mut r = Session::new(solidcraft_doc::Document::from_json(&v.to_string()).unwrap());
|
||||
measure(&mut r);
|
||||
assert!(r.model.results.iter().any(|x| x.error.as_deref().is_some_and(|e| e.contains("degenerate"))), "the Align reports its frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coil_and_loft_to_a_point() {
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
@@ -69,7 +69,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
|
||||
CommandSpec::new("appearance.assign", "Appearance", crate::checked!("appearance.assign", appearance))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("appearance")
|
||||
.key("A")
|
||||
.contract(&ASSIGN_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<AppearanceAssignResult>)
|
||||
.params(
|
||||
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
|
||||
appearance?: library name or stable starter.* ID (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
name?: inert copied look label (requires color, mutually exclusive with appearance); pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); a starter ID or registered starter name resolves its recipe before color overrides; omitted pbr retains that response and pbr: null explicitly clears it. Unavailable glass recipes refuse through either selector. Other custom labels with an explicit color retain legacy scalar behavior. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
|
||||
@@ -86,11 +92,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.replace", "Replace Appearance", appearance_chain::replace).icon("appearance").params(
|
||||
"from: library name | saved look {name,color,opacity,pbr?} (copy appearance.list in_design); to: library name | assignment look {appearance? | color, name?, opacity?, pbr?} (appearance.assign rules; no clear). Matches the full look including name and PBR. scope?: {bodies?: [names], components?: [names or ids]} (a placed copy's name means the body or component it shows), at most 10000 targets; body scope includes face overrides, component scope selects only the component override. Omitted scope means all overrides, including hidden/suppressed ones; physical defaults are unchanged. Duplicate targets count once. Returns count of rewritten overrides; zero adds no history. One undo step, atomic; no dialogs. Designs above 100000 overrides refuse",
|
||||
),
|
||||
CommandSpec::new("appearance.library", "Appearance Library", appearance_library).icon("library")
|
||||
CommandSpec::new("appearance.library", "Appearance Library", crate::checked!("appearance.library", appearance_library))
|
||||
.icon("library")
|
||||
.noundo()
|
||||
.contract(&LIBRARY_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<AppearanceLibraryResult>)
|
||||
.params("→ 20 legacy scalar appearances, six opaque PBR finishes and 40 starter recipes with IDs, categories, provenance, requirements and availability; six starter glass recipes are unavailable"),
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list).icon("list")
|
||||
CommandSpec::new("appearance.list", "List Appearances", crate::checked!("appearance.list", appearance_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.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")
|
||||
.noundo()
|
||||
@@ -531,7 +543,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
pub(crate) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
if let Some(t) = v.as_str() {
|
||||
let h = t.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
@@ -545,6 +557,21 @@ pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
Some([c(0)?, c(1)?, c(2)?])
|
||||
}
|
||||
|
||||
use crate::schema::appearance::{
|
||||
AppearanceAssignParams, AppearanceAssignResult, AppearanceLibraryParams, AppearanceLibraryResult, AppearanceListParams, AppearanceListResult,
|
||||
BodyLooks, ComponentLookReply, FaceIndexLook, FaceLookReply, FaceSpot, LibraryAppearance, LookInDesign, LookReply, PbrReply, PlainAppearance,
|
||||
StarterAppearance,
|
||||
};
|
||||
|
||||
const ASSIGN_CONTRACT: TypedContract<AppearanceAssignParams> = TypedContract::new();
|
||||
const LIBRARY_CONTRACT: TypedContract<AppearanceLibraryParams> = TypedContract::new();
|
||||
const LIST_CONTRACT: TypedContract<AppearanceListParams> = TypedContract::new();
|
||||
|
||||
/// An appearance command's reply, built from its schema's type so the two can't drift apart.
|
||||
fn appearance_reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solidcraft_doc::appearance::FaceLook;
|
||||
let cmd = "appearance.assign";
|
||||
@@ -639,12 +666,9 @@ pub(super) fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
});
|
||||
}
|
||||
Ok(json!({
|
||||
"bodies": bodies,
|
||||
"faces": faces.iter().map(|(b, p)| json!({"body": b, "point": p})).collect::<Vec<_>>(),
|
||||
"components": comps,
|
||||
"appearance": look.as_ref().map(look_json),
|
||||
}))
|
||||
let faces = faces.iter().map(|(body, p)| FaceSpot { body: body.clone(), point: [p.x, p.y, p.z] }).collect();
|
||||
let appearance = look.as_ref().map(LookReply::from);
|
||||
appearance_reply(cmd, AppearanceAssignResult { bodies, faces, components: comps, appearance, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Shared look parser for assignment and atomic replacement.
|
||||
@@ -766,35 +790,45 @@ pub(super) fn attach_pbr(cmd: &str, params: &Value, look: &mut solidcraft_doc::a
|
||||
}
|
||||
|
||||
fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let mut list: Vec<Value> = solidcraft_doc::appearance::LIBRARY
|
||||
let category = |name: &str| name.split(" - ").next().unwrap_or(name).to_string();
|
||||
let mut list: Vec<LibraryAppearance> = solidcraft_doc::appearance::LIBRARY
|
||||
.iter()
|
||||
.map(|(n, c, o)| {
|
||||
let category = n.split(" - ").next().unwrap_or(n);
|
||||
json!({"name": n, "category": category, "color": format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]), "opacity": o})
|
||||
let look = LookReply { name: n.to_string(), color: format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]), opacity: *o, pbr: None };
|
||||
LibraryAppearance::Plain(PlainAppearance { look, category: category(n) })
|
||||
})
|
||||
.collect();
|
||||
list.extend(solidcraft_doc::appearance::OPAQUE_FINISHES.iter().filter_map(|(name, ..)| {
|
||||
let look = solidcraft_doc::appearance::library(name)?;
|
||||
let mut value = look_json(&look);
|
||||
value["category"] = json!(name.split(" - ").next().unwrap_or(name));
|
||||
Some(value)
|
||||
Some(LibraryAppearance::Plain(PlainAppearance { look: LookReply::from(&look), category: category(name) }))
|
||||
}));
|
||||
for preset in solidcraft_doc::appearance_pack::STARTER_PACK {
|
||||
let c = preset.preview_color();
|
||||
let pbr = preset.opaque_look().ok().and_then(|look| look.pbr);
|
||||
list.push(json!({"id":preset.id,"name":preset.name,"category":preset.category,"revision":preset.revision,
|
||||
"color":format!("#{:02x}{:02x}{:02x}",c[0],c[1],c[2]),"opacity":1.0,"pbr":pbr,
|
||||
"available":preset.is_opaque(),"requirements":preset.requirements,"provenance":preset.provenance,
|
||||
"recipe":preset.recipe,"preview_note":preset.preview_note,"future_texture_recipe":preset.future_texture_recipe}));
|
||||
list.push(LibraryAppearance::Starter(StarterAppearance {
|
||||
id: preset.id.to_string(),
|
||||
name: preset.name.to_string(),
|
||||
category: json!(preset.category),
|
||||
revision: preset.revision,
|
||||
color: format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]),
|
||||
opacity: 1.0,
|
||||
pbr: preset.opaque_look().ok().and_then(|look| look.pbr).map(PbrReply::from),
|
||||
available: preset.is_opaque(),
|
||||
requirements: json!(preset.requirements),
|
||||
provenance: json!(preset.provenance),
|
||||
recipe: json!(preset.recipe),
|
||||
preview_note: preset.preview_note.to_string(),
|
||||
future_texture_recipe: json!(preset.future_texture_recipe),
|
||||
}));
|
||||
}
|
||||
Ok(json!({"appearances": list}))
|
||||
appearance_reply("appearance.library", AppearanceLibraryResult { appearances: list, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = &s.doc.appearances;
|
||||
// "In this design": each look used, with where.
|
||||
let mut used: Vec<(solidcraft_doc::appearance::Look, Vec<Value>, Vec<Value>, Vec<Value>)> = Vec::new();
|
||||
fn slot(used: &mut Vec<(solidcraft_doc::appearance::Look, Vec<Value>, Vec<Value>, Vec<Value>)>, l: &solidcraft_doc::appearance::Look) -> usize {
|
||||
type Used = Vec<(solidcraft_doc::appearance::Look, Vec<String>, Vec<FaceSpot>, Vec<u64>)>;
|
||||
let mut used: Used = Vec::new();
|
||||
fn slot(used: &mut Used, l: &solidcraft_doc::appearance::Look) -> usize {
|
||||
match used.iter().position(|(u, ..)| u == l) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
@@ -806,51 +840,49 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (b, l) in &a.bodies {
|
||||
let i = slot(&mut used, l);
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
u.1.push(json!(b));
|
||||
u.1.push(b.clone());
|
||||
}
|
||||
}
|
||||
for f in &a.faces {
|
||||
let i = slot(&mut used, &f.look);
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
u.2.push(json!({"body": f.body, "point": f.point}));
|
||||
u.2.push(FaceSpot { body: f.body.clone(), point: [f.point.x, f.point.y, f.point.z] });
|
||||
}
|
||||
}
|
||||
for (c, l) in &a.components {
|
||||
let i = slot(&mut used, l);
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
u.3.push(json!(c));
|
||||
u.3.push(*c);
|
||||
}
|
||||
}
|
||||
let in_design: Vec<Value> = used
|
||||
.iter()
|
||||
.map(|(l, b, f, c)| {
|
||||
let mut value = look_json(l);
|
||||
value["bodies"] = json!(b);
|
||||
value["faces"] = json!(f);
|
||||
value["components"] = json!(c);
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"in_design": in_design,
|
||||
"bodies": a.bodies.iter().map(|(b, l)| (b.clone(), look_json(l))).collect::<serde_json::Map<_, _>>(),
|
||||
"faces": a.faces.iter().map(|f| json!({"body": f.body, "point": f.point, "appearance": look_json(&f.look)})).collect::<Vec<_>>(),
|
||||
"components": a.components.iter().map(|(c, l)| json!({"component": c, "appearance": look_json(l)})).collect::<Vec<_>>(),
|
||||
});
|
||||
let in_design =
|
||||
used.into_iter().map(|(l, bodies, faces, components)| LookInDesign { look: LookReply::from(&l), bodies, faces, components }).collect();
|
||||
let mut out = AppearanceListResult {
|
||||
in_design,
|
||||
bodies: a.bodies.iter().map(|(b, l)| (b.clone(), LookReply::from(l))).collect(),
|
||||
faces: a
|
||||
.faces
|
||||
.iter()
|
||||
.map(|f| FaceLookReply { body: f.body.clone(), point: [f.point.x, f.point.y, f.point.z], appearance: LookReply::from(&f.look) })
|
||||
.collect(),
|
||||
components: a.components.iter().map(|(c, l)| ComponentLookReply { component: *c, appearance: LookReply::from(l) }).collect(),
|
||||
body: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
if let Some(name) = str_(p, "body") {
|
||||
let st = s.model.state();
|
||||
// A placed copy's name (`KB (K:2)`) means the body it shows, as appearance.assign takes
|
||||
// it (#9740): the reply names that body.
|
||||
let own = s.own_body_name(name);
|
||||
let b = own.as_deref().and_then(|n| st.body(n)).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?;
|
||||
out["body"] = json!({
|
||||
"name": b.name,
|
||||
"appearance": s.doc.model_body_look(b).map(|l| look_json(&l)),
|
||||
"color": s.doc.body_color(b),
|
||||
"faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::<Vec<_>>(),
|
||||
out.body = Some(BodyLooks {
|
||||
name: b.name.clone(),
|
||||
appearance: s.doc.model_body_look(b).as_ref().map(LookReply::from),
|
||||
color: s.doc.body_color(b),
|
||||
faces: s.doc.face_colors(b).iter().map(|(i, l)| FaceIndexLook { face: *i, appearance: LookReply::from(l) }).collect(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
appearance_reply("appearance.list", out)
|
||||
}
|
||||
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -498,7 +498,7 @@ fn same_frame(s: &Session, o: &JointOrigin, at: Vec3, z: Vec3) -> bool {
|
||||
const MAX_FACE_NAME: usize = 16_384;
|
||||
|
||||
/// The component an occurrence places (0 for the root, or an unknown occurrence).
|
||||
fn component_of(s: &Session, occurrence: u64) -> u64 {
|
||||
pub(crate) fn component_of(s: &Session, occurrence: u64) -> u64 {
|
||||
if occurrence == 0 { 0 } else { s.doc.occurrences.iter().find(|o| o.id == occurrence).map_or(0, |o| o.component) }
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) use measure_sel::{measure_items, shape_of};
|
||||
pub(crate) use measure_shape::Shape;
|
||||
#[cfg(test)]
|
||||
mod kernel_heal_tests;
|
||||
mod panels;
|
||||
mod parameter_sweep;
|
||||
mod parameters;
|
||||
mod plastic;
|
||||
@@ -340,6 +341,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(joints::COMMANDS.iter());
|
||||
v.extend(clearance::COMMANDS.iter());
|
||||
v.extend(motion::COMMANDS.iter());
|
||||
v.extend(panels::COMMANDS.iter());
|
||||
v.extend(config::COMMANDS.iter());
|
||||
v.extend(parts::COMMANDS.iter());
|
||||
v.extend(bom::COMMANDS.iter());
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Registry metadata for externally injected application-layout commands.
|
||||
use super::CommandSpec;
|
||||
use crate::schema::panels as wire;
|
||||
|
||||
fn unavailable(_: &mut crate::Session, _: &serde_json::Value) -> crate::Result<serde_json::Value> {
|
||||
Err(crate::EngineError::Panel(crate::panels::error(crate::panels::Rule::Capability, "application layout backend was not injected")))
|
||||
}
|
||||
|
||||
macro_rules! command {
|
||||
($id:literal, $icon:literal, $label:literal, $input:ty, $output:ty, $params:literal) => {
|
||||
CommandSpec::new($id, $label, crate::checked!($id, unavailable))
|
||||
.icon($icon)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&wire::CommandContract::<$input>::new($id))
|
||||
.output(wire::strict_output::<$output>)
|
||||
.params($params)
|
||||
};
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
command!(
|
||||
"layout.apply",
|
||||
"open",
|
||||
"Apply Layout",
|
||||
wire::Apply,
|
||||
crate::panels::LayoutReply,
|
||||
"name, expected_revision?: apply preset to current workspace through retained runtime"
|
||||
),
|
||||
command!("layout.get", "list", "Inspect Layout", wire::Empty, wire::GetReply, "{}: committed state and registered workspace summaries"),
|
||||
command!(
|
||||
"layout.reset",
|
||||
"restore",
|
||||
"Reset Layout",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: restore current workspace's registered default"
|
||||
),
|
||||
command!(
|
||||
"layout.save",
|
||||
"save",
|
||||
"Save Layout",
|
||||
wire::Save,
|
||||
wire::SaveReply,
|
||||
"name, overwrite?: false, expected_revision?: save complete envelope in injected catalogue"
|
||||
),
|
||||
command!(
|
||||
"layout.workspace.select",
|
||||
"display",
|
||||
"Select Layout Workspace",
|
||||
wire::WorkspaceSelect,
|
||||
wire::WorkspaceReply,
|
||||
"workspace, expected_revision?: atomically remember outgoing and replace with destination"
|
||||
),
|
||||
command!(
|
||||
"panel.activate",
|
||||
"activate",
|
||||
"Activate Panel Tab",
|
||||
wire::Select,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, expected_revision?: activate open tab without focus"
|
||||
),
|
||||
command!(
|
||||
"panel.close",
|
||||
"close",
|
||||
"Close Panel",
|
||||
wire::Select,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, expected_revision?: explicit guarded close"
|
||||
),
|
||||
command!(
|
||||
"panel.collapse",
|
||||
"fold",
|
||||
"Collapse Panel Section",
|
||||
wire::Collapse,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, collapsed, expected_revision?"
|
||||
),
|
||||
command!(
|
||||
"panel.dismiss",
|
||||
"close",
|
||||
"Dismiss Panel Peek",
|
||||
wire::Revision,
|
||||
crate::panels::LayoutReply,
|
||||
"expected_revision?: dismiss temporary collapsed-panel presentation"
|
||||
),
|
||||
command!(
|
||||
"panel.float",
|
||||
"move",
|
||||
"Float Panel",
|
||||
wire::Move,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, destination: new_section with floating location, expected_revision?"
|
||||
),
|
||||
command!(
|
||||
"panel.focus",
|
||||
"fit",
|
||||
"Focus Panel",
|
||||
wire::Select,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, expected_revision?: reopen retained state, activate and request focus"
|
||||
),
|
||||
command!(
|
||||
"panel.list",
|
||||
"list",
|
||||
"List Panels",
|
||||
wire::Empty,
|
||||
wire::ListReply,
|
||||
"{}: committed state, active tabs, capabilities, availability and registered kinds"
|
||||
),
|
||||
command!(
|
||||
"panel.move",
|
||||
"move",
|
||||
"Move Panel",
|
||||
wire::Move,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, destination: new_section or join_tab, expected_revision?"
|
||||
),
|
||||
command!(
|
||||
"panel.native_close",
|
||||
"restore",
|
||||
"Redock Native Panel Section",
|
||||
wire::Select,
|
||||
crate::panels::LayoutReply,
|
||||
"target: native_host, expected_revision?: native close redocks without cancelling content"
|
||||
),
|
||||
command!(
|
||||
"panel.open",
|
||||
"open",
|
||||
"Open Panel",
|
||||
wire::Open,
|
||||
crate::panels::LayoutReply,
|
||||
"kind, instance, destination?: required for new instance, expected_revision?"
|
||||
),
|
||||
command!(
|
||||
"panel.peek",
|
||||
"eye",
|
||||
"Peek Collapsed Panel",
|
||||
wire::Select,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, expected_revision?: transient presentation without resizing"
|
||||
),
|
||||
command!(
|
||||
"panel.popout",
|
||||
"display",
|
||||
"Pop Out Panel",
|
||||
wire::Move,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, destination: new_section with window location, expected_revision?: web lowers to floating"
|
||||
),
|
||||
command!(
|
||||
"panel.reset_pair",
|
||||
"restore",
|
||||
"Reset Panel Sash",
|
||||
wire::ResetPair,
|
||||
crate::panels::LayoutReply,
|
||||
"first, second, expected_revision?: reset adjacent dock pair to registered default ratio"
|
||||
),
|
||||
command!(
|
||||
"panel.resize",
|
||||
"scale",
|
||||
"Resize Panel Section",
|
||||
wire::Resize,
|
||||
crate::panels::LayoutReply,
|
||||
"target or legacy id, size: {width,height}, expected_revision?"
|
||||
),
|
||||
command!(
|
||||
"panel.resize_pair",
|
||||
"scale",
|
||||
"Resize Panel Sash",
|
||||
wire::ResizePair,
|
||||
crate::panels::LayoutReply,
|
||||
"first, second, first_size, second_size, expected_revision?: conserve adjacent dock pair extent"
|
||||
),
|
||||
];
|
||||
@@ -81,9 +81,11 @@ fn classify(e: &EngineError) -> Outcome {
|
||||
let msg = e.to_string();
|
||||
match e {
|
||||
EngineError::Internal(..) => Outcome::new("panic", msg),
|
||||
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::Refused { .. } | EngineError::UnknownCommand(_) => {
|
||||
Outcome::new("rejected", msg)
|
||||
}
|
||||
EngineError::Panel(_)
|
||||
| EngineError::BadParams { .. }
|
||||
| EngineError::Disabled(..)
|
||||
| EngineError::Refused { .. }
|
||||
| EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
|
||||
// A failed boolean is a failure even when it says "not supported": those are the cases
|
||||
// to fix. Features the kernel doesn't do yet are limitations.
|
||||
// Picks the generator got wrong (a centroid off its face).
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod frames;
|
||||
pub mod fuzz;
|
||||
pub mod licences;
|
||||
pub mod occurrences;
|
||||
pub mod panels;
|
||||
pub mod params;
|
||||
pub mod product_scene;
|
||||
pub mod recovery;
|
||||
@@ -66,6 +67,8 @@ pub const MAX_LINE: usize = 16 << 20;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("{text}", text = panels::commands::error_text(.0.clone()))]
|
||||
Panel(#[from] panels::PanelError),
|
||||
#[error("unknown command `{0}`{hint}", hint = cmd::did_you_mean(.0))]
|
||||
UnknownCommand(String),
|
||||
#[error("`{0}` is not available right now: {1}")]
|
||||
@@ -1610,6 +1613,12 @@ impl Session {
|
||||
/// the model up to date before anything reads it.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if panels::commands::is_command(spec.id) {
|
||||
// Layout is application state. Even a missing capability must not
|
||||
// clear live links, map picks, snapshot, evaluate or refresh a design.
|
||||
panels::commands::parse_command(spec.id, params)?;
|
||||
return Err(EngineError::Panel(panels::error(panels::Rule::Capability, "application layout backend was not injected")));
|
||||
}
|
||||
// The command, the evaluation it causes and the refresh after it: one profiler span.
|
||||
let _span = solidcraft_profile::span("command", spec.id);
|
||||
let host_viewport = self.render_viewport.clone();
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
//! Application-owned command execution shared by UI, control and headless MCP.
|
||||
//! No Session, document, geometry, dialogs or camera are reachable here.
|
||||
mod catalogue;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::schema::panels as wire;
|
||||
|
||||
pub const MAX_USER_PRESETS: usize = 64;
|
||||
pub const MAX_WORKSPACES: usize = 64;
|
||||
pub const MAX_CATALOGUE_BYTES: usize = 4 * 1024 * 1024;
|
||||
pub const MAX_CATALOGUE_METADATA_BYTES: usize = 16 * 1024;
|
||||
/// Framing is bounded separately from the aggregate payload/metadata budgets.
|
||||
pub const MAX_CATALOGUE_SNAPSHOT_BYTES: usize = MAX_CATALOGUE_BYTES + MAX_CATALOGUE_METADATA_BYTES + 8192;
|
||||
pub const MAX_COMMAND_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// Full envelope plus bounded rejected recovery intent. Arc makes catalogue
|
||||
/// preparation bounded without copying each maximum-sized envelope 64 times.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoredEnvelope {
|
||||
bytes: Arc<[u8]>,
|
||||
rejected: Option<Arc<[u8]>>,
|
||||
}
|
||||
impl StoredEnvelope {
|
||||
pub fn new(envelope: &LayoutEnvelope, rejected: Option<&[u8]>, registry: &PanelRegistry) -> PanelResult<Self> {
|
||||
if rejected.is_some_and(|bytes| bytes.len() > MAX_ENVELOPE_BYTES) {
|
||||
return Err(error(Rule::CollectionLimit, "rejected recovery byte cap"));
|
||||
}
|
||||
Ok(Self { bytes: encode(envelope, registry)?.into(), rejected: rejected.map(Arc::from) })
|
||||
}
|
||||
/// Exact retained inspection, including saved instances whose kinds are now
|
||||
/// absent. This is not a live-layout admission check; stage with
|
||||
/// `project_envelope` before submitting through the authoritative sink.
|
||||
pub fn envelope(&self, registry: &PanelRegistry, workspace: &WorkspaceId) -> PanelResult<LayoutEnvelope> {
|
||||
decode_retained(&self.bytes, registry, workspace)
|
||||
}
|
||||
/// Explicitly stage current-registry quarantine before a guarded live use.
|
||||
/// Exact inspection above deliberately keeps absent-kind saved instances.
|
||||
pub fn project_envelope(&self, registry: &PanelRegistry, workspace: &WorkspaceId) -> PanelResult<LayoutEnvelope> {
|
||||
project_retained_record(&self.bytes, registry, workspace)
|
||||
}
|
||||
/// Hydration retains the original validated bytes. In particular, optional
|
||||
/// order/projection metadata is not filled and recounted against the cap.
|
||||
fn from_record(bytes: &[u8], rejected: Option<&[u8]>, registry: &PanelRegistry) -> PanelResult<Self> {
|
||||
if rejected.is_some_and(|bytes| bytes.len() > MAX_ENVELOPE_BYTES) {
|
||||
return Err(error(Rule::CollectionLimit, "rejected recovery byte cap"));
|
||||
}
|
||||
decode_retained_record(bytes, registry)?;
|
||||
Ok(Self { bytes: Arc::from(bytes), rejected: rejected.map(Arc::from) })
|
||||
}
|
||||
pub fn rejected(&self) -> Option<&[u8]> {
|
||||
self.rejected.as_deref()
|
||||
}
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WorkspaceDescriptor {
|
||||
pub id: WorkspaceId,
|
||||
pub title_key: String,
|
||||
pub default_preset: String,
|
||||
}
|
||||
|
||||
/// Fields are private: registrations and prepared updates must obey aggregate
|
||||
/// bounds before cloning or publishing. There is no independently stored selection.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CatalogueState {
|
||||
workspaces: BTreeMap<WorkspaceId, WorkspaceDescriptor>,
|
||||
builtins: BTreeMap<String, StoredEnvelope>,
|
||||
users: BTreeMap<String, StoredEnvelope>,
|
||||
remembered: BTreeMap<WorkspaceId, StoredEnvelope>,
|
||||
}
|
||||
impl CatalogueState {
|
||||
pub fn summaries(&self) -> Vec<wire::WorkspaceSummary> {
|
||||
self.workspaces
|
||||
.values()
|
||||
.map(|item| wire::WorkspaceSummary {
|
||||
id: item.id.clone(),
|
||||
title_key: item.title_key.clone(),
|
||||
default_preset: item.default_preset.clone(),
|
||||
has_remembered_layout: self.remembered.contains_key(&item.id),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn user_count(&self) -> usize {
|
||||
self.users.len()
|
||||
}
|
||||
pub fn user_presets(&self) -> impl Iterator<Item = (&str, &StoredEnvelope)> {
|
||||
self.users.iter().map(|(name, record)| (name.as_str(), record))
|
||||
}
|
||||
pub fn remembered(&self, workspace: &WorkspaceId) -> Option<&StoredEnvelope> {
|
||||
self.remembered.get(workspace)
|
||||
}
|
||||
pub fn preset(&self, name: &str) -> PanelResult<&StoredEnvelope> {
|
||||
self.builtins.get(name).or_else(|| self.users.get(name)).ok_or_else(|| error(Rule::MissingTarget, "layout preset not registered"))
|
||||
}
|
||||
fn validate(&self) -> PanelResult<()> {
|
||||
if self.users.keys().any(|name| self.builtins.contains_key(name)) {
|
||||
return Err(error(Rule::DuplicateId, "user preset collides with immutable built-in"));
|
||||
}
|
||||
if self.users.len() > MAX_USER_PRESETS
|
||||
|| self.builtins.len() > MAX_USER_PRESETS
|
||||
|| self.workspaces.len() > MAX_WORKSPACES
|
||||
|| self.remembered.len() > MAX_WORKSPACES
|
||||
{
|
||||
return Err(error(Rule::CollectionLimit, "layout catalogue count cap"));
|
||||
}
|
||||
let mut metadata = 0_usize;
|
||||
for (id, item) in &self.workspaces {
|
||||
valid_id(&id.0)?;
|
||||
valid_id(&item.title_key)?;
|
||||
if item.id != *id || !self.builtins.contains_key(&item.default_preset) {
|
||||
return Err(error(Rule::MissingTarget, "workspace requires a registered immutable default"));
|
||||
}
|
||||
metadata = metadata.saturating_add(id.0.len() + item.title_key.len() + item.default_preset.len());
|
||||
}
|
||||
let mut bytes = 0_usize;
|
||||
for (name, stored) in self.builtins.iter().chain(&self.users) {
|
||||
if preset_name(name)? != *name {
|
||||
return Err(error(Rule::Schema, "preset name must be normalised"));
|
||||
}
|
||||
metadata = metadata.saturating_add(name.len());
|
||||
bytes = bytes.saturating_add(stored.bytes.len() + stored.rejected.as_ref().map_or(0, |r| r.len()));
|
||||
}
|
||||
for (id, stored) in &self.remembered {
|
||||
valid_id(&id.0)?;
|
||||
metadata = metadata.saturating_add(id.0.len());
|
||||
bytes = bytes.saturating_add(stored.bytes.len() + stored.rejected.as_ref().map_or(0, |r| r.len()));
|
||||
}
|
||||
if metadata > MAX_CATALOGUE_METADATA_BYTES || bytes > MAX_CATALOGUE_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "aggregate layout catalogue byte cap"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PreparedCatalogue(CatalogueState);
|
||||
impl PreparedCatalogue {
|
||||
pub fn new(state: CatalogueState) -> PanelResult<Self> {
|
||||
state.validate()?;
|
||||
Ok(Self(state))
|
||||
}
|
||||
pub fn state(&self) -> &CatalogueState {
|
||||
&self.0
|
||||
}
|
||||
pub fn into_state(self) -> CatalogueState {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare MUST stage every fallible storage operation. Publish MUST be infallible.
|
||||
/// The executor holds the exclusive catalogue borrow across guarded dispatch.
|
||||
pub trait LayoutCatalogue {
|
||||
fn state(&self) -> &CatalogueState;
|
||||
fn prepare(&mut self, next: CatalogueState) -> PanelResult<PreparedCatalogue>;
|
||||
fn publish(&mut self, prepared: PreparedCatalogue);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InMemoryCatalogue {
|
||||
state: CatalogueState,
|
||||
/// Failure-injectable storage staging; no state has been published on failure.
|
||||
pub fail_stage: bool,
|
||||
}
|
||||
impl LayoutCatalogue for InMemoryCatalogue {
|
||||
fn state(&self) -> &CatalogueState {
|
||||
&self.state
|
||||
}
|
||||
fn prepare(&mut self, next: CatalogueState) -> PanelResult<PreparedCatalogue> {
|
||||
if self.fail_stage {
|
||||
return Err(error(Rule::Capability, "layout storage staging unavailable"));
|
||||
}
|
||||
PreparedCatalogue::new(next)
|
||||
}
|
||||
fn publish(&mut self, prepared: PreparedCatalogue) {
|
||||
self.state = prepared.into_state();
|
||||
}
|
||||
}
|
||||
impl InMemoryCatalogue {
|
||||
/// Hydration builds a validated candidate, without live dispatch or changing
|
||||
/// the mutability of saved user presets. Production storage remains external.
|
||||
pub fn from_snapshot(bytes: &[u8], registry: &PanelRegistry) -> PanelResult<Self> {
|
||||
Ok(Self { state: CatalogueState::hydrate(bytes, registry)?, fail_stage: false })
|
||||
}
|
||||
/// Explicit fixture/peer registration, never populated with invented products.
|
||||
pub fn register_builtin(&mut self, name: &str, envelope: &LayoutEnvelope, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
let name = preset_name(name)?;
|
||||
if self.state.builtins.contains_key(&name) || self.state.users.contains_key(&name) {
|
||||
return Err(error(Rule::DuplicateId, "preset already registered"));
|
||||
}
|
||||
let stored = StoredEnvelope::new(envelope, None, registry)?;
|
||||
let mut next = self.state.clone();
|
||||
next.builtins.insert(name, stored);
|
||||
let prepared = self.prepare(next)?;
|
||||
self.publish(prepared);
|
||||
Ok(())
|
||||
}
|
||||
pub fn register_workspace(&mut self, descriptor: WorkspaceDescriptor) -> PanelResult<()> {
|
||||
valid_id(&descriptor.id.0)?;
|
||||
valid_id(&descriptor.title_key)?;
|
||||
if self.state.workspaces.contains_key(&descriptor.id) {
|
||||
return Err(error(Rule::DuplicateId, "workspace already registered"));
|
||||
}
|
||||
let mut next = self.state.clone();
|
||||
next.workspaces.insert(descriptor.id.clone(), descriptor);
|
||||
let prepared = self.prepare(next)?;
|
||||
self.publish(prepared);
|
||||
Ok(())
|
||||
}
|
||||
pub fn remember(&mut self, envelope: &LayoutEnvelope, rejected: Option<&[u8]>, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
let stored = StoredEnvelope::new(envelope, rejected, registry)?;
|
||||
let mut next = self.state.clone();
|
||||
next.remembered.insert(envelope.layout.workspace.clone(), stored);
|
||||
let prepared = self.prepare(next)?;
|
||||
self.publish(prepared);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preset_name(raw: &str) -> PanelResult<String> {
|
||||
// Bound BEFORE trimming, allocating or iterating over scalars.
|
||||
if raw.len() > 1024 {
|
||||
return Err(error(Rule::CollectionLimit, "raw preset name byte cap"));
|
||||
}
|
||||
let name = raw.trim();
|
||||
if name.is_empty() || name.len() > 256 || name.chars().count() > 64 || raw.chars().any(char::is_control) {
|
||||
return Err(error(Rule::Schema, "preset name requires 1..64 Unicode scalars without controls"));
|
||||
}
|
||||
Ok(name.into())
|
||||
}
|
||||
|
||||
pub fn is_command(id: &str) -> bool {
|
||||
crate::find_command(id).is_some_and(|spec| IDS.contains(&spec.id))
|
||||
}
|
||||
pub const IDS: &[&str] = &[
|
||||
"layout.apply",
|
||||
"layout.get",
|
||||
"layout.reset",
|
||||
"layout.save",
|
||||
"layout.workspace.select",
|
||||
"panel.activate",
|
||||
"panel.close",
|
||||
"panel.collapse",
|
||||
"panel.dismiss",
|
||||
"panel.float",
|
||||
"panel.focus",
|
||||
"panel.list",
|
||||
"panel.move",
|
||||
"panel.native_close",
|
||||
"panel.open",
|
||||
"panel.peek",
|
||||
"panel.popout",
|
||||
"panel.reset_pair",
|
||||
"panel.resize",
|
||||
"panel.resize_pair",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PanelCommand {
|
||||
List,
|
||||
Get,
|
||||
Open(wire::Open),
|
||||
Select { operation: &'static str, params: wire::Select },
|
||||
Move { operation: &'static str, params: wire::Move },
|
||||
Resize(wire::Resize),
|
||||
Collapse(wire::Collapse),
|
||||
Dismiss(wire::Revision),
|
||||
ResizePair(wire::ResizePair),
|
||||
ResetPair(wire::ResetPair),
|
||||
Save(wire::Save),
|
||||
Apply(wire::Apply),
|
||||
Reset(wire::Revision),
|
||||
Workspace(wire::WorkspaceSelect),
|
||||
}
|
||||
fn read<T: DeserializeOwned>(value: &Value) -> PanelResult<T> {
|
||||
serde_json::from_value(value.clone()).map_err(|_| error(Rule::Schema, "invalid panel command fields or types"))
|
||||
}
|
||||
pub fn parse_command(id: &str, params: &Value) -> PanelResult<PanelCommand> {
|
||||
// Keep the engine/control convention: absent/null params mean an empty form.
|
||||
let empty = json!({});
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
if !params.is_object() {
|
||||
return Err(error(Rule::Schema, "panel command parameters must be an object"));
|
||||
}
|
||||
bounded_json(params)?;
|
||||
// Capped writer protects direct callers as well as transports, before clone.
|
||||
struct Counter(usize);
|
||||
impl std::io::Write for Counter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(bytes.len());
|
||||
if self.0 > MAX_COMMAND_BYTES {
|
||||
return Err(std::io::Error::other("command byte cap"));
|
||||
}
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
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"))?;
|
||||
let command = match id {
|
||||
"panel.list" => {
|
||||
let _: wire::Empty = read(params)?;
|
||||
PanelCommand::List
|
||||
}
|
||||
"layout.get" => {
|
||||
let _: wire::Empty = read(params)?;
|
||||
PanelCommand::Get
|
||||
}
|
||||
"panel.open" => PanelCommand::Open(read(params)?),
|
||||
"panel.close" | "panel.activate" | "panel.focus" | "panel.peek" | "panel.native_close" => {
|
||||
PanelCommand::Select { operation: id, params: read(params)? }
|
||||
}
|
||||
"panel.move" | "panel.float" | "panel.popout" => PanelCommand::Move { operation: id, params: read(params)? },
|
||||
"panel.resize" => PanelCommand::Resize(read(params)?),
|
||||
"panel.collapse" => PanelCommand::Collapse(read(params)?),
|
||||
"panel.dismiss" => PanelCommand::Dismiss(read(params)?),
|
||||
"panel.resize_pair" => PanelCommand::ResizePair(read(params)?),
|
||||
"panel.reset_pair" => PanelCommand::ResetPair(read(params)?),
|
||||
"layout.save" => PanelCommand::Save(read(params)?),
|
||||
"layout.apply" => PanelCommand::Apply(read(params)?),
|
||||
"layout.reset" => PanelCommand::Reset(read(params)?),
|
||||
"layout.workspace.select" => PanelCommand::Workspace(read(params)?),
|
||||
_ => return Err(error(Rule::MissingTarget, "not an application layout command")),
|
||||
};
|
||||
// Validate hostile values even when no application capability is injected.
|
||||
validate_command(&command)?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn validate_selector(target: &Option<wire::Target>, id: &Option<String>) -> PanelResult<()> {
|
||||
match (target, id) {
|
||||
(Some(target), None) => match target {
|
||||
wire::Target::Instance(id) => valid_id(&id.0),
|
||||
wire::Target::Section(id) => valid_id(&id.0),
|
||||
wire::Target::Workspace(id) => valid_id(&id.0),
|
||||
wire::Target::NativeHost(id) => valid_id(&id.0),
|
||||
},
|
||||
(None, Some(id)) => valid_id(id),
|
||||
_ => Err(error(Rule::Schema, "supply exactly one typed target or legacy id")),
|
||||
}
|
||||
}
|
||||
fn validate_destination(destination: Destination) -> PanelResult<()> {
|
||||
match destination {
|
||||
Destination::NewSection { section, index, location, size } => {
|
||||
valid_id(§ion.0)?;
|
||||
location.validate()?;
|
||||
size.validate()?;
|
||||
if index > MAX_SECTIONS {
|
||||
return Err(error(Rule::Bounds, "section index cap"));
|
||||
}
|
||||
}
|
||||
Destination::JoinTab { section, index } => {
|
||||
valid_id(§ion.0)?;
|
||||
if index >= MAX_TABS {
|
||||
return Err(error(Rule::Bounds, "tab index cap"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_command(command: &PanelCommand) -> PanelResult<()> {
|
||||
match command {
|
||||
PanelCommand::Open(p) => {
|
||||
valid_id(&p.kind.0)?;
|
||||
valid_id(&p.instance.0)?;
|
||||
if let Some(destination) = &p.destination {
|
||||
validate_destination(destination.clone().into())?;
|
||||
}
|
||||
}
|
||||
PanelCommand::Select { params, .. } => validate_selector(¶ms.target, ¶ms.id)?,
|
||||
PanelCommand::Move { params, .. } => {
|
||||
validate_selector(¶ms.target, ¶ms.id)?;
|
||||
validate_destination(params.destination.clone().into())?;
|
||||
}
|
||||
PanelCommand::Resize(p) => {
|
||||
validate_selector(&p.target, &p.id)?;
|
||||
Size::from(p.size).validate()?;
|
||||
}
|
||||
PanelCommand::Collapse(p) => validate_selector(&p.target, &p.id)?,
|
||||
PanelCommand::ResizePair(p) => {
|
||||
valid_id(&p.first.0)?;
|
||||
valid_id(&p.second.0)?;
|
||||
Size::from(p.first_size).validate()?;
|
||||
Size::from(p.second_size).validate()?;
|
||||
}
|
||||
PanelCommand::ResetPair(p) => {
|
||||
valid_id(&p.first.0)?;
|
||||
valid_id(&p.second.0)?;
|
||||
}
|
||||
PanelCommand::Save(p) => {
|
||||
preset_name(&p.name)?;
|
||||
}
|
||||
PanelCommand::Apply(p) => {
|
||||
preset_name(&p.name)?;
|
||||
}
|
||||
PanelCommand::Workspace(p) => valid_id(&p.workspace.0)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct PanelCommandContext<'a> {
|
||||
pub sink: &'a mut dyn ActionSink,
|
||||
pub registry: &'a PanelRegistry,
|
||||
pub catalogue: &'a mut dyn LayoutCatalogue,
|
||||
/// Web popout is an explicit host policy, never inferred from no native support.
|
||||
pub web: bool,
|
||||
}
|
||||
|
||||
fn target(target: Option<wire::Target>, legacy: Option<String>, layout: &Layout, registry: &PanelRegistry) -> PanelResult<LayoutTarget> {
|
||||
if let Some(target) = target {
|
||||
return Ok(target.into());
|
||||
}
|
||||
let id = legacy.ok_or_else(|| error(Rule::Schema, "target required"))?;
|
||||
let instance = InstanceId::new(id.clone())?;
|
||||
if layout.instance(&instance).is_ok() {
|
||||
return Ok(LayoutTarget::Instance(instance));
|
||||
}
|
||||
let kind = PanelKind::new(id)?;
|
||||
let spec = registry.get(&kind)?;
|
||||
if spec.features.multi_instance {
|
||||
return Err(error(Rule::ExactlyOnce, "kind selector is ambiguous for multi-instance panels; supply typed instance"));
|
||||
}
|
||||
let matches: Vec<_> = layout.instances.iter().filter(|p| p.kind == kind).collect();
|
||||
if matches.len() != 1 {
|
||||
return Err(error(Rule::MissingTarget, "kind requires one canonical retained instance"));
|
||||
}
|
||||
Ok(LayoutTarget::Instance(matches.first().ok_or_else(|| error(Rule::MissingTarget, "canonical instance absent"))?.id.clone()))
|
||||
}
|
||||
fn instance(target: LayoutTarget, layout: &Layout) -> PanelResult<InstanceId> {
|
||||
match target {
|
||||
LayoutTarget::Instance(id) => {
|
||||
layout.instance(&id)?;
|
||||
Ok(id)
|
||||
}
|
||||
_ => Err(error(Rule::Schema, "operation requires instance target")),
|
||||
}
|
||||
}
|
||||
fn section(target: LayoutTarget, layout: &Layout) -> PanelResult<SectionId> {
|
||||
match target {
|
||||
LayoutTarget::Section(id) => {
|
||||
layout.section(&id)?;
|
||||
Ok(id)
|
||||
}
|
||||
LayoutTarget::Instance(id) => Ok(layout.containing(&id)?.id.clone()),
|
||||
_ => Err(error(Rule::Schema, "operation requires section or open instance target")),
|
||||
}
|
||||
}
|
||||
fn revision(expected: Option<u64>, state: &LayoutReply) -> PanelResult<()> {
|
||||
if expected.is_some_and(|r| r != state.revision) {
|
||||
return Err(error(Rule::Revision, "stale layout revision"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lower(command: PanelCommand, state: &LayoutReply, registry: &PanelRegistry, web: bool) -> PanelResult<Mutation> {
|
||||
let layout = &state.layout;
|
||||
Ok(match command {
|
||||
PanelCommand::Open(p) => {
|
||||
revision(p.expected_revision, state)?;
|
||||
Mutation::Open { kind: p.kind, instance: p.instance, destination: p.destination.map(Into::into) }
|
||||
}
|
||||
PanelCommand::Select { operation, params: p } => {
|
||||
revision(p.expected_revision, state)?;
|
||||
let selected = target(p.target, p.id, layout, registry)?;
|
||||
if operation == "panel.native_close" {
|
||||
let LayoutTarget::NativeHost(host) = selected else {
|
||||
return Err(error(Rule::Schema, "native close requires typed native_host target"));
|
||||
};
|
||||
Mutation::NativeClose { host }
|
||||
} else {
|
||||
let instance = instance(selected, layout)?;
|
||||
match operation {
|
||||
"panel.close" => Mutation::Close { instance },
|
||||
"panel.focus" => Mutation::Focus { instance },
|
||||
"panel.activate" => Mutation::Activate { instance },
|
||||
"panel.peek" => Mutation::Peek { instance },
|
||||
_ => return Err(error(Rule::Schema, "unknown target operation")),
|
||||
}
|
||||
}
|
||||
}
|
||||
PanelCommand::Move { operation, params: p } => {
|
||||
revision(p.expected_revision, state)?;
|
||||
let instance = instance(target(p.target, p.id, layout, registry)?, layout)?;
|
||||
let mut destination: Destination = p.destination.into();
|
||||
if operation == "panel.float" && !matches!(&destination, Destination::NewSection { location: SectionLocation::Floating { .. }, .. }) {
|
||||
return Err(error(Rule::Placement, "panel.float requires new_section with floating location"));
|
||||
}
|
||||
if operation == "panel.popout" {
|
||||
if !registry.get(&layout.instance(&instance)?.kind)?.features.popout {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot pop out"));
|
||||
}
|
||||
match &mut destination {
|
||||
Destination::NewSection { location, .. } => match location {
|
||||
SectionLocation::Window { origin, .. } => {
|
||||
if web {
|
||||
*location = SectionLocation::Floating { origin: *origin };
|
||||
}
|
||||
}
|
||||
_ => return Err(error(Rule::Placement, "panel.popout requires new_section with window location")),
|
||||
},
|
||||
_ => return Err(error(Rule::Placement, "panel.popout requires a new section")),
|
||||
}
|
||||
}
|
||||
Mutation::Move { instance, destination }
|
||||
}
|
||||
PanelCommand::Resize(p) => {
|
||||
revision(p.expected_revision, state)?;
|
||||
Mutation::SetSize { section: section(target(p.target, p.id, layout, registry)?, layout)?, size: p.size.into() }
|
||||
}
|
||||
PanelCommand::Collapse(p) => {
|
||||
revision(p.expected_revision, state)?;
|
||||
Mutation::Collapse { section: section(target(p.target, p.id, layout, registry)?, layout)?, collapsed: p.collapsed }
|
||||
}
|
||||
PanelCommand::Dismiss(p) => {
|
||||
revision(p.expected_revision, state)?;
|
||||
Mutation::DismissPeek
|
||||
}
|
||||
PanelCommand::ResizePair(p) => {
|
||||
revision(p.expected_revision, state)?;
|
||||
Mutation::ResizePair { first: p.first, second: p.second, first_size: p.first_size.into(), second_size: p.second_size.into() }
|
||||
}
|
||||
PanelCommand::ResetPair(p) => {
|
||||
revision(p.expected_revision, state)?;
|
||||
Mutation::ResetPair { first: p.first, second: p.second }
|
||||
}
|
||||
_ => return Err(error(Rule::Capability, "catalogue/query commands require individual transactions")),
|
||||
})
|
||||
}
|
||||
|
||||
fn current_envelope(catalogue: &CatalogueState, state: &LayoutReply, registry: &PanelRegistry) -> PanelResult<(LayoutEnvelope, Option<Arc<[u8]>>)> {
|
||||
let workspace = &state.layout.workspace;
|
||||
match catalogue.remembered.get(workspace) {
|
||||
Some(stored) => {
|
||||
Ok((with_live_layout(&stored.project_envelope(registry, workspace)?, state.layout.clone(), registry)?, stored.rejected.clone()))
|
||||
}
|
||||
None => Ok((LayoutEnvelope::new(state.layout.clone()), None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve all opaque sidecars. Reusing an identity with different opaque intent
|
||||
/// is an error, never silently resolved by choosing one preset's value.
|
||||
fn merge_envelopes(current: &LayoutEnvelope, mut next: LayoutEnvelope, registry: &PanelRegistry) -> PanelResult<LayoutEnvelope> {
|
||||
next.layout.workspace = current.layout.workspace.clone();
|
||||
for item in ¤t.quarantine {
|
||||
if next.layout.instances.iter().any(|known| known.id == item.instance) {
|
||||
return Err(error(Rule::DuplicateId, "preset collides with quarantined identity"));
|
||||
}
|
||||
match next.quarantine.iter().find(|q| q.instance == item.instance) {
|
||||
Some(existing) if existing != item => return Err(error(Rule::DuplicateId, "quarantine sidecar collision")),
|
||||
Some(_) => {}
|
||||
None => next.quarantine.push(item.clone()),
|
||||
}
|
||||
}
|
||||
for (id, data) in ¤t.retained_data {
|
||||
if next.retained_data.get(id).is_some_and(|existing| existing != data) {
|
||||
return Err(error(Rule::DuplicateId, "retained data collision"));
|
||||
}
|
||||
if let (Ok(old), Ok(new)) = (current.layout.instance(id), next.layout.instance(id))
|
||||
&& old.kind != new.kind
|
||||
{
|
||||
return Err(error(Rule::DuplicateId, "retained instance kind collision"));
|
||||
}
|
||||
next.retained_data.insert(id.clone(), data.clone());
|
||||
}
|
||||
for (id, snapshot) in ¤t.section_snapshots {
|
||||
if next.section_snapshots.get(id).is_some_and(|existing| existing != snapshot) {
|
||||
return Err(error(Rule::DuplicateId, "section sidecar collision"));
|
||||
}
|
||||
next.section_snapshots.insert(id.clone(), snapshot.clone());
|
||||
}
|
||||
// Preserve dormant gaps from current; subsequent refresh overlays live order.
|
||||
let dormant: std::collections::BTreeSet<_> = next.quarantine.iter().filter_map(|q| q.section.clone()).collect();
|
||||
let live: std::collections::BTreeSet<_> = next.layout.sections.iter().map(|s| s.id.clone()).collect();
|
||||
let mut order: Vec<_> = current.section_order.iter().filter(|id| dormant.contains(*id) || live.contains(*id)).cloned().collect();
|
||||
for id in &next.section_order {
|
||||
if !order.contains(id) {
|
||||
order.push(id.clone());
|
||||
}
|
||||
}
|
||||
next.section_order = order;
|
||||
let layout = next.layout.clone();
|
||||
with_live_layout(&next, layout, registry)
|
||||
}
|
||||
|
||||
fn stage_current(
|
||||
context: &mut PanelCommandContext<'_>,
|
||||
state: &LayoutReply,
|
||||
action: &LayoutAction,
|
||||
envelope: &LayoutEnvelope,
|
||||
recovery: Option<Arc<[u8]>>,
|
||||
mut next: CatalogueState,
|
||||
) -> PanelResult<(LayoutReply, CatalogueState)> {
|
||||
let projected = context.sink.project(action.clone())?;
|
||||
let envelope = with_live_layout(envelope, projected, context.registry)?;
|
||||
let stored = StoredEnvelope::new(&envelope, recovery.as_deref(), context.registry)?;
|
||||
next.remembered.insert(envelope.layout.workspace.clone(), stored);
|
||||
let prepared = context.catalogue.prepare(next)?;
|
||||
// Inspect can change through external adapters between planning and dispatch.
|
||||
// Every action below includes the inspected revision; no stale catalogue publishes.
|
||||
if context.sink.inspect().revision != state.revision {
|
||||
return Err(error(Rule::Revision, "layout changed during storage staging"));
|
||||
}
|
||||
let committed = if *action == LayoutAction::Inspect {
|
||||
let current = context.sink.inspect();
|
||||
revision(Some(state.revision), ¤t)?;
|
||||
current
|
||||
} else {
|
||||
context.sink.dispatch(action.clone())?
|
||||
};
|
||||
context.catalogue.publish(prepared);
|
||||
Ok((committed, context.catalogue.state().clone()))
|
||||
}
|
||||
|
||||
pub fn execute_command(command: PanelCommand, context: &mut PanelCommandContext<'_>) -> PanelResult<Value> {
|
||||
validate_command(&command)?;
|
||||
let state = context.sink.inspect();
|
||||
match command {
|
||||
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() }),
|
||||
PanelCommand::Workspace(ref p) if p.workspace == state.layout.workspace => {
|
||||
revision(p.expected_revision, &state)?;
|
||||
return value(wire::WorkspaceReply {
|
||||
previous_workspace: state.layout.workspace.clone(),
|
||||
source: wire::WorkspaceSource::Unchanged,
|
||||
state,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut next = context.catalogue.state().clone();
|
||||
let (current, recovery) = current_envelope(&next, &state, context.registry)?;
|
||||
match command {
|
||||
PanelCommand::Save(p) => {
|
||||
revision(p.expected_revision, &state)?;
|
||||
let name = preset_name(&p.name)?;
|
||||
if next.builtins.contains_key(&name) {
|
||||
return Err(error(Rule::Capability, "built-in presets are immutable"));
|
||||
}
|
||||
let overwritten = next.users.contains_key(&name);
|
||||
if overwritten && !p.overwrite {
|
||||
return Err(error(Rule::DuplicateId, "preset exists; overwrite must be explicit"));
|
||||
}
|
||||
if !overwritten && next.users.len() >= MAX_USER_PRESETS {
|
||||
return Err(error(Rule::CollectionLimit, "user preset count cap"));
|
||||
}
|
||||
next.users.insert(name.clone(), StoredEnvelope::new(¤t, recovery.as_deref(), context.registry)?);
|
||||
let action = LayoutAction::Inspect;
|
||||
let (state, _) = stage_current(context, &state, &action, ¤t, recovery, next)?;
|
||||
value(wire::SaveReply { name, overwritten, state })
|
||||
}
|
||||
PanelCommand::Apply(p) => {
|
||||
revision(p.expected_revision, &state)?;
|
||||
let stored = next.preset(&preset_name(&p.name)?)?;
|
||||
let mut envelope: LayoutEnvelope =
|
||||
serde_json::from_slice(stored.bytes()).map_err(|_| error(Rule::Schema, "invalid registered envelope"))?;
|
||||
// Decode using its original workspace before rebinding to the current one.
|
||||
envelope = stored.project_envelope(context.registry, &envelope.layout.workspace)?;
|
||||
let mut envelope = merge_envelopes(¤t, envelope, context.registry)?;
|
||||
restore_quarantine(&mut envelope, context.registry)?;
|
||||
let recovery = merge_recovery(recovery, stored.rejected.clone())?;
|
||||
let action = LayoutAction::Replace { expected_revision: Some(state.revision), layout: envelope.layout.clone() };
|
||||
value(stage_current(context, &state, &action, &envelope, recovery, next)?.0)
|
||||
}
|
||||
PanelCommand::Reset(p) => {
|
||||
revision(p.expected_revision, &state)?;
|
||||
let descriptor = next
|
||||
.workspaces
|
||||
.get(&state.layout.workspace)
|
||||
.ok_or_else(|| error(Rule::Capability, "selected workspace has no registered default"))?;
|
||||
let stored = next.preset(&descriptor.default_preset)?;
|
||||
let raw: LayoutEnvelope = serde_json::from_slice(stored.bytes()).map_err(|_| error(Rule::Schema, "invalid default envelope"))?;
|
||||
let mut envelope = merge_envelopes(¤t, stored.project_envelope(context.registry, &raw.layout.workspace)?, context.registry)?;
|
||||
restore_quarantine(&mut envelope, context.registry)?;
|
||||
let recovery = merge_recovery(recovery, stored.rejected.clone())?;
|
||||
let action = LayoutAction::Replace { expected_revision: Some(state.revision), layout: envelope.layout.clone() };
|
||||
value(stage_current(context, &state, &action, &envelope, recovery, next)?.0)
|
||||
}
|
||||
PanelCommand::Workspace(p) => {
|
||||
revision(p.expected_revision, &state)?;
|
||||
let previous_workspace = state.layout.workspace.clone();
|
||||
if p.workspace == previous_workspace {
|
||||
return value(wire::WorkspaceReply { previous_workspace, source: wire::WorkspaceSource::Unchanged, state });
|
||||
}
|
||||
let descriptor = next.workspaces.get(&p.workspace).ok_or_else(|| error(Rule::MissingTarget, "workspace not registered"))?;
|
||||
let (stored, source) = match next.remembered.get(&p.workspace) {
|
||||
Some(stored) => (stored, wire::WorkspaceSource::Remembered),
|
||||
None => (next.preset(&descriptor.default_preset)?, wire::WorkspaceSource::DefaultPreset),
|
||||
};
|
||||
let raw: LayoutEnvelope = serde_json::from_slice(stored.bytes()).map_err(|_| error(Rule::Schema, "invalid workspace envelope"))?;
|
||||
let mut envelope = stored.project_envelope(context.registry, &raw.layout.workspace)?;
|
||||
envelope.layout.workspace = p.workspace;
|
||||
restore_quarantine(&mut envelope, context.registry)?;
|
||||
let incoming_recovery = stored.rejected.clone();
|
||||
next.remembered.insert(previous_workspace.clone(), StoredEnvelope::new(¤t, recovery.as_deref(), context.registry)?);
|
||||
let action = LayoutAction::Replace { expected_revision: Some(state.revision), layout: envelope.layout.clone() };
|
||||
let (state, _) = stage_current(context, &state, &action, &envelope, incoming_recovery, next)?;
|
||||
value(wire::WorkspaceReply { previous_workspace, source, state })
|
||||
}
|
||||
command => {
|
||||
let mutation = lower(command, &state, context.registry, context.web)?;
|
||||
let action = LayoutAction::Apply { expected_revision: Some(state.revision), mutations: vec![mutation] };
|
||||
value(stage_current(context, &state, &action, ¤t, recovery, next)?.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn merge_recovery(first: Option<Arc<[u8]>>, second: Option<Arc<[u8]>>) -> PanelResult<Option<Arc<[u8]>>> {
|
||||
if first.is_some() && second.is_some() && first != second {
|
||||
return Err(error(Rule::DuplicateId, "rejected recovery intent collision"));
|
||||
}
|
||||
Ok(first.or(second))
|
||||
}
|
||||
fn value(value: impl serde::Serialize) -> PanelResult<Value> {
|
||||
serde_json::to_value(value).map_err(|_| error(Rule::Schema, "cannot encode committed layout reply"))
|
||||
}
|
||||
pub fn execute(id: &str, params: &Value, context: &mut PanelCommandContext<'_>) -> PanelResult<Value> {
|
||||
execute_command(parse_command(id, params)?, context)
|
||||
}
|
||||
|
||||
/// A private transaction planner reuses the common executor and the LIVE sink's
|
||||
/// pure projection. It owns no reducer or LayoutService and cannot publish live
|
||||
/// effects. Only the final guarded action and prepared catalogue are published.
|
||||
struct PlanningSink<'a> {
|
||||
authoritative: &'a dyn ActionSink,
|
||||
state: LayoutReply,
|
||||
mutations: Vec<Mutation>,
|
||||
replaced: bool,
|
||||
}
|
||||
impl ActionSink for PlanningSink<'_> {
|
||||
fn inspect(&self) -> LayoutReply {
|
||||
self.state.clone()
|
||||
}
|
||||
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
|
||||
match action {
|
||||
LayoutAction::Inspect => Ok(self.state.layout.clone()),
|
||||
LayoutAction::Replace { expected_revision, layout } => {
|
||||
revision(expected_revision, &self.state)?;
|
||||
if !self.mutations.is_empty() {
|
||||
return Err(error(
|
||||
Rule::Capability,
|
||||
"atomic batches cannot combine snapshot replacement and explicit panel mutations; use rollback:false",
|
||||
));
|
||||
}
|
||||
self.authoritative.project(LayoutAction::Replace { expected_revision: Some(self.state.revision), layout })
|
||||
}
|
||||
LayoutAction::Apply { expected_revision, mutations } => {
|
||||
revision(expected_revision, &self.state)?;
|
||||
if self.replaced {
|
||||
return Err(error(
|
||||
Rule::Capability,
|
||||
"atomic batches cannot combine snapshot replacement and explicit panel mutations; use rollback:false",
|
||||
));
|
||||
}
|
||||
if self.mutations.len().saturating_add(mutations.len()) > MAX_MUTATIONS {
|
||||
return Err(error(Rule::CollectionLimit, "layout batch mutation cap"));
|
||||
}
|
||||
let mut combined = self.mutations.clone();
|
||||
combined.extend(mutations);
|
||||
self.authoritative.project(LayoutAction::Apply { expected_revision: Some(self.state.revision), mutations: combined })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
let layout = self.project(action.clone())?;
|
||||
match action {
|
||||
LayoutAction::Apply { mutations, .. } => self.mutations.extend(mutations),
|
||||
LayoutAction::Replace { .. } => {
|
||||
self.replaced = true;
|
||||
self.state.interaction = InteractionState::default();
|
||||
}
|
||||
LayoutAction::Inspect => {}
|
||||
}
|
||||
self.state.layout = layout;
|
||||
Ok(self.state.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure structural preflight, usable before application context publication.
|
||||
/// Same-workspace selection remains a no-op and may accompany mutations.
|
||||
pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<()> {
|
||||
if steps.is_empty() || steps.len() > MAX_MUTATIONS {
|
||||
return Err(error(Rule::CollectionLimit, "layout batch requires 1..64 steps"));
|
||||
}
|
||||
let mut selected = workspace.clone();
|
||||
let mut replacement = false;
|
||||
let mut mutation = 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"))?;
|
||||
let empty = json!({});
|
||||
match parse_command(id, step.get("params").unwrap_or(&empty))? {
|
||||
PanelCommand::List | PanelCommand::Get | PanelCommand::Save(_) => {}
|
||||
PanelCommand::Apply(_) | PanelCommand::Reset(_) => replacement = true,
|
||||
PanelCommand::Workspace(params) => {
|
||||
replacement |= params.workspace != selected;
|
||||
selected = params.workspace;
|
||||
}
|
||||
_ => mutation = true,
|
||||
}
|
||||
}
|
||||
if replacement && mutation {
|
||||
return Err(error(Rule::Capability, "atomic batches cannot combine snapshot replacement and explicit panel mutations; use rollback:false"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic batches use one Apply or Replace and one catalogue/native/controller
|
||||
/// preflight. Explicit lifecycle intent is never flattened into a replacement.
|
||||
pub fn execute_batch(steps: &[Value], context: &mut PanelCommandContext<'_>) -> PanelResult<Value> {
|
||||
if steps.is_empty() || steps.len() > MAX_MUTATIONS {
|
||||
return Err(error(Rule::CollectionLimit, "layout batch requires 1..64 steps"));
|
||||
}
|
||||
let state = context.sink.inspect();
|
||||
preflight_batch(steps, &state.layout.workspace)?;
|
||||
let mut planned = InMemoryCatalogue { state: context.catalogue.state().clone(), fail_stage: false };
|
||||
let mut sink = PlanningSink { authoritative: &*context.sink, state: state.clone(), mutations: Vec::new(), replaced: false };
|
||||
let mut rows = Vec::new();
|
||||
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"))?;
|
||||
let id = crate::find_command(id).map(|spec| spec.id).ok_or_else(|| error(Rule::MissingTarget, "batch command not registered"))?;
|
||||
let empty = json!({});
|
||||
let result = execute(
|
||||
id,
|
||||
step.get("params").filter(|p| !p.is_null()).unwrap_or(&empty),
|
||||
&mut PanelCommandContext { sink: &mut sink, registry: context.registry, catalogue: &mut planned, web: context.web },
|
||||
)?;
|
||||
rows.push((id, result));
|
||||
}
|
||||
let action = if sink.replaced {
|
||||
LayoutAction::Replace { expected_revision: Some(state.revision), layout: sink.state.layout.clone() }
|
||||
} else if !sink.mutations.is_empty() {
|
||||
LayoutAction::Apply { expected_revision: Some(state.revision), mutations: sink.mutations.clone() }
|
||||
} else {
|
||||
LayoutAction::Inspect
|
||||
};
|
||||
let planned_state = sink.state.clone();
|
||||
let changed = planned.state != *context.catalogue.state() || action != LayoutAction::Inspect;
|
||||
// Release the immutable authoritative borrow before committing through it.
|
||||
drop(sink);
|
||||
let committed = if changed {
|
||||
let (envelope, recovery) = current_envelope(&planned.state, &planned_state, context.registry)?;
|
||||
stage_current(context, &state, &action, &envelope, recovery, planned.state)?.0
|
||||
} else {
|
||||
context.sink.inspect()
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
for (id, mut result) in rows {
|
||||
match id {
|
||||
"panel.list" => result = value(wire::ListReply { state: committed.clone(), kinds: context.registry.specs().cloned().collect() })?,
|
||||
"layout.get" => result = value(wire::GetReply { state: committed.clone(), workspaces: context.catalogue.state().summaries() })?,
|
||||
"layout.save" | "layout.workspace.select" => result["state"] = value(committed.clone())?,
|
||||
_ => result = value(committed.clone())?,
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
Ok(json!({"completed":steps.len(),"results":results,"undone":0}))
|
||||
}
|
||||
|
||||
/// Transport-compatible error text retains the structured foundation identity.
|
||||
/// Control/MCP also expose this as panel_error beside their usual error string.
|
||||
pub fn error_text(reason: PanelError) -> String {
|
||||
serde_json::to_string(&reason).unwrap_or_else(|_| "{\"rule\":\"schema\",\"detail\":\"layout error encoding failed\"}".into())
|
||||
}
|
||||
pub fn structured_error(text: &str) -> Option<Value> {
|
||||
if text.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<PanelError>(text).ok().and_then(|reason| value(error(reason.rule, reason.detail)).ok())
|
||||
}
|
||||
|
||||
pub fn sequential_failure(index: usize, command: Option<&str>, text: String, results: Vec<Value>) -> Value {
|
||||
let mut failed = json!({"index":index,"command":command,"error":text});
|
||||
if let Some(reason) = structured_error(&text) {
|
||||
failed["panel_error"] = reason;
|
||||
}
|
||||
json!({"completed":index,"results":results,"failed":failed,"undone":0,"rolled_back":false})
|
||||
}
|
||||
|
||||
/// One owned, persistent in-memory headless backend, outside Session. No kinds,
|
||||
/// presets or workspace descriptors are invented. Peers/fixtures may inject them.
|
||||
pub struct InMemoryPanelBackend {
|
||||
pub service: LayoutService,
|
||||
pub catalogue: InMemoryCatalogue,
|
||||
}
|
||||
|
||||
/// In-process layout has no native realization. Even a supplied service with
|
||||
/// native flags cannot make this adapter claim that it opened an OS window.
|
||||
struct HeadlessSink<'a>(&'a mut LayoutService);
|
||||
impl ActionSink for HeadlessSink<'_> {
|
||||
fn inspect(&self) -> LayoutReply {
|
||||
let mut state = self.0.inspect();
|
||||
state.capabilities.native_windows = false;
|
||||
state.capabilities.max_native_hosts = 0;
|
||||
state
|
||||
}
|
||||
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
|
||||
let prepared = self.0.prepare(action)?;
|
||||
if prepared.effects().iter().any(|effect| matches!(effect, HostEffect::CreateNative { .. } | HostEffect::RebindNative { .. })) {
|
||||
return Err(error(Rule::Capability, "headless has no native host adapter"));
|
||||
}
|
||||
Ok(prepared.layout().clone())
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
self.project(action.clone())?;
|
||||
let mut state = self.0.dispatch(action)?;
|
||||
state.capabilities.native_windows = false;
|
||||
state.capabilities.max_native_hosts = 0;
|
||||
Ok(state)
|
||||
}
|
||||
}
|
||||
impl InMemoryPanelBackend {
|
||||
pub fn new(service: LayoutService, catalogue: InMemoryCatalogue) -> Self {
|
||||
Self { service, catalogue }
|
||||
}
|
||||
pub fn empty() -> PanelResult<Self> {
|
||||
Ok(Self::new(
|
||||
LayoutService::new(PanelRegistry::default(), Layout::empty(WorkspaceId::new("headless")?), HostCapabilities::default())?,
|
||||
InMemoryCatalogue::default(),
|
||||
))
|
||||
}
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> PanelResult<Value> {
|
||||
let registry = self.service.registry().clone();
|
||||
execute(
|
||||
id,
|
||||
params,
|
||||
&mut PanelCommandContext { sink: &mut HeadlessSink(&mut self.service), registry: ®istry, catalogue: &mut self.catalogue, web: false },
|
||||
)
|
||||
}
|
||||
pub fn batch(&mut self, steps: &[Value]) -> PanelResult<Value> {
|
||||
let registry = self.service.registry().clone();
|
||||
execute_batch(
|
||||
steps,
|
||||
&mut PanelCommandContext { sink: &mut HeadlessSink(&mut self.service), registry: ®istry, catalogue: &mut self.catalogue, web: false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Small versioned storage seam for application preferences, not design files.
|
||||
//! Length-prefixed records keep arbitrary recovery bytes exact and bound every
|
||||
//! allocation before copying. Envelopes retain their foundation JSON limits.
|
||||
use super::*;
|
||||
|
||||
const HEADER: &[u8] = b"SCPCAT01";
|
||||
|
||||
impl CatalogueState {
|
||||
/// Export all immutable registrations, mutable users and remembered full
|
||||
/// envelopes. No storage, live action, selection or Session is touched.
|
||||
pub fn export(&self) -> PanelResult<Vec<u8>> {
|
||||
self.validate()?;
|
||||
let mut writer = Writer(Vec::new());
|
||||
writer.put(HEADER)?;
|
||||
for presets in [&self.builtins, &self.users] {
|
||||
writer.count(presets.len())?;
|
||||
for (name, record) in presets {
|
||||
writer.bytes(name.as_bytes())?;
|
||||
writer.record(record)?;
|
||||
}
|
||||
}
|
||||
writer.count(self.workspaces.len())?;
|
||||
for descriptor in self.workspaces.values() {
|
||||
writer.bytes(descriptor.id.0.as_bytes())?;
|
||||
writer.bytes(descriptor.title_key.as_bytes())?;
|
||||
writer.bytes(descriptor.default_preset.as_bytes())?;
|
||||
}
|
||||
writer.count(self.remembered.len())?;
|
||||
for (workspace, record) in &self.remembered {
|
||||
writer.bytes(workspace.0.as_bytes())?;
|
||||
writer.record(record)?;
|
||||
}
|
||||
Ok(writer.0)
|
||||
}
|
||||
|
||||
/// Build a private validated candidate. Counts, duplicate identities,
|
||||
/// names/IDs, envelope node/depth/byte bounds, rejected recovery and total
|
||||
/// budgets are checked before the result can be published by any catalogue.
|
||||
pub fn hydrate(bytes: &[u8], registry: &PanelRegistry) -> PanelResult<Self> {
|
||||
if bytes.len() > MAX_CATALOGUE_SNAPSHOT_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "catalogue snapshot byte cap"));
|
||||
}
|
||||
let mut reader = Reader { bytes, at: 0 };
|
||||
if reader.take(HEADER.len())? != HEADER {
|
||||
return Err(error(Rule::Version, "unsupported catalogue snapshot"));
|
||||
}
|
||||
let mut next = Self::default();
|
||||
for builtin in [true, false] {
|
||||
let count = reader.count(MAX_USER_PRESETS)?;
|
||||
for _ in 0..count {
|
||||
let name = reader.text(256)?;
|
||||
if preset_name(&name)? != name {
|
||||
return Err(error(Rule::Schema, "stored preset name is not normalised"));
|
||||
}
|
||||
if next.builtins.contains_key(&name) || next.users.contains_key(&name) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate stored preset identity"));
|
||||
}
|
||||
let record = reader.record(registry)?;
|
||||
if builtin {
|
||||
next.builtins.insert(name, record);
|
||||
} else {
|
||||
next.users.insert(name, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
for _ in 0..reader.count(MAX_WORKSPACES)? {
|
||||
let id = WorkspaceId::new(reader.text(MAX_ID_BYTES)?)?;
|
||||
let title_key = reader.text(MAX_ID_BYTES)?;
|
||||
valid_id(&title_key)?;
|
||||
let default_preset = reader.text(256)?;
|
||||
if next.workspaces.contains_key(&id) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate stored workspace identity"));
|
||||
}
|
||||
next.workspaces.insert(id.clone(), WorkspaceDescriptor { id, title_key, default_preset });
|
||||
}
|
||||
for _ in 0..reader.count(MAX_WORKSPACES)? {
|
||||
let workspace = WorkspaceId::new(reader.text(MAX_ID_BYTES)?)?;
|
||||
if next.remembered.contains_key(&workspace) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate remembered workspace identity"));
|
||||
}
|
||||
let record = reader.record(registry)?;
|
||||
// A remembered record belongs to its key, unlike a reusable preset.
|
||||
record.envelope(registry, &workspace)?;
|
||||
next.remembered.insert(workspace, record);
|
||||
}
|
||||
if reader.at != bytes.len() {
|
||||
return Err(error(Rule::Schema, "trailing catalogue snapshot data"));
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer(Vec<u8>);
|
||||
impl Writer {
|
||||
fn put(&mut self, bytes: &[u8]) -> PanelResult<()> {
|
||||
if self.0.len().saturating_add(bytes.len()) > MAX_CATALOGUE_SNAPSHOT_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "catalogue snapshot byte cap"));
|
||||
}
|
||||
self.0.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
}
|
||||
fn count(&mut self, count: usize) -> PanelResult<()> {
|
||||
let count = u32::try_from(count).map_err(|_| error(Rule::CollectionLimit, "catalogue framing cap"))?;
|
||||
self.put(&count.to_le_bytes())
|
||||
}
|
||||
fn bytes(&mut self, bytes: &[u8]) -> PanelResult<()> {
|
||||
self.count(bytes.len())?;
|
||||
self.put(bytes)
|
||||
}
|
||||
fn record(&mut self, record: &StoredEnvelope) -> PanelResult<()> {
|
||||
self.bytes(record.bytes())?;
|
||||
match record.rejected() {
|
||||
Some(rejected) => {
|
||||
self.put(&[1])?;
|
||||
self.bytes(rejected)
|
||||
}
|
||||
None => self.put(&[0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
bytes: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, count: usize) -> PanelResult<&'a [u8]> {
|
||||
let end = self.at.checked_add(count).ok_or_else(|| error(Rule::CollectionLimit, "catalogue framing overflow"))?;
|
||||
let bytes = self.bytes.get(self.at..end).ok_or_else(|| error(Rule::Schema, "truncated catalogue snapshot"))?;
|
||||
self.at = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
fn count(&mut self, max: usize) -> PanelResult<usize> {
|
||||
let raw = <[u8; 4]>::try_from(self.take(4)?).map_err(|_| error(Rule::Schema, "invalid catalogue length"))?;
|
||||
let count = usize::try_from(u32::from_le_bytes(raw)).map_err(|_| error(Rule::CollectionLimit, "catalogue length overflow"))?;
|
||||
if count > max {
|
||||
return Err(error(Rule::CollectionLimit, "catalogue record/count cap"));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
fn bytes(&mut self, max: usize) -> PanelResult<&'a [u8]> {
|
||||
let count = self.count(max)?;
|
||||
self.take(count)
|
||||
}
|
||||
fn text(&mut self, max: usize) -> PanelResult<String> {
|
||||
std::str::from_utf8(self.bytes(max)?).map(str::to_owned).map_err(|_| error(Rule::Schema, "invalid catalogue UTF-8"))
|
||||
}
|
||||
fn record(&mut self, registry: &PanelRegistry) -> PanelResult<StoredEnvelope> {
|
||||
let bytes = self.bytes(MAX_ENVELOPE_BYTES)?;
|
||||
let rejected = match self.take(1)? {
|
||||
[0] => None,
|
||||
[1] => Some(self.bytes(MAX_ENVELOPE_BYTES)?),
|
||||
_ => return Err(error(Rule::Schema, "invalid catalogue recovery tag")),
|
||||
};
|
||||
StoredEnvelope::from_record(bytes, rejected, registry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Executable synthetic contracts for independent host/command/persistence work.
|
||||
//! These identifiers are fixtures, never production registrations or UI strings.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub const FIXTURE_LAYOUT_V1: &[u8] = include_bytes!("fixtures/layout-v1.json");
|
||||
|
||||
pub fn synthetic_registry() -> PanelResult<PanelRegistry> {
|
||||
let mut registry = PanelRegistry::default();
|
||||
for (kind, multi, pinned) in
|
||||
[("fixture.application", false, false), ("fixture.editor", false, false), ("fixture.log", true, false), ("fixture.pin", true, true)]
|
||||
{
|
||||
let mut allowed_zones: std::collections::BTreeSet<_> =
|
||||
[Zone::Left, Zone::Right, Zone::Bottom, Zone::Floating, Zone::Window].into_iter().collect();
|
||||
if pinned {
|
||||
allowed_zones.remove(&Zone::Bottom);
|
||||
}
|
||||
registry.register(PanelSpec {
|
||||
kind: PanelKind::new(kind)?,
|
||||
title_key: "ui.panel".into(),
|
||||
icon: "panel".into(),
|
||||
allowed_zones,
|
||||
default_zone: Zone::Left,
|
||||
min_size: Size { width: 160.0, height: 120.0 },
|
||||
default_size: Size { width: 280.0, height: 240.0 },
|
||||
max_size: Size { width: 1200.0, height: 1200.0 },
|
||||
edge_rule: if pinned { EdgeRule::PinOuter } else { EdgeRule::None },
|
||||
surface: PanelSurface::FollowZone,
|
||||
features: PanelFeatures { multi_instance: multi, ..PanelFeatures::default() },
|
||||
})?;
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
pub fn fixture_open(kind: &str, instance: &str, section: &str, zone: Zone, index: usize) -> PanelResult<Mutation> {
|
||||
Ok(Mutation::Open {
|
||||
kind: PanelKind::new(kind)?,
|
||||
instance: InstanceId::new(instance)?,
|
||||
destination: Some(Destination::NewSection {
|
||||
section: SectionId::new(section)?,
|
||||
index,
|
||||
location: if zone == Zone::Floating {
|
||||
SectionLocation::Floating { origin: Point { x: 40.0, y: 40.0 } }
|
||||
} else {
|
||||
SectionLocation::Dock { zone }
|
||||
},
|
||||
size: Size { width: 280.0, height: 240.0 },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fixture_service(native: bool) -> PanelResult<LayoutService> {
|
||||
LayoutService::new(
|
||||
synthetic_registry()?,
|
||||
Layout::empty(WorkspaceId::new("fixture.workspace")?),
|
||||
HostCapabilities { native_windows: native, max_native_hosts: if native { 8 } else { 0 }, persistence: true },
|
||||
)
|
||||
}
|
||||
|
||||
pub fn transaction(mutations: Vec<Mutation>) -> LayoutAction {
|
||||
LayoutAction::Apply { expected_revision: None, mutations }
|
||||
}
|
||||
|
||||
/// Synthetic catalogue shared by command/control/MCP tests; never registered by
|
||||
/// application startup. Two descriptors may deliberately share one default.
|
||||
pub fn fixture_command_catalogue(registry: &PanelRegistry) -> PanelResult<commands::InMemoryCatalogue> {
|
||||
let mut catalogue = commands::InMemoryCatalogue::default();
|
||||
let empty = LayoutEnvelope::new(Layout::empty(WorkspaceId::new("fixture.workspace")?));
|
||||
catalogue.register_builtin("Fixture default", &empty, registry)?;
|
||||
for id in ["fixture.workspace", "fixture.other"] {
|
||||
catalogue.register_workspace(commands::WorkspaceDescriptor {
|
||||
id: WorkspaceId::new(id)?,
|
||||
title_key: "ui.panel".into(),
|
||||
default_preset: "Fixture default".into(),
|
||||
})?;
|
||||
}
|
||||
Ok(catalogue)
|
||||
}
|
||||
|
||||
pub fn fixture_command_backend() -> PanelResult<commands::InMemoryPanelBackend> {
|
||||
let service = fixture_service(false)?;
|
||||
let catalogue = fixture_command_catalogue(service.registry())?;
|
||||
Ok(commands::InMemoryPanelBackend::new(service, catalogue))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FakePersistence {
|
||||
/// Deliberately separate map demonstrates that layout writes own one key.
|
||||
pub unrelated_preferences: BTreeMap<String, String>,
|
||||
pub layouts: BTreeMap<WorkspaceId, Vec<u8>>,
|
||||
pub fail_save: bool,
|
||||
}
|
||||
impl LayoutPersistence for FakePersistence {
|
||||
fn load(&mut self, workspace: &WorkspaceId) -> PanelResult<Option<Vec<u8>>> {
|
||||
valid_id(&workspace.0)?;
|
||||
Ok(self.layouts.get(workspace).cloned())
|
||||
}
|
||||
fn save(&mut self, workspace: &WorkspaceId, envelope: &[u8]) -> PanelResult<()> {
|
||||
valid_id(&workspace.0)?;
|
||||
if envelope.len() > MAX_ENVELOPE_BYTES || (!self.layouts.contains_key(workspace) && self.layouts.len() >= MAX_SECTIONS) {
|
||||
return Err(error(Rule::CollectionLimit, "fake persistence capacity"));
|
||||
}
|
||||
if self.fail_save {
|
||||
return Err(error(Rule::Capability, "synthetic atomic write failure"));
|
||||
}
|
||||
self.layouts.insert(workspace.clone(), envelope.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"format": "solidcraft.panel-layout",
|
||||
"version": 1,
|
||||
"layout": {
|
||||
"version": 1,
|
||||
"workspace": "fixture.workspace",
|
||||
"instances": [
|
||||
{
|
||||
"id": "editor",
|
||||
"kind": "fixture.editor",
|
||||
"requested_open": true,
|
||||
"memory": {
|
||||
"section": "editor-section",
|
||||
"location": { "type": "dock", "zone": "left" },
|
||||
"section_index": 0,
|
||||
"tab_index": 0,
|
||||
"size": { "width": 280.0, "height": 240.0 },
|
||||
"collapsed": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"sections": [
|
||||
{
|
||||
"id": "editor-section",
|
||||
"location": { "type": "dock", "zone": "left" },
|
||||
"tabs": ["editor"],
|
||||
"active": "editor",
|
||||
"size": { "width": 280.0, "height": 240.0 },
|
||||
"collapsed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"quarantine": [],
|
||||
"section_snapshots": {},
|
||||
"section_order": ["editor-section"],
|
||||
"retained_data": {}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Point {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
impl Point {
|
||||
pub fn validate(self) -> PanelResult<()> {
|
||||
if !self.x.is_finite() || !self.y.is_finite() || self.x.abs() > MAX_LOGICAL || self.y.abs() > MAX_LOGICAL {
|
||||
return Err(error(Rule::Bounds, "invalid logical position"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct LogicalRect {
|
||||
pub origin: Point,
|
||||
pub size: Size,
|
||||
}
|
||||
impl LogicalRect {
|
||||
pub fn validate(self) -> PanelResult<()> {
|
||||
self.origin.validate()?;
|
||||
self.size.validate()?;
|
||||
if (self.origin.x + self.size.width).abs() > MAX_LOGICAL || (self.origin.y + self.size.height).abs() > MAX_LOGICAL {
|
||||
return Err(error(Rule::Bounds, "logical rectangle extent out of range"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn contains(self, p: Point) -> bool {
|
||||
p.x >= self.origin.x && p.y >= self.origin.y && p.x <= self.origin.x + self.size.width && p.y <= self.origin.y + self.size.height
|
||||
}
|
||||
pub fn contains_rect(self, other: Self) -> bool {
|
||||
self.contains(other.origin) && self.contains(Point { x: other.origin.x + other.size.width, y: other.origin.y + other.size.height })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Flow {
|
||||
Tall,
|
||||
Wide,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SizeClass {
|
||||
Compact,
|
||||
Regular,
|
||||
Wide,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Direction {
|
||||
Ltr,
|
||||
Rtl,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PhysicalEdge {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn edge(self, zone: Zone) -> PanelResult<PhysicalEdge> {
|
||||
match (self, zone) {
|
||||
(Self::Ltr, Zone::Left) | (Self::Rtl, Zone::Right) => Ok(PhysicalEdge::Left),
|
||||
(Self::Ltr, Zone::Right) | (Self::Rtl, Zone::Left) => Ok(PhysicalEdge::Right),
|
||||
(_, Zone::Bottom) => Ok(PhysicalEdge::Bottom),
|
||||
_ => Err(error(Rule::Placement, "free host has no logical dock edge")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Usable logical units: egui applies scale exactly once, outside this contract.
|
||||
/// Free/native flow starts at aspect 1, switches Tall->Wide above 1.1, Wide->Tall
|
||||
/// below 0.9, and retains the previous flow inside that band.
|
||||
pub fn responsive(zone: Zone, usable: Size, previous: Option<Flow>) -> PanelResult<(Flow, SizeClass)> {
|
||||
usable.validate()?;
|
||||
let aspect = usable.width / usable.height;
|
||||
let flow = match zone {
|
||||
Zone::Left | Zone::Right => Flow::Tall,
|
||||
Zone::Bottom => Flow::Wide,
|
||||
Zone::Floating | Zone::Window => match previous {
|
||||
Some(Flow::Tall) if aspect <= 1.1 => Flow::Tall,
|
||||
Some(Flow::Wide) if aspect >= 0.9 => Flow::Wide,
|
||||
_ if aspect >= 1.0 => Flow::Wide,
|
||||
_ => Flow::Tall,
|
||||
},
|
||||
};
|
||||
let shorter = usable.width.min(usable.height);
|
||||
let class = if shorter < 240.0 {
|
||||
SizeClass::Compact
|
||||
} else if shorter <= 480.0 {
|
||||
SizeClass::Regular
|
||||
} else {
|
||||
SizeClass::Wide
|
||||
};
|
||||
Ok((flow, class))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InputRole {
|
||||
Surface,
|
||||
Header,
|
||||
Tab,
|
||||
Sash,
|
||||
Action,
|
||||
Rail,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct InputRegion {
|
||||
pub control: ControlId,
|
||||
pub role: InputRole,
|
||||
pub bounds: LogicalRect,
|
||||
pub keyboard_focusable: bool,
|
||||
/// Translation key or a runtime title resolved by the host.
|
||||
pub accessible_name: String,
|
||||
}
|
||||
impl InputRegion {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
valid_id(&self.control.0)?;
|
||||
self.bounds.validate()?;
|
||||
if self.accessible_name.is_empty() || self.accessible_name.len() > 256 {
|
||||
return Err(error(Rule::Bounds, "accessible name requires 1..256 bytes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-resolved geometry has separate outer, body, paint and input bounds. No
|
||||
/// transparent/full-window input shield is implied by an overlay surface.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ResolvedPanel {
|
||||
pub instance: InstanceId,
|
||||
pub section: SectionId,
|
||||
pub zone: Zone,
|
||||
pub outer: LogicalRect,
|
||||
pub body: LogicalRect,
|
||||
pub painted: Vec<LogicalRect>,
|
||||
pub input: Vec<InputRegion>,
|
||||
}
|
||||
impl ResolvedPanel {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
valid_id(&self.instance.0)?;
|
||||
valid_id(&self.section.0)?;
|
||||
self.outer.validate()?;
|
||||
self.body.validate()?;
|
||||
if !self.outer.contains_rect(self.body) {
|
||||
return Err(error(Rule::Bounds, "usable body outside outer host"));
|
||||
}
|
||||
if self.painted.is_empty() || self.painted.len() > MAX_TABS || self.input.len() > MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "resolved region count"));
|
||||
}
|
||||
for rect in &self.painted {
|
||||
rect.validate()?;
|
||||
if !self.outer.contains_rect(*rect) {
|
||||
return Err(error(Rule::Bounds, "paint outside host"));
|
||||
}
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for region in &self.input {
|
||||
region.validate()?;
|
||||
if !ids.insert(®ion.control) {
|
||||
return Err(error(Rule::DuplicateId, "semantic control id repeated"));
|
||||
}
|
||||
if !self.painted.iter().any(|r| r.contains_rect(region.bounds)) {
|
||||
return Err(error(Rule::Bounds, "input must be contained in painted area"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn consumes_pointer(&self, at: Point) -> bool {
|
||||
self.input.iter().any(|r| r.bounds.contains(at))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UnpresentedReason {
|
||||
MinimumOverflow,
|
||||
Unavailable,
|
||||
NativeUnsupported,
|
||||
Collapsed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct UnpresentedSection {
|
||||
pub section: SectionId,
|
||||
pub reason: UnpresentedReason,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ResolvedLayout {
|
||||
pub panels: Vec<ResolvedPanel>,
|
||||
/// Accessible rail controls are chrome, never sub-minimum content bodies.
|
||||
pub rails: Vec<InputRegion>,
|
||||
pub unpresented: Vec<UnpresentedSection>,
|
||||
}
|
||||
impl ResolvedLayout {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
if self.panels.len() > MAX_SECTIONS || self.rails.len() > MAX_SECTIONS || self.unpresented.len() > MAX_SECTIONS {
|
||||
return Err(error(Rule::CollectionLimit, "resolved layout cap"));
|
||||
}
|
||||
let mut instances = std::collections::BTreeSet::new();
|
||||
let mut sections = std::collections::BTreeSet::new();
|
||||
let mut rails = std::collections::BTreeSet::new();
|
||||
for panel in &self.panels {
|
||||
panel.validate()?;
|
||||
if !instances.insert(&panel.instance) || !sections.insert(&panel.section) {
|
||||
return Err(error(Rule::ExactlyOnce, "duplicate resolved instance/section"));
|
||||
}
|
||||
}
|
||||
for rail in &self.rails {
|
||||
rail.validate()?;
|
||||
if rail.role != InputRole::Rail || !rails.insert(&rail.control) {
|
||||
return Err(error(Rule::DuplicateId, "invalid or duplicate rail control"));
|
||||
}
|
||||
}
|
||||
for record in &self.unpresented {
|
||||
valid_id(&record.section.0)?;
|
||||
if !sections.insert(&record.section) {
|
||||
return Err(error(Rule::ExactlyOnce, "section both presented/unpresented or duplicated"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference geometry for synthetic mounts. Production docking owns its richer
|
||||
/// resolver; both return the same meaningful geometry contract. No layout writes.
|
||||
pub fn resolve_geometry(service: &LayoutService, capacity: LogicalRect, direction: Direction, chrome: f32) -> PanelResult<ResolvedLayout> {
|
||||
capacity.validate()?;
|
||||
if !chrome.is_finite() || chrome < 0.0 || chrome > 256.0 {
|
||||
return Err(error(Rule::Bounds, "invalid header/footer chrome extent"));
|
||||
}
|
||||
let mut result = ResolvedLayout::default();
|
||||
let mut left = capacity.origin.x;
|
||||
let mut right = capacity.origin.x + capacity.size.width;
|
||||
let mut bottom = capacity.origin.y + capacity.size.height;
|
||||
let mut left_y = capacity.origin.y;
|
||||
let mut right_y = capacity.origin.y;
|
||||
let mut bottom_x = capacity.origin.x;
|
||||
let mut rail_y = capacity.origin.y;
|
||||
let mut occupied: Vec<LogicalRect> = Vec::new();
|
||||
for section in &service.layout().sections {
|
||||
let id = §ion.active;
|
||||
let zone = section.location.zone();
|
||||
let peek = &service.interaction().peek;
|
||||
let active = peek.as_ref().filter(|p| section.tabs.contains(p)).unwrap_or(id);
|
||||
let available = service.availability(active) == Availability::Available;
|
||||
let reason = if !available {
|
||||
Some(UnpresentedReason::Unavailable)
|
||||
} else if zone == Zone::Window && !service.capabilities().native_windows {
|
||||
Some(UnpresentedReason::NativeUnsupported)
|
||||
} else if section.collapsed && peek.as_ref() != Some(active) {
|
||||
Some(UnpresentedReason::Collapsed)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
result.unpresented.push(UnpresentedSection { section: section.id.clone(), reason: reason.clone() });
|
||||
if reason == UnpresentedReason::Collapsed {
|
||||
append_rail(&mut result, service, active, capacity, direction, zone, &mut rail_y)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let size = section.size;
|
||||
let origin = match §ion.location {
|
||||
SectionLocation::Floating { origin } | SectionLocation::Window { origin, .. } => *origin,
|
||||
SectionLocation::Dock { zone } => match direction.edge(*zone)? {
|
||||
PhysicalEdge::Left => Point { x: left, y: left_y },
|
||||
PhysicalEdge::Right => Point { x: right - size.width, y: right_y },
|
||||
PhysicalEdge::Bottom => Point { x: bottom_x, y: bottom - size.height },
|
||||
},
|
||||
};
|
||||
let outer = LogicalRect { origin, size };
|
||||
let overlaps_dock = zone.docked()
|
||||
&& !section.collapsed
|
||||
&& occupied.iter().any(|r| {
|
||||
r.origin.x < origin.x + size.width
|
||||
&& origin.x < r.origin.x + r.size.width
|
||||
&& r.origin.y < origin.y + size.height
|
||||
&& origin.y < r.origin.y + r.size.height
|
||||
});
|
||||
if !capacity.contains_rect(outer) || size.height <= chrome || overlaps_dock {
|
||||
result.unpresented.push(UnpresentedSection { section: section.id.clone(), reason: UnpresentedReason::MinimumOverflow });
|
||||
append_rail(&mut result, service, active, capacity, direction, zone, &mut rail_y)?;
|
||||
continue;
|
||||
}
|
||||
if zone.docked() && !section.collapsed {
|
||||
occupied.push(outer);
|
||||
match direction.edge(zone)? {
|
||||
PhysicalEdge::Left => {
|
||||
left_y += size.height;
|
||||
left = left.max(origin.x);
|
||||
}
|
||||
PhysicalEdge::Right => {
|
||||
right_y += size.height;
|
||||
right = right.min(origin.x + size.width);
|
||||
}
|
||||
PhysicalEdge::Bottom => {
|
||||
bottom_x += size.width;
|
||||
bottom = bottom.min(origin.y + size.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
let body =
|
||||
LogicalRect { origin: Point { x: origin.x, y: origin.y + chrome / 2.0 }, size: Size { width: size.width, height: size.height - chrome } };
|
||||
let panel = ResolvedPanel {
|
||||
instance: active.clone(),
|
||||
section: section.id.clone(),
|
||||
zone,
|
||||
outer,
|
||||
body,
|
||||
painted: vec![outer],
|
||||
input: vec![InputRegion {
|
||||
control: ControlId::new(format!("host.{}", section.id.0).chars().take(MAX_ID_BYTES).collect::<String>())?,
|
||||
role: InputRole::Surface,
|
||||
bounds: outer,
|
||||
keyboard_focusable: true,
|
||||
accessible_name: service.registry().get(&service.layout().instance(active)?.kind)?.title_key.clone(),
|
||||
}],
|
||||
};
|
||||
panel.validate()?;
|
||||
result.panels.push(panel);
|
||||
}
|
||||
result.validate()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn append_rail(
|
||||
result: &mut ResolvedLayout,
|
||||
service: &LayoutService,
|
||||
active: &InstanceId,
|
||||
capacity: LogicalRect,
|
||||
direction: Direction,
|
||||
zone: Zone,
|
||||
rail_y: &mut f32,
|
||||
) -> PanelResult<()> {
|
||||
// Rails are bounded chrome, not below-minimum content bodies. If every entry
|
||||
// cannot fit, the complete unpresented list remains available to a host's rail.
|
||||
let width = 32.0_f32.min(capacity.size.width);
|
||||
let height = 32.0_f32.min(capacity.size.height);
|
||||
if *rail_y + height <= capacity.origin.y + capacity.size.height {
|
||||
let right = direction.edge(if zone.docked() { zone } else { Zone::Left })? == PhysicalEdge::Right;
|
||||
let x = if right { capacity.origin.x + capacity.size.width - width } else { capacity.origin.x };
|
||||
result.rails.push(InputRegion {
|
||||
control: ControlId::new(format!("rail.{}", result.rails.len()))?,
|
||||
role: InputRole::Rail,
|
||||
bounds: LogicalRect { origin: Point { x, y: *rail_y }, size: Size { width, height } },
|
||||
keyboard_focusable: true,
|
||||
accessible_name: service.registry().get(&service.layout().instance(active)?.kind)?.title_key.clone(),
|
||||
});
|
||||
*rail_y += height;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Surfaces child resolves theme tokens. Invalid authored pairs retain raw import
|
||||
/// intent and last good tokens; they cannot be reported as accessible by recolouring.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum ContrastOutcome {
|
||||
Readable { effective_background_alpha: f32 },
|
||||
RetainReadableFallback { reason: ContrastFailure },
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContrastFailure {
|
||||
InvalidTokens,
|
||||
UnreadableAtFullOpacity,
|
||||
}
|
||||
|
||||
impl ContrastOutcome {
|
||||
/// The surfaces adapter proves readability. This boundary enforces finite
|
||||
/// alpha and the policy that effective background alpha may only increase.
|
||||
pub fn validate(&self, requested_alpha: f32) -> PanelResult<()> {
|
||||
if !requested_alpha.is_finite() || !(0.0..=1.0).contains(&requested_alpha) {
|
||||
return Err(error(Rule::Bounds, "invalid requested background alpha"));
|
||||
}
|
||||
if let Self::Readable { effective_background_alpha } = self
|
||||
&& (!effective_background_alpha.is_finite() || *effective_background_alpha < requested_alpha || *effective_background_alpha > 1.0)
|
||||
{
|
||||
return Err(error(Rule::Bounds, "resolved background alpha must be finite and only raised"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared surface precedence: Opaque wins, then explicit Translucent, then zone
|
||||
/// default (bottom/native opaque, sides/free translucent). No colour implementation.
|
||||
pub fn group_surface<'a>(zone: Zone, specs: impl IntoIterator<Item = &'a PanelSpec>) -> PanelSurface {
|
||||
let mut translucent = false;
|
||||
for spec in specs {
|
||||
if spec.surface == PanelSurface::Opaque {
|
||||
return PanelSurface::Opaque;
|
||||
}
|
||||
translucent |= spec.surface == PanelSurface::Translucent;
|
||||
}
|
||||
if translucent || !matches!(zone, Zone::Bottom | Zone::Window) { PanelSurface::Translucent } else { PanelSurface::Opaque }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Application/workspace layout, deliberately independent of Session and egui.
|
||||
//! Consumers must use `LayoutService` (or its `ActionSink`) for committed mutations.
|
||||
//! The serde values are wire data, not proof of validity; see `validate` and `decode`.
|
||||
pub mod commands;
|
||||
mod fixtures;
|
||||
mod geometry;
|
||||
mod model;
|
||||
mod persistence;
|
||||
mod reducer;
|
||||
|
||||
pub use fixtures::*;
|
||||
pub use geometry::*;
|
||||
pub use model::*;
|
||||
pub use persistence::*;
|
||||
pub use reducer::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,480 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::geometry::Point;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const LAYOUT_VERSION: u32 = 1;
|
||||
pub const PANEL_API_VERSION: u32 = 1;
|
||||
pub const MAX_ID_BYTES: usize = 64;
|
||||
pub const MAX_INSTANCES: usize = 128;
|
||||
pub const MAX_SECTIONS: usize = 64;
|
||||
pub const MAX_TABS: usize = 32;
|
||||
pub const MAX_MUTATIONS: usize = 64;
|
||||
pub const MAX_LOGICAL: f32 = 32_768.0;
|
||||
pub const MAX_ENVELOPE_BYTES: usize = 262_144;
|
||||
|
||||
/// Classic is a permanent compatibility contract; this foundation does not
|
||||
/// select a new preset or migrate existing preferences.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompatibilityPreset {
|
||||
Classic,
|
||||
}
|
||||
|
||||
/// Stable machine-readable rule names; presentation translates these in the adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Rule {
|
||||
Availability,
|
||||
Bounds,
|
||||
Capability,
|
||||
CollectionLimit,
|
||||
DuplicateId,
|
||||
ExactlyOnce,
|
||||
FeatureDisabled,
|
||||
Id,
|
||||
IncompatibleTabs,
|
||||
Lifecycle,
|
||||
MissingTarget,
|
||||
PinConflict,
|
||||
PinOrder,
|
||||
Placement,
|
||||
Revision,
|
||||
Schema,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, thiserror::Error)]
|
||||
#[error("{rule:?}: {detail}")]
|
||||
pub struct PanelError {
|
||||
pub rule: Rule,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
pub type PanelResult<T> = std::result::Result<T, PanelError>;
|
||||
|
||||
pub fn error(rule: Rule, detail: impl Into<String>) -> PanelError {
|
||||
// Error replies are bounded too; never echo a hostile payload in full.
|
||||
PanelError { rule, detail: detail.into().chars().take(256).collect() }
|
||||
}
|
||||
|
||||
pub fn valid_id(value: &str) -> PanelResult<()> {
|
||||
if value.is_empty() || value.len() > MAX_ID_BYTES || !value.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"._-".contains(&c)) {
|
||||
return Err(error(Rule::Id, "expected a nonempty bounded lowercase identifier"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
macro_rules! identity {
|
||||
($($name:ident),+ $(,)?) => {$ (
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub String);
|
||||
impl $name {
|
||||
pub fn new(value: impl Into<String>) -> PanelResult<Self> {
|
||||
let value = value.into();
|
||||
valid_id(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
)+};
|
||||
}
|
||||
identity!(PanelKind, InstanceId, SectionId, WorkspaceId, DocumentId, ViewportId, HostId, ControlId);
|
||||
|
||||
/// Leading/trailing logical sides, mirrored by a host for RTL. There is no Top zone.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Zone {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
Floating,
|
||||
Window,
|
||||
}
|
||||
|
||||
impl Zone {
|
||||
pub fn docked(self) -> bool {
|
||||
matches!(self, Self::Left | Self::Right | Self::Bottom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EdgeRule {
|
||||
None,
|
||||
PinOuter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PanelSurface {
|
||||
FollowZone,
|
||||
Opaque,
|
||||
Translucent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Size {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Size {
|
||||
pub fn validate(self) -> PanelResult<()> {
|
||||
if !self.width.is_finite()
|
||||
|| !self.height.is_finite()
|
||||
|| self.width <= 0.0
|
||||
|| self.height <= 0.0
|
||||
|| self.width > MAX_LOGICAL
|
||||
|| self.height > MAX_LOGICAL
|
||||
{
|
||||
return Err(error(Rule::Bounds, "outer size must be finite and in (0, 32768] logical units"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn within(self, min: Self, max: Self) -> bool {
|
||||
self.width >= min.width && self.height >= min.height && self.width <= max.width && self.height <= max.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PanelFeatures {
|
||||
pub collapsible: bool,
|
||||
pub closable: bool,
|
||||
pub popout: bool,
|
||||
pub tabbable: bool,
|
||||
pub multi_instance: bool,
|
||||
}
|
||||
|
||||
impl Default for PanelFeatures {
|
||||
fn default() -> Self {
|
||||
Self { collapsible: true, closable: true, popout: true, tabbable: true, multi_instance: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable registration metadata. No callbacks, content state or model references.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PanelSpec {
|
||||
pub kind: PanelKind,
|
||||
pub title_key: String,
|
||||
pub icon: String,
|
||||
pub allowed_zones: BTreeSet<Zone>,
|
||||
pub default_zone: Zone,
|
||||
pub min_size: Size,
|
||||
pub default_size: Size,
|
||||
pub max_size: Size,
|
||||
pub edge_rule: EdgeRule,
|
||||
pub surface: PanelSurface,
|
||||
pub features: PanelFeatures,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PanelRegistry {
|
||||
specs: BTreeMap<PanelKind, PanelSpec>,
|
||||
}
|
||||
|
||||
impl PanelRegistry {
|
||||
pub fn register(&mut self, spec: PanelSpec) -> PanelResult<()> {
|
||||
valid_id(&spec.kind.0)?;
|
||||
valid_id(&spec.title_key)?;
|
||||
valid_id(&spec.icon)?;
|
||||
spec.min_size.validate()?;
|
||||
spec.max_size.validate()?;
|
||||
spec.default_size.validate()?;
|
||||
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.allowed_zones.contains(&Zone::Floating) || !spec.allowed_zones.contains(&spec.default_zone) {
|
||||
return Err(error(Rule::Placement, "Floating and default_zone must be allowed"));
|
||||
}
|
||||
if spec.edge_rule == EdgeRule::PinOuter && spec.allowed_zones.contains(&Zone::Bottom) {
|
||||
return Err(error(Rule::Placement, "PinOuter must exclude Bottom"));
|
||||
}
|
||||
if self.specs.contains_key(&spec.kind) {
|
||||
return Err(error(Rule::DuplicateId, "panel kind already registered"));
|
||||
}
|
||||
if self.specs.len() >= MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "registry full"));
|
||||
}
|
||||
self.specs.insert(spec.kind.clone(), spec);
|
||||
Ok(())
|
||||
}
|
||||
pub fn get(&self, kind: &PanelKind) -> PanelResult<&PanelSpec> {
|
||||
self.specs.get(kind).ok_or_else(|| error(Rule::MissingTarget, "unregistered panel kind"))
|
||||
}
|
||||
pub fn specs(&self) -> impl Iterator<Item = &PanelSpec> {
|
||||
self.specs.values()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SectionLocation {
|
||||
Dock { zone: Zone },
|
||||
Floating { origin: Point },
|
||||
Window { host: HostId, origin: Point, return_to: DockReturn },
|
||||
}
|
||||
|
||||
impl SectionLocation {
|
||||
pub fn zone(&self) -> Zone {
|
||||
match self {
|
||||
Self::Dock { zone } => *zone,
|
||||
Self::Floating { .. } => Zone::Floating,
|
||||
Self::Window { .. } => Zone::Window,
|
||||
}
|
||||
}
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
match self {
|
||||
Self::Dock { zone } if !zone.docked() => Err(error(Rule::Placement, "dock must name a docked zone")),
|
||||
Self::Dock { .. } => Ok(()),
|
||||
Self::Floating { origin } => origin.validate(),
|
||||
Self::Window { host, origin, return_to } => {
|
||||
valid_id(&host.0)?;
|
||||
origin.validate()?;
|
||||
return_to.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native return destinations never recursively contain another native host.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct DockReturn {
|
||||
pub zone: Zone,
|
||||
pub index: usize,
|
||||
pub size: Size,
|
||||
pub collapsed: bool,
|
||||
/// Restore a former tab group when it still occupies the recorded dock.
|
||||
/// Missing/reparented groups use the nonrecursive dock fallback above.
|
||||
#[serde(default)]
|
||||
pub join: Option<ReturnTab>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ReturnTab {
|
||||
pub section: SectionId,
|
||||
pub index: usize,
|
||||
}
|
||||
impl DockReturn {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
if !self.zone.docked() || self.index > MAX_SECTIONS {
|
||||
return Err(error(Rule::Placement, "invalid native return destination"));
|
||||
}
|
||||
self.size.validate()?;
|
||||
if let Some(join) = &self.join {
|
||||
valid_id(&join.section.0)?;
|
||||
if join.index >= MAX_TABS {
|
||||
return Err(error(Rule::Bounds, "native return tab index cap"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Section {
|
||||
pub id: SectionId,
|
||||
pub location: SectionLocation,
|
||||
pub tabs: Vec<InstanceId>,
|
||||
pub active: InstanceId,
|
||||
/// Desired outer dimensions; overflow resolution never modifies these.
|
||||
pub size: Size,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PlacementMemory {
|
||||
pub section: SectionId,
|
||||
pub location: SectionLocation,
|
||||
pub section_index: usize,
|
||||
pub tab_index: usize,
|
||||
pub size: Size,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PanelInstance {
|
||||
pub id: InstanceId,
|
||||
pub kind: PanelKind,
|
||||
pub requested_open: bool,
|
||||
pub memory: PlacementMemory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Layout {
|
||||
pub version: u32,
|
||||
pub workspace: WorkspaceId,
|
||||
pub instances: Vec<PanelInstance>,
|
||||
/// Stable order, outermost first within each logical dock edge.
|
||||
pub sections: Vec<Section>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn empty(workspace: WorkspaceId) -> Self {
|
||||
Self { version: LAYOUT_VERSION, workspace, instances: Vec::new(), sections: Vec::new() }
|
||||
}
|
||||
pub fn instance(&self, id: &InstanceId) -> PanelResult<&PanelInstance> {
|
||||
self.instances.iter().find(|p| p.id == *id).ok_or_else(|| error(Rule::MissingTarget, "instance not found"))
|
||||
}
|
||||
pub fn section(&self, id: &SectionId) -> PanelResult<&Section> {
|
||||
self.sections.iter().find(|s| s.id == *id).ok_or_else(|| error(Rule::MissingTarget, "section not found"))
|
||||
}
|
||||
pub fn containing(&self, id: &InstanceId) -> PanelResult<&Section> {
|
||||
self.sections.iter().find(|s| s.tabs.contains(id)).ok_or_else(|| error(Rule::MissingTarget, "instance has no open section"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum Availability {
|
||||
Available,
|
||||
Unavailable { reason: String },
|
||||
}
|
||||
|
||||
impl Availability {
|
||||
pub fn require(&self) -> PanelResult<()> {
|
||||
match self {
|
||||
Self::Available => Ok(()),
|
||||
Self::Unavailable { reason } => Err(error(Rule::Availability, reason)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing entries mean available. Availability is live context, never layout persistence.
|
||||
pub type AvailabilityMap = BTreeMap<InstanceId, Availability>;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct HostCapabilities {
|
||||
pub native_windows: bool,
|
||||
pub persistence: bool,
|
||||
pub max_native_hosts: usize,
|
||||
}
|
||||
|
||||
pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section) -> PanelResult<(Size, Size)> {
|
||||
let mut min = Size { width: 0.0, height: 0.0 };
|
||||
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);
|
||||
}
|
||||
if min.width > max.width || min.height > max.height {
|
||||
return Err(error(Rule::IncompatibleTabs, "tab size constraints have no intersection"));
|
||||
}
|
||||
Ok((min, max))
|
||||
}
|
||||
|
||||
/// Bounded semantic validation, also required after serde and every transaction.
|
||||
pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
if layout.version != LAYOUT_VERSION {
|
||||
return Err(error(Rule::Version, "unsupported layout version"));
|
||||
}
|
||||
valid_id(&layout.workspace.0)?;
|
||||
if layout.instances.len() > MAX_INSTANCES || layout.sections.len() > MAX_SECTIONS {
|
||||
return Err(error(Rule::CollectionLimit, "layout collection limit"));
|
||||
}
|
||||
let mut instances = BTreeSet::new();
|
||||
let mut kinds = BTreeSet::new();
|
||||
for p in &layout.instances {
|
||||
valid_id(&p.id.0)?;
|
||||
valid_id(&p.kind.0)?;
|
||||
valid_id(&p.memory.section.0)?;
|
||||
if !instances.insert(&p.id) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate instance"));
|
||||
}
|
||||
let spec = registry.get(&p.kind)?;
|
||||
if !spec.features.multi_instance && !kinds.insert(&p.kind) {
|
||||
return Err(error(Rule::ExactlyOnce, "single-instance kind repeated, including closed instances"));
|
||||
}
|
||||
p.memory.location.validate()?;
|
||||
p.memory.size.validate()?;
|
||||
if p.memory.tab_index >= MAX_TABS || p.memory.section_index > MAX_SECTIONS {
|
||||
return Err(error(Rule::Bounds, "invalid placement memory"));
|
||||
}
|
||||
if !spec.allowed_zones.contains(&p.memory.location.zone()) {
|
||||
return Err(error(Rule::Placement, "remembered zone forbidden"));
|
||||
}
|
||||
}
|
||||
let mut sections = BTreeSet::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut hosts = BTreeSet::new();
|
||||
let mut pins = BTreeSet::new();
|
||||
let mut edges = BTreeSet::new();
|
||||
for s in &layout.sections {
|
||||
valid_id(&s.id.0)?;
|
||||
s.location.validate()?;
|
||||
s.size.validate()?;
|
||||
if !sections.insert(&s.id) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate section"));
|
||||
}
|
||||
if s.tabs.is_empty() || s.tabs.len() > MAX_TABS {
|
||||
return Err(error(Rule::CollectionLimit, "section requires 1..32 tabs"));
|
||||
}
|
||||
if !s.tabs.contains(&s.active) {
|
||||
return Err(error(Rule::MissingTarget, "active tab outside section"));
|
||||
}
|
||||
if let SectionLocation::Window { host, .. } = &s.location
|
||||
&& !hosts.insert(host)
|
||||
{
|
||||
return Err(error(Rule::DuplicateId, "native host reused"));
|
||||
}
|
||||
let zone = s.location.zone();
|
||||
let first = edges.insert(zone);
|
||||
for id in &s.tabs {
|
||||
let instance = layout.instance(id)?;
|
||||
let spec = registry.get(&instance.kind)?;
|
||||
if !instance.requested_open || !seen.insert(id) {
|
||||
return Err(error(Rule::ExactlyOnce, "closed or duplicate presented instance"));
|
||||
}
|
||||
if !spec.allowed_zones.contains(&zone) {
|
||||
return Err(error(Rule::Placement, "panel zone forbidden"));
|
||||
}
|
||||
if s.tabs.len() > 1 && !spec.features.tabbable {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot join tabs"));
|
||||
}
|
||||
if s.collapsed && !spec.features.collapsible {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot collapse"));
|
||||
}
|
||||
if zone == Zone::Window && !spec.features.popout {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot pop out"));
|
||||
}
|
||||
if spec.edge_rule == EdgeRule::PinOuter && matches!(zone, Zone::Left | Zone::Right) {
|
||||
if !pins.insert(zone) {
|
||||
return Err(error(Rule::PinConflict, "competing pins on logical edge, including same tab group"));
|
||||
}
|
||||
if !first {
|
||||
return Err(error(Rule::PinOrder, "pinned section must be outermost"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (min, max) = tab_bounds(layout, registry, s)?;
|
||||
if !s.size.within(min, max) {
|
||||
return Err(error(Rule::Bounds, "section outside intersected tab bounds"));
|
||||
}
|
||||
if let SectionLocation::Window { return_to, .. } = &s.location {
|
||||
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.collapsed && !spec.features.collapsible)
|
||||
{
|
||||
return Err(error(Rule::Placement, "native return violates member constraints"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in &layout.instances {
|
||||
// Diagnose impossible tab groups before their shared size can make an
|
||||
// individual member's remembered size appear invalid.
|
||||
let spec = registry.get(&p.kind)?;
|
||||
if !p.memory.size.within(spec.min_size, spec.max_size) {
|
||||
return Err(error(Rule::Bounds, "invalid placement memory"));
|
||||
}
|
||||
if p.requested_open != seen.contains(&p.id) {
|
||||
return Err(error(Rule::ExactlyOnce, "open instance must appear exactly once"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub const ENVELOPE_FORMAT: &str = "solidcraft.panel-layout";
|
||||
pub const ENVELOPE_VERSION: u32 = 1;
|
||||
|
||||
/// Unknown panel wire values are retained verbatim as JSON values, including
|
||||
/// fields a future content adapter understands. Total encoded envelope is capped.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct QuarantinedPanel {
|
||||
pub instance: InstanceId,
|
||||
pub kind: PanelKind,
|
||||
pub instance_data: serde_json::Value,
|
||||
pub section: Option<SectionId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SectionSnapshot {
|
||||
pub data: serde_json::Value,
|
||||
/// Latest complete rank; also permits reading earlier v1 envelopes without
|
||||
/// the envelope's explicit complete section_order list.
|
||||
pub index: usize,
|
||||
/// Last materialized section, so a live user edit can be distinguished from
|
||||
/// the fallback active tab/order produced while some members are dormant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub projected: Option<Section>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct LayoutEnvelope {
|
||||
pub format: String,
|
||||
pub version: u32,
|
||||
pub layout: Layout,
|
||||
pub quarantine: Vec<QuarantinedPanel>,
|
||||
/// Shared, lossless section order/activation/future fields, stored once even
|
||||
/// when many unknown tabs refer to the same section. Retained after restore.
|
||||
#[serde(default)]
|
||||
pub section_snapshots: std::collections::BTreeMap<SectionId, SectionSnapshot>,
|
||||
/// Latest complete section ordering, including currently dormant sections.
|
||||
/// Retained snapshots alone cannot rank surviving sections without this list.
|
||||
#[serde(default)]
|
||||
pub section_order: Vec<SectionId>,
|
||||
/// Original unknown wire data remains available after registry reappearance.
|
||||
/// A content/persistence adapter may explicitly consume it; restoring placement
|
||||
/// alone must not erase a future field that this foundation cannot interpret.
|
||||
#[serde(default)]
|
||||
pub retained_data: std::collections::BTreeMap<InstanceId, serde_json::Value>,
|
||||
}
|
||||
impl LayoutEnvelope {
|
||||
pub fn new(layout: Layout) -> Self {
|
||||
let section_order = layout.sections.iter().map(|section| section.id.clone()).collect();
|
||||
Self {
|
||||
format: ENVELOPE_FORMAT.into(),
|
||||
version: ENVELOPE_VERSION,
|
||||
layout,
|
||||
quarantine: Vec::new(),
|
||||
section_snapshots: std::collections::BTreeMap::new(),
|
||||
section_order,
|
||||
retained_data: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LoadOutcome {
|
||||
pub envelope: LayoutEnvelope,
|
||||
/// Adapter reports this once; fallback never blocks startup or touches other preferences.
|
||||
pub notice: Option<PanelError>,
|
||||
/// Bounded corrupt/future input, available to a recovery adapter before replacement.
|
||||
pub rejected: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Implementations read/write ONLY the workspace panel-layout preference key.
|
||||
/// `save` must replace atomically, preserving unrelated preferences. Production
|
||||
/// preference migration is intentionally outside the foundation.
|
||||
pub trait LayoutPersistence {
|
||||
fn load(&mut self, workspace: &WorkspaceId) -> PanelResult<Option<Vec<u8>>>;
|
||||
fn save(&mut self, workspace: &WorkspaceId, envelope: &[u8]) -> PanelResult<()>;
|
||||
}
|
||||
|
||||
pub fn encode(envelope: &LayoutEnvelope, registry: &PanelRegistry) -> PanelResult<Vec<u8>> {
|
||||
validate_envelope(envelope, registry)?;
|
||||
encode_record(envelope)
|
||||
}
|
||||
|
||||
fn encode_record(envelope: &LayoutEnvelope) -> PanelResult<Vec<u8>> {
|
||||
let mut writer = BoundedEnvelopeWriter { bytes: Vec::new() };
|
||||
serde_json::to_writer(&mut writer, envelope).map_err(|_| error(Rule::CollectionLimit, "cannot encode panel envelope within byte cap"))?;
|
||||
// Aggregated opaque entries must also fit the decoder's global node/depth
|
||||
// budget. Never persist bytes that this same version would reject on reopen.
|
||||
let value: serde_json::Value = serde_json::from_slice(&writer.bytes).map_err(|_| error(Rule::Schema, "invalid encoded panel envelope"))?;
|
||||
bounded_json(&value)?;
|
||||
Ok(writer.bytes)
|
||||
}
|
||||
|
||||
struct BoundedEnvelopeWriter {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
impl std::io::Write for BoundedEnvelopeWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if self.bytes.len().saturating_add(bytes.len()) > MAX_ENVELOPE_BYTES {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "panel envelope byte cap"));
|
||||
}
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_envelope(envelope: &LayoutEnvelope, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
if envelope.format != ENVELOPE_FORMAT {
|
||||
return Err(error(Rule::Schema, "unknown panel envelope format"));
|
||||
}
|
||||
if envelope.version != ENVELOPE_VERSION {
|
||||
return Err(error(Rule::Version, "unknown panel envelope version"));
|
||||
}
|
||||
validate(&envelope.layout, registry)?;
|
||||
validate_envelope_metadata(envelope)
|
||||
}
|
||||
|
||||
fn validate_envelope_metadata(envelope: &LayoutEnvelope) -> PanelResult<()> {
|
||||
if envelope.quarantine.len() + envelope.layout.instances.len() > MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "quarantine instance cap"));
|
||||
}
|
||||
if envelope.retained_data.len() > MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "retained data cap"));
|
||||
}
|
||||
for (id, data) in &envelope.retained_data {
|
||||
valid_id(&id.0)?;
|
||||
bounded_json(data)?;
|
||||
}
|
||||
if envelope.section_snapshots.len() > MAX_SECTIONS {
|
||||
return Err(error(Rule::CollectionLimit, "section snapshot cap"));
|
||||
}
|
||||
validate_section_order(&envelope.section_order)?;
|
||||
for (id, snapshot) in &envelope.section_snapshots {
|
||||
validate_snapshot(id, snapshot)?;
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for item in &envelope.quarantine {
|
||||
bounded_json(&item.instance_data)?;
|
||||
valid_id(&item.instance.0)?;
|
||||
valid_id(&item.kind.0)?;
|
||||
if !ids.insert(&item.instance) || envelope.layout.instances.iter().any(|p| p.id == item.instance) {
|
||||
return Err(error(Rule::DuplicateId, "quarantine identity or index invalid"));
|
||||
}
|
||||
let instance: PanelInstance =
|
||||
serde_json::from_value(item.instance_data.clone()).map_err(|_| error(Rule::Schema, "invalid quarantined instance"))?;
|
||||
if instance.id != item.instance || instance.kind != item.kind {
|
||||
return Err(error(Rule::Id, "quarantine identity mismatch"));
|
||||
}
|
||||
if instance.requested_open != item.section.is_some() {
|
||||
return Err(error(Rule::ExactlyOnce, "quarantine open/section mismatch"));
|
||||
}
|
||||
valid_id(&instance.memory.section.0)?;
|
||||
instance.memory.location.validate()?;
|
||||
instance.memory.size.validate()?;
|
||||
if instance.memory.section_index > MAX_SECTIONS || instance.memory.tab_index >= MAX_TABS {
|
||||
return Err(error(Rule::CollectionLimit, "quarantine placement cap"));
|
||||
}
|
||||
if let Some(id) = &item.section {
|
||||
let snapshot = envelope.section_snapshots.get(id).ok_or_else(|| error(Rule::MissingTarget, "quarantined section snapshot absent"))?;
|
||||
let section: Section = serde_json::from_value(snapshot.data.clone()).map_err(|_| error(Rule::Schema, "invalid quarantined section"))?;
|
||||
if !section.tabs.contains(&item.instance) {
|
||||
return Err(error(Rule::ExactlyOnce, "quarantined instance absent from section snapshot"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_section_order(order: &[SectionId]) -> PanelResult<()> {
|
||||
if order.len() > MAX_SECTIONS {
|
||||
return Err(error(Rule::CollectionLimit, "complete section order cap"));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for id in order {
|
||||
valid_id(&id.0)?;
|
||||
if !seen.insert(id) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate complete section rank"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_section_record(section: &Section) -> PanelResult<()> {
|
||||
valid_id(§ion.id.0)?;
|
||||
section.location.validate()?;
|
||||
section.size.validate()?;
|
||||
if section.tabs.is_empty() || section.tabs.len() > MAX_TABS || !section.tabs.contains(§ion.active) {
|
||||
return Err(error(Rule::CollectionLimit, "section snapshot tabs/active bound"));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for id in §ion.tabs {
|
||||
valid_id(&id.0)?;
|
||||
if !seen.insert(id) {
|
||||
return Err(error(Rule::ExactlyOnce, "section snapshot duplicate tab"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_snapshot(id: &SectionId, snapshot: &SectionSnapshot) -> PanelResult<()> {
|
||||
valid_id(&id.0)?;
|
||||
bounded_json(&snapshot.data)?;
|
||||
let section: Section = serde_json::from_value(snapshot.data.clone()).map_err(|_| error(Rule::Schema, "invalid shared section snapshot"))?;
|
||||
if section.id != *id || snapshot.index > MAX_SECTIONS {
|
||||
return Err(error(Rule::Id, "section snapshot identity/index mismatch"));
|
||||
}
|
||||
validate_section_record(§ion)?;
|
||||
if let Some(projected) = &snapshot.projected {
|
||||
if projected.id != *id {
|
||||
return Err(error(Rule::Id, "projected section identity mismatch"));
|
||||
}
|
||||
validate_section_record(projected)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Overlay the live relative order on its previous slots, keeping dormant gaps.
|
||||
/// New live entries attach before their next surviving live neighbour, rather
|
||||
/// than consuming a dormant slot or reversing a run of newly added entries.
|
||||
fn merge_visible_order<T: Clone + Ord>(saved: &[T], live: &[T], dormant: &std::collections::BTreeSet<T>) -> Vec<T> {
|
||||
let survivors: std::collections::BTreeSet<_> = saved.iter().filter(|id| live.contains(id)).cloned().collect();
|
||||
let mut live_survivors = live.iter().filter(|id| survivors.contains(*id));
|
||||
let mut merged = Vec::new();
|
||||
for id in saved {
|
||||
if dormant.contains(id) && !live.contains(id) {
|
||||
merged.push(id.clone());
|
||||
} else if survivors.contains(id)
|
||||
&& let Some(next) = live_survivors.next()
|
||||
{
|
||||
merged.push(next.clone());
|
||||
}
|
||||
}
|
||||
for (index, id) in live.iter().enumerate() {
|
||||
if survivors.contains(id) {
|
||||
continue;
|
||||
}
|
||||
let before = live.iter().skip(index + 1).find(|next| survivors.contains(*next));
|
||||
let at = before.and_then(|next| merged.iter().position(|old| old == next)).unwrap_or(merged.len());
|
||||
merged.insert(at, id.clone());
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Refresh live semantics before a new quarantine episode, or before a direct
|
||||
/// restoration after the caller has edited the known layout. Opaque data stays
|
||||
/// shared; only semantic fields supplied by the current layout are refreshed.
|
||||
fn refresh_section_memory(
|
||||
envelope: &mut LayoutEnvelope,
|
||||
raw: Option<&serde_json::Value>,
|
||||
capture: &std::collections::BTreeSet<SectionId>,
|
||||
) -> PanelResult<()> {
|
||||
if envelope.section_snapshots.len() > MAX_SECTIONS || envelope.quarantine.len() > MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "section memory collection cap"));
|
||||
}
|
||||
validate_section_order(&envelope.section_order)?;
|
||||
for (id, snapshot) in &envelope.section_snapshots {
|
||||
validate_snapshot(id, snapshot)?;
|
||||
}
|
||||
let dormant_sections: std::collections::BTreeSet<_> = envelope.quarantine.iter().filter_map(|item| item.section.clone()).collect();
|
||||
let live_order: Vec<_> = envelope.layout.sections.iter().map(|section| section.id.clone()).collect();
|
||||
let mut saved_order = envelope.section_order.clone();
|
||||
if saved_order.is_empty() {
|
||||
// Read earlier v1 envelopes without the complete rank list. Sort missing
|
||||
// ranks before insertion, never in instance/quarantine processing order.
|
||||
saved_order = live_order.clone();
|
||||
let mut missing = Vec::new();
|
||||
for id in &dormant_sections {
|
||||
let snapshot = envelope.section_snapshots.get(id).ok_or_else(|| error(Rule::MissingTarget, "dormant section snapshot missing"))?;
|
||||
if !saved_order.contains(id) {
|
||||
missing.push((snapshot.index, id.clone()));
|
||||
}
|
||||
}
|
||||
missing.sort();
|
||||
for (index, id) in missing {
|
||||
saved_order.insert(index.min(saved_order.len()), id);
|
||||
}
|
||||
} else if dormant_sections.iter().any(|id| !saved_order.contains(id)) {
|
||||
return Err(error(Rule::MissingTarget, "complete order omits a dormant section"));
|
||||
}
|
||||
envelope.section_order = merge_visible_order(&saved_order, &live_order, &dormant_sections);
|
||||
validate_section_order(&envelope.section_order)?;
|
||||
for (index, section) in envelope.layout.sections.iter().enumerate() {
|
||||
let previous = envelope.section_snapshots.get(§ion.id);
|
||||
if previous.is_none() && !capture.contains(§ion.id) {
|
||||
continue;
|
||||
}
|
||||
let current = match raw {
|
||||
Some(raw) => raw
|
||||
.get("layout")
|
||||
.and_then(|layout| layout.get("sections"))
|
||||
.and_then(|sections| sections.get(index))
|
||||
.cloned()
|
||||
.ok_or_else(|| error(Rule::Schema, "saved section raw data missing"))?,
|
||||
None => serde_json::to_value(section).map_err(|_| error(Rule::Schema, "cannot encode live section semantics"))?,
|
||||
};
|
||||
let (mut data, projected) = match previous {
|
||||
Some(previous) => (previous.data.clone(), previous.projected.clone()),
|
||||
None => (serde_json::json!({}), None),
|
||||
};
|
||||
let dormant_tabs: std::collections::BTreeSet<_> =
|
||||
envelope.quarantine.iter().filter(|item| item.section.as_ref() == Some(§ion.id)).map(|item| item.instance.clone()).collect();
|
||||
let previous_section: Option<Section> = previous
|
||||
.map(|snapshot| serde_json::from_value(snapshot.data.clone()).map_err(|_| error(Rule::Schema, "invalid section memory")))
|
||||
.transpose()?;
|
||||
let tabs = match &previous_section {
|
||||
Some(saved) if !dormant_tabs.is_empty() => merge_visible_order(&saved.tabs, §ion.tabs, &dormant_tabs),
|
||||
_ => section.tabs.clone(),
|
||||
};
|
||||
if tabs.len() > MAX_TABS {
|
||||
return Err(error(Rule::CollectionLimit, "complete tab order cap"));
|
||||
}
|
||||
let active = match &previous_section {
|
||||
Some(saved)
|
||||
if dormant_tabs.contains(&saved.active)
|
||||
&& !section.tabs.contains(&saved.active)
|
||||
&& !projected.as_ref().is_some_and(|last| last.active != section.active) =>
|
||||
{
|
||||
saved.active.clone()
|
||||
}
|
||||
_ => section.active.clone(),
|
||||
};
|
||||
// Overlay current fields, preserving opaque keys absent from the typed
|
||||
// layout. A current raw value for the same key is the latest saved intent.
|
||||
let object = data.as_object_mut().ok_or_else(|| error(Rule::Schema, "section memory must be an object"))?;
|
||||
let current = current.as_object().ok_or_else(|| error(Rule::Schema, "saved section must be an object"))?;
|
||||
for (key, value) in current {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
object.insert("tabs".into(), serde_json::to_value(tabs).map_err(|_| error(Rule::Schema, "cannot encode complete tab order"))?);
|
||||
object.insert("active".into(), serde_json::to_value(active).map_err(|_| error(Rule::Schema, "cannot encode complete activation"))?);
|
||||
let rank = envelope
|
||||
.section_order
|
||||
.iter()
|
||||
.position(|id| *id == section.id)
|
||||
.ok_or_else(|| error(Rule::MissingTarget, "live section has no complete rank"))?;
|
||||
envelope.section_snapshots.insert(section.id.clone(), SectionSnapshot { data, index: rank, projected });
|
||||
}
|
||||
for (id, snapshot) in &mut envelope.section_snapshots {
|
||||
if let Some(rank) = envelope.section_order.iter().position(|known| known == id) {
|
||||
snapshot.index = rank;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_section_projections(envelope: &mut LayoutEnvelope) {
|
||||
let layout = &envelope.layout;
|
||||
for (id, snapshot) in &mut envelope.section_snapshots {
|
||||
snapshot.projected = layout.sections.iter().find(|section| section.id == *id).cloned();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage live edits without losing dormant order, projected activation or opaque
|
||||
/// fields. The caller publishes only after its guarded layout dispatch succeeds.
|
||||
pub fn with_live_layout(envelope: &LayoutEnvelope, layout: Layout, registry: &PanelRegistry) -> PanelResult<LayoutEnvelope> {
|
||||
encode(envelope, registry)?;
|
||||
validate(&layout, registry)?;
|
||||
let mut next = envelope.clone();
|
||||
next.layout = layout;
|
||||
refresh_section_memory(&mut next, None, &std::collections::BTreeSet::new())?;
|
||||
record_section_projections(&mut next);
|
||||
encode(&next, registry)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Iterative traversal also bounds caller-constructed JSON, which did not pass
|
||||
/// through serde_json's parser/recursion limit.
|
||||
pub fn bounded_json(value: &serde_json::Value) -> PanelResult<()> {
|
||||
let mut stack = vec![(value, 0_usize)];
|
||||
let mut nodes = 0_usize;
|
||||
let mut bytes = 0_usize;
|
||||
while let Some((value, depth)) = stack.pop() {
|
||||
nodes = nodes.saturating_add(1);
|
||||
if nodes > 8192 || depth > 32 {
|
||||
return Err(error(Rule::CollectionLimit, "JSON node/depth cap"));
|
||||
}
|
||||
match value {
|
||||
serde_json::Value::String(s) => bytes = bytes.saturating_add(s.len()),
|
||||
serde_json::Value::Array(values) => {
|
||||
if values.len() > 8192 || stack.len().saturating_add(values.len()) > 8192 {
|
||||
return Err(error(Rule::CollectionLimit, "JSON array cap"));
|
||||
}
|
||||
stack.extend(values.iter().map(|value| (value, depth + 1)));
|
||||
}
|
||||
serde_json::Value::Object(values) => {
|
||||
if values.len() > 8192 || stack.len().saturating_add(values.len()) > 8192 {
|
||||
return Err(error(Rule::CollectionLimit, "JSON object cap"));
|
||||
}
|
||||
for (key, value) in values {
|
||||
bytes = bytes.saturating_add(key.len());
|
||||
stack.push((value, depth + 1));
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(n) if n.as_f64().is_some_and(|n| !n.is_finite()) => return Err(error(Rule::Bounds, "nonfinite JSON value")),
|
||||
_ => {}
|
||||
}
|
||||
if bytes > MAX_ENVELOPE_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "JSON byte cap"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode bounded bytes, quarantine unknown kinds, and retry retained panels when
|
||||
/// registered again. Corrupt/future input returns a validated safe preset plus notice.
|
||||
pub fn decode(bytes: &[u8], registry: &PanelRegistry, fallback: &Layout) -> PanelResult<LoadOutcome> {
|
||||
validate(fallback, registry)?;
|
||||
match decode_inner(bytes, registry, &fallback.workspace, true) {
|
||||
Ok(envelope) => Ok(LoadOutcome { envelope, notice: None, rejected: None }),
|
||||
Err(reason) => Ok(LoadOutcome {
|
||||
envelope: LayoutEnvelope::new(fallback.clone()),
|
||||
notice: Some(reason),
|
||||
rejected: if bytes.len() <= MAX_ENVELOPE_BYTES { Some(bytes.to_vec()) } else { None },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an already-retained record exactly, without generating section order,
|
||||
/// projections or quarantine/restoration metadata. Missing registrations do not
|
||||
/// invalidate saved intent. Structure and every applicable known-kind constraint
|
||||
/// are still validated, but this retained layout is NOT permission to commit it
|
||||
/// to a live sink. Use `project_retained_record` to stage registry migration.
|
||||
pub fn decode_retained_record(bytes: &[u8], registry: &PanelRegistry) -> PanelResult<LayoutEnvelope> {
|
||||
if bytes.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "panel envelope exceeds byte cap"));
|
||||
}
|
||||
let raw: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| error(Rule::Schema, "invalid panel envelope JSON"))?;
|
||||
bounded_json(&raw)?;
|
||||
let envelope: LayoutEnvelope = serde_json::from_value(raw).map_err(|_| error(Rule::Schema, "invalid panel envelope shape"))?;
|
||||
if envelope.format != ENVELOPE_FORMAT {
|
||||
return Err(error(Rule::Schema, "unknown panel envelope format"));
|
||||
}
|
||||
if envelope.version != ENVELOPE_VERSION {
|
||||
return Err(error(Rule::Version, "unknown panel envelope version"));
|
||||
}
|
||||
validate_retained_layout(&envelope.layout, registry)?;
|
||||
validate_envelope_metadata(&envelope)?;
|
||||
// The same canonical encodability budget as admission, without filling any
|
||||
// metadata or requiring absent peers. The caller retains the ORIGINAL bytes.
|
||||
encode_record(&envelope)?;
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
/// Read a retained record in its workspace without consuming a reappearance
|
||||
/// obligation or materializing absent order/projection metadata.
|
||||
pub fn decode_retained(bytes: &[u8], registry: &PanelRegistry, workspace: &WorkspaceId) -> PanelResult<LayoutEnvelope> {
|
||||
valid_id(&workspace.0)?;
|
||||
let envelope = decode_retained_record(bytes, registry)?;
|
||||
if envelope.layout.workspace != *workspace {
|
||||
return Err(error(Rule::Schema, "panel envelope workspace mismatch"));
|
||||
}
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
/// Stage a usable current-registry layout without consuming quarantined
|
||||
/// reappearance obligations. Newly absent kinds move to the foundation's
|
||||
/// lossless quarantine. Metadata expansion can fail at the existing caps; the
|
||||
/// original record remains available for retry and no live state is published.
|
||||
pub fn project_retained_record(bytes: &[u8], registry: &PanelRegistry, workspace: &WorkspaceId) -> PanelResult<LayoutEnvelope> {
|
||||
let envelope = decode_retained(bytes, registry, workspace)?;
|
||||
if envelope.layout.instances.iter().any(|instance| registry.get(&instance.kind).is_err()) {
|
||||
decode_inner(bytes, registry, workspace, false)
|
||||
} else {
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_inner(bytes: &[u8], registry: &PanelRegistry, workspace: &WorkspaceId, restore: bool) -> PanelResult<LayoutEnvelope> {
|
||||
if bytes.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "panel envelope exceeds byte cap"));
|
||||
}
|
||||
// serde_json's recursion limit remains enabled. Parsing starts only after the
|
||||
// byte bound, so oversized arrays/strings cannot allocate without a bound.
|
||||
let raw: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| error(Rule::Schema, "invalid panel envelope JSON"))?;
|
||||
bounded_json(&raw)?;
|
||||
let mut envelope: LayoutEnvelope = serde_json::from_value(raw.clone()).map_err(|_| error(Rule::Schema, "invalid panel envelope shape"))?;
|
||||
if envelope.version != ENVELOPE_VERSION || envelope.layout.version != LAYOUT_VERSION {
|
||||
return Err(error(Rule::Version, "future or unsupported panel layout"));
|
||||
}
|
||||
if envelope.format != ENVELOPE_FORMAT || envelope.layout.workspace != *workspace {
|
||||
return Err(error(Rule::Schema, "panel envelope format/workspace mismatch"));
|
||||
}
|
||||
if envelope.layout.instances.len() > MAX_INSTANCES
|
||||
|| envelope.layout.sections.len() > MAX_SECTIONS
|
||||
|| envelope.quarantine.len() > MAX_INSTANCES
|
||||
|| envelope.layout.sections.iter().any(|s| s.tabs.len() > MAX_TABS)
|
||||
{
|
||||
return Err(error(Rule::CollectionLimit, "panel collection limit"));
|
||||
}
|
||||
validate_retained_layout(&envelope.layout, registry)?;
|
||||
validate_envelope_metadata(&envelope)?;
|
||||
let unknown: Vec<_> = envelope.layout.instances.iter().filter(|p| registry.get(&p.kind).is_err()).cloned().collect();
|
||||
let capture: std::collections::BTreeSet<_> = envelope
|
||||
.layout
|
||||
.sections
|
||||
.iter()
|
||||
.filter(|section| unknown.iter().any(|p| section.tabs.contains(&p.id)))
|
||||
.map(|section| section.id.clone())
|
||||
.collect();
|
||||
// Refresh from the saved live layout BEFORE newly unknown members are
|
||||
// removed. A previous fully-restored snapshot is opaque metadata, not an
|
||||
// authority that can overwrite intervening saved edits.
|
||||
refresh_section_memory(&mut envelope, Some(&raw), &capture)?;
|
||||
for instance in unknown {
|
||||
let section_index = envelope.layout.sections.iter().position(|s| s.tabs.contains(&instance.id));
|
||||
let instance_data = raw
|
||||
.get("layout")
|
||||
.and_then(|l| l.get("instances"))
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|a| a.iter().find(|v| v.get("id").and_then(|s| s.as_str()) == Some(instance.id.0.as_str())))
|
||||
.cloned()
|
||||
.ok_or_else(|| error(Rule::Schema, "unknown instance raw data missing"))?;
|
||||
let section = if let Some(index) = section_index {
|
||||
let id = envelope.layout.sections.get(index).ok_or_else(|| error(Rule::MissingTarget, "unknown section absent"))?.id.clone();
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
envelope.quarantine.push(QuarantinedPanel { instance: instance.id.clone(), kind: instance.kind, instance_data, section });
|
||||
envelope.layout.instances.retain(|p| p.id != instance.id);
|
||||
}
|
||||
let retained: std::collections::BTreeSet<_> = envelope.layout.instances.iter().map(|p| p.id.clone()).collect();
|
||||
for section in &mut envelope.layout.sections {
|
||||
section.tabs.retain(|id| retained.contains(id));
|
||||
if !section.tabs.contains(§ion.active)
|
||||
&& let Some(first) = section.tabs.first()
|
||||
{
|
||||
section.active = first.clone();
|
||||
}
|
||||
}
|
||||
envelope.layout.sections.retain(|s| !s.tabs.is_empty());
|
||||
// Newly quarantining an active tab creates a fallback selection. That
|
||||
// projection is produced by us, not an intervening user activation.
|
||||
record_section_projections(&mut envelope);
|
||||
validate_envelope(&envelope, registry)?;
|
||||
if restore {
|
||||
restore_quarantine(&mut envelope, registry)?;
|
||||
}
|
||||
// Success means the lossless transformed envelope is itself persistable,
|
||||
// including its aggregate byte/node/depth budget. Otherwise retain recovery.
|
||||
encode(&envelope, registry)?;
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
pub fn restore_quarantine(envelope: &mut LayoutEnvelope, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
restore_quarantine_with_availability(envelope, registry, &AvailabilityMap::new())
|
||||
}
|
||||
|
||||
pub fn restore_quarantine_with_availability(
|
||||
envelope: &mut LayoutEnvelope,
|
||||
registry: &PanelRegistry,
|
||||
availability: &AvailabilityMap,
|
||||
) -> PanelResult<()> {
|
||||
validate_envelope(envelope, registry)?;
|
||||
if availability.len() > MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "reappearance availability cap"));
|
||||
}
|
||||
for (id, state) in availability {
|
||||
valid_id(&id.0)?;
|
||||
if matches!(state, Availability::Unavailable { reason } if reason.is_empty() || reason.len() > 256) {
|
||||
return Err(error(Rule::Availability, "invalid reappearance availability reason"));
|
||||
}
|
||||
}
|
||||
let mut working = envelope.clone();
|
||||
// Direct consumers may have edited the live layout since the last decode or
|
||||
// restore. Merge those edits before using saved ranks/activation again.
|
||||
refresh_section_memory(&mut working, None, &std::collections::BTreeSet::new())?;
|
||||
// Build a candidate for each entry. An incompatible reappearance stays in
|
||||
// quarantine instead of invalidating the known layout or erasing its wire data.
|
||||
let mut remaining = Vec::new();
|
||||
for item in &envelope.quarantine {
|
||||
if registry.get(&item.kind).is_err() || availability.get(&item.instance).is_some_and(|state| *state != Availability::Available) {
|
||||
remaining.push(item.clone());
|
||||
continue;
|
||||
}
|
||||
let mut candidate = working.layout.clone();
|
||||
let instance: PanelInstance =
|
||||
serde_json::from_value(item.instance_data.clone()).map_err(|_| error(Rule::Schema, "invalid quarantined instance"))?;
|
||||
candidate.instances.push(instance.clone());
|
||||
if instance.requested_open {
|
||||
let Some(section_id) = &item.section else {
|
||||
remaining.push(item.clone());
|
||||
continue;
|
||||
};
|
||||
let snapshot = working.section_snapshots.get(section_id).ok_or_else(|| error(Rule::MissingTarget, "shared snapshot missing"))?;
|
||||
let mut saved: Section = serde_json::from_value(snapshot.data.clone()).map_err(|_| error(Rule::Schema, "invalid quarantined section"))?;
|
||||
if let Some(section) = candidate.sections.iter_mut().find(|s| s.id == saved.id) {
|
||||
let members: std::collections::BTreeSet<_> = section.tabs.iter().cloned().chain(std::iter::once(instance.id.clone())).collect();
|
||||
section.tabs = saved
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|id| members.contains(*id))
|
||||
.cloned()
|
||||
.chain(section.tabs.iter().filter(|id| !saved.tabs.contains(id)).cloned())
|
||||
.collect();
|
||||
if saved.active == instance.id {
|
||||
section.active = saved.active.clone();
|
||||
}
|
||||
} else {
|
||||
saved.tabs = vec![instance.id.clone()];
|
||||
saved.active = instance.id.clone();
|
||||
candidate.sections.push(saved);
|
||||
}
|
||||
}
|
||||
// Rank against the full saved order, not an index clamped into a partial
|
||||
// list. Sorting also includes surviving sections with no snapshot.
|
||||
candidate.sections.sort_by_key(|section| working.section_order.iter().position(|id| *id == section.id).unwrap_or(MAX_SECTIONS));
|
||||
if validate(&candidate, registry).is_ok() {
|
||||
if working.retained_data.len() >= MAX_INSTANCES && !working.retained_data.contains_key(&item.instance) {
|
||||
remaining.push(item.clone());
|
||||
continue;
|
||||
}
|
||||
working
|
||||
.retained_data
|
||||
.entry(item.instance.clone())
|
||||
.or_insert_with(|| serde_json::json!({"instance":item.instance_data,"section":item.section}));
|
||||
working.layout = candidate;
|
||||
} else {
|
||||
remaining.push(item.clone());
|
||||
}
|
||||
}
|
||||
working.quarantine = remaining;
|
||||
record_section_projections(&mut working);
|
||||
encode(&working, registry)?;
|
||||
*envelope = working;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate saved identities/ownership independently of optional peers, then use
|
||||
/// the ordinary validator for known members. The temporary known-only view is
|
||||
/// validation input only: it is never returned, encoded, or dispatched.
|
||||
fn validate_retained_layout(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
|
||||
validate_wire_structure(layout)?;
|
||||
if layout.instances.iter().all(|instance| registry.get(&instance.kind).is_ok()) {
|
||||
return validate(layout, registry);
|
||||
}
|
||||
let mut edges = std::collections::BTreeSet::new();
|
||||
for section in &layout.sections {
|
||||
let zone = section.location.zone();
|
||||
let first = edges.insert(zone);
|
||||
for id in §ion.tabs {
|
||||
let instance = layout.instance(id)?;
|
||||
if let Ok(spec) = registry.get(&instance.kind) {
|
||||
// Filtering absent tabs/sections must not hide these constraints
|
||||
// of a known member in the ORIGINAL full saved placement.
|
||||
if section.tabs.len() > 1 && !spec.features.tabbable {
|
||||
return Err(error(Rule::FeatureDisabled, "panel cannot join tabs"));
|
||||
}
|
||||
if spec.edge_rule == EdgeRule::PinOuter && matches!(zone, Zone::Left | Zone::Right) && !first {
|
||||
return Err(error(Rule::PinOrder, "pinned section must be outermost"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut known = layout.clone();
|
||||
known.instances.retain(|instance| registry.get(&instance.kind).is_ok());
|
||||
let ids: std::collections::BTreeSet<_> = known.instances.iter().map(|instance| instance.id.clone()).collect();
|
||||
for section in &mut known.sections {
|
||||
section.tabs.retain(|id| ids.contains(id));
|
||||
if !section.tabs.contains(§ion.active)
|
||||
&& let Some(first) = section.tabs.first()
|
||||
{
|
||||
section.active = first.clone();
|
||||
}
|
||||
}
|
||||
known.sections.retain(|section| !section.tabs.is_empty());
|
||||
validate(&known, registry)
|
||||
}
|
||||
|
||||
fn validate_wire_structure(layout: &Layout) -> PanelResult<()> {
|
||||
if layout.version != LAYOUT_VERSION {
|
||||
return Err(error(Rule::Version, "unsupported layout version"));
|
||||
}
|
||||
valid_id(&layout.workspace.0)?;
|
||||
if layout.instances.len() > MAX_INSTANCES || layout.sections.len() > MAX_SECTIONS {
|
||||
return Err(error(Rule::CollectionLimit, "layout collection limit"));
|
||||
}
|
||||
let mut instances = std::collections::BTreeSet::new();
|
||||
let mut sections = std::collections::BTreeSet::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut hosts = std::collections::BTreeSet::new();
|
||||
for instance in &layout.instances {
|
||||
valid_id(&instance.id.0)?;
|
||||
valid_id(&instance.kind.0)?;
|
||||
valid_id(&instance.memory.section.0)?;
|
||||
instance.memory.location.validate()?;
|
||||
instance.memory.size.validate()?;
|
||||
if instance.memory.tab_index >= MAX_TABS || instance.memory.section_index > MAX_SECTIONS {
|
||||
return Err(error(Rule::Bounds, "invalid placement memory"));
|
||||
}
|
||||
if !instances.insert(&instance.id) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate unknown/known instance"));
|
||||
}
|
||||
}
|
||||
for section in &layout.sections {
|
||||
valid_id(§ion.id.0)?;
|
||||
section.location.validate()?;
|
||||
section.size.validate()?;
|
||||
if !sections.insert(§ion.id) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate unknown/known section"));
|
||||
}
|
||||
if section.tabs.len() > MAX_TABS {
|
||||
return Err(error(Rule::CollectionLimit, "section tab cap"));
|
||||
}
|
||||
if let SectionLocation::Window { host, .. } = §ion.location
|
||||
&& !hosts.insert(host)
|
||||
{
|
||||
return Err(error(Rule::DuplicateId, "native host reused"));
|
||||
}
|
||||
if section.tabs.is_empty() || !section.tabs.contains(§ion.active) {
|
||||
return Err(error(Rule::ExactlyOnce, "invalid unknown/known active tab"));
|
||||
}
|
||||
for id in §ion.tabs {
|
||||
if !instances.contains(id) || !seen.insert(id) {
|
||||
return Err(error(Rule::ExactlyOnce, "unknown/known tab ownership invalid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for instance in &layout.instances {
|
||||
if instance.requested_open != seen.contains(&instance.id) {
|
||||
return Err(error(Rule::ExactlyOnce, "unknown/known open state invalid"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// JSON schema for the wire contract. Semantic constraints are enforced by decode
|
||||
/// and the reducer; schema alone is never treated as a validation proof.
|
||||
pub fn layout_action_schema() -> schemars::Schema {
|
||||
schemars::schema_for!(LayoutAction)
|
||||
}
|
||||
pub fn layout_reply_schema() -> schemars::Schema {
|
||||
schemars::schema_for!(LayoutReply)
|
||||
}
|
||||
pub fn layout_error_schema() -> schemars::Schema {
|
||||
schemars::schema_for!(PanelError)
|
||||
}
|
||||
pub fn layout_envelope_schema() -> schemars::Schema {
|
||||
schemars::schema_for!(LayoutEnvelope)
|
||||
}
|
||||
|
||||
pub fn decode_action(bytes: &[u8]) -> PanelResult<LayoutAction> {
|
||||
if bytes.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "action byte cap"));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| error(Rule::Schema, "invalid action JSON"))?;
|
||||
bounded_json(&value)?;
|
||||
let action: LayoutAction = serde_json::from_value(value).map_err(|_| error(Rule::Schema, "invalid action shape"))?;
|
||||
if let LayoutAction::Apply { mutations, .. } = &action
|
||||
&& (mutations.is_empty() || mutations.len() > MAX_MUTATIONS)
|
||||
{
|
||||
return Err(error(Rule::CollectionLimit, "action mutation cap"));
|
||||
}
|
||||
Ok(action)
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Joining an existing tab group and creating a split are deliberately distinct.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Destination {
|
||||
NewSection { section: SectionId, index: usize, location: SectionLocation, size: Size },
|
||||
JoinTab { section: SectionId, index: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Mutation {
|
||||
Open {
|
||||
kind: PanelKind,
|
||||
instance: InstanceId,
|
||||
destination: Option<Destination>,
|
||||
},
|
||||
Close {
|
||||
instance: InstanceId,
|
||||
},
|
||||
/// Explicit owner cleanup, only after close. Hiding is never destruction.
|
||||
Destroy {
|
||||
instance: InstanceId,
|
||||
},
|
||||
Move {
|
||||
instance: InstanceId,
|
||||
destination: Destination,
|
||||
},
|
||||
Activate {
|
||||
instance: InstanceId,
|
||||
},
|
||||
Focus {
|
||||
instance: InstanceId,
|
||||
},
|
||||
Peek {
|
||||
instance: InstanceId,
|
||||
},
|
||||
DismissPeek,
|
||||
Collapse {
|
||||
section: SectionId,
|
||||
collapsed: bool,
|
||||
},
|
||||
SetSize {
|
||||
section: SectionId,
|
||||
size: Size,
|
||||
},
|
||||
/// Both neighbours change together; their shared axis extent is conserved.
|
||||
ResizePair {
|
||||
first: SectionId,
|
||||
second: SectionId,
|
||||
first_size: Size,
|
||||
second_size: Size,
|
||||
},
|
||||
ResetPair {
|
||||
first: SectionId,
|
||||
second: SectionId,
|
||||
},
|
||||
/// A native window's close button redocks its whole section.
|
||||
NativeClose {
|
||||
host: HostId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum LayoutAction {
|
||||
Inspect,
|
||||
Apply {
|
||||
expected_revision: Option<u64>,
|
||||
mutations: Vec<Mutation>,
|
||||
},
|
||||
/// Apply a complete saved layout/preset, including a workspace change.
|
||||
/// Omission and requested_open=false hide content without lifecycle intent.
|
||||
Replace {
|
||||
expected_revision: Option<u64>,
|
||||
layout: Layout,
|
||||
},
|
||||
}
|
||||
|
||||
/// Stable targets for a later command adapter; none are registered with Session yet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", content = "id", rename_all = "snake_case")]
|
||||
pub enum LayoutTarget {
|
||||
Workspace(WorkspaceId),
|
||||
Instance(InstanceId),
|
||||
Section(SectionId),
|
||||
NativeHost(HostId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HostEffect {
|
||||
Focus { instance: InstanceId },
|
||||
Peek { instance: InstanceId },
|
||||
DismissPeek,
|
||||
CreateNative { host: HostId, section: SectionId },
|
||||
RebindNative { host: HostId, section: SectionId },
|
||||
ReleaseNative { host: HostId },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct InteractionState {
|
||||
pub focused: Option<InstanceId>,
|
||||
pub peek: Option<InstanceId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct LayoutReply {
|
||||
pub api_version: u32,
|
||||
pub revision: u64,
|
||||
pub layout: Layout,
|
||||
pub interaction: InteractionState,
|
||||
pub effects: Vec<HostEffect>,
|
||||
pub capabilities: HostCapabilities,
|
||||
pub availability: AvailabilityMap,
|
||||
}
|
||||
|
||||
/// Same boundary for GUI and programmatic callers. No mutable Session reference.
|
||||
pub trait ActionSink {
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply>;
|
||||
fn inspect(&self) -> LayoutReply;
|
||||
/// Pure projection on the authoritative service. Adapters stage bounded
|
||||
/// envelope/storage changes from this before guarded dispatch publishes.
|
||||
fn project(&self, _action: LayoutAction) -> PanelResult<Layout> {
|
||||
Err(error(Rule::Capability, "layout sink cannot stage commands"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LayoutService {
|
||||
identity: std::sync::Arc<()>,
|
||||
registry: PanelRegistry,
|
||||
layout: Layout,
|
||||
revision: u64,
|
||||
availability: AvailabilityMap,
|
||||
interaction: InteractionState,
|
||||
capabilities: HostCapabilities,
|
||||
identities: std::collections::BTreeMap<InstanceId, PanelKind>,
|
||||
}
|
||||
|
||||
/// An optimistic, fully validated candidate. Fields stay private so it cannot be forged.
|
||||
pub struct PreparedLayout {
|
||||
service_identity: std::sync::Arc<()>,
|
||||
base_revision: u64,
|
||||
next: Layout,
|
||||
interaction: InteractionState,
|
||||
effects: Vec<HostEffect>,
|
||||
changed: bool,
|
||||
availability: AvailabilityMap,
|
||||
identities: std::collections::BTreeMap<InstanceId, PanelKind>,
|
||||
}
|
||||
|
||||
impl PreparedLayout {
|
||||
pub fn layout(&self) -> &Layout {
|
||||
&self.next
|
||||
}
|
||||
pub fn effects(&self) -> &[HostEffect] {
|
||||
&self.effects
|
||||
}
|
||||
/// Runtime availability is staged with the same atomic layout commit.
|
||||
pub fn set_availability(&mut self, availability: AvailabilityMap) -> PanelResult<()> {
|
||||
validate_availability(&availability)?;
|
||||
self.changed |= self.availability != availability;
|
||||
self.availability = availability;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutService {
|
||||
pub fn new(registry: PanelRegistry, layout: Layout, capabilities: HostCapabilities) -> PanelResult<Self> {
|
||||
validate(&layout, ®istry)?;
|
||||
if capabilities.max_native_hosts > MAX_SECTIONS {
|
||||
return Err(error(Rule::CollectionLimit, "native capability exceeds section cap"));
|
||||
}
|
||||
// Restored native entries remain data even on web; resolve_geometry marks them
|
||||
// unpresented. New native mutations still require a capable adapter.
|
||||
Ok(Self {
|
||||
identity: std::sync::Arc::new(()),
|
||||
registry,
|
||||
identities: layout.instances.iter().map(|p| (p.id.clone(), p.kind.clone())).collect(),
|
||||
layout,
|
||||
revision: 0,
|
||||
availability: AvailabilityMap::new(),
|
||||
interaction: InteractionState::default(),
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
pub fn registry(&self) -> &PanelRegistry {
|
||||
&self.registry
|
||||
}
|
||||
pub fn layout(&self) -> &Layout {
|
||||
&self.layout
|
||||
}
|
||||
pub fn interaction(&self) -> &InteractionState {
|
||||
&self.interaction
|
||||
}
|
||||
pub fn capabilities(&self) -> &HostCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
pub fn availability(&self, id: &InstanceId) -> Availability {
|
||||
self.availability.get(id).cloned().unwrap_or(Availability::Available)
|
||||
}
|
||||
pub fn set_availability(&mut self, values: AvailabilityMap) -> PanelResult<()> {
|
||||
validate_availability(&values)?;
|
||||
if self.availability != values {
|
||||
let revision = self.revision.checked_add(1).ok_or_else(|| error(Rule::Revision, "layout revision exhausted"))?;
|
||||
self.availability = values;
|
||||
self.revision = revision;
|
||||
self.reconcile_interaction();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn reconcile_interaction(&mut self) {
|
||||
let layout = &self.layout;
|
||||
let availability = &self.availability;
|
||||
let visible = |id: &InstanceId| {
|
||||
layout.instance(id).is_ok_and(|p| p.requested_open) && availability.get(id).is_none_or(|state| *state == Availability::Available)
|
||||
};
|
||||
if self.interaction.focused.as_ref().is_some_and(|id| !visible(id)) {
|
||||
self.interaction.focused = None;
|
||||
}
|
||||
if self.interaction.peek.as_ref().is_some_and(|id| !visible(id)) {
|
||||
self.interaction.peek = None;
|
||||
}
|
||||
}
|
||||
pub fn prepare(&self, action: LayoutAction) -> PanelResult<PreparedLayout> {
|
||||
let mut next = self.layout.clone();
|
||||
let mut interaction = self.interaction.clone();
|
||||
let mut effects = Vec::new();
|
||||
let mut availability = self.availability.clone();
|
||||
let mut destroyed = std::collections::BTreeSet::new();
|
||||
if let LayoutAction::Replace { expected_revision, layout } = &action {
|
||||
if expected_revision.is_some_and(|r| r != self.revision) {
|
||||
return Err(error(Rule::Revision, "stale layout revision"));
|
||||
}
|
||||
validate(layout, &self.registry)?;
|
||||
next = layout.clone();
|
||||
availability.clear();
|
||||
interaction = InteractionState::default();
|
||||
}
|
||||
if let LayoutAction::Apply { expected_revision, mutations } = action {
|
||||
if expected_revision.is_some_and(|r| r != self.revision) {
|
||||
return Err(error(Rule::Revision, "stale layout revision"));
|
||||
}
|
||||
if mutations.is_empty() || mutations.len() > MAX_MUTATIONS {
|
||||
return Err(error(Rule::CollectionLimit, "transaction requires 1..64 mutations"));
|
||||
}
|
||||
validate_lifecycle_intents(&mutations)?;
|
||||
for mutation in &mutations {
|
||||
if let Mutation::Destroy { instance } = mutation {
|
||||
destroyed.insert(instance.clone());
|
||||
}
|
||||
}
|
||||
for mutation in mutations {
|
||||
apply(&mut next, &self.registry, &self.availability, &self.capabilities, &mut interaction, &mut effects, mutation)?;
|
||||
// A bounded intermediate state cannot smuggle oversized collections into
|
||||
// a later mutation. Semantic invariants are checked at atomic commit.
|
||||
if next.instances.len() > MAX_INSTANCES || next.sections.len() > MAX_SECTIONS || next.sections.iter().any(|s| s.tabs.len() > MAX_TABS)
|
||||
{
|
||||
return Err(error(Rule::CollectionLimit, "transaction collection limit"));
|
||||
}
|
||||
}
|
||||
validate(&next, &self.registry)?;
|
||||
for before in &self.layout.instances {
|
||||
if next.instances.iter().any(|after| after.id == before.id && after.kind != before.kind) {
|
||||
return Err(error(Rule::DuplicateId, "transaction cannot reuse an instance for another kind"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut identities = self.identities.clone();
|
||||
for id in destroyed {
|
||||
identities.remove(&id);
|
||||
}
|
||||
for p in &next.instances {
|
||||
if identities.get(&p.id).is_some_and(|kind| *kind != p.kind) {
|
||||
return Err(error(Rule::DuplicateId, "instance identity cannot change kind across layouts/workspaces"));
|
||||
}
|
||||
identities.insert(p.id.clone(), p.kind.clone());
|
||||
}
|
||||
if identities.len() > MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "retained instance identity cap"));
|
||||
}
|
||||
// Native effects describe the committed difference, never intermediate windows.
|
||||
let old = native_hosts(&self.layout);
|
||||
let new = native_hosts(&next);
|
||||
for (host, section) in &new {
|
||||
if !old.iter().any(|(h, _)| h == host) {
|
||||
if !self.capabilities.native_windows || new.len() > self.capabilities.max_native_hosts {
|
||||
return Err(error(Rule::Capability, "native hosts unavailable or at capacity"));
|
||||
}
|
||||
effects.push(HostEffect::CreateNative { host: host.clone(), section: section.clone() });
|
||||
} else if self.layout.workspace != next.workspace || old.iter().any(|(h, s)| h == host && s != section) {
|
||||
if !self.capabilities.native_windows {
|
||||
return Err(error(Rule::Capability, "cannot rebind unsupported native host"));
|
||||
}
|
||||
effects.push(HostEffect::RebindNative { host: host.clone(), section: section.clone() });
|
||||
}
|
||||
}
|
||||
for (host, _) in old {
|
||||
if !new.iter().any(|(h, _)| *h == host) {
|
||||
effects.push(HostEffect::ReleaseNative { host });
|
||||
}
|
||||
}
|
||||
if interaction.focused.as_ref().is_some_and(|id| !next.containing(id).is_ok_and(|s| !s.collapsed && s.active == *id)) {
|
||||
interaction.focused = None;
|
||||
}
|
||||
if interaction
|
||||
.peek
|
||||
.as_ref()
|
||||
.is_some_and(|id| !next.instance(id).is_ok_and(|p| p.requested_open) || !next.containing(id).is_ok_and(|s| s.collapsed))
|
||||
{
|
||||
interaction.peek = None;
|
||||
}
|
||||
effects.retain(|effect| match effect {
|
||||
HostEffect::Focus { instance } => interaction.focused.as_ref() == Some(instance),
|
||||
HostEffect::Peek { instance } => interaction.peek.as_ref() == Some(instance),
|
||||
_ => true,
|
||||
});
|
||||
let changed = next != self.layout || interaction != self.interaction || availability != self.availability || !effects.is_empty();
|
||||
if changed && self.revision == u64::MAX {
|
||||
return Err(error(Rule::Revision, "layout revision exhausted"));
|
||||
}
|
||||
Ok(PreparedLayout {
|
||||
service_identity: self.identity.clone(),
|
||||
base_revision: self.revision,
|
||||
next,
|
||||
interaction,
|
||||
effects,
|
||||
changed,
|
||||
availability,
|
||||
identities,
|
||||
})
|
||||
}
|
||||
pub fn commit(&mut self, prepared: PreparedLayout) -> PanelResult<LayoutReply> {
|
||||
if !std::sync::Arc::ptr_eq(&prepared.service_identity, &self.identity) || prepared.base_revision != self.revision {
|
||||
return Err(error(Rule::Revision, "candidate became stale"));
|
||||
}
|
||||
if prepared.changed {
|
||||
self.revision = self.revision.checked_add(1).ok_or_else(|| error(Rule::Revision, "layout revision exhausted"))?;
|
||||
}
|
||||
self.layout = prepared.next;
|
||||
self.interaction = prepared.interaction;
|
||||
self.availability = prepared.availability;
|
||||
self.identities = prepared.identities;
|
||||
self.reconcile_interaction();
|
||||
let mut reply = self.inspect();
|
||||
reply.effects = prepared.effects;
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_availability(values: &AvailabilityMap) -> PanelResult<()> {
|
||||
if values.len() > MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "availability map full"));
|
||||
}
|
||||
for (id, state) in values {
|
||||
valid_id(&id.0)?;
|
||||
if let Availability::Unavailable { reason } = state
|
||||
&& (reason.is_empty() || reason.len() > 256)
|
||||
{
|
||||
return Err(error(Rule::Availability, "availability reason must contain 1..256 bytes"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close/Destroy are explicit, irreversible content intents. Do not infer them
|
||||
/// solely from net layout differences, or permit later mutations to erase them.
|
||||
pub fn validate_lifecycle_intents(mutations: &[Mutation]) -> PanelResult<()> {
|
||||
if mutations.len() > MAX_MUTATIONS {
|
||||
return Err(error(Rule::CollectionLimit, "lifecycle intent batch cap"));
|
||||
}
|
||||
let mut counts = std::collections::BTreeMap::<&InstanceId, (usize, bool)>::new();
|
||||
for mutation in mutations {
|
||||
let (id, terminal) = match mutation {
|
||||
Mutation::Close { instance } | Mutation::Destroy { instance } => (instance, true),
|
||||
Mutation::Open { instance, .. }
|
||||
| Mutation::Move { instance, .. }
|
||||
| Mutation::Activate { instance }
|
||||
| Mutation::Focus { instance }
|
||||
| Mutation::Peek { instance } => (instance, false),
|
||||
_ => continue,
|
||||
};
|
||||
let entry = counts.entry(id).or_default();
|
||||
entry.0 += 1;
|
||||
entry.1 |= terminal;
|
||||
if entry.1 && entry.0 > 1 {
|
||||
return Err(error(Rule::Lifecycle, "Close/Destroy cannot share a batch with another operation on that instance"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ActionSink for LayoutService {
|
||||
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
|
||||
Ok(self.prepare(action)?.layout().clone())
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
let prepared = self.prepare(action)?;
|
||||
self.commit(prepared)
|
||||
}
|
||||
fn inspect(&self) -> LayoutReply {
|
||||
LayoutReply {
|
||||
api_version: PANEL_API_VERSION,
|
||||
revision: self.revision,
|
||||
layout: self.layout.clone(),
|
||||
interaction: self.interaction.clone(),
|
||||
effects: Vec::new(),
|
||||
capabilities: self.capabilities.clone(),
|
||||
availability: self.availability.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn native_hosts(layout: &Layout) -> Vec<(HostId, SectionId)> {
|
||||
layout
|
||||
.sections
|
||||
.iter()
|
||||
.filter_map(|s| match &s.location {
|
||||
SectionLocation::Window { host, .. } => Some((host.clone(), s.id.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn available(map: &AvailabilityMap, id: &InstanceId) -> PanelResult<()> {
|
||||
if let Some(state) = map.get(id) {
|
||||
state.require()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn section_mut<'a>(layout: &'a mut Layout, id: &SectionId) -> PanelResult<&'a mut Section> {
|
||||
layout.sections.iter_mut().find(|s| s.id == *id).ok_or_else(|| error(Rule::MissingTarget, "section not found"))
|
||||
}
|
||||
|
||||
fn remember(layout: &mut Layout, id: &InstanceId) -> PanelResult<()> {
|
||||
let section = layout.containing(id)?.clone();
|
||||
let section_index = layout.sections.iter().position(|s| s.id == section.id).ok_or_else(|| error(Rule::MissingTarget, "section not found"))?;
|
||||
let tab_index = section.tabs.iter().position(|p| p == id).ok_or_else(|| error(Rule::MissingTarget, "tab not found"))?;
|
||||
let p = layout.instances.iter_mut().find(|p| p.id == *id).ok_or_else(|| error(Rule::MissingTarget, "instance not found"))?;
|
||||
p.memory = PlacementMemory {
|
||||
section: section.id,
|
||||
location: section.location,
|
||||
section_index,
|
||||
tab_index,
|
||||
size: section.size,
|
||||
collapsed: section.collapsed,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detach(layout: &mut Layout, id: &InstanceId) {
|
||||
for s in &mut layout.sections {
|
||||
s.tabs.retain(|p| p != id);
|
||||
if s.active == *id
|
||||
&& let Some(first) = s.tabs.first()
|
||||
{
|
||||
s.active = first.clone();
|
||||
}
|
||||
}
|
||||
layout.sections.retain(|s| !s.tabs.is_empty());
|
||||
}
|
||||
|
||||
fn place(layout: &mut Layout, id: &InstanceId, destination: Destination) -> PanelResult<()> {
|
||||
match destination {
|
||||
Destination::NewSection { section, index, location, size } => {
|
||||
valid_id(§ion.0)?;
|
||||
location.validate()?;
|
||||
size.validate()?;
|
||||
if layout.sections.iter().any(|s| s.id == section) {
|
||||
return Err(error(Rule::DuplicateId, "new-section identity already exists"));
|
||||
}
|
||||
if index > layout.sections.len() {
|
||||
return Err(error(Rule::Bounds, "section insertion index out of bounds"));
|
||||
}
|
||||
layout.sections.insert(index, Section { id: section, location, tabs: vec![id.clone()], active: id.clone(), size, collapsed: false });
|
||||
}
|
||||
Destination::JoinTab { section, index } => {
|
||||
let s = section_mut(layout, §ion)?;
|
||||
if index > s.tabs.len() {
|
||||
return Err(error(Rule::Bounds, "tab insertion index out of bounds"));
|
||||
}
|
||||
s.tabs.insert(index, id.clone());
|
||||
// Joining never steals active tab or resizes the group.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reopen_destination(layout: &Layout, p: &PanelInstance) -> Destination {
|
||||
if let Ok(s) = layout.section(&p.memory.section) {
|
||||
Destination::JoinTab { section: s.id.clone(), index: p.memory.tab_index.min(s.tabs.len()) }
|
||||
} else {
|
||||
Destination::NewSection {
|
||||
section: p.memory.section.clone(),
|
||||
index: p.memory.section_index.min(layout.sections.len()),
|
||||
location: p.memory.location.clone(),
|
||||
size: p.memory.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(
|
||||
layout: &mut Layout,
|
||||
registry: &PanelRegistry,
|
||||
availability: &AvailabilityMap,
|
||||
caps: &HostCapabilities,
|
||||
interaction: &mut InteractionState,
|
||||
effects: &mut Vec<HostEffect>,
|
||||
mutation: Mutation,
|
||||
) -> PanelResult<()> {
|
||||
match mutation {
|
||||
Mutation::Open { kind, instance, destination } => {
|
||||
valid_id(&instance.0)?;
|
||||
registry.get(&kind)?;
|
||||
available(availability, &instance)?;
|
||||
if let Ok(existing) = layout.instance(&instance) {
|
||||
if existing.kind != kind {
|
||||
return Err(error(Rule::DuplicateId, "instance belongs to another kind"));
|
||||
}
|
||||
if existing.requested_open {
|
||||
if destination.is_some() {
|
||||
return Err(error(Rule::Placement, "use Move for an open instance"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let p = existing.clone();
|
||||
let restore_collapsed = destination.is_none() && layout.section(&p.memory.section).is_err();
|
||||
let destination = destination.unwrap_or_else(|| reopen_destination(layout, &p));
|
||||
place(layout, &instance, destination)?;
|
||||
if let Some(entry) = layout.instances.iter_mut().find(|p| p.id == instance) {
|
||||
entry.requested_open = true;
|
||||
}
|
||||
if restore_collapsed && let Some(s) = layout.sections.iter_mut().find(|s| s.id == p.memory.section) {
|
||||
s.collapsed = p.memory.collapsed;
|
||||
}
|
||||
} else {
|
||||
let destination = destination.ok_or_else(|| error(Rule::Placement, "new instance requires explicit destination"))?;
|
||||
place(layout, &instance, destination)?;
|
||||
let s = layout.containing(&instance)?.clone();
|
||||
layout.instances.push(PanelInstance {
|
||||
id: instance.clone(),
|
||||
kind,
|
||||
requested_open: true,
|
||||
memory: PlacementMemory {
|
||||
section: s.id,
|
||||
location: s.location,
|
||||
section_index: 0,
|
||||
tab_index: 0,
|
||||
size: s.size,
|
||||
collapsed: s.collapsed,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Mutation::Close { instance } => {
|
||||
let p = layout.instance(&instance)?;
|
||||
if !registry.get(&p.kind)?.features.closable {
|
||||
return Err(error(Rule::FeatureDisabled, "panel is not closable"));
|
||||
}
|
||||
if p.requested_open {
|
||||
remember(layout, &instance)?;
|
||||
detach(layout, &instance);
|
||||
if let Some(p) = layout.instances.iter_mut().find(|p| p.id == instance) {
|
||||
p.requested_open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Mutation::Destroy { instance } => {
|
||||
if layout.instance(&instance)?.requested_open {
|
||||
return Err(error(Rule::Lifecycle, "close before owner destruction"));
|
||||
}
|
||||
layout.instances.retain(|p| p.id != instance);
|
||||
}
|
||||
Mutation::Move { instance, destination } => {
|
||||
if !layout.instance(&instance)?.requested_open {
|
||||
return Err(error(Rule::Placement, "cannot move a closed instance"));
|
||||
}
|
||||
let collapsed = layout.containing(&instance)?.collapsed;
|
||||
remember(layout, &instance)?;
|
||||
detach(layout, &instance);
|
||||
let new_section = match &destination {
|
||||
Destination::NewSection { section, .. } => Some(section.clone()),
|
||||
_ => None,
|
||||
};
|
||||
place(layout, &instance, destination)?;
|
||||
if let Some(id) = new_section {
|
||||
section_mut(layout, &id)?.collapsed = collapsed;
|
||||
}
|
||||
}
|
||||
Mutation::Activate { instance } => {
|
||||
available(availability, &instance)?;
|
||||
let section = layout.containing(&instance)?.id.clone();
|
||||
section_mut(layout, §ion)?.active = instance;
|
||||
}
|
||||
Mutation::Focus { instance } => {
|
||||
available(availability, &instance)?;
|
||||
let p = layout.instance(&instance)?.clone();
|
||||
if !p.requested_open {
|
||||
let destination = reopen_destination(layout, &p);
|
||||
place(layout, &instance, destination)?;
|
||||
if let Some(entry) = layout.instances.iter_mut().find(|p| p.id == instance) {
|
||||
entry.requested_open = true;
|
||||
}
|
||||
}
|
||||
let section = layout.containing(&instance)?.id.clone();
|
||||
let s = section_mut(layout, §ion)?;
|
||||
s.active = instance.clone();
|
||||
s.collapsed = false;
|
||||
interaction.peek = None;
|
||||
interaction.focused = Some(instance.clone());
|
||||
effects.push(HostEffect::Focus { instance });
|
||||
}
|
||||
Mutation::Peek { instance } => {
|
||||
available(availability, &instance)?;
|
||||
let s = layout.containing(&instance)?;
|
||||
if !s.collapsed {
|
||||
return Err(error(Rule::Placement, "peek requires a collapsed section"));
|
||||
}
|
||||
interaction.peek = Some(instance.clone());
|
||||
effects.push(HostEffect::Peek { instance });
|
||||
}
|
||||
Mutation::DismissPeek => {
|
||||
interaction.peek = None;
|
||||
effects.push(HostEffect::DismissPeek);
|
||||
}
|
||||
Mutation::Collapse { section, collapsed } => {
|
||||
section_mut(layout, §ion)?.collapsed = collapsed;
|
||||
}
|
||||
Mutation::SetSize { section, size } => {
|
||||
size.validate()?;
|
||||
section_mut(layout, §ion)?.size = size;
|
||||
}
|
||||
Mutation::ResizePair { first, second, first_size, second_size } => {
|
||||
first_size.validate()?;
|
||||
second_size.validate()?;
|
||||
let (a, b, zone) = pair(layout, &first, &second)?;
|
||||
let sum = |a: Size, b: Size| if zone == Zone::Bottom { a.width + b.width } else { a.height + b.height };
|
||||
if (sum(a.size, b.size) - sum(first_size, second_size)).abs() > 0.01 {
|
||||
return Err(error(Rule::Bounds, "paired sash must conserve shared extent"));
|
||||
}
|
||||
if zone == Zone::Bottom && (first_size.height != a.size.height || second_size.height != b.size.height)
|
||||
|| zone != Zone::Bottom && (first_size.width != a.size.width || second_size.width != b.size.width)
|
||||
{
|
||||
return Err(error(Rule::Bounds, "paired sash changes only split axis"));
|
||||
}
|
||||
section_mut(layout, &first)?.size = first_size;
|
||||
section_mut(layout, &second)?.size = second_size;
|
||||
}
|
||||
Mutation::ResetPair { first, second } => {
|
||||
let (a, b, zone) = pair(layout, &first, &second)?;
|
||||
let mut a_size = a.size;
|
||||
let mut b_size = b.size;
|
||||
let (a_min, a_max) = tab_bounds(layout, registry, a)?;
|
||||
let (b_min, b_max) = tab_bounds(layout, registry, b)?;
|
||||
let axis = |s: Size| if zone == Zone::Bottom { s.width } else { s.height };
|
||||
let extent = axis(a_size) + axis(b_size);
|
||||
let lo = axis(a_min).max(extent - axis(b_max));
|
||||
let hi = axis(a_max).min(extent - axis(b_min));
|
||||
if lo > hi {
|
||||
return Err(error(Rule::Bounds, "pair cannot satisfy defaults within bounds"));
|
||||
}
|
||||
let a_default = registry.get(&layout.instance(&a.active)?.kind)?.default_size;
|
||||
let b_default = registry.get(&layout.instance(&b.active)?.kind)?.default_size;
|
||||
let preferred = extent * axis(a_default) / (axis(a_default) + axis(b_default));
|
||||
let split = preferred.clamp(lo, hi);
|
||||
if zone == Zone::Bottom {
|
||||
a_size.width = split;
|
||||
b_size.width = extent - split;
|
||||
} else {
|
||||
a_size.height = split;
|
||||
b_size.height = extent - split;
|
||||
}
|
||||
section_mut(layout, &first)?.size = a_size;
|
||||
section_mut(layout, &second)?.size = b_size;
|
||||
}
|
||||
Mutation::NativeClose { host } => {
|
||||
valid_id(&host.0)?;
|
||||
let index = layout
|
||||
.sections
|
||||
.iter()
|
||||
.position(|s| matches!(&s.location, SectionLocation::Window { host: h, .. } if *h == host))
|
||||
.ok_or_else(|| error(Rule::MissingTarget, "native host not found"))?;
|
||||
let mut section = layout.sections.remove(index);
|
||||
let SectionLocation::Window { return_to, .. } = section.location else {
|
||||
return Err(error(Rule::Placement, "not a native section"));
|
||||
};
|
||||
if let Some(join) = &return_to.join
|
||||
&& let Some(target) = layout.sections.iter_mut().find(|s| s.id == join.section && s.location.zone() == return_to.zone)
|
||||
{
|
||||
if target.tabs.len().saturating_add(section.tabs.len()) > MAX_TABS {
|
||||
return Err(error(Rule::CollectionLimit, "native return tab capacity"));
|
||||
}
|
||||
let index = join.index.min(target.tabs.len());
|
||||
drop(target.tabs.splice(index..index, section.tabs));
|
||||
return Ok(());
|
||||
}
|
||||
section.location = SectionLocation::Dock { zone: return_to.zone };
|
||||
section.size = return_to.size;
|
||||
section.collapsed = return_to.collapsed;
|
||||
layout.sections.insert(return_to.index.min(layout.sections.len()), section);
|
||||
}
|
||||
}
|
||||
let windows = native_hosts(layout);
|
||||
// Retained unsupported windows can be closed/redocked without native capability.
|
||||
if caps.native_windows && windows.len() > caps.max_native_hosts {
|
||||
return Err(error(Rule::Capability, "native host limit"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pair<'a>(layout: &'a Layout, first: &SectionId, second: &SectionId) -> PanelResult<(&'a Section, &'a Section, Zone)> {
|
||||
if first == second {
|
||||
return Err(error(Rule::Placement, "sash requires two distinct neighbours"));
|
||||
}
|
||||
let a = layout.section(first)?;
|
||||
let b = layout.section(second)?;
|
||||
let zone = a.location.zone();
|
||||
let edge: Vec<_> = layout.sections.iter().filter(|s| s.location.zone() == zone).collect();
|
||||
if !zone.docked()
|
||||
|| b.location.zone() != zone
|
||||
|| !edge.windows(2).any(|p| p.first().is_some_and(|s| s.id == *first) && p.get(1).is_some_and(|s| s.id == *second))
|
||||
{
|
||||
return Err(error(Rule::Placement, "sash requires ordered neighbours in one dock"));
|
||||
}
|
||||
Ok((a, b, zone))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
//! Contracts and result types for the appearance commands in `cmd/inspect.rs`:
|
||||
//! `appearance.assign`, `appearance.library` and `appearance.list`.
|
||||
|
||||
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, json};
|
||||
use solidcraft_doc::appearance::Look;
|
||||
use solidcraft_doc::pbr_appearance::PbrParameters;
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::{Point3, StringList};
|
||||
use crate::params;
|
||||
|
||||
/// Give bodies, faces or components a look, or clear theirs: a library appearance or a starter
|
||||
/// recipe by `appearance`, or a `color`. With neither (or `color: null`) the targets' own looks
|
||||
/// are cleared, so a face follows its body, a body its component and a component its material.
|
||||
/// One undo step; no dialogs.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppearanceAssignParams {
|
||||
/// Bodies, by name (a placed copy's name, `KB (K:2)`, means the body it shows).
|
||||
#[serde(default)]
|
||||
pub bodies: Option<StringList>,
|
||||
/// Faces, by a point on each, at most 10000.
|
||||
#[serde(default)]
|
||||
pub faces: Option<FaceSpots>,
|
||||
/// The body the `faces` points are on, for points that don't name one. Default: the
|
||||
/// nearest body with a face there.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// Components, by name or id, or a placement's name (`K:2`).
|
||||
#[serde(default)]
|
||||
pub components: Option<StringList>,
|
||||
/// A library appearance's name or a starter recipe's name or stable `starter.*` id (see
|
||||
/// `appearance.library`). With `color` too, that colour under this name. A recipe that
|
||||
/// needs refraction (the glass ones) is refused.
|
||||
#[serde(default)]
|
||||
pub appearance: Option<String>,
|
||||
/// The colour, `"#rrggbb"` or `[r, g, b]` (0–255); with `appearance`, it replaces the
|
||||
/// recipe's colour. `null` (without `appearance`) clears the targets' looks.
|
||||
#[serde(default)]
|
||||
pub color: Option<ColorParam>,
|
||||
/// 0 (clear) to 1 (opaque). Default: 1, or the appearance's own. With `color` alone it
|
||||
/// must be within 0–1; with `appearance` it is clamped there.
|
||||
#[serde(default)]
|
||||
pub opacity: Option<f64>,
|
||||
/// A label for a copied look: needs `color`, not with `appearance`.
|
||||
#[serde(default)]
|
||||
pub name: Option<LookLabel>,
|
||||
/// The opaque PBR response (needs `color` or `appearance`, and opacity 1). Left out, a
|
||||
/// recipe keeps its own; `null` clears it.
|
||||
#[serde(default)]
|
||||
pub pbr: Option<PbrParam>,
|
||||
}
|
||||
|
||||
/// `appearance.assign`'s `faces`: at most 10000 points, each `[x, y, z]` (or `{x, y, z}`) or
|
||||
/// `{"point": [x, y, z], "body": name}`, read as the handler reads them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FaceSpots(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for FaceSpots {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let fail = || D::Error::custom("must list points [x, y, z]");
|
||||
let list = match Value::deserialize(d)? {
|
||||
Value::Array(list) if list.len() <= 10_000 => list,
|
||||
_ => return Err(fail()),
|
||||
};
|
||||
let point = |v: &Value| params::vec3(v).or_else(|| v.get("point").and_then(params::vec3));
|
||||
if list.iter().all(|v| point(v).is_some()) { Ok(FaceSpots(list)) } else { Err(fail()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FaceSpots {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FaceSpots".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
let on_body = json!({
|
||||
"type": "object",
|
||||
"properties": {"point": point, "body": {"type": "string"}},
|
||||
"required": ["point"]
|
||||
});
|
||||
json_schema!({
|
||||
"description": "Points in mm, one on each face: [x, y, z], or {\"point\": [x, y, z], \"body\": name} to say which body.",
|
||||
"type": "array",
|
||||
"items": {"anyOf": [point, on_body]},
|
||||
"maxItems": 10000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour, `"#rrggbb"` (the `#` optional) or `[r, g, b]` of numbers 0–255, as
|
||||
/// `inspect::color_param` reads it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ColorParam(pub [u8; 3]);
|
||||
|
||||
impl<'de> Deserialize<'de> for ColorParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::inspect::color_param(&Value::deserialize(d)?)
|
||||
.map(ColorParam)
|
||||
.ok_or_else(|| D::Error::custom("must be \"#rrggbb\" or [r, g, b] with values 0–255"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ColorParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Color".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let channel = json!({"type": "number", "minimum": 0, "maximum": 255});
|
||||
json_schema!({
|
||||
"description": "A colour: \"#rrggbb\" or [r, g, b] with values 0–255.",
|
||||
"anyOf": [
|
||||
{"type": "string", "pattern": "^#?[0-9A-Fa-f]{6}$"},
|
||||
{"type": "array", "items": channel, "minItems": 3, "maxItems": 3}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A copied look's label: 1 to 128 characters, none of them a control character.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LookLabel(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for LookLabel {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.is_empty() && s.chars().count() <= 128 && !s.chars().any(char::is_control) => Ok(LookLabel(s)),
|
||||
_ => Err(D::Error::custom("must be a nonempty label of at most 128 characters without controls")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for LookLabel {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"LookLabel".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A label for a copied look, without control characters.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An opaque PBR response, exactly `metallic`, `roughness` and `dielectric_ior`, checked as
|
||||
/// the handler checks it (`attach_pbr`), with its messages.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PbrParam(pub PbrParameters);
|
||||
|
||||
impl<'de> Deserialize<'de> for PbrParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior")))
|
||||
.ok_or_else(|| D::Error::custom(whole("`pbr` requires exactly metallic, roughness and dielectric_ior")))?;
|
||||
let number = |key: &str| {
|
||||
object.get(key).and_then(Value::as_f64).ok_or_else(|| D::Error::custom(whole(format!("`pbr.{key}` must be a finite number"))))
|
||||
};
|
||||
PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?).map(PbrParam).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PbrParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PbrParameters".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An opaque PBR response.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metallic": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"roughness": {"type": "number", "minimum": 0.045, "maximum": 1},
|
||||
"dielectric_ior": {"type": "number", "minimum": 1, "maximum": 3}
|
||||
},
|
||||
"required": ["metallic", "roughness", "dielectric_ior"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A look's PBR response, as the appearance commands report it.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
pub struct PbrReply {
|
||||
pub metallic: f64,
|
||||
pub roughness: f64,
|
||||
pub dielectric_ior: f64,
|
||||
}
|
||||
|
||||
impl From<PbrParameters> for PbrReply {
|
||||
fn from(p: PbrParameters) -> Self {
|
||||
PbrReply { metallic: p.metallic(), roughness: p.roughness(), dielectric_ior: p.dielectric_ior() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A look, as the appearance commands report it.
|
||||
#[derive(Clone, Debug, Serialize, JsonSchema)]
|
||||
pub struct LookReply {
|
||||
/// The library appearance's or recipe's name, or the look's label (`Custom` for a plain
|
||||
/// colour).
|
||||
pub name: String,
|
||||
/// `"#rrggbb"`.
|
||||
pub color: String,
|
||||
/// 0 (clear) to 1 (opaque).
|
||||
pub opacity: f64,
|
||||
/// Its opaque PBR response, when it has one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pbr: Option<PbrReply>,
|
||||
}
|
||||
|
||||
impl From<&Look> for LookReply {
|
||||
fn from(l: &Look) -> Self {
|
||||
LookReply { name: l.name.clone(), color: l.hex(), opacity: l.opacity, pbr: l.pbr.map(PbrReply::from) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A face with a look of its own: its body and the point on it the look was given at.
|
||||
#[derive(Clone, Debug, Serialize, JsonSchema)]
|
||||
pub struct FaceSpot {
|
||||
pub body: String,
|
||||
/// `[x, y, z]` in mm.
|
||||
pub point: [f64; 3],
|
||||
}
|
||||
|
||||
/// What `appearance.assign` returns: the targets, and the look they got (`null`: cleared).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AppearanceAssignResult {
|
||||
pub bodies: Vec<String>,
|
||||
pub faces: Vec<FaceSpot>,
|
||||
/// The components, by id.
|
||||
pub components: Vec<u64>,
|
||||
pub appearance: Option<LookReply>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// `appearance.library` takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppearanceLibraryParams {}
|
||||
|
||||
/// What `appearance.library` returns: every appearance `appearance.assign` takes by name.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AppearanceLibraryResult {
|
||||
pub appearances: Vec<LibraryAppearance>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// One library entry: a plain appearance or finish, or a starter recipe.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LibraryAppearance {
|
||||
Plain(PlainAppearance),
|
||||
Starter(StarterAppearance),
|
||||
}
|
||||
|
||||
/// A legacy scalar appearance, or an opaque PBR finish (which has `pbr`).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PlainAppearance {
|
||||
#[serde(flatten)]
|
||||
pub look: LookReply,
|
||||
/// The part of the name before ` - `, such as `Aluminum`.
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
/// A starter recipe: its look, where it comes from and what showing it needs.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StarterAppearance {
|
||||
/// Its stable id, `starter.*`, which `appearance.assign` takes as `appearance`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// wood, metal, plastic, glass, solar or regolith.
|
||||
pub category: Value,
|
||||
pub revision: u32,
|
||||
/// Its preview colour, `"#rrggbb"`.
|
||||
pub color: String,
|
||||
pub opacity: f64,
|
||||
/// Its opaque PBR response; `null` for a recipe that has none (glass).
|
||||
pub pbr: Option<PbrReply>,
|
||||
/// False for a recipe this build can't show (the glass ones need refraction).
|
||||
pub available: bool,
|
||||
/// What showing it needs: opaque_pbr, refraction, certified_thin_or_closed.
|
||||
pub requirements: Value,
|
||||
/// Who made it and under which licence: {author, license, source, kind}.
|
||||
pub provenance: Value,
|
||||
/// Its optical recipe: {kind: opaque, color, metallic, roughness, dielectric_ior} or
|
||||
/// {kind: glass, dielectric_ior, transmission_linear_rgb, reference_path_mm, boundary}.
|
||||
pub recipe: Value,
|
||||
pub preview_note: String,
|
||||
/// The texture it is meant to get later ({id, repeat_mm, grain_axis}), or `null`.
|
||||
pub future_texture_recipe: Value,
|
||||
}
|
||||
|
||||
/// `appearance.list` takes one parameter, `body`, optional.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppearanceListParams {
|
||||
/// A body (or a placed copy's name, `KB (K:2)`, for the body it shows): the reply adds
|
||||
/// `body`, its resolved look and its faces' looks.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// What `appearance.list` returns: the looks the design assigns, by look and by target.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AppearanceListResult {
|
||||
/// Each look the design uses, with where it is used.
|
||||
pub in_design: Vec<LookInDesign>,
|
||||
/// The bodies with a look of their own, by name.
|
||||
pub bodies: BTreeMap<String, LookReply>,
|
||||
/// The faces with a look of their own.
|
||||
pub faces: Vec<FaceLookReply>,
|
||||
/// The components with a look of their own.
|
||||
pub components: Vec<ComponentLookReply>,
|
||||
/// With `body`: that body's resolved look and its faces'.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<BodyLooks>,
|
||||
/// 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 look the design uses, and the bodies, faces and components it is given to.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct LookInDesign {
|
||||
#[serde(flatten)]
|
||||
pub look: LookReply,
|
||||
pub bodies: Vec<String>,
|
||||
pub faces: Vec<FaceSpot>,
|
||||
/// Components, by id.
|
||||
pub components: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A face's own look.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FaceLookReply {
|
||||
pub body: String,
|
||||
/// `[x, y, z]` in mm: the point the look was given at.
|
||||
pub point: [f64; 3],
|
||||
pub appearance: LookReply,
|
||||
}
|
||||
|
||||
/// A component's own look.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ComponentLookReply {
|
||||
/// The component's id.
|
||||
pub component: u64,
|
||||
pub appearance: LookReply,
|
||||
}
|
||||
|
||||
/// A body's resolved look and its faces' looks.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BodyLooks {
|
||||
pub name: String,
|
||||
/// The look it shows (its own, else its component's, else its material's), or `null`.
|
||||
pub appearance: Option<LookReply>,
|
||||
/// The colour it is drawn in, `[r, g, b]` from 0 to 1, or `null` for the default.
|
||||
pub color: Option<[f32; 3]>,
|
||||
/// Its faces with a look (their own or imported), by face index.
|
||||
pub faces: Vec<FaceIndexLook>,
|
||||
}
|
||||
|
||||
/// A face's look, by the face's index in `model.faces`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FaceIndexLook {
|
||||
pub face: usize,
|
||||
pub appearance: LookReply,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "appearance_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,82 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
AppearanceAssignParams, AppearanceAssignResult, AppearanceLibraryParams, AppearanceLibraryResult, AppearanceListParams, AppearanceListResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<AppearanceAssignParams>(),
|
||||
&["appearance", "bodies", "body", "color", "components", "faces", "name", "opacity", "pbr"],
|
||||
);
|
||||
assert_keys(input_schema_for::<AppearanceLibraryParams>(), &[]);
|
||||
assert_keys(input_schema_for::<AppearanceListParams>(), &["body"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("appearance.assign", json!({"faces": [[1, 2]]})),
|
||||
("appearance.assign", json!({"faces": [{"body": "Box1"}]})),
|
||||
("appearance.assign", json!({"color": "red"})),
|
||||
("appearance.assign", json!({"color": [0, 128, 300]})),
|
||||
("appearance.assign", json!({"color": "#ff0000", "name": ""})),
|
||||
("appearance.assign", json!({"color": "#ff0000", "pbr": {"metallic": 1}})),
|
||||
("appearance.assign", json!({"color": "#ff0000", "pbr": {"metallic": "1", "roughness": 0.5, "dielectric_ior": 1.5}})),
|
||||
("appearance.assign", json!({"color": "#ff0000", "pbr": {"metallic": 2, "roughness": 0.5, "dielectric_ior": 1.5}})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A library look, a starter recipe, a colour with PBR on a face, a cleared body, the library
|
||||
/// and the list with and without `body`, in Warn: each reply matches its output schema and
|
||||
/// carries no warning; a misspelt key's warning still matches.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let matches = |schema: schemars::Schema, id: &str, reply: &Value| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
};
|
||||
let assigned = |reply: &Value| {
|
||||
assert!(reply.get("param_warnings").is_none(), "{reply}");
|
||||
matches(output_schema_for::<AppearanceAssignResult>(), "appearance.assign", reply);
|
||||
};
|
||||
let made = run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, 0]}));
|
||||
let body = made["created_bodies"][0].as_str().unwrap_or_default().to_string();
|
||||
let library = run("appearance.library", json!({}));
|
||||
assert!(library.get("param_warnings").is_none(), "{library}");
|
||||
matches(output_schema_for::<AppearanceLibraryResult>(), "appearance.library", &library);
|
||||
let entries = library["appearances"].as_array().cloned().unwrap_or_default();
|
||||
let named = |pick: &dyn Fn(&Value) -> bool| entries.iter().find(|e| pick(e)).and_then(|e| e["name"].as_str()).unwrap_or_default().to_string();
|
||||
let plain = named(&|e| e.get("id").is_none() && e.get("pbr").is_none());
|
||||
let finish = named(&|e| e.get("id").is_none() && e.get("pbr").is_some());
|
||||
let starter = entries.iter().find(|e| e["available"] == true).and_then(|e| e["id"].as_str()).unwrap_or_default().to_string();
|
||||
assert!(!plain.is_empty() && !finish.is_empty() && !starter.is_empty(), "{library}");
|
||||
assigned(&run("appearance.assign", json!({"bodies": [body], "appearance": plain, "opacity": 0.5})));
|
||||
assigned(&run("appearance.assign", json!({"bodies": body, "appearance": finish})));
|
||||
assigned(&run("appearance.assign", json!({"bodies": [body], "appearance": starter})));
|
||||
let face = run(
|
||||
"appearance.assign",
|
||||
json!({"faces": [{"point": [5, 5, 10], "body": body}], "color": [200, 40, 40], "name": "Signal red",
|
||||
"pbr": {"metallic": 0, "roughness": 0.4, "dielectric_ior": 1.5}}),
|
||||
);
|
||||
assert_eq!(face["faces"][0]["point"], json!([5.0, 5.0, 10.0]), "{face}");
|
||||
assigned(&face);
|
||||
let all = run("appearance.list", json!({}));
|
||||
assert!(all.get("param_warnings").is_none() && all.get("body").is_none(), "{all}");
|
||||
matches(output_schema_for::<AppearanceListResult>(), "appearance.list", &all);
|
||||
let one = run("appearance.list", json!({"body": body}));
|
||||
assert_eq!(one["body"]["faces"].as_array().map(Vec::len), Some(1), "{one}");
|
||||
matches(output_schema_for::<AppearanceListResult>(), "appearance.list", &one);
|
||||
let cleared = run("appearance.assign", json!({"bodies": [body]}));
|
||||
assert!(cleared["appearance"].is_null(), "{cleared}");
|
||||
assigned(&cleared);
|
||||
let warned = run("appearance.list", json!({"bodyy": body}));
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{warned}");
|
||||
matches(output_schema_for::<AppearanceListResult>(), "appearance.list", &warned);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Contracts and result types for the commands in `cmd/browser.rs`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::types::{FeatureCommon, LengthExpr, feature_common};
|
||||
@@ -112,6 +115,55 @@ pub struct RepairResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The units a design can show lengths in.
|
||||
pub const DESIGN_UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
|
||||
|
||||
/// One of [`DESIGN_UNITS`], with `document.units`' message for anything else.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DesignUnits(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for DesignUnits {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(u) if DESIGN_UNITS.contains(&u.as_str()) => Ok(DesignUnits(u)),
|
||||
_ => Err(D::Error::custom("must be mm, cm, m, in or ft")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DesignUnits {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DesignUnits".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "The units the design shows lengths in.", "type": "string", "enum": DESIGN_UNITS})
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the units the design shows lengths in, and reads a bare number in. Values keep their
|
||||
/// meaning: a unit-less parameter holding a bare number that lengths read is written with the
|
||||
/// old unit (`10` becomes `10 mm`) when that keeps those lengths where they were. One undo
|
||||
/// step; the same units again changes nothing.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DocumentUnitsParams {
|
||||
/// The design's new units: mm, cm, m, in or ft.
|
||||
pub units: DesignUnits,
|
||||
}
|
||||
|
||||
/// What `document.units` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentUnitsResult {
|
||||
/// The design's units now.
|
||||
pub units: String,
|
||||
/// The unit-less parameters now written with the old unit, so the lengths that read them
|
||||
/// stay where they were. Empty when nothing needed it, or the units didn't change.
|
||||
pub kept: 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>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "browser_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{RepairParams, RepairResult};
|
||||
use super::{DocumentUnitsParams, DocumentUnitsResult, RepairParams, RepairResult};
|
||||
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]
|
||||
@@ -26,3 +26,43 @@ fn replies_match_the_output_schema() {
|
||||
let r = s.execute("body.repair", &json!({"body": "Box", "colour": "red"}));
|
||||
assert!(r.is_err(), "nothing left to repair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DocumentUnitsParams>(), &["units"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[("document.units", json!({})), ("document.units", json!({"units": "furlong"}))]);
|
||||
}
|
||||
|
||||
/// A change that keeps a parameter's value with the old unit, one that keeps none, and the same
|
||||
/// units again, in Warn: each reply matches the output schema and carries no warning.
|
||||
#[test]
|
||||
fn units_replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<DocumentUnitsResult>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |p: Value| s.execute("document.units", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let mut check = |p: Value| {
|
||||
let reply = run(p.clone());
|
||||
assert!(reply.get("param_warnings").is_none() && conforms(schema, schema, &reply), "{p}: {reply} doesn't match {schema}");
|
||||
reply
|
||||
};
|
||||
check(json!({"units": "cm"}));
|
||||
assert_eq!(check(json!({"units": "cm"}))["kept"], json!([]));
|
||||
check(json!({"units": "in"}));
|
||||
}
|
||||
|
||||
/// A unit-less parameter a length reads is kept with the old unit, and the reply names it.
|
||||
#[test]
|
||||
fn the_reply_names_the_parameters_kept_in_the_old_unit() {
|
||||
let mut s = Session::default();
|
||||
s.execute("parameters.add", &json!({"name": "w", "expression": "10", "unit": ""})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.execute("solid.box", &json!({"length": "w", "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let reply = s.execute("document.units", &json!({"units": "in"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let schema = output_schema_for::<DocumentUnitsResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
|
||||
assert_eq!(reply["kept"], json!(["w"]), "{reply}");
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ mod coverage_tests;
|
||||
mod tests;
|
||||
|
||||
// The contracts, by command file.
|
||||
pub mod appearance;
|
||||
pub mod bom;
|
||||
pub mod browser;
|
||||
pub mod component;
|
||||
@@ -68,6 +69,7 @@ pub mod joints;
|
||||
pub mod libraries;
|
||||
pub mod mass_properties;
|
||||
pub mod motion;
|
||||
pub mod panels;
|
||||
pub mod print_face;
|
||||
pub mod render;
|
||||
pub mod render_lights;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Strict external application-layout contracts. These are command parameters,
|
||||
//! not another persistent layout model.
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::panels::*;
|
||||
|
||||
/// Discovery and ParamCheck use the exact parser the injected executor uses.
|
||||
pub struct CommandContract<P> {
|
||||
id: &'static str,
|
||||
typed: super::TypedContract<P>,
|
||||
}
|
||||
impl<P> CommandContract<P> {
|
||||
pub const fn new(id: &'static str) -> Self {
|
||||
Self { id, typed: super::TypedContract::new() }
|
||||
}
|
||||
}
|
||||
impl<P: serde::de::DeserializeOwned + JsonSchema + 'static> super::Contract for CommandContract<P> {
|
||||
fn input_schema(&self) -> schemars::Schema {
|
||||
super::Contract::input_schema(&self.typed)
|
||||
}
|
||||
fn allowed_keys(&self) -> std::sync::Arc<std::collections::BTreeSet<String>> {
|
||||
super::Contract::allowed_keys(&self.typed)
|
||||
}
|
||||
fn check(&self, params: &serde_json::Value) -> Vec<super::Issue> {
|
||||
match crate::panels::commands::parse_command(self.id, params) {
|
||||
Ok(_) => Vec::new(),
|
||||
Err(reason) => {
|
||||
// Keep the shared parser's bounds ahead of deserialization, but
|
||||
// retain ordinary contract paths and unknown-key suggestions.
|
||||
if reason.rule == Rule::Schema {
|
||||
let issues = super::Contract::check(&self.typed, params);
|
||||
if !issues.is_empty() {
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
vec![super::Issue { path: String::new(), message: reason.to_string(), suggestion: None }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", content = "id", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Target {
|
||||
Workspace(WorkspaceId),
|
||||
Instance(InstanceId),
|
||||
Section(SectionId),
|
||||
NativeHost(HostId),
|
||||
}
|
||||
impl From<Target> for LayoutTarget {
|
||||
fn from(value: Target) -> Self {
|
||||
match value {
|
||||
Target::Workspace(id) => Self::Workspace(id),
|
||||
Target::Instance(id) => Self::Instance(id),
|
||||
Target::Section(id) => Self::Section(id),
|
||||
Target::NativeHost(id) => Self::NativeHost(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OuterSize {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
impl From<OuterSize> for Size {
|
||||
fn from(value: OuterSize) -> Self {
|
||||
Self { width: value.width, height: value.height }
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Origin {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
impl From<Origin> for Point {
|
||||
fn from(value: Origin) -> Self {
|
||||
Self { x: value.x, y: value.y }
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReturnJoin {
|
||||
pub section: SectionId,
|
||||
pub index: usize,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReturnTo {
|
||||
pub zone: Zone,
|
||||
pub index: usize,
|
||||
pub size: OuterSize,
|
||||
pub collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub join: Option<ReturnJoin>,
|
||||
}
|
||||
impl From<ReturnTo> for DockReturn {
|
||||
fn from(value: ReturnTo) -> Self {
|
||||
Self {
|
||||
zone: value.zone,
|
||||
index: value.index,
|
||||
size: value.size.into(),
|
||||
collapsed: value.collapsed,
|
||||
join: value.join.map(|join| ReturnTab { section: join.section, index: join.index }),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Location {
|
||||
Dock { zone: Zone },
|
||||
Floating { origin: Origin },
|
||||
Window { host: HostId, origin: Origin, return_to: ReturnTo },
|
||||
}
|
||||
impl From<Location> for SectionLocation {
|
||||
fn from(value: Location) -> Self {
|
||||
match value {
|
||||
Location::Dock { zone } => Self::Dock { zone },
|
||||
Location::Floating { origin } => Self::Floating { origin: origin.into() },
|
||||
Location::Window { host, origin, return_to } => Self::Window { host, origin: origin.into(), return_to: return_to.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Placement {
|
||||
NewSection { section: SectionId, index: usize, location: Location, size: OuterSize },
|
||||
JoinTab { section: SectionId, index: usize },
|
||||
}
|
||||
impl From<Placement> for Destination {
|
||||
fn from(value: Placement) -> Self {
|
||||
match value {
|
||||
Placement::NewSection { section, index, location, size } => {
|
||||
Self::NewSection { section, index, location: location.into(), size: size.into() }
|
||||
}
|
||||
Placement::JoinTab { section, index } => Self::JoinTab { section, index },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Empty {}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Open {
|
||||
pub kind: PanelKind,
|
||||
pub instance: InstanceId,
|
||||
pub destination: Option<Placement>,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Select {
|
||||
pub target: Option<Target>,
|
||||
/// Compatibility selector: exact instance, or a single-instance canonical kind.
|
||||
pub id: Option<String>,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Move {
|
||||
pub target: Option<Target>,
|
||||
pub id: Option<String>,
|
||||
pub destination: Placement,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Resize {
|
||||
pub target: Option<Target>,
|
||||
pub id: Option<String>,
|
||||
pub size: OuterSize,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Collapse {
|
||||
pub target: Option<Target>,
|
||||
pub id: Option<String>,
|
||||
pub collapsed: bool,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Revision {
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResizePair {
|
||||
pub first: SectionId,
|
||||
pub second: SectionId,
|
||||
pub first_size: OuterSize,
|
||||
pub second_size: OuterSize,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResetPair {
|
||||
pub first: SectionId,
|
||||
pub second: SectionId,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Save {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub overwrite: bool,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Apply {
|
||||
pub name: String,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceSelect {
|
||||
pub workspace: WorkspaceId,
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceSummary {
|
||||
pub id: WorkspaceId,
|
||||
pub title_key: String,
|
||||
pub default_preset: String,
|
||||
pub has_remembered_layout: bool,
|
||||
}
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GetReply {
|
||||
pub state: LayoutReply,
|
||||
pub workspaces: Vec<WorkspaceSummary>,
|
||||
}
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListReply {
|
||||
pub state: LayoutReply,
|
||||
pub kinds: Vec<PanelSpec>,
|
||||
}
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SaveReply {
|
||||
pub name: String,
|
||||
pub overwritten: bool,
|
||||
pub state: LayoutReply,
|
||||
}
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceSource {
|
||||
Remembered,
|
||||
DefaultPreset,
|
||||
Unchanged,
|
||||
}
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceReply {
|
||||
pub previous_workspace: WorkspaceId,
|
||||
pub source: WorkspaceSource,
|
||||
pub state: LayoutReply,
|
||||
}
|
||||
|
||||
/// Successful wire replies have no extensible object fields. Maps (availability)
|
||||
/// remain maps. Persistence schemas intentionally retain their own opaque fields.
|
||||
pub fn strict_output<T: JsonSchema>() -> schemars::Schema {
|
||||
let mut schema = super::output_schema_for::<T>();
|
||||
fn seal(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
if object.contains_key("properties") {
|
||||
object.insert("additionalProperties".into(), false.into());
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
seal(value);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => values.iter_mut().for_each(seal),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(object) = schema.as_object_mut() {
|
||||
if object.contains_key("properties") {
|
||||
object.insert("additionalProperties".into(), false.into());
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
seal(value);
|
||||
}
|
||||
}
|
||||
schema
|
||||
}
|
||||
@@ -2,9 +2,6 @@
|
||||
# (coverage_tests.rs). This list only shrinks: when you give a command its contract and output
|
||||
# type, remove its line. Never add one: a new command comes with both schemas. Sorted, one id
|
||||
# per line.
|
||||
appearance.assign
|
||||
appearance.library
|
||||
appearance.list
|
||||
body.rename
|
||||
browser.collapse_all
|
||||
browser.expand_all
|
||||
@@ -61,7 +58,6 @@ document.inspect
|
||||
document.metadata
|
||||
document.metadata.remove
|
||||
document.metadata.set
|
||||
document.units
|
||||
engine.commands
|
||||
explode.create
|
||||
explode.delete
|
||||
|
||||
@@ -1182,7 +1182,7 @@ fn only_own_ids_name_commands() {
|
||||
fn unknown_commands_suggest_the_closest_ids() {
|
||||
let mut s = Session::default();
|
||||
let mut msg = |id: &str| s.execute(id, &serde_json::json!({})).unwrap_err().to_string();
|
||||
assert_eq!(msg("file.open"), "unknown command `file.open` (did you mean `doc.open`?)");
|
||||
assert_eq!(msg("file.open"), "unknown command `file.open` (did you mean `doc.open` or `panel.open`?)");
|
||||
assert!(msg("solid.extrud").starts_with("unknown command `solid.extrud` (did you mean `solid.extrude`"), "{}", msg("solid.extrud"));
|
||||
assert!(msg("Extrude").contains("`solid.extrude`"), "{}", msg("Extrude"));
|
||||
assert_eq!(msg("FusionFilletEdgesCommand"), "unknown command `FusionFilletEdgesCommand`");
|
||||
|
||||
@@ -348,7 +348,7 @@ fn modelled_internal_threads_m3_to_m20() {
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -1], "radius": d1 / 2.0, "height": len + 2.0, "body_name": "Bore"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Nut", "tools": ["Bore"], "operation": "cut"}));
|
||||
let before = volume(&mut s, "Nut");
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [d1 / 2.0, 0, len / 2.0], "modeled": true}));
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [d1 / 2.0, 0, len / 2.0], "designation": format!("M{d}"), "modeled": true}));
|
||||
assert!(th["thread"].is_null(), "{th}");
|
||||
// Bolt-shaped space: P/8 flat at the major diameter, widening towards the bore.
|
||||
let want = per_turn(d1 / 2.0, d / 2.0, |r| p / 8.0 + 2.0 * (d / 2.0 - r) * slope) * len / p;
|
||||
@@ -362,14 +362,16 @@ fn modelled_internal_threads_m3_to_m20() {
|
||||
/// that would cut next to nothing is refused, with the drill to use and the size that fits;
|
||||
/// a cosmetic one is still only a note.
|
||||
#[test]
|
||||
fn a_modelled_thread_that_would_cut_nothing_is_refused() {
|
||||
fn a_modelled_thread_that_would_cut_nothing_narrows_the_hole_instead() {
|
||||
// #11609: the hole is narrowed to the thread's minor diameter, and the reply says so.
|
||||
let mut s = hex_nut(5.0);
|
||||
let before = volume(&mut s, "Nut");
|
||||
let e = s.execute("solid.thread", &json!({"face": [2.5, 0, 2], "designation": "M5", "modeled": true})).err().map(|e| e.to_string());
|
||||
let e = e.unwrap_or_default();
|
||||
assert!(e.contains("this 5 mm hole") && e.contains("drill 4.2 mm for M5") && e.contains("thread this hole M6"), "{e}");
|
||||
assert!((volume(&mut s, "Nut") - before).abs() < 1e-9);
|
||||
run(&mut s, "solid.thread", json!({"face": [2.5, 0, 2], "designation": "M5"}));
|
||||
let before = volume(&mut s, "Nut");
|
||||
let made = run(&mut s, "solid.thread", json!({"face": [2.5, 0, 2], "designation": "M5", "modeled": true}));
|
||||
let warning = s.model.result(made["feature"].as_u64().unwrap()).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("narrowed the 5 mm hole to 4.1") && warning.contains("M5"), "{warning}");
|
||||
assert_eq!(made["warning"], json!(warning), "the reply says so too");
|
||||
assert!(volume(&mut s, "Nut") > before + 1.0);
|
||||
|
||||
// A shaft no thicker than the thread's root (17.29 mm for M20) is refused the same way.
|
||||
let mut s = Session::default();
|
||||
@@ -401,30 +403,30 @@ fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
|
||||
}
|
||||
|
||||
/// The limit is a quarter of the basic depth 5H/8: a hole up to 4.78 mm for M5 (4.86 mm for
|
||||
/// M5x0.5) still takes a modelled thread, a wider one is refused with that pitch's drill.
|
||||
/// M5x0.5) is cut as drilled, a wider one is narrowed to the thread's minor diameter first.
|
||||
#[test]
|
||||
fn the_cut_nothing_limit_holds_for_coarse_and_fine_pitch() {
|
||||
for (m, hole, refused) in [
|
||||
("M5", 4.5, None),
|
||||
("M5", 4.75, None),
|
||||
("M5", 4.8, Some("drill 4.2 mm for M5, or thread this hole M6")),
|
||||
("M5", 4.8, Some("narrowed the 4.8 mm hole")),
|
||||
("M5x0.5", 4.5, None),
|
||||
("M5x0.5", 4.85, None),
|
||||
("M5x0.5", 4.9, Some("drill 4.5 mm for M5x0.5, or thread this hole M6")),
|
||||
("M5x0.5", 5.0, Some("drill 4.5 mm for M5x0.5, or thread this hole M6")),
|
||||
("M5x0.5", 4.9, Some("narrowed the 4.9 mm hole")),
|
||||
("M5x0.5", 5.0, Some("narrowed the 5 mm hole")),
|
||||
] {
|
||||
let mut s = hex_nut(hole);
|
||||
let before = volume(&mut s, "Nut");
|
||||
let made = s.execute("solid.thread", &json!({"face": [hole / 2.0, 0, 2], "designation": m, "modeled": true}));
|
||||
let cut = before - volume(&mut s, "Nut");
|
||||
match (refused, made) {
|
||||
(None, Ok(_)) => assert!(cut > 0.05, "{m} in {hole} mm: removed {cut}"),
|
||||
(Some(want), Err(e)) => {
|
||||
let e = e.to_string();
|
||||
assert!(e.contains(&format!("this {hole} mm hole")) && e.contains(want), "{m} in {hole} mm: {e}");
|
||||
assert!(cut.abs() < 1e-9, "{m} in {hole} mm: removed {cut}");
|
||||
let warning = made.ok().and_then(|v| v["feature"].as_u64()).and_then(|f| s.model.result(f)).and_then(|r| r.warning.clone());
|
||||
match (refused, warning) {
|
||||
(None, None) => assert!(cut > 0.05, "{m} in {hole} mm: removed {cut}"),
|
||||
(Some(want), Some(w)) => {
|
||||
assert!(w.contains(want) && w.contains(m), "{m} in {hole} mm: {w}");
|
||||
assert!(cut < -0.05, "{m} in {hole} mm: the bore was filled in, removed {cut}");
|
||||
}
|
||||
(want, got) => panic!("{m} in {hole} mm: want refused {want:?}, got {:?}", got.map(|_| ())),
|
||||
(want, got) => panic!("{m} in {hole} mm: want narrowed {want:?}, got {got:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1291,3 +1293,104 @@ fn a_tapped_hole_given_no_diameter_drills_the_tap_makers_drill() {
|
||||
assert_eq!(drilled(json!({"position":[25,10,10],"thread":"1/4-20 UNC"})), "5.1054 mm");
|
||||
assert_eq!(drilled(json!({"position":[33,10,10],"thread":"M8","diameter":"6.9 mm"})), "6.9 mm");
|
||||
}
|
||||
|
||||
/// A block with a bore of `dia` cut through it by a sketch circle.
|
||||
fn block_with_bore(dia: f64) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": 20, "body_name": "Blk"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": dia}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "H", "through_all": true, "direction": "positive", "operation": "cut"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// #11609: a 12 mm hole took M14 (the size whose tap drill is 12 mm) and a modelled M12 in it was
|
||||
/// refused. Automatic sizing now takes the size with the hole's nominal diameter first; a hole
|
||||
/// near no nominal diameter is still matched by tap drill.
|
||||
#[test]
|
||||
fn a_hole_takes_the_size_of_its_nominal_diameter_first() {
|
||||
for (dia, want) in [(12.0, "M12"), (12.1, "M12"), (10.0, "M10"), (8.0, "M8"), (14.0, "M14"), (10.2, "M12"), (13.0, "M14"), (11.0, "M12")] {
|
||||
let mut s = block_with_bore(dia);
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [dia / 2.0, 0, 10]}));
|
||||
assert_eq!(th["thread"]["designation"], json!(want), "a {dia} mm hole");
|
||||
}
|
||||
// A shaft is still its major diameter.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 6, "height": 30, "body_name": "Sh"}));
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [6, 0, 15]}));
|
||||
assert_eq!(th["thread"]["designation"], json!("M12"));
|
||||
}
|
||||
|
||||
/// #11609: M12 modelled in a 12 mm hole narrows the bore to the thread's minor diameter and cuts
|
||||
/// the thread there, and says so; the cosmetic M12 is the same size on the same bore.
|
||||
#[test]
|
||||
fn a_modelled_thread_in_a_hole_as_wide_as_it_narrows_the_bore() {
|
||||
for (dia, d, p) in [(12.0, 12.0, 1.75), (14.0, 14.0, 2.0), (12.2, 12.0, 1.75), (13.0, 12.0, 1.75)] {
|
||||
let mut s = block_with_bore(dia);
|
||||
let before = volume(&mut s, "Blk");
|
||||
let made = run(&mut s, "solid.thread", json!({"face": [dia / 2.0, 0, 10], "designation": format!("M{d}"), "modeled": true}));
|
||||
let warning = s.model.result(made["feature"].as_u64().unwrap()).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("narrowed the") && warning.contains(&format!("M{d}")), "{warning}");
|
||||
// The bore is now the minor diameter's, so there is material where there was air, but
|
||||
// less than a full sleeve (the groove cut into it).
|
||||
let h = p * 3f64.sqrt() / 2.0;
|
||||
let minor = d - 2.0 * 5.0 * h / 8.0;
|
||||
let sleeve = PI * ((dia / 2.0).powi(2) - (minor / 2.0).powi(2)) * 20.0;
|
||||
let added = volume(&mut s, "Blk") - before;
|
||||
assert!(added > 0.2 * sleeve && added < sleeve, "M{d} in {dia}: added {added}, a full sleeve is {sleeve}");
|
||||
}
|
||||
// Automatic and modelled, and cosmetic, on the 12 mm bore.
|
||||
let mut s = block_with_bore(12.0);
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [6, 0, 10], "designation": "M12"}));
|
||||
assert_eq!(th["thread"]["designation"], json!("M12"));
|
||||
let mut s = block_with_bore(12.0);
|
||||
let made = run(&mut s, "solid.thread", json!({"face": [6, 0, 10], "modeled": true}));
|
||||
assert!(s.model.result(made["feature"].as_u64().unwrap()).and_then(|r| r.warning.clone()).unwrap_or_default().contains("M12"));
|
||||
}
|
||||
|
||||
/// A hole that already has the wall for the groove is not narrowed, and a size nowhere near the
|
||||
/// hole is refused with the size it does take.
|
||||
#[test]
|
||||
fn a_hole_with_wall_for_the_groove_is_left_as_drilled() {
|
||||
let mut s = block_with_bore(10.2);
|
||||
let made = run(&mut s, "solid.thread", json!({"face": [5.1, 0, 10], "designation": "M12", "modeled": true}));
|
||||
assert!(s.model.result(made["feature"].as_u64().unwrap()).and_then(|r| r.warning.clone()).is_none());
|
||||
let mut s = block_with_bore(20.0);
|
||||
let e = s.execute("solid.thread", &json!({"face": [10, 0, 10], "designation": "M12", "modeled": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("does not fit a 20.00 mm hole") && e.contains("takes M20"), "{e}");
|
||||
}
|
||||
|
||||
/// #11609: a design saved with an automatic thread keeps the size it had (a 12 mm hole stays
|
||||
/// M14), while the same thread made now takes M12.
|
||||
#[test]
|
||||
fn a_saved_automatic_thread_keeps_its_old_size() {
|
||||
let mut s = block_with_bore(12.0);
|
||||
run(&mut s, "solid.thread", json!({"face": [6, 0, 10]}));
|
||||
let designations = |s: &Session| -> Vec<String> { s.model.state().threads.iter().map(|t| t.designation.clone()).collect() };
|
||||
assert_eq!(designations(&s), ["M12"]);
|
||||
// As the release before saved it: the last folded format, none of today's pending changes.
|
||||
let mut v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
v["format"] = json!("solidcraft/49");
|
||||
v.as_object_mut().unwrap().remove("format_pending");
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&v.to_string()).unwrap());
|
||||
assert_eq!(designations(&reopened), ["M14"]);
|
||||
let saved: Value = serde_json::from_str(&reopened.doc.to_json()).unwrap();
|
||||
let thread = saved["features"].as_array().unwrap().iter().find(|f| f["type"] == json!("thread")).unwrap();
|
||||
assert_eq!(thread["old_auto_size"], json!(true));
|
||||
}
|
||||
|
||||
/// Editing a saved automatic thread (which keeps its old size) does not change its size.
|
||||
#[test]
|
||||
fn editing_a_saved_automatic_thread_keeps_its_old_size() {
|
||||
let mut s = block_with_bore(12.0);
|
||||
let made = run(&mut s, "solid.thread", json!({"face": [6, 0, 10]}));
|
||||
let mut v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
v["format"] = json!("solidcraft/49");
|
||||
v.as_object_mut().unwrap().remove("format_pending");
|
||||
let mut s = Session::new(solidcraft_doc::Document::from_json(&v.to_string()).unwrap());
|
||||
let designations = |s: &Session| -> Vec<String> { s.model.state().threads.iter().map(|t| t.designation.clone()).collect() };
|
||||
assert_eq!(designations(&s), ["M14"]);
|
||||
run(&mut s, "timeline.redefine", json!({"feature": made["feature"], "command": "solid.thread", "params": {"face": [6, 0, 10], "hand": "left"}}));
|
||||
assert_eq!(designations(&s), ["M14"]);
|
||||
}
|
||||
|
||||
+24
-11
@@ -481,7 +481,9 @@ fn nurbs_derivs(degree: [usize; 2], knots: &[Vec<f64>; 2], count: [usize; 2], ct
|
||||
return None;
|
||||
}
|
||||
let (su, sv) = (span_of(&knots[0], pu, nu, u), span_of(&knots[1], pv, nv, v));
|
||||
let (bu, bv) = (basis_ders(&knots[0], su, pu, u)?, basis_ders(&knots[1], sv, pv, v)?);
|
||||
let (mut bu, mut bv) = ([[0.0f64; W]; 2], [[0.0f64; W]; 2]);
|
||||
basis_ders_into(&knots[0], su, pu, u, &mut bu)?;
|
||||
basis_ders_into(&knots[1], sv, pv, v, &mut bv)?;
|
||||
let (mut s, mut s_u, mut s_v) = ([0.0; 4], [0.0; 4], [0.0; 4]);
|
||||
for (b, (nv0, nv1)) in bv[0].iter().zip(&bv[1]).take(pv + 1).enumerate() {
|
||||
for (a, (nu0, nu1)) in bu[0].iter().zip(&bu[1]).take(pu + 1).enumerate() {
|
||||
@@ -518,12 +520,12 @@ pub(crate) fn span_of(knots: &[f64], p: usize, n: usize, t: f64) -> usize {
|
||||
|
||||
/// The non-zero basis functions of degree `p` on span `span` (NURBS Book A2.2): element `j` is
|
||||
/// `N_{span−p+j}`.
|
||||
fn basis_funs(knots: &[f64], span: usize, p: usize, t: f64) -> Option<[f64; W]> {
|
||||
fn basis_funs(knots: &[f64], span: usize, p: usize, t: f64, n: &mut [f64; W]) -> Option<()> {
|
||||
if p >= W {
|
||||
return None;
|
||||
}
|
||||
let kn = |i: usize| knots.get(i).copied();
|
||||
let mut n = [0.0f64; W];
|
||||
n.fill(0.0);
|
||||
n[0] = 1.0;
|
||||
for j in 1..=p {
|
||||
let mut saved = 0.0;
|
||||
@@ -538,7 +540,7 @@ fn basis_funs(knots: &[f64], span: usize, p: usize, t: f64) -> Option<[f64; W]>
|
||||
}
|
||||
n[j] = saved;
|
||||
}
|
||||
Some(n)
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Non-zero basis functions of degree `p` on span `span` and their first derivatives
|
||||
@@ -546,30 +548,41 @@ fn basis_funs(knots: &[f64], span: usize, p: usize, t: f64) -> Option<[f64; W]>
|
||||
/// `N_{span−p+j}`, `ders[1][j]` its derivative. One-dimensional tables, as the GPU twin keeps
|
||||
/// them, so a high degree costs its own work only.
|
||||
pub(crate) fn basis_ders(knots: &[f64], span: usize, p: usize, t: f64) -> Option<[[f64; W]; 2]> {
|
||||
let mut ders = [[0.0f64; W]; 2];
|
||||
basis_ders_into(knots, span, p, t, &mut ders)?;
|
||||
Some(ders)
|
||||
}
|
||||
|
||||
/// [`basis_ders`] into a table the caller keeps: surface evaluation runs this for every point,
|
||||
/// and moving the tables by value took about a quarter of the time of meshing a curved face.
|
||||
fn basis_ders_into(knots: &[f64], span: usize, p: usize, t: f64, ders: &mut [[f64; W]; 2]) -> Option<()> {
|
||||
if p == 0 || p >= W {
|
||||
return None;
|
||||
}
|
||||
let kn = |i: usize| knots.get(i).copied();
|
||||
let lower = basis_funs(knots, span, p - 1, t)?;
|
||||
let mut ders = [basis_funs(knots, span, p, t)?, [0.0f64; W]];
|
||||
let [values, slopes] = ders;
|
||||
// The degree p − 1 functions first, kept in the derivative row: walked from the top down, so
|
||||
// each slot still holds them when it and the slot above it are read.
|
||||
basis_funs(knots, span, p - 1, t, slopes)?;
|
||||
basis_funs(knots, span, p, t, values)?;
|
||||
// N'_{i,p} = p·(N_{i,p−1}/(u_{i+p}−u_i) − N_{i+1,p−1}/(u_{i+p+1}−u_{i+1})), i = span − p + r.
|
||||
for r in 0..=p {
|
||||
for r in (0..=p).rev() {
|
||||
let mut d = 0.0;
|
||||
if r >= 1 {
|
||||
let den = kn(span + r)? - kn((span + r).checked_sub(p)?)?;
|
||||
if den != 0.0 {
|
||||
d += lower[r - 1] / den;
|
||||
d += slopes[r - 1] / den;
|
||||
}
|
||||
}
|
||||
if r < p {
|
||||
let den = kn(span + r + 1)? - kn((span + r + 1).checked_sub(p)?)?;
|
||||
if den != 0.0 {
|
||||
d -= lower[r] / den;
|
||||
d -= slopes[r] / den;
|
||||
}
|
||||
}
|
||||
ders[1][r] = d * p as f64;
|
||||
slopes[r] = d * p as f64;
|
||||
}
|
||||
Some(ders)
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -217,6 +217,7 @@ impl Executor {
|
||||
lost.store(false, Ordering::Release);
|
||||
log::warn!("gpu-path device lost ({reason:?}); executor unavailable: {message}");
|
||||
});
|
||||
let compiling = one_compile_at_a_time();
|
||||
let scopes = scopes(&device);
|
||||
let shader =
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gpu-path"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
@@ -251,11 +252,21 @@ impl Executor {
|
||||
cache: None,
|
||||
});
|
||||
checked(scopes)?;
|
||||
drop(compiling);
|
||||
warm_up(&device, &queue, &pipeline, &placeholder_bind(&device, &layout, 7)?, &healthy)?;
|
||||
Ok(Self { context: Arc::new(Context { healthy, device, queue, pipeline, layout, denoise: std::sync::OnceLock::new() }) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Native drivers compile the path pipeline for minutes and gigabytes on a cold shader cache.
|
||||
/// Executors opened side by side (parallel tests, each on its own device) compiled it together
|
||||
/// until memory ran out (#6706). One compile at a time per process: the first fills the driver's
|
||||
/// cache, and the waiting ones then get it from there.
|
||||
fn one_compile_at_a_time() -> std::sync::MutexGuard<'static, ()> {
|
||||
static COMPILE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
COMPILE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
// Small zeroed bindings: binding 0 is a uniform whose zero work count makes every
|
||||
// invocation return at once; the others are storage buffers.
|
||||
fn placeholder_bind(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, count: u32) -> Result<wgpu::BindGroup> {
|
||||
|
||||
@@ -593,6 +593,25 @@ fn path_devices_are_vulkan_or_metal_only() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #6706: executors opened side by side never compile the path pipeline at the same time.
|
||||
#[test]
|
||||
fn path_pipelines_compile_one_at_a_time() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
let inside = AtomicUsize::new(0);
|
||||
let most = AtomicUsize::new(0);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..8 {
|
||||
scope.spawn(|| {
|
||||
let _compiling = one_compile_at_a_time();
|
||||
most.fetch_max(inside.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
inside.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(most.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_path_backends_keep_windows_on_its_selected_backend() {
|
||||
for backend in [wgpu::Backend::Dx12, wgpu::Backend::Gl, wgpu::Backend::Vulkan] {
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::time::{Duration, Instant};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_engine::panels::ActionSink;
|
||||
use solidcraft_engine::panels::commands::LayoutCatalogue;
|
||||
use solidcraft_engine::render::StandardView;
|
||||
use solidcraft_engine::schema::ParamCheck;
|
||||
use solidcraft_engine::view::Guides;
|
||||
@@ -559,6 +561,11 @@ impl Remote {
|
||||
/// `strict` refuses a call that breaks its command's contract without sending it, and `warn`
|
||||
/// sends it and adds the issues to the app's reply.
|
||||
fn execute_checked(&mut self, params: Value) -> Result<Value, String> {
|
||||
if let Some(id) = step_command(¶ms).filter(|id| solidcraft_engine::panels::commands::is_command(id)) {
|
||||
let empty = json!({});
|
||||
solidcraft_engine::panels::commands::parse_command(id, params.get("params").filter(|p| !p.is_null()).unwrap_or(&empty))
|
||||
.map_err(solidcraft_engine::panels::commands::error_text)?;
|
||||
}
|
||||
if self.app_checks() {
|
||||
let params = self.at_level(params);
|
||||
let reply = self.call_once("engine.execute", params, true);
|
||||
@@ -616,6 +623,7 @@ impl Backend for Remote {
|
||||
"control.pending" => self.pending_status(),
|
||||
"engine.execute" if self.param_check != ParamCheck::Off => self.execute_checked(params),
|
||||
"engine.batch" if self.param_check != ParamCheck::Off => self.batch_checked(params),
|
||||
"engine.script" if self.param_check != ParamCheck::Off => self.call_once(method, self.at_level(params), true),
|
||||
_ => self.call_once(method, params, true),
|
||||
}
|
||||
}
|
||||
@@ -644,6 +652,8 @@ pub struct Headless {
|
||||
pub session: Session,
|
||||
/// Orientation for renders; the camera is refitted to the model every time.
|
||||
pub view: StandardView,
|
||||
/// Application layout persists across calls, outside Session/document undo.
|
||||
pub panels: solidcraft_engine::panels::PanelResult<solidcraft_engine::panels::commands::InMemoryPanelBackend>,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
@@ -662,16 +672,77 @@ impl Headless {
|
||||
}
|
||||
Err(e) => session.log.push(format!("image host unavailable: {e}")),
|
||||
}
|
||||
Headless { session, view: StandardView::Iso }
|
||||
Headless { session, view: StandardView::Iso, panels: solidcraft_engine::panels::commands::InMemoryPanelBackend::empty() }
|
||||
}
|
||||
pub fn with_panels(mut self, panels: solidcraft_engine::panels::commands::InMemoryPanelBackend) -> Self {
|
||||
self.panels = Ok(panels);
|
||||
self
|
||||
}
|
||||
fn execute_command(&mut self, id: &str, params: &Value) -> Result<Value, String> {
|
||||
use solidcraft_engine::panels::commands;
|
||||
if commands::is_command(id) {
|
||||
self.panels.as_mut().map_err(|reason| commands::error_text(reason.clone()))?.execute(id, params).map_err(commands::error_text)
|
||||
} else {
|
||||
self.session.execute(id, params).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
fn panel_sequence(&mut self, method: &str, p: &Value, steps: &[Value]) -> Result<Value, String> {
|
||||
use solidcraft_engine::panels::{Rule, commands, error};
|
||||
if steps.len() > 100_000 {
|
||||
return Err("script too long".into());
|
||||
}
|
||||
if method == "engine.batch"
|
||||
&& let Some(refusal) = solidcraft_engine::batch_preflight::check(steps, self.session.param_check, &[])
|
||||
{
|
||||
return Ok(refusal.reply(steps.len()));
|
||||
}
|
||||
let panels = steps.iter().filter(|step| step_command(step).is_some_and(commands::is_command)).count();
|
||||
if method == "engine.batch" && p.get("rollback").and_then(Value::as_bool).unwrap_or(true) {
|
||||
if panels != steps.len() {
|
||||
return Err(commands::error_text(error(
|
||||
Rule::Capability,
|
||||
"atomic mixed document/layout batches are unavailable; use rollback:false or separate transactions",
|
||||
)));
|
||||
}
|
||||
return self.panels.as_mut().map_err(|reason| commands::error_text(reason.clone()))?.batch(steps).map_err(commands::error_text);
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let empty = json!({});
|
||||
let params = step.get("params").filter(|p| !p.is_null()).unwrap_or(&empty);
|
||||
let result = step_command(step).ok_or_else(|| "missing command".to_string()).and_then(|id| self.execute_command(id, params));
|
||||
match result {
|
||||
Ok(result) => results.push(result),
|
||||
Err(error) if method == "engine.batch" => return Ok(commands::sequential_failure(i, step_command(step), error, results)),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
if method == "engine.batch" { Ok(json!({"completed":results.len(),"results":results,"undone":0})) } else { Ok(json!(results)) }
|
||||
}
|
||||
}
|
||||
|
||||
fn step_command(step: &Value) -> Option<&str> {
|
||||
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn dim(p: &Value, k: &str, default: f64) -> u32 {
|
||||
p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(default).clamp(16.0, 4096.0) as u32
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
|
||||
impl Headless {
|
||||
fn call_at_current_level(&mut self, method: &str, p: Value) -> Result<Value, String> {
|
||||
if matches!(method, "engine.execute" | "command") && step_command(&p).is_some_and(solidcraft_engine::panels::commands::is_command) {
|
||||
let empty = json!({});
|
||||
return self.execute_command(step_command(&p).unwrap_or_default(), p.get("params").filter(|p| !p.is_null()).unwrap_or(&empty));
|
||||
}
|
||||
if matches!(method, "engine.script" | "engine.batch")
|
||||
&& let Some(steps) = p
|
||||
.as_array()
|
||||
.or_else(|| p.get("commands").and_then(Value::as_array))
|
||||
.filter(|steps| steps.iter().any(|step| step_command(step).is_some_and(solidcraft_engine::panels::commands::is_command)))
|
||||
{
|
||||
return self.panel_sequence(method, &p, steps);
|
||||
}
|
||||
let s = &mut self.session;
|
||||
let str_p = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
match method {
|
||||
@@ -738,6 +809,19 @@ impl Backend for Headless {
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
|
||||
let own = self.session.param_check;
|
||||
if matches!(method, "engine.execute" | "command" | "engine.batch" | "engine.script")
|
||||
&& let Some(value) = p.get("param_check")
|
||||
{
|
||||
self.session.param_check = value.as_str().and_then(ParamCheck::parse).ok_or("`param_check` must be off, warn or strict")?;
|
||||
}
|
||||
let reply = self.call_at_current_level(method, p);
|
||||
self.session.param_check = own;
|
||||
reply
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -773,7 +857,10 @@ impl Auto {
|
||||
|
||||
/// Switch to the app when the session is still empty and one is running.
|
||||
fn look_for_app(&mut self) {
|
||||
let empty = self.headless.as_ref().is_some_and(|h| h.session.doc.features.is_empty());
|
||||
let empty = self.headless.as_ref().is_some_and(|h| {
|
||||
h.session.doc.features.is_empty()
|
||||
&& h.panels.as_ref().is_ok_and(|panels| panels.service.inspect().revision == 0 && panels.catalogue.state().user_count() == 0)
|
||||
});
|
||||
if !empty {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,315 @@ fn server() -> Server {
|
||||
Server::new(Box::new(Headless::default()))
|
||||
}
|
||||
|
||||
fn panel_headless() -> Headless {
|
||||
Headless::new(solidcraft_engine::Session::default()).with_panels(solidcraft_engine::panels::fixture_command_backend().unwrap())
|
||||
}
|
||||
fn panel_mcp_steps() -> Vec<Value> {
|
||||
vec![
|
||||
json!({"command":"panel.open","params":{"kind":"fixture.log","instance":"log","destination":{"type":"new_section","section":"logs","index":0,
|
||||
"location":{"type":"dock","zone":"left"},"size":{"width":280,"height":240}}}}),
|
||||
json!({"command":"panel.collapse","params":{"target":{"type":"instance","id":"log"},"collapsed":true}}),
|
||||
]
|
||||
}
|
||||
#[test]
|
||||
fn headless_panel_single_batch_and_script_keep_one_backend_and_never_change_the_session() {
|
||||
use crate::Backend;
|
||||
let mut one = panel_headless();
|
||||
let mut batch = panel_headless();
|
||||
let mut script = panel_headless();
|
||||
let doc = one.session.doc.clone();
|
||||
let model = one.session.model.state();
|
||||
let before = (one.session.document_id.clone(), one.session.revision, one.session.undo.len(), one.session.is_dirty(), one.session.camera);
|
||||
for step in panel_mcp_steps() {
|
||||
one.call("engine.execute", step).unwrap();
|
||||
}
|
||||
batch.call("engine.batch", json!({"commands":panel_mcp_steps()})).unwrap();
|
||||
script.call("engine.script", json!({"commands":panel_mcp_steps()})).unwrap();
|
||||
let state = one.call("engine.execute", json!({"command":"layout.get"})).unwrap();
|
||||
assert_eq!(batch.call("engine.execute", json!({"command":"layout.get"})).unwrap()["state"]["layout"], state["state"]["layout"]);
|
||||
assert_eq!(script.call("engine.execute", json!({"command":"layout.get"})).unwrap()["state"]["layout"], state["state"]["layout"]);
|
||||
assert_eq!(state["state"]["capabilities"]["native_windows"], false);
|
||||
assert_eq!((one.session.document_id.clone(), one.session.revision, one.session.undo.len(), one.session.is_dirty(), one.session.camera), before);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &one.session.doc));
|
||||
assert!(std::sync::Arc::ptr_eq(&model, &one.session.model.state()));
|
||||
one.call("engine.script", json!({"commands":[{"command":"layout.save","params":{"name":"MCP saved"}},{"command":"layout.reset"}]})).unwrap();
|
||||
one.call("engine.execute", json!({"command":"layout.apply","params":{"name":"MCP saved"}})).unwrap();
|
||||
assert_eq!(one.call("engine.execute", json!({"command":"layout.get"})).unwrap()["state"]["layout"], state["state"]["layout"]);
|
||||
}
|
||||
#[test]
|
||||
fn mcp_panel_tools_publish_structured_errors_and_committed_state_through_shared_backend() {
|
||||
let mut s = Server::new(Box::new(panel_headless()));
|
||||
let input = panel_mcp_steps()[0].clone();
|
||||
let result = tool(&mut s, "execute", input);
|
||||
assert_eq!(result["layout"]["instances"][0]["id"], "log");
|
||||
let result = tool(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"commands":[
|
||||
{"command":"panel.collapse","params":{"target":{"type":"instance","id":"log"},"collapsed":true}},
|
||||
{"command":"panel.peek","params":{"target":{"type":"instance","id":"log"}}}]}),
|
||||
);
|
||||
assert_eq!(result["completed"], 2);
|
||||
let result = raw_tool(&mut s, "execute", json!({"command":"panel.focus","params":{"id":"fixture.log"}}));
|
||||
assert_eq!(result["result"]["isError"], true);
|
||||
assert_eq!(result["result"]["structuredContent"]["panel_error"]["rule"], "exactly_once");
|
||||
let result = raw_tool(&mut s, "execute", json!({"command":"layout.get","params":{"unknown":true}}));
|
||||
assert_eq!(result["result"]["structuredContent"]["panel_error"]["rule"], "schema");
|
||||
}
|
||||
#[test]
|
||||
fn headless_catalogue_atomic_batch_failure_keeps_presets_and_layout_and_session_only_is_truthful() {
|
||||
use crate::Backend;
|
||||
use solidcraft_engine::panels::{ActionSink, commands::LayoutCatalogue};
|
||||
let mut h = panel_headless();
|
||||
let state = h.panels.as_ref().unwrap().service.inspect();
|
||||
let catalogue = h.panels.as_ref().unwrap().catalogue.state().clone();
|
||||
let error = h
|
||||
.call(
|
||||
"engine.batch",
|
||||
json!({"commands":[
|
||||
{"command":"layout.save","params":{"name":"Not committed"}},
|
||||
{"command":"layout.workspace.select","params":{"workspace":"not-registered"}}]}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(solidcraft_engine::panels::commands::structured_error(&error).unwrap()["rule"], "missing_target");
|
||||
assert_eq!(h.panels.as_ref().unwrap().service.inspect(), state);
|
||||
assert_eq!(h.panels.as_ref().unwrap().catalogue.state(), &catalogue);
|
||||
let mut plain = Headless::default();
|
||||
let got = plain.call("engine.execute", json!({"command":"panel.list"})).unwrap();
|
||||
assert!(got["kinds"].as_array().unwrap().is_empty());
|
||||
assert_eq!(got["state"]["capabilities"]["native_windows"], false);
|
||||
assert!(plain.call("engine.execute", json!({"command":"layout.reset"})).is_err());
|
||||
assert!(solidcraft_engine::Session::default().run_script(&json!({"commands":[{"command":"layout.get"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_backend_keeps_headless_layout_work_even_without_document_features() {
|
||||
use crate::Backend;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
let mut headless = panel_headless();
|
||||
headless.call("engine.execute", json!({"command":"layout.save","params":{"name":"Owned headless layout"}})).unwrap();
|
||||
let discoveries = Rc::new(Cell::new(0));
|
||||
let seen = discoveries.clone();
|
||||
let mut auto = crate::Auto::new(
|
||||
headless,
|
||||
Box::new(move || {
|
||||
seen.set(seen.get() + 1);
|
||||
None
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(auto.call("engine.execute", json!({"command":"layout.get"})).unwrap()["state"]["layout"]["workspace"], "fixture.workspace");
|
||||
assert_eq!(discoveries.get(), 0, "owned layout must not be discarded while probing for an app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_panel_single_batch_and_script_forward_to_one_authoritative_backend() {
|
||||
use crate::Backend;
|
||||
let authoritative = std::sync::Arc::new(std::sync::Mutex::new(solidcraft_engine::panels::fixture_command_backend().unwrap()));
|
||||
let state = authoritative.clone();
|
||||
// This is an authored local transport fixture, never run during implementation.
|
||||
let (addr, seen) = scripted_app(move |request| {
|
||||
let mut backend = state.lock().unwrap();
|
||||
let params = &request["params"];
|
||||
let result = match request["method"].as_str() {
|
||||
Some("engine.execute") => backend.execute(params["command"].as_str().unwrap(), params.get("params").unwrap_or(&json!({}))),
|
||||
Some("engine.batch") => backend.batch(params["commands"].as_array().unwrap()),
|
||||
Some("engine.script") => {
|
||||
let mut replies = Vec::new();
|
||||
for step in params["commands"].as_array().unwrap() {
|
||||
replies.push(backend.execute(step["command"].as_str().unwrap(), step.get("params").unwrap_or(&json!({}))).unwrap());
|
||||
}
|
||||
Ok(json!(replies))
|
||||
}
|
||||
_ => return json!({"ok":false,"error":"unknown method"}),
|
||||
};
|
||||
match result {
|
||||
Ok(result) => json!({"ok":true,"result":result}),
|
||||
Err(reason) => json!({"ok":false,"error":solidcraft_engine::panels::commands::error_text(reason)}),
|
||||
}
|
||||
});
|
||||
let mut remote = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap();
|
||||
remote.call("engine.execute", panel_mcp_steps()[0].clone()).unwrap();
|
||||
remote
|
||||
.call(
|
||||
"engine.batch",
|
||||
json!({"commands":[panel_mcp_steps()[1].clone(),{"command":"panel.peek","params":{"target":{"type":"instance","id":"log"}}}]}),
|
||||
)
|
||||
.unwrap();
|
||||
authoritative.lock().unwrap().execute("panel.collapse", &json!({"target":{"type":"instance","id":"log"},"collapsed":false})).unwrap();
|
||||
assert_eq!(
|
||||
remote.call("engine.execute", json!({"command":"layout.get"})).unwrap()["state"]["layout"]["sections"][0]["collapsed"],
|
||||
false,
|
||||
"remote reads external live changes"
|
||||
);
|
||||
remote
|
||||
.call("engine.script", json!({"commands":[{"command":"layout.save","params":{"name":"Remote saved"}},{"command":"layout.reset"}]}))
|
||||
.unwrap();
|
||||
assert!(remote.call("engine.execute", json!({"command":"layout.get"})).unwrap()["state"]["layout"]["instances"].as_array().unwrap().is_empty());
|
||||
let seen = seen.lock().unwrap();
|
||||
assert!(seen.1.iter().any(|method| method == "engine.batch"));
|
||||
assert!(seen.1.iter().any(|method| method == "engine.script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_null_params_match_all_panel_routes_and_normal_document_sequences() {
|
||||
use crate::Backend;
|
||||
let mut headless = panel_headless();
|
||||
let expected = headless.call("engine.execute", json!({"command":"panel.list","params":null})).unwrap();
|
||||
for (method, rollback) in [("engine.batch", true), ("engine.batch", false), ("engine.script", false)] {
|
||||
let reply = headless
|
||||
.call(
|
||||
method,
|
||||
json!({"rollback":rollback,"commands":[
|
||||
{"command":"panel.list","params":null},{"command":"layout.get","params":null},
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let results = if method == "engine.script" { &reply } else { &reply["results"] };
|
||||
assert_eq!(results[0], expected);
|
||||
assert_eq!(results[1]["state"], expected["state"]);
|
||||
}
|
||||
for method in ["engine.batch", "engine.script"] {
|
||||
let previous = headless.session.document_id.clone();
|
||||
let reply = headless
|
||||
.call(
|
||||
method,
|
||||
json!({"rollback":false,"commands":[
|
||||
{"command":"file.new","params":null},{"command":"panel.list","params":null},{"command":"parameters.list","params":null},
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reply.get("failed").is_none(), "{reply}");
|
||||
assert_ne!(headless.session.document_id, previous);
|
||||
}
|
||||
}
|
||||
|
||||
struct LayoutFallbackBackend {
|
||||
headless: Headless,
|
||||
calls: Vec<(String, String)>,
|
||||
}
|
||||
impl LayoutFallbackBackend {
|
||||
fn new() -> Self {
|
||||
Self { headless: panel_headless(), calls: Vec::new() }
|
||||
}
|
||||
}
|
||||
impl crate::Backend for LayoutFallbackBackend {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.calls.push((method.into(), params.get("command").and_then(Value::as_str).unwrap_or_default().into()));
|
||||
if method == "engine.batch" {
|
||||
return Err("engine.batch unavailable: fixture has no atomic endpoint".into());
|
||||
}
|
||||
crate::Backend::call(&mut self.headless, method, params)
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_process(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"synthetic layout fallback boundary".into()
|
||||
}
|
||||
}
|
||||
fn fallback_tool_reply(backend: &mut LayoutFallbackBackend, args: Value) -> (Value, Value) {
|
||||
let tool = crate::tools::call_tool(backend, "batch", &args);
|
||||
let body = serde_json::from_str(tool["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
(tool, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_tool_layout_batches_never_emulate_an_unavailable_backend_with_document_undo() {
|
||||
use solidcraft_engine::panels::{ActionSink, commands::LayoutCatalogue};
|
||||
let open = panel_mcp_steps()[0].clone();
|
||||
for steps in [
|
||||
json!([open.clone()]),
|
||||
json!([open.clone(),{"command":"parameters.change","params":{"name":"broken","expression":"("}}]),
|
||||
json!([{"command":"execute","params":{"command":"panel.open","params":open["params"]}}]),
|
||||
] {
|
||||
let mut backend = LayoutFallbackBackend::new();
|
||||
let before = backend.headless.panels.as_ref().unwrap().service.inspect();
|
||||
let catalogue = backend.headless.panels.as_ref().unwrap().catalogue.state().clone();
|
||||
let document = backend.headless.session.doc.clone();
|
||||
let model = backend.headless.session.model.state();
|
||||
let session = (backend.headless.session.revision, backend.headless.session.undo.len(), backend.headless.session.is_dirty());
|
||||
let (tool, body) = fallback_tool_reply(&mut backend, json!({"commands":steps}));
|
||||
assert_eq!(tool["isError"], true);
|
||||
assert_eq!(tool["structuredContent"]["panel_error"]["rule"], "capability");
|
||||
assert_eq!(body["completed"], 0);
|
||||
assert_eq!(body["applied"], json!([]));
|
||||
assert_eq!(body["rolled_back"], false);
|
||||
assert_eq!(backend.calls, vec![("engine.batch".into(), String::new())], "no checkpoint, inspection or single layout call");
|
||||
assert_eq!(backend.headless.panels.as_ref().unwrap().service.inspect(), before);
|
||||
assert_eq!(backend.headless.panels.as_ref().unwrap().catalogue.state(), &catalogue);
|
||||
assert!(std::sync::Arc::ptr_eq(&document, &backend.headless.session.doc));
|
||||
assert!(std::sync::Arc::ptr_eq(&model, &backend.headless.session.model.state()));
|
||||
assert_eq!((backend.headless.session.revision, backend.headless.session.undo.len(), backend.headless.session.is_dirty()), session);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_atomic_tool_layout_batch_refuses_before_any_backend_or_checkpoint_call() {
|
||||
use solidcraft_engine::panels::{ActionSink, commands::LayoutCatalogue};
|
||||
let mut backend = LayoutFallbackBackend::new();
|
||||
let before = backend.headless.panels.as_ref().unwrap().service.inspect();
|
||||
let catalogue = backend.headless.panels.as_ref().unwrap().catalogue.state().clone();
|
||||
let document = backend.headless.session.doc.clone();
|
||||
let model = backend.headless.session.model.state();
|
||||
let args = json!({"commands":[panel_mcp_steps()[0],{"command":"parameters.change","params":{"name":"broken","expression":"x".repeat(solidcraft_engine::MAX_LINE-32768)}}]});
|
||||
assert!(args.to_string().len() < solidcraft_engine::MAX_LINE, "fixture fits the incoming line but exceeds the outgoing margin");
|
||||
let (tool, body) = fallback_tool_reply(&mut backend, args);
|
||||
assert_eq!(tool["isError"], true);
|
||||
assert_eq!(tool["structuredContent"]["panel_error"]["rule"], "collection_limit");
|
||||
assert_eq!(body["completed"], 0);
|
||||
assert_eq!(body["applied"], json!([]));
|
||||
assert!(backend.calls.is_empty());
|
||||
assert_eq!(backend.headless.panels.as_ref().unwrap().service.inspect(), before);
|
||||
assert_eq!(backend.headless.panels.as_ref().unwrap().catalogue.state(), &catalogue);
|
||||
assert!(std::sync::Arc::ptr_eq(&document, &backend.headless.session.doc));
|
||||
assert!(std::sync::Arc::ptr_eq(&model, &backend.headless.session.model.state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_tool_layout_fallback_truthfully_retains_completed_changes_without_checkpointing() {
|
||||
use solidcraft_engine::panels::{ActionSink, InstanceId, commands::LayoutCatalogue};
|
||||
let mut backend = LayoutFallbackBackend::new();
|
||||
let (_, body) = fallback_tool_reply(
|
||||
&mut backend,
|
||||
json!({"rollback":false,"commands":[
|
||||
panel_mcp_steps()[0],{"command":"parameters.change","params":{"name":"broken","expression":"("}},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(body["ok"], false);
|
||||
assert_eq!(body["completed"], 1);
|
||||
assert_eq!(body["applied"], json!([0]));
|
||||
assert_eq!(body["rolled_back"], false);
|
||||
assert_eq!(body["undone"], 0);
|
||||
let panels = backend.headless.panels.as_ref().unwrap();
|
||||
assert!(panels.service.layout().instance(&InstanceId::new("log").unwrap()).unwrap().requested_open);
|
||||
assert_eq!(panels.service.inspect().revision, 1);
|
||||
assert!(panels.catalogue.state().remembered(&panels.service.layout().workspace).is_some());
|
||||
assert!(!backend.calls.iter().any(|(_, command)| command == "edit.checkpoint" || command == "edit.restore_checkpoint"));
|
||||
assert_eq!(backend.calls.iter().filter(|(_, command)| command == "panel.open").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_only_atomic_tool_fallback_keeps_existing_checkpoint_rollback_behavior() {
|
||||
let mut backend = LayoutFallbackBackend::new();
|
||||
let (_, body) = fallback_tool_reply(
|
||||
&mut backend,
|
||||
json!({"commands":[
|
||||
{"command":"parameters.change","params":{"name":"temporary","expression":1}},
|
||||
{"command":"parameters.change","params":{"name":"broken","expression":"("}},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(body["rolled_back"], true, "{body}");
|
||||
assert_eq!(body["applied"], json!([]));
|
||||
assert!(backend.headless.session.doc.param("temporary").is_none());
|
||||
assert!(backend.calls.iter().any(|(_, command)| command == "edit.checkpoint"));
|
||||
assert!(backend.calls.iter().any(|(_, command)| command == "edit.restore_checkpoint"));
|
||||
}
|
||||
|
||||
/// Screenshots are drawn on the GPU by the renderer the host installs (`solidcraft-cli mcp`); the
|
||||
/// tests install the same one. `false`, with the reason printed, on a machine without a GPU
|
||||
/// adapter: the screenshots are then left out of the test.
|
||||
|
||||
+49
-1
@@ -514,6 +514,13 @@ fn text_result(v: &Value, is_error: bool) -> Value {
|
||||
let mut r = json!({"content": [{"type": "text", "text": text}]});
|
||||
if is_error {
|
||||
r["isError"] = json!(true);
|
||||
if let Some(reason) = v
|
||||
.as_str()
|
||||
.and_then(solidcraft_engine::panels::commands::structured_error)
|
||||
.or_else(|| v.get("failed").and_then(|failed| failed.get("panel_error")).cloned())
|
||||
{
|
||||
r["structuredContent"] = json!({"panel_error":reason});
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -1161,12 +1168,36 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_step(step: &Value) -> bool {
|
||||
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or_default();
|
||||
let command = if command == "execute" {
|
||||
step.get("params").and_then(|params| params.get("command")).and_then(Value::as_str).unwrap_or_default()
|
||||
} else {
|
||||
command
|
||||
};
|
||||
command.split('.').next().is_some_and(|area| area.eq_ignore_ascii_case("panel") || area.eq_ignore_ascii_case("layout"))
|
||||
}
|
||||
|
||||
fn atomic_layout_refusal(count: usize, rule: solidcraft_engine::panels::Rule, detail: &str) -> (Value, bool) {
|
||||
use solidcraft_engine::panels::{commands, error};
|
||||
let mut reply = commands::sequential_failure(0, None, commands::error_text(error(rule, detail)), Vec::new());
|
||||
reply["ok"] = json!(false);
|
||||
reply["applied"] = json!([]);
|
||||
reply["not_run"] = if count <= MAX_BATCH { json!((0..count).collect::<Vec<_>>()) } else { json!([]) };
|
||||
reply["not_run_count"] = json!(count);
|
||||
reply["note"] = json!("nothing was run: atomic application layout requires the authoritative backend transaction");
|
||||
(reply, true)
|
||||
}
|
||||
|
||||
/// The batch itself: its reply and whether it failed.
|
||||
fn batch_run(b: &mut dyn Backend, a: &Map<String, Value>) -> (Value, bool) {
|
||||
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
|
||||
return (json!("`commands` must be an array of {command, params}"), true);
|
||||
};
|
||||
if steps.len() > MAX_BATCH {
|
||||
if a.get("rollback").and_then(Value::as_bool).unwrap_or(true) && steps.iter().any(layout_step) {
|
||||
return atomic_layout_refusal(steps.len(), solidcraft_engine::panels::Rule::CollectionLimit, "atomic layout batch step count cap");
|
||||
}
|
||||
return (json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
|
||||
}
|
||||
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
|
||||
@@ -1217,6 +1248,7 @@ fn batch_run(b: &mut dyn Backend, a: &Map<String, Value>) -> (Value, bool) {
|
||||
}
|
||||
}
|
||||
let steps = &mapped;
|
||||
let atomic_layout = rollback && steps.iter().any(layout_step);
|
||||
// A step the batch would refuse at its turn refuses it now, before anything runs (#5853).
|
||||
// Only where the commands run in this process: a running app may be another version, with
|
||||
// commands this one lacks, and checks the batch against its own registry when it gets it.
|
||||
@@ -1251,7 +1283,8 @@ fn batch_run(b: &mut dyn Backend, a: &Map<String, Value>) -> (Value, bool) {
|
||||
o.insert("timings".into(), Value::Bool(true));
|
||||
o.insert("profile".into(), Value::Bool(profile));
|
||||
}
|
||||
if params.to_string().len() + 64 * 1024 <= solidcraft_engine::MAX_LINE {
|
||||
let fits_transport = params.to_string().len().saturating_add(64 * 1024) <= solidcraft_engine::MAX_LINE;
|
||||
if fits_transport {
|
||||
match b.call("engine.batch", params) {
|
||||
Ok(mut r) => {
|
||||
if let Some(failed) = r.get_mut("failed")
|
||||
@@ -1266,6 +1299,7 @@ fn batch_run(b: &mut dyn Backend, a: &Map<String, Value>) -> (Value, bool) {
|
||||
}
|
||||
// Older apps and the headless session, or the app wants the steps one by one.
|
||||
Err(e) if e.starts_with("unknown method") || e.starts_with("engine.batch unavailable") => {}
|
||||
Err(e) if solidcraft_engine::panels::commands::structured_error(&e).is_some() => return (Value::String(e), true),
|
||||
// Never sent: no app to send it to. Or sent and still running (it was not cancelled):
|
||||
// the `pending` tool gets its result.
|
||||
Err(e) if e.starts_with(crate::backend::NOT_RUNNING) || e.starts_with(STILL_RUNNING) => {
|
||||
@@ -1278,6 +1312,20 @@ fn batch_run(b: &mut dyn Backend, a: &Map<String, Value>) -> (Value, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Application layout is outside document checkpoints. Neither an unavailable
|
||||
// batch endpoint nor an oversized transport request authorizes a sequential
|
||||
// emulation of an atomic layout/mixed transaction.
|
||||
if atomic_layout {
|
||||
return atomic_layout_refusal(
|
||||
steps.len(),
|
||||
if fits_transport { solidcraft_engine::panels::Rule::Capability } else { solidcraft_engine::panels::Rule::CollectionLimit },
|
||||
if fits_transport {
|
||||
"authoritative atomic layout batch capability is unavailable"
|
||||
} else {
|
||||
"atomic layout batch exceeds transport size cap"
|
||||
},
|
||||
);
|
||||
}
|
||||
let start = if rollback { Undo::begin(b) } else { Undo::None };
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
let mut rows = Vec::new();
|
||||
|
||||
+10
-39
@@ -6,7 +6,6 @@
|
||||
//! fork fixed in its own sources; each one turned on here says which fork commit it matches.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
|
||||
@@ -410,43 +409,15 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
#[cfg(test)]
|
||||
const HELD_BACK: &[&str] = &["fillet_refuse_rounds_breaking_through"];
|
||||
|
||||
/// How far apart two kernels' procedural blend serials start. A body keeps the serials of the
|
||||
/// kernel that made it and may be used on any other, whose memo of solved stations is keyed by
|
||||
/// serial alone: the ranges keep one definition's stations from answering for another's.
|
||||
pub(crate) const BLEND_SERIALS_PER_KERNEL: u64 = 1 << 40;
|
||||
|
||||
/// A new kernel. Each takes the next range of blend serials, the first starting at 0.
|
||||
fn fresh() -> Kernel {
|
||||
static MADE: AtomicU64 = AtomicU64::new(0);
|
||||
let mut k = Kernel::new(&KernelOptions::default());
|
||||
k.fixes = fixes();
|
||||
k.blend_last_serial.set(MADE.fetch_add(1, Ordering::Relaxed).wrapping_mul(BLEND_SERIALS_PER_KERNEL));
|
||||
k
|
||||
}
|
||||
|
||||
/// Most kernels kept for later calls; those over it are dropped when their call ends.
|
||||
const MOST_IDLE: usize = 8;
|
||||
pub(crate) static KERNEL: Mutex<Option<Kernel>> = Mutex::new(None);
|
||||
|
||||
/// The kernels no call is using, the last used on top. The lock is only held to take or put back
|
||||
/// a kernel, never during a call: a call that runs long (or never returns) holds up nothing but
|
||||
/// its own body, so one design's long operation never delays another's. Calls one after another
|
||||
/// all get the same kernel, as a single kernel would serve them; one that starts while another
|
||||
/// runs gets one of its own (solidcore keeps no global state, and a body's identities are valid
|
||||
/// only in the kernel that issued them, so any kernel takes any body).
|
||||
static IDLE: Mutex<Vec<Kernel>> = Mutex::new(Vec::new());
|
||||
|
||||
fn take_kernel() -> Kernel {
|
||||
IDLE.lock().unwrap_or_else(PoisonError::into_inner).pop().unwrap_or_else(fresh)
|
||||
}
|
||||
|
||||
fn put_kernel(k: Kernel) {
|
||||
let mut idle = IDLE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if idle.len() < MOST_IDLE {
|
||||
idle.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on a kernel of its own. A panic in solidcore is caught: the kernel, which it may have
|
||||
/// Runs `f` on the process's kernel. A panic in solidcore is caught: the kernel, which it may have
|
||||
/// left half-updated, is replaced by a fresh one and the call fails with [`Error::Internal`].
|
||||
pub(crate) fn run<T: dump::Answer>(call: &'static str, f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>) -> Result<T> {
|
||||
run_on(call, &[], f)
|
||||
@@ -470,10 +441,11 @@ pub(crate) fn run_on<T: dump::Answer>(
|
||||
inputs: dump::Inputs<'_>,
|
||||
f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>,
|
||||
) -> Result<T> {
|
||||
let mut k = take_kernel();
|
||||
let mut guard = KERNEL.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let k = guard.get_or_insert_with(fresh);
|
||||
// A profiler span per call: where the kernel's time goes.
|
||||
let _span = solidcraft_profile::span("solidcore", call);
|
||||
let in_flight = dump::in_flight(call);
|
||||
dump::set_current(Some(call));
|
||||
struct Depth;
|
||||
impl Drop for Depth {
|
||||
fn drop(&mut self) {
|
||||
@@ -482,28 +454,27 @@ pub(crate) fn run_on<T: dump::Answer>(
|
||||
}
|
||||
IN_CALL.with(|d| d.set(d.get() + 1));
|
||||
let depth = Depth;
|
||||
let caught = catch_unwind(AssertUnwindSafe(|| f(&mut k)));
|
||||
let caught = catch_unwind(AssertUnwindSafe(|| f(k)));
|
||||
drop(depth);
|
||||
let r = match caught {
|
||||
Ok(Ok(v)) => Ok(v),
|
||||
Ok(Err(e)) => Err(Error::Solidcore(format!("{call}: {e}"))),
|
||||
Err(p) => {
|
||||
k = fresh();
|
||||
*guard = Some(fresh());
|
||||
let why = p.downcast_ref::<&str>().map(|s| (*s).to_owned()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
log::warn!(target: "smlib", "solidcore panicked in {call}: {why}");
|
||||
Err(Error::Internal(format!("{call}: {why}")))
|
||||
}
|
||||
};
|
||||
drop(in_flight);
|
||||
dump::set_current(None);
|
||||
let why = match &r {
|
||||
Ok(v) => v.refusal(),
|
||||
Err(e) => Some(e.to_string()),
|
||||
};
|
||||
if let Some(why) = why {
|
||||
// The kernel the call ran on, or the fresh one that replaced it after a panic.
|
||||
dump::failed(&k, call, &why, inputs);
|
||||
dump::failed(guard.get_or_insert_with(fresh), call, &why, inputs);
|
||||
}
|
||||
put_kernel(k);
|
||||
r
|
||||
}
|
||||
|
||||
@@ -520,7 +491,7 @@ pub(crate) fn answer<T>(r: std::result::Result<T, solidcore::SolidError>) -> std
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on a kernel and `body`, its lock held for the call.
|
||||
/// Runs `f` on the kernel and `body`, locking the kernel first.
|
||||
pub(crate) fn with_body<T: dump::Answer>(
|
||||
call: &'static str,
|
||||
body: &Body,
|
||||
|
||||
@@ -604,7 +604,7 @@ pub(super) fn finish(b: Brep, solid_in: bool) -> solidcore::Result<Out<Brep>> {
|
||||
Ok(Ok(b))
|
||||
}
|
||||
|
||||
/// Runs `f` on a copy of `body` (`brep_copy`).
|
||||
/// Runs `f` on a copy of `body` (`brep_copy`) under the kernel's lock.
|
||||
pub(super) fn on_copy(call: &'static str, body: &Body, f: impl FnOnce(&mut Kernel, Brep) -> solidcore::Result<Out<Brep>>) -> Result<Body> {
|
||||
if body.0.is_poisoned() {
|
||||
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
|
||||
|
||||
@@ -92,9 +92,9 @@ fn operands<'a>(bodies: &[&'a Body]) -> Vec<(&'static str, &'a Body)> {
|
||||
}
|
||||
|
||||
/// Copies of `bodies`, in order (a body given twice is copied twice, as the capi copies each
|
||||
/// operand).
|
||||
/// operand), all made under the kernel's lock.
|
||||
fn copies(k: &mut Kernel, bodies: &[&Body]) -> SolidResult<Vec<Brep>> {
|
||||
// One body's lock at a time, here as everywhere: two calls never deadlock on them.
|
||||
// Under the kernel's lock (`run` holds it), as every body lock is: two never deadlock.
|
||||
let mut out = Vec::with_capacity(bodies.len());
|
||||
for b in bodies {
|
||||
let c = brep_copy(k, &b.0.lock().unwrap_or_else(PoisonError::into_inner))?;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! fails, so a call that answers costs nothing more than it would with no folder.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use solidcore::kernel::Kernel;
|
||||
@@ -90,7 +90,6 @@ plain!(
|
||||
bool,
|
||||
f64,
|
||||
usize,
|
||||
[usize; 2],
|
||||
[usize; 3],
|
||||
([f64; 3], [f64; 3]),
|
||||
Vec<u8>,
|
||||
@@ -104,30 +103,30 @@ plain!(
|
||||
|
||||
impl<T> Answer for Option<T> {}
|
||||
|
||||
/// The calls running now, in the order they started.
|
||||
static IN_FLIGHT: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
|
||||
/// The longest call name [`current`] gives.
|
||||
const NAME_MAX: usize = 96;
|
||||
static NAME: [AtomicU8; NAME_MAX] = [const { AtomicU8::new(0) }; NAME_MAX];
|
||||
static NAME_LEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// A call in [`current`]'s list until dropped.
|
||||
pub(super) struct InFlight(&'static str);
|
||||
|
||||
impl Drop for InFlight {
|
||||
fn drop(&mut self) {
|
||||
let mut calls = IN_FLIGHT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(at) = calls.iter().position(|c| *c == self.0) {
|
||||
calls.remove(at);
|
||||
}
|
||||
/// `call` is running now (`None`: none is). One at a time, under the kernel's lock.
|
||||
pub(super) fn set_current(call: Option<&str>) {
|
||||
NAME_LEN.store(0, Ordering::SeqCst);
|
||||
let Some(call) = call else { return };
|
||||
let bytes = call.as_bytes();
|
||||
let n = bytes.len().min(NAME_MAX);
|
||||
for (slot, b) in NAME.iter().zip(bytes) {
|
||||
slot.store(*b, Ordering::Relaxed);
|
||||
}
|
||||
NAME_LEN.store(n, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// `call` is running from now until the answer is dropped.
|
||||
pub(super) fn in_flight(call: &'static str) -> InFlight {
|
||||
IN_FLIGHT.lock().unwrap_or_else(PoisonError::into_inner).push(call);
|
||||
InFlight(call)
|
||||
}
|
||||
|
||||
/// The calls running now, joined by commas. Never waits, for crash reports: `None` as well if a
|
||||
/// call is starting or ending at this very moment.
|
||||
/// The call running now. Takes no lock, for crash reports, so it may be cut short or mixed with the
|
||||
/// next call's name if read just as one starts.
|
||||
pub(crate) fn current() -> Option<String> {
|
||||
let calls = IN_FLIGHT.try_lock().ok()?;
|
||||
(!calls.is_empty()).then(|| calls.join(", "))
|
||||
let n = NAME_LEN.load(Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = NAME.iter().take(n).map(|b| b.load(Ordering::Relaxed)).collect();
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
+7
-113
@@ -8,6 +8,7 @@ fn box_end_to_end() {
|
||||
let c = b.try_clone().unwrap();
|
||||
drop(b);
|
||||
assert!((c.volume(1e-7).unwrap() - 6000.0).abs() < 1e-6);
|
||||
assert_eq!(current_call(), None, "no call in flight between calls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -320,8 +321,12 @@ fn a_failing_solidcore_call_leaves_a_dump_and_names_itself() {
|
||||
});
|
||||
core::dump::set_dir(None);
|
||||
assert!(r.is_err());
|
||||
assert!(seen.lock().unwrap().as_deref().is_some_and(|c| c.contains("smlib_test_failure")), "{seen:?}");
|
||||
assert!(!core::dump::current().is_some_and(|c| c.contains("smlib_test_failure")), "no longer in flight");
|
||||
assert_eq!(seen.lock().unwrap().as_deref(), Some("smlib_test_failure"));
|
||||
{
|
||||
// Under the kernel's lock no solidcore call is in flight.
|
||||
let _kernel = core::KERNEL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(core::dump::current(), None);
|
||||
}
|
||||
let got: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
let text = got.iter().find(|n| n.ends_with("-smlib_test_failure.txt")).unwrap_or_else(|| panic!("{got:?}"));
|
||||
assert!(std::fs::read_to_string(dir.join(text)).unwrap().contains("refused"));
|
||||
@@ -399,114 +404,3 @@ fn an_edge_a_few_um_past_its_patch_keeps_its_interval() {
|
||||
let [u, v] = solidcraft_geom::brep::pcurve::eval(pc, 1.0).unwrap();
|
||||
assert!((u - 1.0).abs() < 1e-9 && v.abs() < 1e-9, "{u} {v}");
|
||||
}
|
||||
|
||||
/// A solidcore call blocked on a channel until `release`, on its own thread: a call that runs long
|
||||
/// or never returns, as one design's slow operation does.
|
||||
fn call_held_open(body: &str) -> (std::sync::mpsc::Sender<()>, std::thread::JoinHandle<Result<()>>) {
|
||||
let call: &'static str = Box::leak(body.to_owned().into_boxed_str());
|
||||
let (started, is_started) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel::<()>();
|
||||
let held = std::thread::spawn(move || {
|
||||
core::run(call, |_| {
|
||||
let _ = started.send(());
|
||||
let _ = released.recv();
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
is_started.recv().unwrap();
|
||||
(release, held)
|
||||
}
|
||||
|
||||
/// Runs `f` on another thread and gives its answer; a failure here rather than a test that hangs
|
||||
/// when `f` waits for a call that does not end. The limit is no measure of speed: `f` is free to
|
||||
/// take all of it, and only a wait with no end fails.
|
||||
fn answered<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(120)).expect("this call waited for a call on another thread that has not ended")
|
||||
}
|
||||
|
||||
/// One design's long kernel call must never hold up another's: the kernel call of a design closed
|
||||
/// or still open, blocked here on a channel, while another makes and measures a body of its own.
|
||||
#[test]
|
||||
fn a_kernel_call_that_does_not_end_holds_up_no_other_call() {
|
||||
let (release, held) = call_held_open("held_open");
|
||||
let volume = answered(|| {
|
||||
let a = Brep::make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
let b = Brep::make_box([5.0, 0.0, 0.0], [10.0; 3]).unwrap();
|
||||
a.boolean(&b, BooleanOp::Union).unwrap().unwrap().volume(1e-7).unwrap()
|
||||
});
|
||||
assert!((volume - 1500.0).abs() < 1e-6, "{volume}");
|
||||
assert!(current_call().is_some_and(|c| c.contains("held_open")), "the call still runs");
|
||||
// And one holds up no other held open beside it.
|
||||
let (release_too, held_too) = call_held_open("held_open_too");
|
||||
assert!(current_call().is_some_and(|c| c.contains("held_open") && c.contains("held_open_too")));
|
||||
for (release, held) in [(release, held), (release_too, held_too)] {
|
||||
release.send(()).unwrap();
|
||||
held.join().unwrap().unwrap();
|
||||
}
|
||||
assert!(!current_call().is_some_and(|c| c.contains("held_open")), "neither is in flight any more");
|
||||
}
|
||||
|
||||
/// Bodies are made and used on whichever kernel is free, at the same time on several threads, and
|
||||
/// each answer is the one a single kernel gives: a body made while another call held the first
|
||||
/// kernel is used once it is free, and two bodies from different kernels go into one boolean.
|
||||
#[test]
|
||||
fn bodies_move_between_kernels_and_calls_run_side_by_side() {
|
||||
let (release, held) = call_held_open("held_open");
|
||||
let on_second = answered(|| Brep::make_box([5.0, 0.0, 0.0], [10.0; 3]).unwrap());
|
||||
release.send(()).unwrap();
|
||||
held.join().unwrap().unwrap();
|
||||
let first = Brep::make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
let v = first.boolean(&on_second, BooleanOp::Subtract).unwrap().unwrap().volume(1e-7).unwrap();
|
||||
assert!((v - 500.0).abs() < 1e-6, "{v}");
|
||||
|
||||
let shared = std::sync::Arc::new(Brep::make_box([0.0; 3], [10.0; 3]).unwrap());
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|n| {
|
||||
let shared = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..20 {
|
||||
let side = 2.0 + f64::from((n + i) % 5);
|
||||
let tool = Brep::make_box([10.0 - side; 3], [side + 1.0; 3]).unwrap();
|
||||
let cut = shared.boolean(&tool, BooleanOp::Subtract).unwrap().unwrap();
|
||||
let rounded = Brep::make_box([0.0; 3], [10.0; 3]).unwrap().fillet(&[[5.0, 0.0, 0.0]], 1e-6, 1.0).unwrap();
|
||||
let lost = (1.0 - std::f64::consts::FRAC_PI_4) * 10.0;
|
||||
assert!((cut.volume(1e-7).unwrap() - (1000.0 - side.powi(3))).abs() < 1e-5);
|
||||
assert!((rounded.volume(1e-7).unwrap() - (1000.0 - lost)).abs() < 1e-4);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for worker in workers {
|
||||
answered(move || worker.join().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
/// Each kernel has its own range of blend serials: a body keeps the serials of the kernel that
|
||||
/// made it, and another kernel's memo of solved stations must not take them for its own.
|
||||
#[test]
|
||||
fn kernels_never_issue_the_same_blend_serial() {
|
||||
let range = core::BLEND_SERIALS_PER_KERNEL as usize;
|
||||
let ranges = core::run("outer", |outer| {
|
||||
let inner = core::run("inner", |inner| Ok(inner.blend_last_serial.get() as usize)).unwrap_or_default();
|
||||
Ok([outer.blend_last_serial.get() as usize / range, inner / range])
|
||||
});
|
||||
let [outer, inner] = ranges.unwrap();
|
||||
assert_ne!(outer, inner);
|
||||
}
|
||||
|
||||
/// A panic in a call running beside another costs neither: the panicking call's kernel is thrown
|
||||
/// away and the other call and later ones go on.
|
||||
#[test]
|
||||
fn a_panic_beside_a_running_call_costs_only_its_own_kernel() {
|
||||
let (release, held) = call_held_open("held_open");
|
||||
let r: Result<()> = core::run("panicking", |_| panic!("on purpose"));
|
||||
assert!(matches!(r, Err(Error::Internal(_))), "{r:?}");
|
||||
let v = answered(|| Brep::make_box([0.0; 3], [2.0; 3]).unwrap().volume(1e-7).unwrap());
|
||||
assert!((v - 8.0).abs() < 1e-6, "{v}");
|
||||
release.send(()).unwrap();
|
||||
held.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
@@ -404,17 +404,24 @@ fn start_test(app: &SolveApp, e: &agents::Entry, key: Option<String>) -> Test {
|
||||
|
||||
/// An endpoint's request timeout field: seconds, 30 s to an hour.
|
||||
fn timeout_field(ui: &mut egui::Ui, secs: &mut u64, handle: &str) -> bool {
|
||||
let r = ui_kit::number_field(ui, secs, agents::MIN_REQUEST_TIMEOUT_S..=agents::MAX_REQUEST_TIMEOUT_S, 5.0, " s")
|
||||
let r = ui_kit::number_field(ui, secs, agents::MIN_REQUEST_TIMEOUT_S..=agents::MAX_REQUEST_TIMEOUT_S, 5.0, "")
|
||||
.on_hover_text(tr("ui.ai_request_timeout_hint"));
|
||||
r.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), *secs as f64);
|
||||
info.label = Some(tr("prefs.assistant_seconds_aria").to_owned());
|
||||
info
|
||||
});
|
||||
ui.label(tr("prefs.assistant_seconds"));
|
||||
crate::scenario::publish_target(handle, r.rect);
|
||||
r.changed()
|
||||
}
|
||||
|
||||
/// The free-text reasoning effort field with its hover hint; true when the text changed.
|
||||
fn effort_field(ui: &mut egui::Ui, effort: &mut String) -> bool {
|
||||
ui_kit::cell_editor(ui, ui.id().with("ai_reasoning_effort"), effort, tr("ui.ai_reasoning_effort_field_hint"), false)
|
||||
.on_hover_text(tr("ui.ai_reasoning_effort_hint"))
|
||||
.changed()
|
||||
fn effort_field(ui: &mut egui::Ui, effort: &mut String, handle: &str) -> bool {
|
||||
let response = ui_kit::cell_editor(ui, ui.id().with("ai_reasoning_effort"), effort, tr("ui.ai_reasoning_effort_field_hint"), false)
|
||||
.on_hover_text(tr("ui.ai_reasoning_effort_hint"));
|
||||
crate::scenario::publish_target(handle, response.rect);
|
||||
response.changed()
|
||||
}
|
||||
|
||||
fn test_line(ui: &mut egui::Ui, test: &Option<&Test>) {
|
||||
@@ -435,73 +442,84 @@ fn test_line(ui: &mut egui::Ui, test: &Option<&Test>) {
|
||||
/// The Assistant page of Settings. Copy stays close to its control; full security details
|
||||
/// remain accessible on hover, and high-risk permissions always retain a visible warning.
|
||||
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::settings_heading(ui, tr("prefs.assistant"), tr("ai.prefs.intro"));
|
||||
ui_kit::settings_section(ui, "assistant_availability", tr("ai.prefs.availability"), |ui| {
|
||||
let response =
|
||||
ui_kit::checkbox(ui, tr("ui.ai_show_features"), &mut app.preferences.ai_features).on_hover_text(tr("ui.ai_show_features_note"));
|
||||
crate::scenario::publish_handle("prefs:ai_features", response.rect.center());
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
if app.preferences.ai_features {
|
||||
ui_kit::settings_page(ui, |ui| {
|
||||
ui_kit::settings_heading(ui, tr("prefs.assistant"), tr("ai.prefs.intro"));
|
||||
ui_kit::settings_section(ui, "assistant_availability", tr("ai.prefs.availability"), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let response =
|
||||
ui_kit::checkbox(ui, tr("ui.ai_show_features"), &mut app.preferences.ai_features).on_hover_text(tr("ui.ai_show_features_note"));
|
||||
crate::scenario::publish_handle("prefs:ai_features", response.rect.center());
|
||||
ui_kit::help_icon(ui, tr("ui.ai_show_features_note"));
|
||||
});
|
||||
ui.add_enabled_ui(app.preferences.ai_features && !cfg!(target_arch = "wasm32"), |ui| {
|
||||
let response = ui_kit::checkbox(ui, tr("ui.ai_toolbar_button"), &mut app.preferences.ai_toolbar_button)
|
||||
.on_hover_text(tr("ui.ai_toolbar_button_note"));
|
||||
crate::scenario::publish_handle("prefs:ai_toolbar_button", response.rect.center());
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.connect_at_start"), &mut app.preferences.agent_connect_at_start)
|
||||
.on_hover_text(tr("ai.prefs.connect_at_start_note"));
|
||||
crate::scenario::publish_handle("prefs:agent_connect_at_start", response.rect.center());
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.open_at_start"), &mut app.preferences.agent_chat_open_at_start)
|
||||
.on_hover_text(tr("ai.prefs.open_at_start_note"));
|
||||
crate::scenario::publish_handle("prefs:agent_chat_open_at_start", response.rect.center());
|
||||
});
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
ui_kit::text(ui, tr("ui.agent_chat_desktop_only"), ui_kit::Text::Hint("desktop"));
|
||||
}
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.background_commands"), &mut app.preferences.agent_off_thread)
|
||||
.on_hover_text(tr("ui.agent_off_thread_note"));
|
||||
crate::scenario::publish_handle("prefs:agent_off_thread", response.rect.center());
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.allow_control"), &mut app.preferences.agent_control)
|
||||
.on_hover_text(tr("ai.prefs.allow_control_note"));
|
||||
crate::scenario::publish_handle("prefs:agent_control", response.rect.center());
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.connect_apps"), &mut app.preferences.connect_ai_apps)
|
||||
.on_hover_text(tr("ai.prefs.connect_apps_note"));
|
||||
crate::scenario::publish_handle("prefs:connect_ai_apps", response.rect.center());
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
ui_kit::text(ui, tr("ui.agent_chat_desktop_only"), ui_kit::Text::Hint("desktop"));
|
||||
});
|
||||
ui.add_enabled_ui(app.preferences.ai_features && !cfg!(target_arch = "wasm32"), |ui| {
|
||||
permissions_section(app, ui);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
|
||||
if app.preferences.ai_features && browsing && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, &ui.ctx().clone());
|
||||
}
|
||||
agents_section(app, ui);
|
||||
}
|
||||
panel_section(app, ui);
|
||||
connecting_section(app, ui);
|
||||
});
|
||||
ui.add_enabled_ui(app.preferences.ai_features, |ui| pointer_section(app, ui));
|
||||
});
|
||||
}
|
||||
|
||||
fn connecting_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::settings_section(ui, "assistant_connecting", tr("prefs.assistant_connecting"), |ui| {
|
||||
for (label, help, handle, value) in [
|
||||
("ai.prefs.allow_control", "ai.prefs.allow_control_note", "agent_control", &mut app.preferences.agent_control),
|
||||
("ai.prefs.connect_apps", "ai.prefs.connect_apps_note", "connect_ai_apps", &mut app.preferences.connect_ai_apps),
|
||||
("ai.prefs.background_commands", "ui.agent_off_thread_note", "agent_off_thread", &mut app.preferences.agent_off_thread),
|
||||
("ai.prefs.connect_at_start", "ai.prefs.connect_at_start_note", "agent_connect_at_start", &mut app.preferences.agent_connect_at_start),
|
||||
("ai.prefs.open_at_start", "ai.prefs.open_at_start_note", "agent_chat_open_at_start", &mut app.preferences.agent_chat_open_at_start),
|
||||
] {
|
||||
ui.horizontal(|ui| {
|
||||
let response = ui_kit::checkbox(ui, tr(label), value).on_hover_text(tr(help));
|
||||
crate::scenario::publish_handle(&format!("prefs:{handle}"), response.rect.center());
|
||||
ui_kit::help_icon(ui, tr(help));
|
||||
});
|
||||
}
|
||||
});
|
||||
if !cfg!(target_arch = "wasm32") && app.preferences.ai_features {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
|
||||
if browsing && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, &ui.ctx().clone());
|
||||
}
|
||||
agents_section(app, ui);
|
||||
}
|
||||
permissions_section(app, ui);
|
||||
panel_section(app, ui);
|
||||
}
|
||||
pointer_section(app, ui);
|
||||
}
|
||||
|
||||
fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::settings_section(ui, "permissions", tr("ui.ai_permissions"), |ui| {
|
||||
let mut selected = Mode::ALL.iter().position(|mode| *mode == app.preferences.ai_permissions).unwrap_or(0);
|
||||
let all_help = format!("{}\n\n{}", tr("prefs.assistant_use_care"), tr("ui.ai_mode_all_note"));
|
||||
let choices = [
|
||||
ui_kit::SettingsChoice {
|
||||
icon: None,
|
||||
key: "prefs:ai_mode:ask",
|
||||
title: tr("ai.prefs.permission_ask"),
|
||||
summary: tr("ai.prefs.permission_ask_summary"),
|
||||
detail: tr("ui.ai_mode_ask_note"),
|
||||
},
|
||||
ui_kit::SettingsChoice {
|
||||
icon: None,
|
||||
key: "prefs:ai_mode:edits",
|
||||
title: tr("ai.prefs.permission_edits"),
|
||||
summary: tr("ai.prefs.permission_edits_summary"),
|
||||
detail: tr("ui.ai_mode_edits_note"),
|
||||
},
|
||||
ui_kit::SettingsChoice {
|
||||
icon: Some("warning"),
|
||||
key: "prefs:ai_mode:all",
|
||||
title: tr("ai.prefs.permission_all"),
|
||||
summary: tr("ai.prefs.permission_all_summary"),
|
||||
detail: tr("ui.ai_mode_all_note"),
|
||||
detail: &all_help,
|
||||
},
|
||||
];
|
||||
if ui_kit::settings_choices(ui, &choices, &mut selected)
|
||||
@@ -512,7 +530,7 @@ fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.preferences.ai_permissions == Mode::All {
|
||||
ui_kit::text(ui, tr("ui.ai_mode_all_warning"), ui_kit::Text::Report("permissions_warning", true));
|
||||
}
|
||||
ui_kit::text(ui, tr("ai.prefs.permission_scope"), ui_kit::Text::Hint("scope")).on_hover_text(tr("ui.ai_modes_scope"));
|
||||
ui_kit::text(ui, tr("ui.ai_modes_scope"), ui_kit::Text::Hint("permissions_scope"));
|
||||
let remembered: Vec<_> = app
|
||||
.preferences
|
||||
.agent_always_allow
|
||||
@@ -529,7 +547,9 @@ fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
ui_kit::settings_block(ui, names.get(&agent).unwrap_or(&agent), t.space_24 * 6.0 + t.space_6, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, tr("ui.ai_reset"), ui_kit::Button::Small).clicked() {
|
||||
let reset = ui_kit::button(ui, tr("ui.ai_reset"), ui_kit::Button::Small);
|
||||
crate::scenario::publish_target(&format!("prefs:ai_approvals_reset:{agent}"), reset.rect);
|
||||
if reset.clicked() {
|
||||
app.preferences.agent_always_allow.remove(&agent);
|
||||
}
|
||||
ui_kit::settings_short_note(ui, &tools.join(", "));
|
||||
@@ -543,7 +563,11 @@ fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
ui_kit::settings_section(ui, "panel", tr("ui.ai_panel"), |ui| {
|
||||
ui_kit::settings_form(ui, t.space_24 * 6.0 + t.space_6, |ui| {
|
||||
let label_width = [tr("ui.agent_chat_side"), tr("ui.agent_chat_turn_limit")]
|
||||
.iter()
|
||||
.map(|label| ui_kit::ContentWidth::label(ui, label) + t.space_4)
|
||||
.fold(0.0, f32::max);
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
ui_kit::row(ui, tr("ui.agent_chat_side"), |ui| {
|
||||
let p = &mut app.preferences;
|
||||
let mut selected = if p.agent_chat_popped { 2 } else { usize::from(p.agent_chat_side != "left") };
|
||||
@@ -557,16 +581,41 @@ fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
ui_kit::row(ui, tr("ui.agent_chat_turn_limit"), |ui| {
|
||||
let p = &mut app.preferences;
|
||||
let mut unlimited = p.agent_chat_turn_limit == 0;
|
||||
if ui_kit::checkbox(ui, tr("ui.agent_chat_turn_unlimited"), &mut unlimited).changed() {
|
||||
p.agent_chat_turn_limit = if unlimited { 0 } else { gate::SEEDED_TURN_LIMIT };
|
||||
let previous = usize::from(p.agent_chat_turn_limit != 0);
|
||||
let mut limit = previous;
|
||||
let options = [tr("ui.agent_chat_turn_unlimited"), tr("prefs.assistant_limited")];
|
||||
crate::prefs_search::note_current(ui, &options.join(" "));
|
||||
let dropdown_width = ui_kit::ContentWidth::dropdown(ui, &options);
|
||||
let response =
|
||||
ui_kit::settings_control_width(ui, dropdown_width, |ui| ui_kit::dropdown(ui, "assistant_turn_limit", &mut limit, &options));
|
||||
let query = crate::prefs_search::query(ui);
|
||||
if let Some(option) = options
|
||||
.iter()
|
||||
.find(|option| option.to_lowercase() == query)
|
||||
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
|
||||
{
|
||||
crate::prefs_search::remember(ui, option, response.rect);
|
||||
}
|
||||
if !unlimited {
|
||||
ui_kit::settings_number(ui, &mut p.agent_chat_turn_limit, 1..=200, 1.0).on_hover_text(tr("ai.prefs.turn_limit_hint"));
|
||||
for option in options {
|
||||
crate::prefs_search::follow(ui, option, response.rect);
|
||||
}
|
||||
crate::scenario::publish_target("prefs:agent_chat_turn_limit:mode", response.rect);
|
||||
if limit != previous {
|
||||
p.agent_chat_turn_limit = if limit == 0 { 0 } else { gate::SEEDED_TURN_LIMIT };
|
||||
}
|
||||
if limit != 0 {
|
||||
let response =
|
||||
ui_kit::settings_number(ui, &mut p.agent_chat_turn_limit, 1..=200, 1.0).on_hover_text(tr("ai.prefs.turn_limit_hint"));
|
||||
response.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), f64::from(p.agent_chat_turn_limit));
|
||||
info.label = Some(tr("prefs.assistant_limit").to_owned());
|
||||
info
|
||||
});
|
||||
crate::scenario::publish_target("prefs:agent_chat_turn_limit:value", response.rect);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui_kit::text(ui, tr("ai.prefs.privacy_note"), ui_kit::Text::Hint("privacy"));
|
||||
ui_kit::text(ui, tr("ai.prefs.privacy_note"), ui_kit::Text::Hint("privacy_note"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -575,15 +624,39 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::settings_pair(
|
||||
ui,
|
||||
|ui| {
|
||||
ui_kit::checkbox(ui, tr("ai.prefs.show_pointer"), &mut app.ui.agent_cursor.show)
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.show_pointer"), &mut app.ui.agent_cursor.show)
|
||||
.on_hover_text(crate::i18n::tr_label("Show the pointer an AI agent moves (#35)"));
|
||||
crate::scenario::publish_target("prefs:agent_pointer_show", response.rect);
|
||||
ui.add_enabled_ui(app.ui.agent_cursor.show, |ui| {
|
||||
let speed_label_width = ui_kit::ContentWidth::label(ui, tr("ai.prefs.pointer_speed")) + crate::theme::Tokens::get().space_4;
|
||||
ui_kit::settings_block(ui, tr("ai.prefs.pointer_speed"), speed_label_width, |ui| {
|
||||
crate::agent_cursor::speed_slider(ui, &mut app.ui.agent_cursor.speed);
|
||||
ui_kit::settings_form(ui, speed_label_width, |ui| {
|
||||
ui_kit::row(ui, tr("ai.prefs.pointer_speed"), |ui| {
|
||||
let name = tr("prefs.assistant_pointer_speed");
|
||||
crate::prefs_search::note_current(ui, name);
|
||||
let response =
|
||||
ui_kit::named_control(ui, name, |ui| crate::agent_cursor::speed_slider(ui, &mut app.ui.agent_cursor.speed));
|
||||
crate::scenario::publish_target("prefs:agent_pointer_speed", response.rect);
|
||||
let value = match app.ui.agent_cursor.speed {
|
||||
crate::agent_cursor::Speed::Times(value) => value,
|
||||
crate::agent_cursor::Speed::Instant => 5.5,
|
||||
};
|
||||
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), value, name));
|
||||
// The visible row owns Speed and its legacy Motion speed alias. The
|
||||
// native editor's name owns queries that only match Pointer speed.
|
||||
if !crate::prefs_search::texts_hit(ui, &[tr("ai.prefs.pointer_speed")]) {
|
||||
crate::prefs_search::remember(ui, name, response.rect);
|
||||
}
|
||||
for label in [tr("ai.prefs.pointer_speed"), name] {
|
||||
crate::prefs_search::follow(ui, label, response.rect);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.follow_camera"), &mut app.ui.agent_cursor.follow_camera)
|
||||
.on_hover_text(tr("prefs.assistant_follow_help"));
|
||||
crate::scenario::publish_target("prefs:agent_pointer_follow", response.rect);
|
||||
ui_kit::help_icon(ui, tr("prefs.assistant_follow_help"));
|
||||
});
|
||||
ui_kit::checkbox(ui, tr("ai.prefs.follow_camera"), &mut app.ui.agent_cursor.follow_camera)
|
||||
.on_hover_text(crate::i18n::tr_label("Follow the camera (Look At sketches)"));
|
||||
});
|
||||
(app.ui.agent_cursor.show, app.ui.agent_cursor.speed.label(), app.ui.agent_cursor.follow_camera)
|
||||
},
|
||||
@@ -605,7 +678,7 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let mut reeffort: Option<(String, String)> = None;
|
||||
let mut picked_model: Option<(String, ui_kit::Picked)> = None;
|
||||
let t = crate::theme::Tokens::get();
|
||||
ui_kit::settings_section(ui, "agents", tr("ui.ai_agents"), |ui| {
|
||||
ui_kit::settings_section_count(ui, "agents", tr("ui.ai_agents"), Some(&all.len().to_string()), |ui| {
|
||||
if app.agent_chat.detecting.is_some() {
|
||||
ui_kit::text(ui, tr("ui.agent_chat_detecting"), ui_kit::Text::Hint("detecting"));
|
||||
} else if all.is_empty() {
|
||||
@@ -615,84 +688,140 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::empty_box(ui, "assistant_agents", tr("ai.prefs.no_agents"));
|
||||
}
|
||||
}
|
||||
for (index, entry) in all.iter().enumerate() {
|
||||
for entry in &all {
|
||||
ui.push_id(&entry.id, |ui| {
|
||||
if index > 0 {
|
||||
ui_kit::divider(ui, "agent_divider");
|
||||
}
|
||||
ui_kit::settings_block(ui, &entry.name, t.space_24 * 6.0 + t.space_6, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let is_default = default.as_deref() == Some(entry.id.as_str());
|
||||
let choice = ui_kit::button(
|
||||
ui,
|
||||
tr(if is_default { "ui.ai_default" } else { "ai.prefs.make_default" }),
|
||||
if is_default { ui_kit::Button::SmallSelected } else { ui_kit::Button::Small },
|
||||
);
|
||||
crate::scenario::publish_target(&format!("dialog:list:{}", entry.id), choice.rect);
|
||||
if choice.clicked() {
|
||||
app.preferences.agent_default = entry.id.clone();
|
||||
}
|
||||
let test_button = ui_kit::button(ui, tr("ui.ai_test"), ui_kit::Button::Small);
|
||||
crate::scenario::publish_handle(&format!("prefs:ai_test:{}", entry.id), test_button.rect.center());
|
||||
let tip = match entry.kind {
|
||||
Kind::Acp => "ui.ai_test_acp_tip",
|
||||
Kind::Api => "ui.ai_test_api_tip",
|
||||
Kind::Anthropic => "ui.ai_test_anthropic_tip",
|
||||
};
|
||||
if test_button.on_hover_text(tr(tip)).clicked() {
|
||||
test = Some(entry.clone());
|
||||
}
|
||||
if !entry.found {
|
||||
let edit_button = ui_kit::button(ui, tr("ui.ai_edit"), ui_kit::Button::Small);
|
||||
crate::scenario::publish_handle(&format!("prefs:ai_edit:{}", entry.id), edit_button.rect.center());
|
||||
if edit_button.on_hover_text(tr("ui.ai_edit_tip")).clicked() {
|
||||
edit = Some(entry.id.clone());
|
||||
}
|
||||
}
|
||||
if !entry.found && ui_kit::button(ui, tr("ui.ai_remove"), ui_kit::Button::Ghost).clicked() {
|
||||
remove = Some(entry.id.clone());
|
||||
}
|
||||
match entry.login {
|
||||
"login_found" => ui_kit::settings_badge(ui, tr("ui.agent_chat_login_found"), false),
|
||||
"signed_in" => ui_kit::settings_badge(ui, tr("ui.agent_chat_signed_in"), false),
|
||||
"key" => ui_kit::settings_badge(ui, tr("ui.agent_chat_key_in_env"), false),
|
||||
"signed_out" => ui_kit::settings_badge(ui, tr("ui.agent_chat_not_signed_in"), true),
|
||||
_ => {}
|
||||
}
|
||||
if !entry.found {
|
||||
ui_kit::settings_badge(ui, tr("ui.ai_added"), false);
|
||||
}
|
||||
});
|
||||
ui_kit::settings_short_note(ui, &entry.detail());
|
||||
// Wide enough for an endpoint's "Request timeout" or "Reasoning effort" too,
|
||||
// so all rows line up.
|
||||
let label_w = (t.form_label_width + t.space_16)
|
||||
.max(ui_kit::ContentWidth::label(ui, tr("ui.ai_request_timeout")) + t.space_16)
|
||||
.max(ui_kit::ContentWidth::label(ui, tr("ui.ai_reasoning_effort")) + t.space_16);
|
||||
ui_kit::settings_form(ui, label_w, |ui| {
|
||||
ui_kit::row(ui, tr("ui.ai_permissions"), |ui| {
|
||||
let current = app.preferences.agent_permissions.get(&entry.id).copied();
|
||||
let shown = current.map_or_else(|| tr("ui.ai_mode_global").to_owned(), |mode| mode.label().to_owned());
|
||||
let mut pick: Option<Option<Mode>> = None;
|
||||
ui_kit::dropdown_menu(ui, ("sc_ai_mode", &entry.id), &shown, |ui| {
|
||||
if ui_kit::list_picker(ui, "global", tr("ui.ai_mode_global"), current.is_none()).clicked() {
|
||||
pick = Some(None);
|
||||
}
|
||||
for mode in Mode::ALL {
|
||||
if ui_kit::list_picker(ui, mode.id(), mode.label(), current == Some(mode)).clicked() {
|
||||
pick = Some(Some(mode));
|
||||
let detail = entry.detail();
|
||||
let gate = crate::prefs_search::block_gate(ui, &entry.name, Some(&format!("{} {detail}", entry.id)));
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
let mut paint = |ui: &mut egui::Ui| {
|
||||
let card = ui_kit::settings_card(ui, |ui| {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
crate::prefs_search::visible(ui, &[&entry.name, &entry.id, &detail]);
|
||||
ui.label(egui::RichText::new(&entry.name).strong().size(t.font_label).color(t.form_ink));
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width(), t.control_height),
|
||||
egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true),
|
||||
|ui| {
|
||||
let is_default = default.as_deref() == Some(entry.id.as_str());
|
||||
let choice = ui_kit::button(
|
||||
ui,
|
||||
tr(if is_default { "ui.ai_default" } else { "ai.prefs.make_default" }),
|
||||
if is_default { ui_kit::Button::SmallSelected } else { ui_kit::Button::Small },
|
||||
);
|
||||
crate::scenario::publish_target(&format!("dialog:list:{}", entry.id), choice.rect);
|
||||
if choice.clicked() {
|
||||
app.preferences.agent_default = entry.id.clone();
|
||||
}
|
||||
}
|
||||
let test_button = ui_kit::button(ui, tr("ui.ai_test"), ui_kit::Button::Small);
|
||||
crate::scenario::publish_handle(&format!("prefs:ai_test:{}", entry.id), test_button.rect.center());
|
||||
let tip = match entry.kind {
|
||||
Kind::Acp => "ui.ai_test_acp_tip",
|
||||
Kind::Api => "ui.ai_test_api_tip",
|
||||
Kind::Anthropic => "ui.ai_test_anthropic_tip",
|
||||
};
|
||||
if test_button.on_hover_text(tr(tip)).clicked() {
|
||||
test = Some(entry.clone());
|
||||
}
|
||||
if !entry.found {
|
||||
let edit_button = ui_kit::button(ui, tr("ui.ai_edit"), ui_kit::Button::Small);
|
||||
crate::scenario::publish_handle(&format!("prefs:ai_edit:{}", entry.id), edit_button.rect.center());
|
||||
if edit_button.on_hover_text(tr("ui.ai_edit_tip")).clicked() {
|
||||
edit = Some(entry.id.clone());
|
||||
}
|
||||
}
|
||||
if !entry.found {
|
||||
let button = ui_kit::button(ui, tr("ui.ai_remove"), ui_kit::Button::Ghost);
|
||||
crate::scenario::publish_target(&format!("prefs:ai_remove:{}", entry.id), button.rect);
|
||||
if button.clicked() {
|
||||
remove = Some(entry.id.clone());
|
||||
}
|
||||
}
|
||||
match entry.login {
|
||||
"login_found" => ui_kit::settings_badge(ui, tr("ui.agent_chat_login_found"), false),
|
||||
"signed_in" => ui_kit::settings_badge(ui, tr("ui.agent_chat_signed_in"), false),
|
||||
"key" => ui_kit::settings_badge(ui, tr("ui.agent_chat_key_in_env"), false),
|
||||
"signed_out" => ui_kit::settings_badge(ui, tr("ui.agent_chat_not_signed_in"), true),
|
||||
_ => {}
|
||||
}
|
||||
if !entry.found {
|
||||
ui_kit::settings_badge(ui, tr("ui.ai_added"), false);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui_kit::settings_short_note(ui, &detail).on_hover_text(&entry.id);
|
||||
// Wide enough for an endpoint's "Request timeout" or "Reasoning effort" too,
|
||||
// so all rows line up.
|
||||
let label_w = [tr("ui.ai_permissions"), tr("ui.ai_request_timeout"), tr("ui.ai_reasoning_effort")]
|
||||
.iter()
|
||||
.map(|label| ui_kit::ContentWidth::label(ui, label) + t.space_4)
|
||||
.fold(0.0, f32::max);
|
||||
ui_kit::settings_form(ui, label_w, |ui| {
|
||||
ui_kit::row(ui, tr("ui.ai_permissions"), |ui| {
|
||||
let current = app.preferences.agent_permissions.get(&entry.id).copied();
|
||||
let shown = current.map_or_else(|| tr("ui.ai_mode_global").to_owned(), |mode| mode.label().to_owned());
|
||||
let mut pick: Option<Option<Mode>> = None;
|
||||
let options: Vec<_> = std::iter::once(tr("ui.ai_mode_global")).chain(Mode::ALL.map(Mode::label)).collect();
|
||||
crate::prefs_search::note_current(ui, &options.join(" "));
|
||||
let response = ui_kit::settings_control_width(ui, t.space_24 * 9.0 + t.space_4, |ui| {
|
||||
ui_kit::dropdown_menu(ui, ("sc_ai_mode", &entry.id), &shown, |ui| {
|
||||
if ui_kit::list_picker(ui, "global", tr("ui.ai_mode_global"), current.is_none()).clicked() {
|
||||
pick = Some(None);
|
||||
}
|
||||
for mode in Mode::ALL {
|
||||
if ui_kit::list_picker(ui, mode.id(), mode.label(), current == Some(mode)).clicked() {
|
||||
pick = Some(Some(mode));
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
let query = crate::prefs_search::query(ui);
|
||||
if let Some(option) = options
|
||||
.iter()
|
||||
.find(|option| option.to_lowercase() == query)
|
||||
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
|
||||
{
|
||||
crate::prefs_search::remember(ui, option, response.rect);
|
||||
}
|
||||
for option in options {
|
||||
crate::prefs_search::follow(ui, option, response.rect);
|
||||
}
|
||||
match pick {
|
||||
Some(Some(mode)) => {
|
||||
app.preferences.agent_permissions.insert(entry.id.clone(), mode);
|
||||
}
|
||||
Some(None) => {
|
||||
app.preferences.agent_permissions.remove(&entry.id);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
match pick {
|
||||
Some(Some(mode)) => {
|
||||
app.preferences.agent_permissions.insert(entry.id.clone(), mode);
|
||||
}
|
||||
Some(None) => {
|
||||
app.preferences.agent_permissions.remove(&entry.id);
|
||||
}
|
||||
None => {}
|
||||
if entry.kind == Kind::Api
|
||||
&& let Some(c) = app.preferences.agents.iter_mut().find(|c| c.id == entry.id)
|
||||
{
|
||||
ui_kit::settings_form(ui, label_w, |ui| {
|
||||
ui_kit::row(ui, tr("ui.ai_request_timeout"), |ui| {
|
||||
let mut secs = entry.request_timeout_s;
|
||||
if timeout_field(ui, &mut secs, &format!("prefs:ai_timeout:{}", entry.id)) {
|
||||
c.request_timeout_s = secs;
|
||||
retimed = Some((entry.id.clone(), secs));
|
||||
}
|
||||
});
|
||||
ui_kit::row(ui, tr("ui.ai_reasoning_effort"), |ui| {
|
||||
let mut effort = c.reasoning_effort.clone();
|
||||
if effort_field(ui, &mut effort, &format!("prefs:ai_reasoning:{}", entry.id)) {
|
||||
let effort = agents::reasoning_effort(&effort);
|
||||
c.reasoning_effort = effort.clone();
|
||||
reeffort = Some((entry.id.clone(), effort));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if app.preferences.agent_permissions.get(&entry.id) == Some(&Mode::All) {
|
||||
ui_kit::text(ui, tr("ai.prefs.override_all_warning"), ui_kit::Text::Report("agent_permissions_warning", true))
|
||||
.on_hover_text(tr("ui.ai_mode_all_warning"));
|
||||
}
|
||||
test_line(ui, &app.agent_chat.settings.tests.get(&entry.id));
|
||||
});
|
||||
if let Some(protocol) = entry.kind.protocol() {
|
||||
ui_kit::row(ui, tr("ui.ai_model"), |ui| {
|
||||
@@ -744,33 +873,26 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
});
|
||||
if entry.kind == Kind::Api
|
||||
&& let Some(c) = app.preferences.agents.iter_mut().find(|c| c.id == entry.id)
|
||||
{
|
||||
ui_kit::settings_form(ui, label_w, |ui| {
|
||||
ui_kit::row(ui, tr("ui.ai_request_timeout"), |ui| {
|
||||
let mut secs = entry.request_timeout_s;
|
||||
if timeout_field(ui, &mut secs, &format!("prefs:ai_timeout:{}", entry.id)) {
|
||||
c.request_timeout_s = secs;
|
||||
retimed = Some((entry.id.clone(), secs));
|
||||
}
|
||||
});
|
||||
ui_kit::row(ui, tr("ui.ai_reasoning_effort"), |ui| {
|
||||
let mut effort = c.reasoning_effort.clone();
|
||||
if effort_field(ui, &mut effort) {
|
||||
let effort = agents::reasoning_effort(&effort);
|
||||
c.reasoning_effort = effort.clone();
|
||||
reeffort = Some((entry.id.clone(), effort));
|
||||
}
|
||||
});
|
||||
});
|
||||
crate::scenario::publish_rect(&format!("prefs:ai_card:{}", entry.id), card.response.rect);
|
||||
let metadata = [&entry.name, &entry.id, &detail];
|
||||
if let Some(text) = metadata.iter().find(|text| crate::prefs_search::texts_hit(ui, &[*text])) {
|
||||
crate::prefs_search::remember(ui, text, card.response.rect);
|
||||
}
|
||||
if app.preferences.agent_permissions.get(&entry.id) == Some(&Mode::All) {
|
||||
ui_kit::text(ui, tr("ai.prefs.override_all_warning"), ui_kit::Text::Report("agent_permissions_warning", true))
|
||||
.on_hover_text(tr("ui.ai_mode_all_warning"));
|
||||
for text in metadata {
|
||||
crate::prefs_search::follow(ui, text, card.response.rect);
|
||||
}
|
||||
test_line(ui, &app.agent_chat.settings.tests.get(&entry.id));
|
||||
});
|
||||
};
|
||||
let show = gate.as_ref().is_none_or(|gate| gate.show);
|
||||
if show {
|
||||
paint(ui);
|
||||
} else {
|
||||
crate::prefs_search::run_hidden(ui, &entry.id, |ui| {
|
||||
paint(ui);
|
||||
});
|
||||
}
|
||||
if let Some(gate) = gate {
|
||||
crate::prefs_search::block_finish(ui, &gate, show, before);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -998,7 +1120,7 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
timeout_field(ui, &mut s.request_timeout_s, "ai_add:request_timeout");
|
||||
});
|
||||
ui_kit::row(ui, tr("ui.ai_reasoning_effort"), |ui| {
|
||||
effort_field(ui, &mut s.reasoning_effort);
|
||||
effort_field(ui, &mut s.reasoning_effort, "ai_add:reasoning_effort");
|
||||
});
|
||||
ui_kit::row(ui, tr("ui.ai_key"), |ui| {
|
||||
ui.add_enabled_ui(has_keychain, |ui| {
|
||||
@@ -1172,6 +1294,337 @@ mod card_tests {
|
||||
use crate::scenario::{self, Harness};
|
||||
use serde_json::json;
|
||||
|
||||
/// The fixed Preferences window scrolls even on a tall desktop. Use actual wheel input
|
||||
/// before clicking a card action whose published geometry may be outside the viewport.
|
||||
fn reveal_card_action(h: &mut Harness, handle: &str) -> Result<(), String> {
|
||||
for _ in 0..32 {
|
||||
let at = scenario::handle_at(handle).ok_or_else(|| format!("{handle}: the real card action is not published"))?;
|
||||
let body = scenario::rect_of("prefs:page_body").ok_or("Preferences has no scroll viewport")?;
|
||||
if body.contains(at) {
|
||||
return Ok(());
|
||||
}
|
||||
h.call("ui.scroll", json!({"x":body.center().x,"y":body.center().y,"delta":if at.y < body.top() {100} else {-100}}));
|
||||
h.frames(30);
|
||||
}
|
||||
Err(format!("{handle} could not be scrolled into the Preferences viewport"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_production_heading_and_sections_use_page_metrics() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.preferences.ai_features = false;
|
||||
h.app.agent_chat.detection = Some(Default::default());
|
||||
h.call("ui.resize", json!({"width":1600,"height":4000}));
|
||||
h.call("ui.prefs", json!({"open":"Assistant","theme":theme}));
|
||||
h.frames(4);
|
||||
let warning = scenario::rect_of("prefs:ai_mode:all:icon").unwrap();
|
||||
assert!(scenario::rect_of("prefs:ai_mode:all").unwrap().contains_rect(warning));
|
||||
let heading = scenario::rect_of("prefs:page_heading").unwrap();
|
||||
let body = scenario::rect_of("prefs:page_body").unwrap();
|
||||
assert!(body.contains_rect(heading));
|
||||
let mut page_font = false;
|
||||
let mut scope_visible = false;
|
||||
let mut privacy_visible = false;
|
||||
for _ in 0..24 {
|
||||
for shape in &h.shapes {
|
||||
crate::test_support::each_shape(&shape.shape, &mut |shape| {
|
||||
if let egui::Shape::Text(text) = shape {
|
||||
scope_visible |= text.galley.job.text == tr("ui.ai_modes_scope");
|
||||
privacy_visible |= text.galley.job.text == tr("ai.prefs.privacy_note");
|
||||
page_font |= text.galley.job.text == "Assistant"
|
||||
&& text
|
||||
.galley
|
||||
.job
|
||||
.sections
|
||||
.iter()
|
||||
.all(|section| section.format.font_id.size == crate::theme::Tokens::get().font_page_heading);
|
||||
}
|
||||
});
|
||||
}
|
||||
if scope_visible && privacy_visible {
|
||||
break;
|
||||
}
|
||||
h.call("ui.scroll", json!({"x":body.center().x,"y":body.center().y,"delta":-160}));
|
||||
h.frames(30);
|
||||
}
|
||||
assert!(page_font, "the production heading uses the page font");
|
||||
assert!(scope_visible, "the full localized permission scope remains visible without hovering, including with AI off");
|
||||
assert!(privacy_visible, "the localized privacy note remains visible without hovering, including with AI off");
|
||||
let mut previous: Option<f32> = None;
|
||||
for key in ["assistant_availability", "permissions", "agents", "panel", "assistant_connecting", "agent_pointer"] {
|
||||
let section = scenario::rect_of(&format!("prefs:section:{key}")).unwrap();
|
||||
assert!((section.left() - body.left()).abs() <= 1.0 && (section.right() - body.right()).abs() <= 1.0);
|
||||
assert!((section.height() - (crate::theme::Tokens::get().control_height + crate::theme::Tokens::get().space_2)).abs() <= 1.0);
|
||||
if let Some(bottom) = previous {
|
||||
assert!((section.top() - bottom - 38.0).abs() <= 1.0, "{theme}: {key} retains the page section rhythm");
|
||||
}
|
||||
previous = Some(scenario::rect_of(&format!("prefs:section:{key}:body")).unwrap().bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_speed_search_reveals_one_real_slider_in_all_enablement_states() {
|
||||
use crate::prefs_search::{nav, set_cursor};
|
||||
for theme in ["light", "dark"] {
|
||||
for (ai, show) in [(true, true), (true, false), (false, true), (false, false)] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.agent_chat.detection = Some(Default::default());
|
||||
h.app.preferences.ai_features = ai;
|
||||
h.app.ui.agent_cursor.show = show;
|
||||
h.app.ui.agent_cursor.speed = crate::agent_cursor::Speed::Times(3.5);
|
||||
h.call("ui.resize", json!({"width":1600,"height":1400}));
|
||||
h.call("ui.prefs", json!({"open":"General","theme":theme}));
|
||||
for query in ["Speed", "Pointer speed", "Motion speed"] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(6);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let matches: Vec<_> = hits.iter().enumerate().filter(|(_, hit)| hit.page == crate::prefs::PAGE_ASSISTANT).collect();
|
||||
assert_eq!(matches.len(), 1, "{theme} AI={ai} show={show}: {query} owns one editor");
|
||||
set_cursor(&h.ctx, matches[0].0);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(30);
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_ASSISTANT);
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
let slider = scenario::rect_of("prefs:agent_pointer_speed").unwrap();
|
||||
assert_eq!(slider, scenario::rect_of("dialog:slider:Pointer speed").unwrap());
|
||||
let body = scenario::rect_of("dialog:page_body").unwrap();
|
||||
assert!(body.contains_rect(slider), "{query} reveals the editor inside the page");
|
||||
assert!(scenario::handle_at("prefs:revealed").unwrap().distance(slider.center()) <= 1.0, "{query} reveals the slider itself");
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
let sliders: Vec<_> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, node)| node.role() == egui::accesskit::Role::Slider && node.label() == Some("Pointer speed"))
|
||||
.collect();
|
||||
assert_eq!(sliders.len(), 1);
|
||||
assert_eq!(sliders[0].1.is_disabled(), !(ai && show));
|
||||
assert_eq!(h.app.ui.agent_cursor.speed, crate::agent_cursor::Speed::Times(3.5));
|
||||
assert_eq!((h.app.preferences.ai_features, h.app.ui.agent_cursor.show), (ai, show));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_agent_metadata_has_one_card_target_and_keeps_fields_indexed() {
|
||||
use crate::prefs_search::{nav, set_cursor};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.agent_chat.detection = Some(Default::default());
|
||||
h.app.preferences.agent_connect_at_start = false;
|
||||
h.app.preferences.agents = vec![
|
||||
AgentConfig {
|
||||
id: "acp-current".into(),
|
||||
name: "Current fixture".into(),
|
||||
command: "solidcraft-fixture-agent --acp".into(),
|
||||
..Default::default()
|
||||
},
|
||||
AgentConfig {
|
||||
id: "api-public".into(),
|
||||
name: "Public endpoint".into(),
|
||||
kind: Kind::Api,
|
||||
base_url: "https://example.com/v1".into(),
|
||||
model: "public-model".into(),
|
||||
reasoning_effort: "high".into(),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
h.app.preferences.agent_default = "acp-current".into();
|
||||
h.app.agent_chat.agent_id = Some("acp-current".into());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let seen = calls.clone();
|
||||
h.app.services.agent_http = Some(Arc::new(move |_| {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
Err("fixture must not be queried".into())
|
||||
}));
|
||||
h.call("ui.resize", json!({"width":1600,"height":1800}));
|
||||
h.call("ui.prefs", json!({"open":"General","theme":theme}));
|
||||
for query in ["public", "api-public", "public-model"] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(6);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let matches: Vec<_> = hits.iter().enumerate().filter(|(_, hit)| hit.page == crate::prefs::PAGE_ASSISTANT).collect();
|
||||
assert_eq!(matches.len(), 1, "{query}: overlapping name/id/detail own one card stop");
|
||||
set_cursor(&h.ctx, matches[0].0);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(30);
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_ASSISTANT);
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
let card = scenario::rect_of("prefs:ai_card:api-public").unwrap();
|
||||
assert!(scenario::rect_of("dialog:page_body").unwrap().contains_rect(card));
|
||||
assert!(scenario::handle_at("prefs:revealed").unwrap().distance(card.center()) <= 1.0, "{query} reveals the same complete card");
|
||||
}
|
||||
for (query, handle) in [("Request timeout", "prefs:ai_timeout:api-public"), ("Reasoning effort", "prefs:ai_reasoning:api-public")] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(6);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let matches: Vec<_> =
|
||||
hits.iter().enumerate().filter(|(_, hit)| hit.page == crate::prefs::PAGE_ASSISTANT && hit.label == query).collect();
|
||||
assert_eq!(matches.len(), 1, "{query} keeps its independent setting target");
|
||||
set_cursor(&h.ctx, matches[0].0);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(30);
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
assert!(scenario::rect_of("dialog:page_body").unwrap().contains_rect(scenario::rect_of(handle).unwrap()));
|
||||
}
|
||||
h.app.prefs_window.filter = "Permissions".into();
|
||||
h.frames(6);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
assert!(
|
||||
hits.iter().filter(|hit| hit.page == crate::prefs::PAGE_ASSISTANT && hit.label == "Permissions").count() >= 2,
|
||||
"real per-agent Permissions remain independently indexed"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0, "ACP-current/API-noncurrent search never probes or tests an endpoint");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_off_keeps_dependent_controls_disabled_and_cancel_restores_the_pointer() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.agent_chat.detection = Some(Default::default());
|
||||
h.app.ui.agent_cursor.speed = crate::agent_cursor::Speed::Times(3.5);
|
||||
h.call("ui.resize", json!({"width":1600,"height":1800}));
|
||||
h.call("ui.prefs", json!({"open":"Assistant"}));
|
||||
h.app.preferences.ai_features = false;
|
||||
h.app.ui.agent_cursor.speed = crate::agent_cursor::Speed::Instant;
|
||||
h.frames(4);
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
for label in [
|
||||
tr("ui.ai_toolbar_button"),
|
||||
tr("ai.prefs.allow_control"),
|
||||
tr("ai.prefs.connect_apps"),
|
||||
tr("ai.prefs.background_commands"),
|
||||
tr("ai.prefs.connect_at_start"),
|
||||
tr("ai.prefs.open_at_start"),
|
||||
tr("ai.prefs.show_pointer"),
|
||||
tr("ai.prefs.follow_camera"),
|
||||
] {
|
||||
let nodes: Vec<_> =
|
||||
tree.nodes.iter().filter(|(_, node)| node.role() == egui::accesskit::Role::CheckBox && node.label() == Some(label)).collect();
|
||||
assert_eq!(nodes.len(), 1, "{label} is retained exactly once");
|
||||
assert!(nodes[0].1.is_disabled(), "{label} dims when AI is off");
|
||||
}
|
||||
assert!(tree.nodes.iter().any(|(_, node)| node.label() == Some(tr("ui.ai_show_features")) && !node.is_disabled()));
|
||||
assert!(h.app.agent_chat.detecting.is_none() && h.app.agent_chat.backend.is_none());
|
||||
h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap();
|
||||
assert!(h.app.preferences.ai_features);
|
||||
assert_eq!(h.app.ui.agent_cursor.speed, crate::agent_cursor::Speed::Times(3.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_limit_dropdown_preserves_values_and_seeds_only_on_transition() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.agent_chat.detection = Some(Default::default());
|
||||
h.app.preferences.agent_chat_turn_limit = 37;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1400}));
|
||||
h.call("ui.prefs", json!({"open":"Assistant"}));
|
||||
h.app.prefs_window.filter = tr("ui.agent_chat_turn_limit").into();
|
||||
h.frames(4);
|
||||
for limit in [1, 37, 199, 200] {
|
||||
h.app.preferences.agent_chat_turn_limit = limit;
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.preferences.agent_chat_turn_limit, limit, "rendering preserves every supported value");
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"prefs:agent_chat_turn_limit:mode"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("dialog:{}:{}", tr("ui.agent_chat_turn_limit"), tr("ui.agent_chat_turn_unlimited"))}})).unwrap();
|
||||
assert_eq!(h.app.preferences.agent_chat_turn_limit, 0);
|
||||
h.step(&json!({"click":{"handle":"prefs:agent_chat_turn_limit:mode"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("dialog:{}:{}", tr("ui.agent_chat_turn_limit"), tr("prefs.assistant_limited"))}})).unwrap();
|
||||
assert_eq!(h.app.preferences.agent_chat_turn_limit, gate::SEEDED_TURN_LIMIT);
|
||||
h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap();
|
||||
assert_eq!(h.app.preferences.agent_chat_turn_limit, 37);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_card_reads_the_key_only_for_test_and_removal_clears_remembered_state() {
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
#[derive(Default)]
|
||||
struct Keys {
|
||||
values: MemoryKeys,
|
||||
reads: AtomicUsize,
|
||||
}
|
||||
impl Secrets for Keys {
|
||||
fn get(&self, id: &str) -> Result<Option<String>, String> {
|
||||
self.reads.fetch_add(1, Ordering::SeqCst);
|
||||
self.values.get(id)
|
||||
}
|
||||
fn set(&self, id: &str, secret: &str) -> Result<(), String> {
|
||||
self.values.set(id, secret)
|
||||
}
|
||||
fn delete(&self, id: &str) -> Result<(), String> {
|
||||
self.values.delete(id)
|
||||
}
|
||||
}
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.agent_chat.detection = Some(Default::default());
|
||||
let keys = Arc::new(Keys::default());
|
||||
keys.set("api-public", "render-fixture-key").unwrap();
|
||||
h.app.services.secrets = Some(keys.clone());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
h.app.services.agent_http = Some(Arc::new(move |_| {
|
||||
let _ = tx.send(());
|
||||
Ok((200, br#"{"data":[{"id":"public-model"}]}"#.to_vec()))
|
||||
}));
|
||||
// An ACP default keeps the API fixture noncurrent; baseline capability polling is unchanged.
|
||||
h.app.preferences.agents = vec![
|
||||
AgentConfig {
|
||||
id: "acp-public".into(),
|
||||
name: "Public ACP".into(),
|
||||
command: "solidcraft-fixture-agent --acp".into(),
|
||||
..Default::default()
|
||||
},
|
||||
AgentConfig {
|
||||
id: "api-public".into(),
|
||||
name: "Public endpoint".into(),
|
||||
kind: Kind::Api,
|
||||
base_url: "https://example.com/v1".into(),
|
||||
model: "public-model".into(),
|
||||
key: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
h.app.preferences.agent_default = "acp-public".into();
|
||||
h.app.preferences.agent_last = "api-public".into();
|
||||
h.app.preferences.agent_permissions.insert("api-public".into(), Mode::Ask);
|
||||
h.app.preferences.agent_always_allow.insert("api-public".into(), vec!["view.fit".into()]);
|
||||
h.call("ui.resize", json!({"width":1600,"height":1400}));
|
||||
h.call("ui.prefs", json!({"open":"Assistant"}));
|
||||
h.app.prefs_window.filter = "Public endpoint".into();
|
||||
h.frames(4);
|
||||
assert_eq!(keys.reads.load(Ordering::SeqCst), 0, "rendering never retrieves a stored key");
|
||||
assert!(rx.try_recv().is_err(), "a noncurrent card does not probe or test its endpoint");
|
||||
assert!(h.widget("render-fixture-key").is_none());
|
||||
h.step(&json!({"click":{"handle":"prefs:ai_test:api-public"}})).unwrap();
|
||||
rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(keys.reads.load(Ordering::SeqCst), 1, "only the explicit Test action retrieves the key");
|
||||
h.step(&json!({"click":{"handle":"prefs:ai_remove:api-public"}})).unwrap();
|
||||
assert!(!h.app.preferences.agents.iter().any(|agent| agent.id == "api-public"));
|
||||
assert!(!h.app.preferences.agent_permissions.contains_key("api-public"));
|
||||
assert!(!h.app.preferences.agent_always_allow.contains_key("api-public"));
|
||||
assert!(!h.app.agent_chat.settings.tests.contains_key("api-public"));
|
||||
assert!(!h.app.agent_chat.login_results.contains_key("api-public"));
|
||||
assert!(h.app.preferences.agent_last.is_empty());
|
||||
assert_eq!(keys.values.get("api-public").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_model_warning_follows_endpoint_model_and_agent_selection() {
|
||||
let mut h = Harness::new();
|
||||
@@ -1305,6 +1758,7 @@ mod card_tests {
|
||||
h.app.preferences.agent_always_allow.insert("custom-1".into(), vec!["read".into()]);
|
||||
h.call("ui.prefs", json!({"open": "AI"}));
|
||||
h.frames(4);
|
||||
reveal_card_action(&mut h, "prefs:ai_edit:custom-1").unwrap();
|
||||
let edit = scenario::handle_at("prefs:ai_edit:custom-1").expect("an added agent has Edit");
|
||||
h.call("ui.click", json!({"x": edit.x, "y": edit.y}));
|
||||
h.frames(4);
|
||||
@@ -1663,6 +2117,7 @@ mod card_tests {
|
||||
});
|
||||
h.step(&json!({"call":"ui.prefs","params":{"open":"AI"}})).unwrap();
|
||||
h.frames(4);
|
||||
reveal_card_action(&mut h, "prefs:ai_model:api-1").unwrap();
|
||||
assert!(scenario::handle_at("prefs:ai_model:api-1").is_some());
|
||||
assert!(seen.lock().unwrap().is_empty(), "looking at the page asks nobody");
|
||||
|
||||
|
||||
@@ -2013,6 +2013,17 @@ fn the_card_of_a_running_endpoint_changes_the_chats_model() {
|
||||
script.report(models_reply("fast"));
|
||||
h.step(&json!({"call": "ui.prefs", "params": {"open": "AI"}})).unwrap();
|
||||
h.frames(4);
|
||||
// The permission cards put endpoint controls below the fixed page's viewport.
|
||||
for _ in 0..32 {
|
||||
let at = handle_at("prefs:ai_model:api-1").unwrap();
|
||||
let body = crate::scenario::rect_of("prefs:page_body").unwrap();
|
||||
if body.contains(at) {
|
||||
break;
|
||||
}
|
||||
h.call("ui.scroll", json!({"x":body.center().x,"y":body.center().y,"delta":if at.y < body.top() {100} else {-100}}));
|
||||
h.frames(30);
|
||||
}
|
||||
assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains(handle_at("prefs:ai_model:api-1").unwrap()));
|
||||
h.step(&json!({"click": {"handle": "prefs:ai_model:api-1"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(handle_at("prefs:ai_model:api-1:smart").is_some());
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn speed_slider(ui: &mut egui::Ui, speed: &mut Speed) -> egui::Response {
|
||||
1.0..=5.5,
|
||||
crate::ui_kit::Slider {
|
||||
step: Some(0.5),
|
||||
format: |v| if v > 5.25 { crate::i18n::tr_label("Instant").into() } else { Speed::Times(v).label() },
|
||||
format: |v| if v > 5.25 { crate::i18n::tr_label("Instant").into() } else { format!("{v:.2}x") },
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
@@ -272,6 +272,9 @@ fn batch_targets(steps: &[Value]) -> Vec<(Pos2, &'static str)> {
|
||||
/// The places a command's parameters pick, in the world: [x, y, z] points (faces, edges,
|
||||
/// vertices, positions), active-sketch [x, y] points, and a new sketch's plane.
|
||||
pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
|
||||
if solidcraft_engine::panels::commands::is_command(command) {
|
||||
return Vec::new();
|
||||
}
|
||||
let id = solidcraft_engine::find_command(command).map_or(command, |c| c.id);
|
||||
let mut out = Vec::new();
|
||||
if id == "sketch.create"
|
||||
|
||||
@@ -89,6 +89,10 @@ pub fn visual_template(text: &str) -> Cow<'_, str> {
|
||||
}
|
||||
|
||||
fn visual_line(line: &str, output: &mut String) {
|
||||
// Empty paragraphs have no bidi levels. Their separators are kept by screen_order.
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let info = unicode_bidi::ParagraphBidiInfo::new(line, None);
|
||||
let (levels, runs) = info.visual_runs(0..line.len());
|
||||
for run in runs {
|
||||
@@ -169,6 +173,9 @@ mod tests {
|
||||
#[test]
|
||||
fn each_line_is_its_own_paragraph() {
|
||||
assert_eq!(visual("שלום עולם\nworld"), "עולם שלום\nworld");
|
||||
assert_eq!(visual("\nשלום עולם\n\nworld\n"), "\nעולם שלום\n\nworld\n");
|
||||
assert_eq!(visual_template("שלום\n\n{count}"), "שלום\n\n{count}");
|
||||
assert_eq!(screen_order("\n\n"), "\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -770,7 +770,7 @@ fn browser_occurrence_clicks_fill_align_components_input() {
|
||||
h.app.start("solid.align");
|
||||
{
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.kind = crate::dialogs::Kind::Align { flip: false, components: true, axes: [true; 3] };
|
||||
d.kind = crate::dialogs::Kind::Align { flip: false, components: true, axes: [true; 3], angle: String::new() };
|
||||
d.inputs[0].label = "Components";
|
||||
d.active = 0;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ pub const DOCUMENTED: &[&str] = &[
|
||||
"joint.reach",
|
||||
"joint.rigid_group",
|
||||
"joint.screw",
|
||||
"layout.apply",
|
||||
"layout.get",
|
||||
"layout.reset",
|
||||
"layout.save",
|
||||
"layout.workspace.select",
|
||||
"library.apply",
|
||||
"library.export",
|
||||
"library.inspect",
|
||||
@@ -101,6 +106,21 @@ pub const DOCUMENTED: &[&str] = &[
|
||||
"motion.trace",
|
||||
"occurrence.ground",
|
||||
"occurrence.move",
|
||||
"panel.activate",
|
||||
"panel.close",
|
||||
"panel.collapse",
|
||||
"panel.dismiss",
|
||||
"panel.float",
|
||||
"panel.focus",
|
||||
"panel.list",
|
||||
"panel.move",
|
||||
"panel.native_close",
|
||||
"panel.open",
|
||||
"panel.peek",
|
||||
"panel.popout",
|
||||
"panel.reset_pair",
|
||||
"panel.resize",
|
||||
"panel.resize_pair",
|
||||
"parameters.change",
|
||||
"parameters.export",
|
||||
"parameters.graph",
|
||||
|
||||
@@ -176,7 +176,12 @@ fn ok(v: Value) -> Outcome {
|
||||
Outcome::Done(json!({"ok": true, "result": v}))
|
||||
}
|
||||
fn err(e: impl std::fmt::Display) -> Outcome {
|
||||
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
|
||||
let text = e.to_string();
|
||||
let mut reply = json!({"ok": false, "error": text});
|
||||
if let Some(reason) = solidcraft_engine::panels::commands::structured_error(&text) {
|
||||
reply["panel_error"] = reason;
|
||||
}
|
||||
Outcome::Done(reply)
|
||||
}
|
||||
fn wrap(r: Result<Value, String>) -> Outcome {
|
||||
match r {
|
||||
@@ -266,6 +271,10 @@ fn key_from(name: &str) -> Option<egui::Key> {
|
||||
/// Run one command as `engine.execute` does: through `app.run`, then the first-body fit and the
|
||||
/// camera the command asked for.
|
||||
fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result<Value, String> {
|
||||
if solidcraft_engine::panels::commands::is_command(id) {
|
||||
// No first-body meshing, camera-follow or fit tail for application layout.
|
||||
return app.run(id, params);
|
||||
}
|
||||
// User bindings live in the UI. A bridged agent needs the same key the menus show,
|
||||
// including null for a cleared binding; the standalone engine keeps its defaults.
|
||||
if id == "engine.commands" {
|
||||
@@ -379,6 +388,38 @@ fn batch(app: &mut SolveApp, p: &Value) -> Outcome {
|
||||
let Some(steps) = p.get("commands").and_then(Value::as_array).filter(|s| s.len() <= MAX_BATCH) else {
|
||||
return err(format!("engine.batch: commands must be an array of at most {MAX_BATCH} steps"));
|
||||
};
|
||||
let panels = steps.iter().filter(|step| command(step).is_some_and(solidcraft_engine::panels::commands::is_command)).count();
|
||||
if panels > 0 {
|
||||
if let Some(why) = batch_refusal(steps) {
|
||||
return err(why);
|
||||
}
|
||||
if let Some(refusal) = solidcraft_engine::batch_preflight::check(steps, app.session.param_check, &["view.pick"]) {
|
||||
return ok(refusal.reply(steps.len()));
|
||||
}
|
||||
let rollback = p.get("rollback").and_then(Value::as_bool).unwrap_or(true);
|
||||
if rollback {
|
||||
if panels != steps.len() {
|
||||
return err(solidcraft_engine::panels::commands::error_text(solidcraft_engine::panels::error(
|
||||
solidcraft_engine::panels::Rule::Capability,
|
||||
"atomic mixed document/layout batches are unavailable; use rollback:false or separate transactions",
|
||||
)));
|
||||
}
|
||||
return wrap(app.run_panel_batch(steps).map_err(solidcraft_engine::panels::commands::error_text));
|
||||
}
|
||||
// Explicit sequential mode has per-command atomicity and no document
|
||||
// checkpoint; previous application changes are truthfully retained.
|
||||
let mut results = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let result = command(step)
|
||||
.ok_or_else(|| "missing command".to_string())
|
||||
.and_then(|id| execute(app, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))));
|
||||
match result {
|
||||
Ok(result) => results.push(result),
|
||||
Err(error) => return ok(solidcraft_engine::panels::commands::sequential_failure(i, command(step), error, results)),
|
||||
}
|
||||
}
|
||||
return ok(json!({"completed":results.len(),"results":results,"undone":0}));
|
||||
}
|
||||
// (With the agent cursor shown, the pointer has already acted out a short summary of the
|
||||
// batch: `agent_cursor::intercept`.)
|
||||
if let Some(why) = batch_refusal(steps) {
|
||||
@@ -684,6 +725,30 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
if let Some(c) = steps.and_then(|steps| steps.iter().find_map(switches_design)) {
|
||||
return err(format!("engine.script unavailable: {c} switches to another design (send it on its own)"));
|
||||
}
|
||||
if let Some(steps) =
|
||||
steps.filter(|steps| steps.iter().any(|step| command(step).is_some_and(solidcraft_engine::panels::commands::is_command)))
|
||||
{
|
||||
if steps.len() > 100_000 {
|
||||
return err("script too long");
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let result = command(step)
|
||||
.ok_or_else(|| "missing command".to_string())
|
||||
.and_then(|id| execute(app, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))));
|
||||
match result {
|
||||
Ok(result) => results.push(result),
|
||||
Err(error) => {
|
||||
let mut reply = json!({"ok":false,"error":error,"step":i + 1});
|
||||
if let Some(reason) = solidcraft_engine::panels::commands::structured_error(&error) {
|
||||
reply["panel_error"] = reason;
|
||||
}
|
||||
return Outcome::Done(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok(json!(results));
|
||||
}
|
||||
let mut replaced = false;
|
||||
let r = app
|
||||
.session
|
||||
@@ -1466,6 +1531,166 @@ pub(crate) fn write_screenshot(image: &egui::ColorImage, path: &str) -> Result<(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn panel_command_app() -> crate::SolveApp {
|
||||
use crate::panel_runtime::*;
|
||||
let h = PanelMountHarness::new(false).unwrap();
|
||||
let catalogue = solidcraft_engine::panels::fixture_command_catalogue(h.runtime.layout().registry()).unwrap();
|
||||
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.enable_panel_runtime(h.runtime, Box::<FakeAppPanelServices>::default()).unwrap();
|
||||
app.panel_runtime_mut().unwrap().set_command_adapters(Box::new(catalogue), Box::new(FakeNativeAdapter::new(false))).unwrap();
|
||||
app
|
||||
}
|
||||
fn panel_control(app: &mut crate::SolveApp, method: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let (request, _) = super::ControlRequest::new(method, params);
|
||||
match super::handle(app, &egui::Context::default(), &request) {
|
||||
super::Outcome::Done(reply) => reply,
|
||||
_ => panic!("unexpected screenshot"),
|
||||
}
|
||||
}
|
||||
fn panel_steps() -> Vec<serde_json::Value> {
|
||||
vec![
|
||||
json!({"command":"panel.open","params":{"kind":"fixture.log","instance":"log","destination":{"type":"new_section","section":"logs","index":0,
|
||||
"location":{"type":"dock","zone":"left"},"size":{"width":280,"height":240}}}}),
|
||||
json!({"command":"panel.collapse","params":{"target":{"type":"instance","id":"log"},"collapsed":true}}),
|
||||
]
|
||||
}
|
||||
#[test]
|
||||
fn panel_control_single_atomic_batch_and_script_share_live_runtime_without_geometry_or_camera_work() {
|
||||
use solidcraft_engine::panels::ActionSink;
|
||||
let mut single = panel_command_app();
|
||||
let mut batch = panel_command_app();
|
||||
let mut script = panel_command_app();
|
||||
// A one-body design with a deliberately different camera exercises the
|
||||
// old first-body-fit condition, rather than making it vacuously false.
|
||||
for app in [&mut single, &mut batch, &mut script] {
|
||||
app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
app.cam.distance *= 1.5;
|
||||
app.session.mark_saved();
|
||||
}
|
||||
let doc = single.session.doc.clone();
|
||||
let model = single.session.model.state();
|
||||
let camera = single.cam;
|
||||
let meshes: Vec<_> = model.bodies.iter().map(|body| body.cached_mesh()).collect();
|
||||
let invariant = (single.session.document_id.clone(), single.session.revision, single.session.undo.len(), single.session.is_dirty());
|
||||
for step in panel_steps() {
|
||||
assert_eq!(panel_control(&mut single, "engine.execute", step)["ok"], true);
|
||||
}
|
||||
assert_eq!(panel_control(&mut batch, "engine.batch", json!({"commands":panel_steps(),"param_check":"strict"}))["ok"], true);
|
||||
assert_eq!(panel_control(&mut script, "engine.script", json!({"commands":panel_steps(),"param_check":"strict"}))["ok"], true);
|
||||
let live = single.panel_runtime().unwrap().layout().inspect();
|
||||
assert_eq!(batch.panel_runtime().unwrap().layout().layout(), &live.layout);
|
||||
assert_eq!(script.panel_runtime().unwrap().layout().layout(), &live.layout);
|
||||
assert_eq!(panel_control(&mut single, "engine.execute", json!({"command":"layout.get"}))["result"]["state"]["revision"], live.revision);
|
||||
assert!(crate::agent_cursor::picks(&single, "panel.open", &panel_steps()[0]["params"]).is_empty());
|
||||
assert_eq!(single.cam, camera);
|
||||
assert_eq!((single.session.document_id.clone(), single.session.revision, single.session.undo.len(), single.session.is_dirty()), invariant);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &single.session.doc));
|
||||
assert!(std::sync::Arc::ptr_eq(&model, &single.session.model.state()));
|
||||
for (old, new) in meshes.iter().zip(single.session.model.state().bodies.iter().map(|body| body.cached_mesh())) {
|
||||
match (old, new) {
|
||||
(Some(a), Some(b)) => assert!(std::sync::Arc::ptr_eq(a, &b)),
|
||||
(None, None) => {}
|
||||
_ => panic!("layout changed tessellation cache"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn panel_control_reports_structured_missing_capability_unknown_fields_and_lifecycle_batch_errors() {
|
||||
use solidcraft_engine::panels::ActionSink;
|
||||
let mut disabled = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
let reply = panel_control(&mut disabled, "engine.execute", json!({"command":"layout.get"}));
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert_eq!(reply["panel_error"]["rule"], "capability");
|
||||
let mut app = panel_command_app();
|
||||
for check in ["off", "warn", "strict"] {
|
||||
let reply = panel_control(&mut app, "engine.execute", json!({"command":"panel.list","params":{"unknown":true},"param_check":check}));
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert_eq!(reply["panel_error"]["rule"], "schema");
|
||||
assert_eq!(reply["param_check"], check);
|
||||
}
|
||||
panel_control(&mut app, "engine.execute", panel_steps()[0].clone());
|
||||
let before = app.panel_runtime().unwrap().layout().inspect();
|
||||
let reply = panel_control(
|
||||
&mut app,
|
||||
"engine.batch",
|
||||
json!({"commands":[
|
||||
{"command":"panel.close","params":{"target":{"type":"instance","id":"log"}}},
|
||||
{"command":"panel.open","params":{"kind":"fixture.log","instance":"log"}}]}),
|
||||
);
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert_eq!(reply["panel_error"]["rule"], "lifecycle");
|
||||
assert_eq!(app.panel_runtime().unwrap().layout().inspect(), before);
|
||||
}
|
||||
#[test]
|
||||
fn panel_control_catalogue_batches_and_scripts_retain_memory_and_refuse_unsupported_atomic_domains() {
|
||||
let mut app = panel_command_app();
|
||||
panel_control(&mut app, "engine.execute", panel_steps()[0].clone());
|
||||
let reply = panel_control(
|
||||
&mut app,
|
||||
"engine.batch",
|
||||
json!({"commands":[
|
||||
{"command":"layout.save","params":{"name":"Control saved"}},
|
||||
{"command":"layout.reset"}, {"command":"layout.apply","params":{"name":"Control saved"}}, {"command":"layout.get"}]}),
|
||||
);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert_eq!(reply["result"]["completed"], 4);
|
||||
assert_eq!(reply["result"]["results"][3]["state"]["layout"]["instances"][0]["id"], "log");
|
||||
let mixed = json!({"commands":[{"command":"layout.get"},{"command":"file.new"}]});
|
||||
let before = app.session.document_id.clone();
|
||||
let reply = panel_control(&mut app, "engine.batch", mixed);
|
||||
assert_eq!(reply["panel_error"]["rule"], "capability");
|
||||
assert_eq!(app.session.document_id, before);
|
||||
let reply = panel_control(
|
||||
&mut app,
|
||||
"engine.script",
|
||||
json!({"commands":[
|
||||
{"command":"layout.workspace.select","params":{"workspace":"fixture.other"}},
|
||||
{"command":"layout.apply","params":{"name":"Control saved"}}, {"command":"layout.get"}]}),
|
||||
);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert_eq!(reply["result"][2]["state"]["layout"]["workspace"], "fixture.other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_panel_params_match_single_atomic_sequential_and_script_routes() {
|
||||
let mut app = panel_command_app();
|
||||
let expected = app.run("panel.list", json!({})).unwrap();
|
||||
assert_eq!(app.run("panel.list", serde_json::Value::Null).unwrap(), expected);
|
||||
let single = panel_control(&mut app, "engine.execute", json!({"command":"panel.list","params":null}));
|
||||
assert_eq!(single["result"], expected);
|
||||
for (method, rollback) in [("engine.batch", true), ("engine.batch", false), ("engine.script", false)] {
|
||||
let reply = panel_control(
|
||||
&mut app,
|
||||
method,
|
||||
json!({"rollback":rollback,"commands":[
|
||||
{"command":"panel.list","params":null},{"command":"layout.get","params":null},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(reply["ok"], true, "{method}: {reply}");
|
||||
let results = if method == "engine.script" { &reply["result"] } else { &reply["result"]["results"] };
|
||||
assert_eq!(results[0], expected, "{method}");
|
||||
assert_eq!(results[1]["state"], expected["state"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_normal_document_params_remain_accepted_in_panel_containing_sequences() {
|
||||
for method in ["engine.batch", "engine.script"] {
|
||||
let mut app = panel_command_app();
|
||||
let previous = app.session.document_id.clone();
|
||||
let reply = panel_control(
|
||||
&mut app,
|
||||
method,
|
||||
json!({"rollback":false,"commands":[
|
||||
{"command":"file.new","params":null},{"command":"panel.list","params":null},{"command":"parameters.list","params":null},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert!(reply["result"].get("failed").is_none(), "{reply}");
|
||||
assert_ne!(app.session.document_id, previous);
|
||||
assert_eq!(app.panel_runtime().unwrap().environment().document.as_ref().unwrap().0, app.session.document_id);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn blur_control_validates_before_mutation_and_reports_no_gpu() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -135,19 +135,6 @@ pub(crate) fn changes_the_design(method: &str) -> bool {
|
||||
matches!(method, "engine.execute" | "command" | "engine.script" | "engine.batch")
|
||||
}
|
||||
|
||||
/// Whether `doc` is the design `session` shows, as it is or edited since (an undo step away).
|
||||
fn shows_design(session: &solidcraft_engine::Session, doc: &Arc<solidcraft_engine::doc::Document>) -> bool {
|
||||
Arc::ptr_eq(&session.doc, doc) || session.undo.iter().chain(&session.redo).any(|snap| Arc::ptr_eq(&snap.doc, doc))
|
||||
}
|
||||
|
||||
/// The reply to a request whose design left the window while it computed.
|
||||
fn design_replaced(label: &str) -> Value {
|
||||
json!({
|
||||
"ok": false,
|
||||
"error": format!("the design was replaced (another tab, or a file opened) while `{label}` computed; nothing was applied"),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a request computes off the window thread.
|
||||
enum Work {
|
||||
/// Evaluated ahead: once in, the request runs here. `doc` and `revision` are the design it
|
||||
@@ -503,16 +490,6 @@ impl SolveApp {
|
||||
self.control_job = None;
|
||||
return;
|
||||
}
|
||||
// The design it computes for has left the window (closed, another tab, a file opened):
|
||||
// refused now, however long its computation has left, so the requests behind it, which
|
||||
// are for the design shown, don't wait for it.
|
||||
let left = matches!(&job.work, Work::Ahead { doc, .. } if !shows_design(&self.session, doc));
|
||||
if left {
|
||||
if let Some(job) = self.control_job.take() {
|
||||
let _ = job.req.reply.send(design_replaced(&job.label));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if job.poll().is_none() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
return;
|
||||
@@ -526,8 +503,13 @@ impl SolveApp {
|
||||
let Work::Ahead { commands, doc, revision, tries, kept } = job.work else { return };
|
||||
let s = &self.session;
|
||||
let same = Arc::ptr_eq(&s.doc, &doc);
|
||||
if !shows_design(s, &doc) {
|
||||
let _ = job.req.reply.send(design_replaced(&job.label));
|
||||
// The design it was computed for is still this one, edited since (an undo step away).
|
||||
let edited = !same && s.undo.iter().chain(&s.redo).any(|snap| Arc::ptr_eq(&snap.doc, &doc));
|
||||
if !same && !edited {
|
||||
let _ = job.req.reply.send(json!({
|
||||
"ok": false,
|
||||
"error": format!("the design was replaced (another tab, or a file opened) while `{}` computed; nothing was applied", job.label),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!same || s.revision != revision) && tries < MAX_TRIES {
|
||||
|
||||
@@ -63,17 +63,6 @@ impl Deferred {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one design has computing off the window thread or waiting its turn. It belongs to that
|
||||
/// design: it waits with its tab while another is shown, holds up nothing there, and goes when
|
||||
/// the design closes (the worker runs on to its end and its result is dropped).
|
||||
#[derive(Default)]
|
||||
pub struct DocJobs {
|
||||
deferred: Option<Deferred>,
|
||||
queue: Vec<(String, Value)>,
|
||||
start_after_roll: Option<(String, u64)>,
|
||||
delete_check: Option<DeleteCheck>,
|
||||
}
|
||||
|
||||
/// How a command asked to run off the window thread goes.
|
||||
enum Start {
|
||||
/// It computes off the window thread.
|
||||
@@ -144,32 +133,6 @@ fn replaced_by_a_later_one(id: &str) -> bool {
|
||||
}
|
||||
|
||||
impl SolveApp {
|
||||
/// Swap the active design's [`DocJobs`] for `incoming`, the jobs of the design taking its
|
||||
/// place, and hand back the outgoing ones.
|
||||
pub(crate) fn swap_jobs(&mut self, incoming: DocJobs) -> DocJobs {
|
||||
DocJobs {
|
||||
deferred: std::mem::replace(&mut self.deferred, incoming.deferred),
|
||||
queue: std::mem::replace(&mut self.deferred_queue, incoming.queue),
|
||||
start_after_roll: std::mem::replace(&mut self.start_after_roll, incoming.start_after_roll),
|
||||
delete_check: std::mem::replace(&mut self.delete_check, incoming.delete_check),
|
||||
}
|
||||
}
|
||||
|
||||
/// The active design's revision moved from `before` to `after` only to refresh the views
|
||||
/// (a tab shown again): its jobs were not computed for an older design.
|
||||
pub(crate) fn jobs_view_changed(&mut self, before: u64, after: u64) {
|
||||
if let Some(job) = &mut self.deferred
|
||||
&& job.revision == before
|
||||
{
|
||||
job.revision = after;
|
||||
}
|
||||
if let Some(check) = &mut self.delete_check
|
||||
&& check.revision == before
|
||||
{
|
||||
check.revision = after;
|
||||
}
|
||||
}
|
||||
|
||||
/// A direct exit or editor switch supersedes any queued sketch exit. A late worker must
|
||||
/// never finish a different sketch that was opened while it computed.
|
||||
pub(crate) fn take_sketch_exit(&mut self) -> Option<String> {
|
||||
|
||||
@@ -102,6 +102,9 @@ const PROJECT_GEOMETRY: Accept =
|
||||
/// What Intersect cuts with the sketch plane, picked one by one: faces, model edges and the
|
||||
/// curves of other sketches (#10484); an edge or a sketch line gives the points where it crosses.
|
||||
const INTERSECT_GEOMETRY: Accept = EDGES | FACES | selection::SKETCH_LINES;
|
||||
/// What Align's From and To pick: model geometry, planes and the origin, and sketch points and
|
||||
/// circles (a frame the bodies follow, #6257).
|
||||
const ALIGN_PICKS: Accept = FACES | EDGES | selection::VERTICES | PLANES | selection::ORIGIN | selection::POINTS | CURVES;
|
||||
const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"];
|
||||
const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"];
|
||||
|
||||
@@ -288,6 +291,8 @@ crate::dialog_descriptor::dialog_descriptors! {
|
||||
components: bool,
|
||||
/// Point-to-point: which of the world X, Y and Z axes take the offset.
|
||||
axes: [bool; 3],
|
||||
/// A turn about the target's direction after the move (blank: none).
|
||||
angle: String,
|
||||
},
|
||||
/// Remove bodies from here on in the timeline.
|
||||
Remove,
|
||||
@@ -910,12 +915,14 @@ impl Dialog {
|
||||
}
|
||||
"solid.align" => {
|
||||
let components = s.selection.iter().any(|x| matches!(x, Sel::Occurrence { .. }));
|
||||
// Bodies also take sketch points and circles: a frame the bodies follow (#6257).
|
||||
let picks = if components { FACES | EDGES | selection::VERTICES | PLANES | selection::ORIGIN } else { ALIGN_PICKS };
|
||||
Dialog::new(
|
||||
Kind::Align { flip: false, components, axes: [true; 3] },
|
||||
Kind::Align { flip: false, components, axes: [true; 3], angle: String::new() },
|
||||
vec![
|
||||
SelInput::new(if components { "Components" } else { "Bodies" }, BODIES, true),
|
||||
SelInput::new("From", FACES | EDGES | selection::VERTICES | PLANES | selection::ORIGIN, false),
|
||||
SelInput::new("To", FACES | EDGES | selection::VERTICES | PLANES | selection::ORIGIN, false),
|
||||
SelInput::new("From", picks, false),
|
||||
SelInput::new("To", picks, false),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -5087,7 +5094,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
need(0, "faces")?;
|
||||
("face.delete", json!({"faces": face_points(0)}))
|
||||
}
|
||||
Kind::Align { flip, components, axes } => {
|
||||
Kind::Align { flip, components, axes, angle } => {
|
||||
need(0, if *components { "components" } else { "bodies" })?;
|
||||
need(1, "what to move from")?;
|
||||
need(2, "where to move it to")?;
|
||||
@@ -5107,6 +5114,17 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
for (i, key) in [(1, "from"), (2, "to")] {
|
||||
let snap = d.extra.get(&format!("{key}_snap")).and_then(Value::as_str).unwrap_or("picked");
|
||||
match (sels(d, i).first(), snap) {
|
||||
// A sketch point or circle is a frame whatever the snap: the bodies follow it
|
||||
// when it moves (#6257).
|
||||
(Some(sel @ (Sel::SketchPoint { .. } | Sel::SketchCurve { .. })), _) if !*components => {
|
||||
p[format!("{key}_frame")] = construct_ref(app, sel)?
|
||||
}
|
||||
// The joint origin the Joint dialog makes of the pick, found again by name.
|
||||
(Some(sel @ (Sel::Face { .. } | Sel::Edge { .. } | Sel::Vertex { .. })), "origin")
|
||||
if !*components && !matches!(sel, Sel::Vertex { body, .. } if body.is_empty()) =>
|
||||
{
|
||||
p[format!("{key}_frame")] = crate::dialogs_assembly::snap_of(s, sel).ok_or("this pick has no joint origin")?.param;
|
||||
}
|
||||
(Some(Sel::Face { point, .. }), "center") => p[key] = json!({"snap": "face_center", "at": pt(*point)}),
|
||||
(Some(Sel::Face { point, .. } | Sel::Edge { point, .. }), "circle") => {
|
||||
p[key] = json!({"snap": "circle_center", "at": pt(*point)})
|
||||
@@ -5118,9 +5136,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
(Some(Sel::Vertex { point, .. }), _) => p[key] = pt(*point),
|
||||
// A plane stays a plane, so the bodies follow it when it moves.
|
||||
(Some(Sel::Plane { name }), _) => p[format!("{key}_plane")] = json!(name),
|
||||
_ => return Err("pick faces, edges, vertices or planes".into()),
|
||||
_ => return Err("pick faces, edges, vertices, planes or sketch points".into()),
|
||||
}
|
||||
}
|
||||
// Frames and a turn are the bodies' (#6257); components align without them.
|
||||
if !*components && !angle.trim().is_empty() {
|
||||
p["angle"] = json!(angle.trim());
|
||||
}
|
||||
(if *components { "occurrence.align" } else { "solid.align" }, p)
|
||||
}
|
||||
Kind::Remove => {
|
||||
@@ -5901,7 +5923,7 @@ fn construct_picks(id: &str) -> Option<(&'static str, &'static str, &'static [(&
|
||||
),
|
||||
"construct.point.center" => ("construct.point.center", "POINT AT CENTER", &[("Circle or sphere", FACES | CURVE, "of")]),
|
||||
"construct.point.edge_plane" => {
|
||||
("construct.point.edge_plane", "POINT AT EDGE AND PLANE", &[("Edge", CURVE, "edge"), ("Plane", PLANE, "plane")])
|
||||
("construct.point.edge_plane", "POINT AT EDGE AND PLANE", &[("Edge", CURVE | AXES, "edge"), ("Plane", PLANE, "plane")])
|
||||
}
|
||||
"construct.point.along_path" => ("construct.point.along_path", "POINT ALONG PATH", &[("Path", CURVE, "path")]),
|
||||
"construct.plane.axis_point" => {
|
||||
|
||||
@@ -37,6 +37,53 @@ fn feature_inputs_reject_external_sketch_geometry() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_edge_and_plane_accepts_preselected_axes() {
|
||||
for (name, expected) in [("TestAxis", Vec3::new(20.0, 10.0, 0.0)), ("Z", Vec3::ZERO)] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("construct.axis.two_points", json!({"a":{"point":[20,10,-20]},"b":{"point":[20,10,20]},"name":"TestAxis"})).unwrap();
|
||||
let axis = Sel::Axis { name: name.into() };
|
||||
h.app.session.selection = vec![axis.clone(), Sel::Plane { name: "XY".into() }];
|
||||
let d = Dialog::for_command(&h.app, "construct.point.edge_plane").unwrap();
|
||||
assert_eq!(d.inputs[0].items, vec![axis], "{name}: the preselected axis fills Edge");
|
||||
let commands = apply_commands(&h.app, &d).unwrap();
|
||||
assert_eq!(commands[0].1["edge"], json!({"axis":name}));
|
||||
for (command, params) in commands {
|
||||
h.app.run(&command, params).unwrap();
|
||||
}
|
||||
let points = solidcraft_engine::view::construction_points(&h.app.session);
|
||||
assert_eq!(points.len(), 1);
|
||||
assert!(points[0].2.dist(expected) < 1e-9, "{name}: {:?}", points[0].2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_edge_and_plane_picks_a_construction_axis() {
|
||||
for browser in [true, false] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.tree.collapsed.insert("c0/features".into());
|
||||
h.app.run("construct.axis.two_points", json!({"a":{"point":[20,10,-20]},"b":{"point":[20,10,20]},"name":"TestAxis"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Plane { name: "XY".into() }];
|
||||
h.app.start("construct.point.edge_plane");
|
||||
h.frames(4);
|
||||
let axis = Sel::Axis { name: "TestAxis".into() };
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.candidate(&h.app.session, &Hit::Axis { name: "TestAxis".into() }), Some(axis.clone()));
|
||||
assert!(fits(d.inputs[0].accept, &Sel::Edge { body: "Body1".into(), index: 0, point: Vec3::ZERO }));
|
||||
assert!(fits(d.inputs[0].accept, &Sel::SketchCurve { id: "l1".into(), sketch: None }));
|
||||
let target = if browser { json!({"handle":"row:TestAxis"}) } else { json!({"world":[20,10,-20]}) };
|
||||
h.step(&json!({"click":target})).unwrap();
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, vec![axis], "browser: {browser}");
|
||||
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let points = solidcraft_engine::view::construction_points(&h.app.session);
|
||||
assert_eq!(points.len(), 1);
|
||||
assert!(points[0].2.dist(Vec3::new(20.0, 10.0, 0.0)) < 1e-9, "{:?}", points[0].2);
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
for (name, x) in [("A", 0), ("B", 30), ("C", 60), ("D", 90)] {
|
||||
|
||||
@@ -896,17 +896,25 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo
|
||||
});
|
||||
ui.add(egui::Label::new(crate::i18n::tr("ui.emboss_wrap_note")).wrap());
|
||||
}
|
||||
Kind::Align { flip, components, axes: [ax, ay, az] } => {
|
||||
Kind::Align { flip, components, axes: [ax, ay, az], angle } => {
|
||||
// Where on the picked face or edge to snap: as picked (a face turns to meet
|
||||
// the other, an edge at its middle), its centre, or a round one's centre.
|
||||
// the other, an edge at its middle), its center, a round one's center, or, for bodies,
|
||||
// the joint origin the Joint dialog makes of it (found again as the part changes, #6257).
|
||||
let snaps: &[(&str, &str)] = if *components {
|
||||
&[("As Picked", "picked"), ("Center", "center"), ("Circle Center", "circle")]
|
||||
} else {
|
||||
&[("As Picked", "picked"), ("Center", "center"), ("Circle Center", "circle"), ("Joint Origin", "origin")]
|
||||
};
|
||||
for (label, key) in [("From Snap", "from_snap"), ("To Snap", "to_snap")] {
|
||||
form_row(ui, label, |ui| {
|
||||
extra_choice(ui, &mut d.extra, key, key, &[("As Picked", "picked"), ("Center", "center"), ("Circle Center", "circle")]);
|
||||
extra_choice(ui, &mut d.extra, key, key, snaps);
|
||||
});
|
||||
}
|
||||
if *components {
|
||||
ui_kit::options(ui, "feature_options", &mut [("Flip", flip)]);
|
||||
} else {
|
||||
// A turn about the target's direction after the move (#6257).
|
||||
enter |= hint_row(ui, "Angle", angle, tr("none"));
|
||||
ui_kit::options(ui, "feature_options", &mut [("Flip", flip), ("X", ax), ("Y", ay), ("Z", az)]);
|
||||
}
|
||||
// A part inside a subassembly placed more than once sits where that shared
|
||||
|
||||
@@ -653,6 +653,8 @@ pub(crate) fn reopen(d: &mut Dialog, s: &Session, kind: &FeatureKind) {
|
||||
chamfer_angle,
|
||||
chamfer_depth,
|
||||
steep_flank,
|
||||
// Kept by the edit itself (`timeline.redefine`), not a dialog option.
|
||||
old_auto_size: _,
|
||||
} = kind
|
||||
else {
|
||||
return;
|
||||
|
||||
@@ -52,8 +52,6 @@ pub struct DocSlot {
|
||||
edit_in_place: Option<crate::edit_in_place::PartContext>,
|
||||
/// The dialog or tool it had open and its toolbar tab, kept while another design is shown.
|
||||
work: DocWork,
|
||||
/// What it has computing off the window thread or waiting its turn.
|
||||
jobs: crate::deferred::DocJobs,
|
||||
}
|
||||
|
||||
/// What a design was doing when its tab was left, with the "Keep dialogs and tools open when
|
||||
@@ -145,7 +143,6 @@ impl DocSlot {
|
||||
link_stamps: Vec::new(),
|
||||
edit_in_place: None,
|
||||
work: DocWork::default(),
|
||||
jobs: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +248,6 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
link_stamps: std::mem::replace(&mut app.docs.link_stamps, slot.link_stamps),
|
||||
edit_in_place: std::mem::replace(&mut app.edit_in_place.active, slot.edit_in_place),
|
||||
work,
|
||||
jobs: app.swap_jobs(slot.jobs),
|
||||
};
|
||||
// Only the displayed design owns the process-wide crash replay journal.
|
||||
app.session.crash_journal = out.session.crash_journal;
|
||||
@@ -271,7 +267,6 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
if let Some(pending) = &mut app.pending_feature_edit {
|
||||
pending.view_changed(before, app.session.revision);
|
||||
}
|
||||
app.jobs_view_changed(before, app.session.revision);
|
||||
put_work(app, slot.work, before);
|
||||
out
|
||||
}
|
||||
@@ -549,8 +544,6 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
|
||||
if let Some(a) = app.autosave.as_mut().and_then(|a| a.active.take()) {
|
||||
a.discard();
|
||||
}
|
||||
// What it had computing goes with it: the blank design waits for none of it.
|
||||
app.cancel_computing();
|
||||
app.big_operation = false;
|
||||
crate::sketch_text_move::cancel_drag(app);
|
||||
let rev = app.session.revision;
|
||||
|
||||
@@ -680,79 +680,6 @@ fn an_agent_command_is_refused_when_its_design_was_replaced() {
|
||||
assert!(h.app.session.doc.features.is_empty(), "nothing went into the new design");
|
||||
}
|
||||
|
||||
/// The tab of a design whose command is still computing closes: the requests behind it are for
|
||||
/// the design shown now, and don't wait for a computation that may take minutes. The worker is
|
||||
/// held open here by an agent command that never finishes on this frame's clock: it is refused
|
||||
/// on the next frame, whether or not its result has come in.
|
||||
#[test]
|
||||
fn an_agent_command_for_a_closed_design_is_refused_at_once_and_holds_up_nothing() {
|
||||
let (mut h, tx) = agent_harness();
|
||||
let hole = ask(&tx, "engine.execute", second_hole());
|
||||
h.frames(1);
|
||||
assert!(h.app.agent_computing().is_some());
|
||||
crate::documents::close(&mut h.app, 0, true);
|
||||
h.frames(1);
|
||||
let r = hole.try_recv().expect("refused on the next frame");
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("replaced")), "{r}");
|
||||
assert!(h.app.agent_computing().is_none());
|
||||
let next = ask(&tx, "engine.execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
|
||||
assert_eq!(reply_to(&mut h, &next)["ok"], true);
|
||||
assert_eq!(h.app.session.doc.features.len(), 1, "only the new design's box");
|
||||
}
|
||||
|
||||
/// A history command of a design closed while it computes (the last tab: a blank design takes its
|
||||
/// place), and those waiting behind it, go with it: the new design starts with nothing computing,
|
||||
/// its Extrude from a finished sketch doesn't wait for them, and their late result is dropped.
|
||||
#[test]
|
||||
fn a_design_closed_while_its_history_computes_holds_up_nothing() {
|
||||
let mut h = block_boss_hole();
|
||||
let (job, late) = crate::deferred::Deferred::never_done("timeline.roll_to", json!({"position": 1}), h.app.session.revision);
|
||||
h.app.deferred = Some(job);
|
||||
h.app.deferred_queue.push(("timeline.roll_to".into(), json!({"position": 2})));
|
||||
h.app.start_after_roll = Some(("solid.extrude".into(), 1));
|
||||
crate::documents::close(&mut h.app, 0, true);
|
||||
assert!(h.app.computing_since().is_none(), "the blank design has nothing computing");
|
||||
assert!(h.app.deferred.is_none() && h.app.deferred_queue.is_empty() && h.app.start_after_roll.is_none());
|
||||
h.app.home.open = false;
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})).unwrap();
|
||||
h.app.start("solid.extrude");
|
||||
assert!(h.app.start_after_roll.is_none(), "the extrude waits for nothing");
|
||||
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.extrude"));
|
||||
assert!(late.send(None).is_err(), "nobody is left to take the closed design's result");
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.extrude"));
|
||||
}
|
||||
|
||||
/// A design's history command stays with its tab: another tab doesn't wait for it and doesn't get
|
||||
/// its result, even once it is in. Shown again, the design picks it up.
|
||||
#[test]
|
||||
fn a_history_command_stays_with_its_tab() {
|
||||
let mut h = block_boss_hole();
|
||||
let (job, done) = crate::deferred::Deferred::never_done("timeline.roll_to", json!({"position": 1}), h.app.session.revision);
|
||||
h.app.deferred = Some(job);
|
||||
h.app.deferred_queue.push(("timeline.roll_to".into(), json!({"position": 2})));
|
||||
|
||||
crate::documents::new_tab(&mut h.app);
|
||||
assert_eq!(h.app.docs.active, 1);
|
||||
assert!(h.app.computing_since().is_none(), "the new tab has nothing computing");
|
||||
assert!(h.app.deferred.is_none() && h.app.deferred_queue.is_empty());
|
||||
// The other design's command finishes while this one is shown: nothing lands here.
|
||||
done.send(None).unwrap();
|
||||
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
h.app.run_heavy("timeline.roll_to", json!({"position": 0})).unwrap();
|
||||
until_settled(&mut h);
|
||||
assert_eq!(h.app.session.doc.features.len(), 1, "this tab's box and nothing else");
|
||||
assert_eq!(marker(&h), Some(0), "this tab's own marker move ran at once");
|
||||
|
||||
crate::documents::switch(&mut h.app, 0);
|
||||
assert_eq!(h.app.deferred_queue.len(), 1, "the first tab's command and the one behind it came back with it");
|
||||
assert!(h.app.computing_since().is_some());
|
||||
until_settled(&mut h);
|
||||
assert_eq!(marker(&h), Some(2), "they ran on the design they were asked for");
|
||||
}
|
||||
|
||||
/// Stop answers the agent at once, applies nothing, and lets the requests behind it go on.
|
||||
#[test]
|
||||
fn stopping_an_agent_command_applies_nothing_and_goes_on() {
|
||||
|
||||
@@ -338,7 +338,7 @@ fn hole_design_preferences_keep_both_controls_and_cancel_after_page_split() {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"language":"en", "open":"Tools"}));
|
||||
let page = rect_of("prefs:page_body").unwrap();
|
||||
for label in ["Snap to face and edge centers", "Move holes by clicking or dragging"] {
|
||||
for label in ["Snap to centers", "Move by click or drag"] {
|
||||
let mut point = None;
|
||||
for _ in 0..12 {
|
||||
point = h.output.accesskit_update.as_ref().and_then(|tree| {
|
||||
|
||||
@@ -1004,6 +1004,33 @@ mod tests {
|
||||
assert!(visible(&h.app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_near_and_far_fields_edit_coarse_and_fine_without_ordering_them() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", json!({"language":"en","theme":theme,"open":"Tools"}));
|
||||
for (query, key, text, expected) in
|
||||
[("Near-axis step", "joint_rotation_coarse", "0.025", (0.025, 0.1)), ("Far-axis step", "joint_rotation_fine", "12.5", (0.025, 12.5))]
|
||||
{
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
let target = format!("prefs:{key}");
|
||||
crate::prefs::reveal_preference(&mut h, &target);
|
||||
h.step(&json!({"click":{"handle":target},"double":true})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":text})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_eq!((h.app.preferences.joint_rotation_coarse, h.app.preferences.joint_rotation_fine), expected, "{theme}: {query}");
|
||||
}
|
||||
h.call("ui.prefs", json!({"button":"apply"}));
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert_eq!((h.app.preferences.joint_rotation_coarse, h.app.preferences.joint_rotation_fine), (10.0, 0.1));
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!((h.app.preferences.joint_rotation_coarse, h.app.preferences.joint_rotation_fine), (0.025, 12.5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_settings_persist_validate_and_read_old_preferences() {
|
||||
let mut app = SolveApp::new(Default::default(), Default::default());
|
||||
|
||||
+427
-160
@@ -613,59 +613,69 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let mut action: Option<(String, &str)> = None;
|
||||
ui_kit::scope(ui, |ui| {
|
||||
if !filtering {
|
||||
let actions = [
|
||||
ui_kit::SearchAction { label: app.tr_label("Reset All"), icon: None, visible: !app.keymap.read_only, tooltip: None },
|
||||
ui_kit::SearchAction { label: app.tr_label("Import…"), icon: Some("import_tray"), visible: !app.keymap.read_only, tooltip: None },
|
||||
ui_kit::SearchAction {
|
||||
label: app.tr_label("Export…"),
|
||||
icon: Some("export_tray"),
|
||||
visible: true,
|
||||
tooltip: Some(app.tr_label("Keys, S box favorites and the toolbar, as a file")),
|
||||
},
|
||||
];
|
||||
let (filter, toolbar_action) = ui_kit::search_actions_row(
|
||||
ui,
|
||||
egui::Id::new("sc_keymap_filter"),
|
||||
&mut app.keymap.filter,
|
||||
crate::i18n::tr_label("Filter commands or keys"),
|
||||
&actions,
|
||||
);
|
||||
crate::scenario::publish_target("keymap:filter", filter.rect);
|
||||
crate::scenario::publish_handle("shortcut:filter", filter.rect.center());
|
||||
if filter.clicked() {
|
||||
action = Some((String::new(), "cancel"));
|
||||
}
|
||||
if let Some(index) = toolbar_action {
|
||||
action = Some((
|
||||
String::new(),
|
||||
match index {
|
||||
0 => "reset_all",
|
||||
1 => "import",
|
||||
_ => "export",
|
||||
},
|
||||
));
|
||||
let t = crate::theme::Tokens::get();
|
||||
let edit_label = if app.keymap.read_only { app.tr_label("Edit shortcuts") } else { app.tr("install.done") };
|
||||
let actions_width = ui_kit::ContentWidth::button(ui, edit_label)
|
||||
+ ui_kit::icon_label_width(ui, app.tr("prefs.profile_export"), Some("export_tray"), false)
|
||||
+ if app.keymap.read_only {
|
||||
0.0
|
||||
} else {
|
||||
ui_kit::icon_label_width(ui, app.tr("prefs.shortcuts_import"), Some("import_tray"), false) + t.space_8
|
||||
}
|
||||
+ t.space_8 * 2.0;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_8;
|
||||
let width = (ui.available_width() - actions_width).max(t.form_label_width).min(ui.available_width());
|
||||
let filter = ui
|
||||
.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui_kit::search_field(
|
||||
ui,
|
||||
egui::Id::new("sc_keymap_filter"),
|
||||
&mut app.keymap.filter,
|
||||
crate::i18n::tr_label("Filter commands or keys"),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
crate::scenario::publish_target("keymap:filter", filter.rect);
|
||||
crate::scenario::publish_handle("shortcut:filter", filter.rect.center());
|
||||
if filter.clicked() {
|
||||
action = Some((String::new(), "cancel"));
|
||||
}
|
||||
if app.keymap.read_only {
|
||||
let edit = ui_kit::button(ui, app.tr_label("Edit shortcuts"), ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("keymap:edit", edit.rect);
|
||||
if edit.clicked() {
|
||||
app.keymap.read_only = false;
|
||||
}
|
||||
} else {
|
||||
let done = ui_kit::button(ui, app.tr("install.done"), ui_kit::Button::Primary);
|
||||
crate::scenario::publish_target("keymap:done", done.rect);
|
||||
if done.clicked() {
|
||||
action = Some((String::new(), "done"));
|
||||
}
|
||||
}
|
||||
let export = ui_kit::icon_label_button(ui, app.tr("prefs.profile_export"), "export_tray", true, false)
|
||||
.on_hover_text(app.tr_label("Keys, S box favorites and the toolbar, as a file"));
|
||||
crate::scenario::publish_target("keymap:export", export.rect);
|
||||
if export.clicked() {
|
||||
action = Some((String::new(), "export"));
|
||||
}
|
||||
if !app.keymap.read_only {
|
||||
let import = ui_kit::icon_label_button(ui, app.tr("prefs.shortcuts_import"), "import_tray", true, false);
|
||||
crate::scenario::publish_target("keymap:import", import.rect);
|
||||
if import.clicked() {
|
||||
action = Some((String::new(), "import"));
|
||||
}
|
||||
}
|
||||
});
|
||||
if app.keymap.read_only {
|
||||
ui_kit::text(ui, app.tr_label("Read-only list: shortcuts can't be changed here."), ui_kit::Text::Note("shortcut_read_only"));
|
||||
}
|
||||
ui_kit::checkbox(ui, app.tr("ui.sketch_keys_only"), &mut app.preferences.sketch_keys_only)
|
||||
.on_hover_text(app.tr("ui.sketch_keys_only_tip"));
|
||||
if let Some(chosen) = outcome(app, ui) {
|
||||
action = Some((String::new(), chosen));
|
||||
}
|
||||
ui_kit::text(ui, app.tr_label("Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation)."), ui_kit::Text::Note("navigation"));
|
||||
ui_kit::checkbox(ui, app.tr("ui.sketch_keys_only"), &mut app.preferences.sketch_keys_only)
|
||||
.on_hover_text(app.tr("ui.sketch_keys_only_tip"));
|
||||
}
|
||||
// The list Help and Home open looks like the Shortcuts page of Preferences: say it can't
|
||||
// be changed here, and lead to the page where it can (#7409).
|
||||
if !filtering && app.keymap.read_only {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui_kit::text(ui, app.tr_label("Read-only list: shortcuts can't be changed here."), ui_kit::Text::Note("shortcut_read_only"));
|
||||
let edit = ui_kit::button(ui, app.tr_label("Edit shortcuts"), ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("keymap:edit", edit.rect);
|
||||
if edit.clicked() {
|
||||
app.keymap.read_only = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if !filtering && !app.keymap.read_only {
|
||||
ui_kit::text(ui, app.tr("ui.shortcut_capture_hint"), ui_kit::Text::Note("shortcut_capture_help"));
|
||||
if app.keymap.capturing.is_some() {
|
||||
let hint = ctx.input(|input| app.keymap.recorder.hint(input));
|
||||
ui_kit::text(ui, app.tr(hint), ui_kit::Text::Hint("shortcut_capture_state"));
|
||||
@@ -676,41 +686,47 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let keys = keys_for_spec(app, c);
|
||||
let displayed: Vec<_> = keys.iter().map(|key| display(key)).collect();
|
||||
let label = app.tr_label(c.label);
|
||||
let group = shortcut_group(c);
|
||||
let hit = if filtering {
|
||||
crate::prefs_search::matches_text(&q, &[c.label, c.id, label].into_iter().chain(keys.iter().copied()).collect::<Vec<_>>())
|
||||
crate::prefs_search::shows_all(ui)
|
||||
|| crate::prefs_search::matches_text(
|
||||
&q,
|
||||
&[c.label, c.id, label, &group]
|
||||
.into_iter()
|
||||
.chain(keys.iter().copied())
|
||||
.chain(displayed.iter().map(String::as_str))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
q.is_empty() || [c.label, c.id, label].iter().chain(&keys).any(|s| s.to_lowercase().contains(&q))
|
||||
q.is_empty()
|
||||
|| [c.label, c.id, label, &group]
|
||||
.iter()
|
||||
.chain(&keys)
|
||||
.copied()
|
||||
.chain(displayed.iter().map(String::as_str))
|
||||
.any(|s| s.to_lowercase().contains(&q))
|
||||
};
|
||||
hit.then_some((c, keys, label))
|
||||
hit.then_some((c, keys, label, group))
|
||||
})
|
||||
.collect();
|
||||
if filtering && commands.is_empty() {
|
||||
return;
|
||||
}
|
||||
let count = commands.len();
|
||||
let shortcut_title = app.tr("prefs.shortcuts").to_string();
|
||||
let paint = |ui: &mut egui::Ui| {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let spacing = ui.spacing().item_spacing.x;
|
||||
let key_width = commands
|
||||
.iter()
|
||||
.map(|(_, keys, _)| keys.iter().map(|key| ui_kit::ContentWidth::button(ui, &display(key)) + spacing).sum::<f32>() + t.control_height)
|
||||
.chain(app.keymap.recorder.preview().filter(|_| app.keymap.capturing.is_some()).map(|text| ui_kit::ContentWidth::button(ui, &text)))
|
||||
.fold(ui_kit::ContentWidth::button(ui, app.tr_label("press a key…")), f32::max)
|
||||
.max(ui_kit::ContentWidth::label(ui, app.tr_label("Keyboard Shortcuts")) + t.space_8);
|
||||
let action_width = if app.keymap.read_only {
|
||||
t.control_height
|
||||
// Keep registry order within each real registry group; no command is lost because it
|
||||
// has no ribbon placement. Such commands use their registry id's namespace.
|
||||
let mut groups: Vec<(String, Vec<_>)> = Vec::new();
|
||||
for (c, keys, label, group) in commands {
|
||||
if let Some((_, rows)) = groups.iter_mut().find(|(name, _)| name == &group) {
|
||||
rows.push((c, keys, label));
|
||||
} else {
|
||||
ui_kit::ContentWidth::button(ui, app.tr_label("Clear")) + ui_kit::ContentWidth::button(ui, app.tr_label("Reset")) + t.space_4
|
||||
};
|
||||
let columns = [
|
||||
ui_kit::Column::new("Command", 1.5).flex().min_content_width(t.form_label_width),
|
||||
ui_kit::Column::new(crate::i18n::tr("keymap.command_id"), 1.8).flex().min_content_width(t.form_label_width),
|
||||
ui_kit::Column::new("Keyboard Shortcuts", 1.0).flex().min_content_width(key_width),
|
||||
ui_kit::Column::new("", 0.0).icon_only().min_content_width(action_width),
|
||||
];
|
||||
// A search shows a recording's outcome and its next step above the shortcuts it found.
|
||||
groups.push((group, vec![(c, keys, label)]));
|
||||
}
|
||||
}
|
||||
let shortcut_title = app.tr("prefs.shortcuts").to_string();
|
||||
let mut paint = |ui: &mut egui::Ui| {
|
||||
if filtering {
|
||||
if let Some(chosen) = outcome(app, ui) {
|
||||
action = Some((String::new(), chosen));
|
||||
@@ -720,76 +736,37 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::text(ui, app.tr(hint), ui_kit::Text::Hint("shortcut_capture_state"));
|
||||
}
|
||||
}
|
||||
ui_kit::table(ui, "keymap", &columns, crate::i18n::tr("keymap.empty"), |ui, table| {
|
||||
for (c, keys, translated_label) in commands {
|
||||
table.row_visible(ui, c.id, app.keymap.capturing.as_deref() == Some(c.id), false, |row| {
|
||||
row.cell(|ui| {
|
||||
ui_kit::text(ui, translated_label, ui_kit::Text::Cell);
|
||||
});
|
||||
row.cell(|ui| {
|
||||
ui_kit::text(ui, c.id, ui_kit::Text::Cell);
|
||||
});
|
||||
let capturing = app.keymap.capturing.as_deref() == Some(c.id);
|
||||
let changed = app.keymap.custom.contains_key(c.id);
|
||||
row.cell(|ui| {
|
||||
if app.keymap.read_only {
|
||||
let text = if keys.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
keys.iter().map(|key| display(key)).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
ui_kit::text(ui, &text, ui_kit::Text::Cell);
|
||||
return;
|
||||
}
|
||||
let tip = app.tr_label("Click, then press the new key");
|
||||
ui.horizontal(|ui| {
|
||||
// Each key replaces itself when clicked; the last slot adds one.
|
||||
for slot in 0..=keys.len() {
|
||||
let capture = if capturing && app.keymap.slot == slot {
|
||||
let text = app.keymap.recorder.preview().unwrap_or_else(|| app.tr_label("press a key…").to_string());
|
||||
ui_kit::button(ui, &text, ui_kit::Button::Normal).on_hover_text(tip)
|
||||
} else if let Some(key) = keys.get(slot) {
|
||||
ui_kit::button(ui, display(key), ui_kit::Button::Normal).on_hover_text(tip)
|
||||
} else {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(t.control_height, t.control_height), egui::Sense::hover());
|
||||
ui_kit::quiet_icon_button_at(ui, rect, "plus", tip)
|
||||
};
|
||||
let name = if slot == 0 { c.id.to_string() } else { format!("{}:{slot}", c.id) };
|
||||
crate::scenario::publish_target(&format!("keymap:capture:{name}"), capture.rect);
|
||||
if ui.is_rect_visible(capture.rect) {
|
||||
crate::scenario::publish_handle(&format!("shortcut:{name}"), capture.rect.center());
|
||||
}
|
||||
if capture.clicked() {
|
||||
capture.surrender_focus();
|
||||
action = Some((format!("{}\n{slot}", c.id), "capture"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
row.cell(|ui| {
|
||||
if !app.keymap.read_only {
|
||||
ui.horizontal(|ui| {
|
||||
let clear = ui_kit::button(ui, app.tr_label("Clear"), ui_kit::Button::Enabled(!keys.is_empty()));
|
||||
crate::scenario::publish_target(&format!("keymap:clear:{}", c.id), clear.rect);
|
||||
if clear.clicked() {
|
||||
action = Some((c.id.to_string(), "clear"));
|
||||
}
|
||||
let reset = ui_kit::button(ui, app.tr_label("Reset"), ui_kit::Button::Enabled(changed));
|
||||
crate::scenario::publish_target(&format!("keymap:reset:{}", c.id), reset.rect);
|
||||
if reset.clicked() {
|
||||
action = Some((c.id.to_string(), "reset"));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if count == 0 {
|
||||
ui_kit::text(ui, app.tr("keymap.empty"), ui_kit::Text::Hint("shortcuts_empty"));
|
||||
}
|
||||
for (group, rows) in &groups {
|
||||
ui_kit::section_heading(ui, group);
|
||||
let heading = crate::scenario::rect_of(&format!("dialog:heading:{group}"));
|
||||
if let Some(rect) = heading {
|
||||
crate::prefs_search::remember(ui, group, rect);
|
||||
crate::prefs_search::follow(ui, group, rect);
|
||||
}
|
||||
});
|
||||
for (command, keys, label) in rows {
|
||||
if let Some(chosen) = shortcut_row(app, ui, command, keys, label, filtering) {
|
||||
action = Some(chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if filtering {
|
||||
ui_kit::search_fold(ui, &shortcut_title, count, paint);
|
||||
let open = ui_kit::search_fold(ui, &shortcut_title, count, paint).is_some();
|
||||
if !open && let Some(rect) = crate::scenario::rect_of("prefs:search:shortcuts") {
|
||||
// Collapsed matches still participate in Up/Down and Enter. Enter lands on
|
||||
// the real group or command, where follow() reveals its complete row.
|
||||
for (group, rows) in &groups {
|
||||
crate::prefs_search::remember(ui, group, rect);
|
||||
for (command, keys, label) in rows {
|
||||
crate::prefs_search::remember(ui, &shortcut_search_label(command, label, keys), rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui_kit::settings_scroll(ui, "sc_keymap_scroll", paint);
|
||||
ui_kit::settings_scroll(ui, "sc_keymap_scroll", &mut paint);
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_count("shortcut_recording", f64::from(app.keymap.capturing.is_some()));
|
||||
@@ -808,7 +785,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
Some((id, "clear")) => drop(bind(app, &id, "", false)),
|
||||
Some((id, "reset")) => reset(app, Some(&id)),
|
||||
Some((_, "reset_all")) => reset(app, None),
|
||||
Some((_, "done")) => stop_capture(app),
|
||||
Some((_, "export")) => {
|
||||
let text = crate::toolbar_custom::export(app).to_string();
|
||||
app.pick_save("SolidCraft shortcuts.json", &["json"], move |app, path| {
|
||||
@@ -844,6 +821,174 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A global search result must be visible even after the page's own filter was used.
|
||||
pub(crate) fn reveal(app: &mut SolveApp) {
|
||||
app.keymap.filter.clear();
|
||||
}
|
||||
|
||||
fn shortcut_group(command: &CommandSpec) -> String {
|
||||
let group = if command.tab.is_empty() { command.id.split('.').next().unwrap_or(command.id) } else { command.tab };
|
||||
crate::workspace::tab_name(&group.to_uppercase())
|
||||
}
|
||||
|
||||
fn shortcut_search_label(command: &CommandSpec, label: &str, keys: &[&str]) -> String {
|
||||
let displayed: Vec<_> = keys.iter().map(|key| display(key)).collect();
|
||||
format!("{label} / {} ({}) · {} · {}", command.label, command.id, keys.join(", "), displayed.join(", "))
|
||||
}
|
||||
|
||||
/// Keep every binding and recorder slot, while skipping the construction of off-screen rows.
|
||||
fn shortcut_row(
|
||||
app: &SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
command: &CommandSpec,
|
||||
keys: &[&str],
|
||||
label: &str,
|
||||
filtering: bool,
|
||||
) -> Option<(String, &'static str)> {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let editing = !app.keymap.read_only;
|
||||
let capturing = app.keymap.capturing.as_deref() == Some(command.id);
|
||||
let mut slots: Vec<(usize, String, bool)> = keys.iter().enumerate().map(|(slot, key)| (slot, display(key), true)).collect();
|
||||
if slots.is_empty() || (editing && slots.len() < MAX_KEYS) {
|
||||
slots.push((slots.len(), String::new(), false));
|
||||
}
|
||||
for (slot, text, _) in &mut slots {
|
||||
if capturing && *slot == app.keymap.slot {
|
||||
*text = app.keymap.recorder.preview().unwrap_or_else(|| app.tr("prefs.shortcuts_press").to_owned());
|
||||
}
|
||||
}
|
||||
let widths: Vec<f32> = slots
|
||||
.iter()
|
||||
.map(|(slot, text, assigned)| {
|
||||
if capturing && *slot == app.keymap.slot {
|
||||
ui_kit::ContentWidth::button(ui, text)
|
||||
} else if *assigned {
|
||||
ui_kit::shortcut_binding_width(ui, text)
|
||||
} else if keys.is_empty() {
|
||||
ui_kit::shortcut_binding_width(ui, "")
|
||||
} else {
|
||||
t.control_height
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let width = ui.available_width().max(0.0);
|
||||
let actions_width = if editing { t.control_height * 2.0 + t.space_4 } else { 0.0 };
|
||||
let name_width = ui_kit::ContentWidth::label(ui, label).min(width * 0.45);
|
||||
let widest = widths.iter().copied().fold(0.0, f32::max);
|
||||
let beside = width - name_width - t.space_14 - actions_width - t.space_8 >= widest;
|
||||
let controls_width = (width - actions_width - t.space_8 - if beside { name_width + t.space_14 } else { 0.0 }).max(0.0);
|
||||
let mut lines = 1usize;
|
||||
let mut line_width = 0.0;
|
||||
for next in &widths {
|
||||
if line_width > 0.0 && line_width + t.space_8 + next > controls_width.max(widest) {
|
||||
lines += 1;
|
||||
line_width = 0.0;
|
||||
}
|
||||
line_width += if line_width > 0.0 { t.space_8 + next } else { *next };
|
||||
}
|
||||
let label_galley = ui.painter().layout(
|
||||
label.to_owned(),
|
||||
egui::FontId::proportional(t.font_label),
|
||||
t.form_ink,
|
||||
if beside { name_width.max(1.0) } else { width.max(1.0) },
|
||||
);
|
||||
let label_height = label_galley.size().y.max(t.control_height);
|
||||
let control_height = lines as f32 * t.control_height + lines.saturating_sub(1) as f32 * t.space_4;
|
||||
let height = if beside { label_height.max(control_height) } else { label_height + control_height + t.space_4 } + t.space_8;
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
|
||||
let search_label = shortcut_search_label(command, label, keys);
|
||||
crate::prefs_search::follow(ui, &search_label, rect);
|
||||
if !filtering && !capturing && !ui.is_rect_visible(rect) {
|
||||
return None;
|
||||
}
|
||||
if filtering {
|
||||
crate::prefs_search::reveal(ui);
|
||||
crate::prefs_search::push_force(ui);
|
||||
}
|
||||
let mut action = None;
|
||||
ui.push_id(command.id, |ui| {
|
||||
let name =
|
||||
egui::Rect::from_min_size(rect.min + egui::vec2(0.0, t.space_4), egui::vec2(if beside { name_width } else { width }, label_height));
|
||||
ui.painter().galley(egui::pos2(name.left(), name.center().y - label_galley.size().y / 2.0), label_galley, t.form_ink);
|
||||
let response = ui.interact(name, ui.id().with("command"), egui::Sense::hover()).on_hover_text(command.id);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), format!("{label} ({})", command.id)));
|
||||
let top = if beside { name.top() } else { name.bottom() + t.space_4 };
|
||||
let total = widths.iter().sum::<f32>() + widths.len().saturating_sub(1) as f32 * t.space_8;
|
||||
let left = if beside { rect.right() - actions_width - t.space_8 - total.min(controls_width) } else { rect.left() };
|
||||
let used_width = total.min(controls_width);
|
||||
let controls = egui::Rect::from_min_size(egui::pos2(left, top), egui::vec2(used_width, control_height));
|
||||
crate::scenario::publish_rect(&format!("keymap:bindings:{}", command.id), controls);
|
||||
let mut draw = |ui: &mut egui::Ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(used_width.max(widest), t.control_height),
|
||||
egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true),
|
||||
|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_8, t.space_4);
|
||||
for (slot, text, assigned) in &slots {
|
||||
let active = capturing && *slot == app.keymap.slot;
|
||||
let capture = if active {
|
||||
ui_kit::button(ui, text, ui_kit::Button::Primary)
|
||||
} else if *assigned {
|
||||
ui_kit::shortcut_binding(ui, text, editing)
|
||||
} else if keys.is_empty() {
|
||||
ui_kit::shortcut_binding(ui, "", editing)
|
||||
} else {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(t.control_height, t.control_height), egui::Sense::hover());
|
||||
ui_kit::quiet_icon_button_at(ui, r, "plus", app.tr_label("Click, then press the new key"))
|
||||
};
|
||||
if editing {
|
||||
let capture = capture.on_hover_text(app.tr_label("Click, then press the new key"));
|
||||
let name = if *slot == 0 { command.id.to_owned() } else { format!("{}:{slot}", command.id) };
|
||||
crate::scenario::publish_target(&format!("keymap:capture:{name}"), capture.rect);
|
||||
if ui.is_rect_visible(capture.rect) {
|
||||
crate::scenario::publish_handle(&format!("shortcut:{name}"), capture.rect.center());
|
||||
}
|
||||
if capture.clicked() {
|
||||
capture.surrender_focus();
|
||||
action = Some((format!("{}\n{slot}", command.id), "capture"));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(controls).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
|
||||
if widest > controls_width {
|
||||
egui::ScrollArea::horizontal().id_salt("binding_scroll").auto_shrink([false, true]).show(ui, &mut draw);
|
||||
} else {
|
||||
draw(ui);
|
||||
}
|
||||
});
|
||||
if editing {
|
||||
let actions = egui::Rect::from_min_size(egui::pos2(rect.right() - actions_width, top), egui::vec2(actions_width, t.control_height));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(actions).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_4;
|
||||
for (kind, icon, enabled, label) in [
|
||||
("reset", "undo", app.keymap.custom.contains_key(command.id), app.tr_label("Reset")),
|
||||
("clear", "close", !keys.is_empty(), app.tr_label("Clear")),
|
||||
] {
|
||||
let button = ui
|
||||
.add_enabled_ui(enabled, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(t.control_height, t.control_height), egui::Sense::hover());
|
||||
ui_kit::quiet_icon_button_at(ui, r, icon, label)
|
||||
})
|
||||
.inner;
|
||||
crate::scenario::publish_target(&format!("keymap:{kind}:{}", command.id), button.rect);
|
||||
if button.clicked() {
|
||||
action = Some((command.id.to_owned(), kind));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
crate::prefs_search::remember(ui, &search_label, rect);
|
||||
t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule);
|
||||
if filtering {
|
||||
crate::prefs_search::pop_force(ui);
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// A recording's outcome: its message, and for a key another command uses, Replace and Cancel
|
||||
/// (the one chosen).
|
||||
fn outcome(app: &SolveApp, ui: &mut egui::Ui) -> Option<&'static str> {
|
||||
@@ -1085,7 +1230,7 @@ mod tests {
|
||||
#[test]
|
||||
fn recorder_waits_for_late_modifiers_instead_of_binding_the_first_key_down() {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
a.keymap.capturing = Some("solid.chamfer".into());
|
||||
recorder_frame(
|
||||
&mut a,
|
||||
@@ -1121,9 +1266,9 @@ mod tests {
|
||||
#[test]
|
||||
fn recorder_previews_each_modifier_and_explains_the_next_step() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.keymap.filter = "solid.chamfer".into();
|
||||
h.call("ui.prefs", json!({"open":"Keyboard Shortcuts"}));
|
||||
h.step(&json!({"click":{"handle":"shortcut:filter"}})).unwrap();
|
||||
h.call("ui.text", json!({"text":"solid.chamfer"}));
|
||||
h.step(&json!({"click":{"handle":"shortcut:solid.chamfer"}})).unwrap();
|
||||
h.call("ui.key", json!({"key":"ShiftLeft","pressed":true,"shift":true}));
|
||||
assert!(h.widget("Shift+…").is_some(), "show Shift immediately, before any ordinary key");
|
||||
@@ -1134,7 +1279,7 @@ mod tests {
|
||||
h.call("ui.key", json!({"key":"SuperLeft","pressed":false,"shift":true}));
|
||||
assert!(h.widget("Shift+…").is_some(), "remove released modifiers before an ordinary key");
|
||||
h.call("ui.key", json!({"key":"ShiftLeft","pressed":false}));
|
||||
assert!(h.widget("press a key…").is_some());
|
||||
assert!(h.widget(h.app.tr("prefs.shortcuts_press")).is_some(), "the empty recorder explains the next step");
|
||||
assert_eq!(effective(&h.app, "solid.chamfer"), None);
|
||||
h.call("ui.key", json!({"key":"ShiftRight","pressed":true,"shift":true}));
|
||||
h.call("ui.key", json!({"key":"C","pressed":true,"shift":true}));
|
||||
@@ -1163,7 +1308,7 @@ mod tests {
|
||||
(Key::SuperLeft, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND, "Ctrl+C"),
|
||||
(Key::SuperRight, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND, "Ctrl+C"),
|
||||
] {
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut recorder = Recorder::default();
|
||||
for (time, events, complete) in [
|
||||
(0.0, vec![egui::Event::ModifiersChanged(mods), event(key, true, mods)], false),
|
||||
@@ -1193,7 +1338,7 @@ mod tests {
|
||||
#[test]
|
||||
fn recorder_accumulates_late_modifiers_and_settles_for_one_second() {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
a.keymap.capturing = Some("sketch.mirror".into());
|
||||
recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::M, true, egui::Modifiers::NONE)]);
|
||||
recorder_frame(&mut a, &ctx, 0.4, vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)]);
|
||||
@@ -1214,7 +1359,7 @@ mod tests {
|
||||
#[test]
|
||||
fn recorder_waits_indefinitely_for_modifier_only_input_and_for_release() {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
a.keymap.capturing = Some("sketch.mirror".into());
|
||||
let mods = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true };
|
||||
recorder_frame(&mut a, &ctx, 0.0, vec![egui::Event::ModifiersChanged(mods)]);
|
||||
@@ -1238,7 +1383,7 @@ mod tests {
|
||||
fn recorder_rejects_two_ordinary_keys_and_escape_preserves_the_old_binding() {
|
||||
for second in [egui::Key::N, egui::Key::Escape] {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
a.keymap.capturing = Some("solid.extrude".into());
|
||||
recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::M, true, egui::Modifiers::SHIFT)]);
|
||||
recorder_frame(&mut a, &ctx, 0.5, vec![event(second, true, egui::Modifiers::SHIFT)]);
|
||||
@@ -1251,7 +1396,7 @@ mod tests {
|
||||
#[test]
|
||||
fn recorder_conflicts_only_after_settling_and_plain_keys_still_work() {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
a.keymap.capturing = Some("solid.chamfer".into());
|
||||
recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::M, true, egui::Modifiers::NONE), event(egui::Key::M, false, egui::Modifiers::NONE)]);
|
||||
assert!(a.keymap.conflict.is_none());
|
||||
@@ -1269,7 +1414,7 @@ mod tests {
|
||||
#[test]
|
||||
fn recorder_cancels_on_focus_loss_and_dialog_close() {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
a.keymap.capturing = Some("solid.extrude".into());
|
||||
recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::K, true, egui::Modifiers::SHIFT)]);
|
||||
let raw = egui::RawInput { time: Some(0.5), focused: false, events: vec![egui::Event::WindowFocused(false)], ..Default::default() };
|
||||
@@ -1352,7 +1497,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_background_control_channel_recording_waits_for_an_actual_focus_loss() {
|
||||
let mut a = app();
|
||||
let ctx = egui::Context::default();
|
||||
let ctx = crate::fonts::test_context();
|
||||
a.keymap.capturing = Some("sketch.mirror".into());
|
||||
let raw =
|
||||
egui::RawInput { time: Some(0.0), focused: false, events: vec![event(egui::Key::M, true, egui::Modifiers::SHIFT)], ..Default::default() };
|
||||
@@ -1366,9 +1511,9 @@ mod tests {
|
||||
fn enter_and_space_are_recorded_without_reactivating_the_focused_button() {
|
||||
for key in ["Enter", "Space"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.keymap.filter = "sketch.mirror".into();
|
||||
h.call("ui.prefs", json!({"open": "Keyboard Shortcuts"}));
|
||||
h.step(&json!({"click":{"handle":"shortcut:filter"}})).unwrap();
|
||||
h.call("ui.text", json!({"text":"sketch.mirror"}));
|
||||
h.step(&json!({"click":{"handle":"shortcut:sketch.mirror"}})).unwrap();
|
||||
h.call("ui.key", json!({"key":key}));
|
||||
assert!(h.app.keymap.capturing.is_some(), "{key} should still be pending");
|
||||
@@ -1403,6 +1548,14 @@ mod tests {
|
||||
h.call("ui.resize", json!({"width": 1200, "height": 600}));
|
||||
h.call("ui.prefs", json!({"open": "Keyboard Shortcuts"}));
|
||||
h.frames(3);
|
||||
let filter = crate::scenario::rect_of("keymap:filter").unwrap();
|
||||
let mut previous = filter;
|
||||
for handle in ["keymap:done", "keymap:export", "keymap:import"] {
|
||||
let action = crate::scenario::rect_of(handle).unwrap();
|
||||
assert!((action.center().y - filter.center().y).abs() <= 1.0, "{handle}: shares the search row");
|
||||
assert!(action.left() >= previous.right(), "{handle}: no overlap");
|
||||
previous = action;
|
||||
}
|
||||
let commands = command_specs();
|
||||
let first = commands.first().unwrap().id;
|
||||
let last = commands.last().unwrap().id;
|
||||
@@ -1487,9 +1640,8 @@ mod tests {
|
||||
fn the_shortcuts_page_adds_a_second_key() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.keymap.filter = "solid.extrude".into();
|
||||
h.call("ui.prefs", json!({"open":"Keyboard Shortcuts"}));
|
||||
h.step(&json!({"click":{"handle":"shortcut:filter"}})).unwrap();
|
||||
h.call("ui.text", json!({"text":"solid.extrude"}));
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"shortcut:solid.extrude:1"}})).unwrap();
|
||||
assert_eq!(h.app.keymap.capturing.as_deref(), Some("solid.extrude"));
|
||||
@@ -1652,6 +1804,118 @@ mod card_tests {
|
||||
use super::*;
|
||||
use crate::scenario::{self, Harness};
|
||||
|
||||
#[test]
|
||||
fn preferences_shortcuts_uses_the_page_heading_and_right_aligned_profile_tag() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
h.call("ui.prefs", json!({"open":"Shortcuts","theme":theme}));
|
||||
h.frames(4);
|
||||
assert!(!h.app.keymap.read_only, "fresh Preferences retains its editable default");
|
||||
let heading = scenario::rect_of("prefs:page_heading").unwrap();
|
||||
let body = scenario::rect_of("prefs:page_body").unwrap();
|
||||
let tag = scenario::rect_of("prefs:in_profile").unwrap();
|
||||
assert!(body.contains_rect(heading));
|
||||
assert!((heading.right() - tag.right()).abs() <= 1.0, "the profile tag uses the production page's far-right alignment");
|
||||
let mut page_font = false;
|
||||
for shape in &h.shapes {
|
||||
crate::test_support::each_shape(&shape.shape, &mut |shape| {
|
||||
if let egui::Shape::Text(text) = shape {
|
||||
page_font |= text.galley.job.text == "Shortcuts"
|
||||
&& text
|
||||
.galley
|
||||
.job
|
||||
.sections
|
||||
.iter()
|
||||
.all(|section| section.format.font_id.size == crate::theme::Tokens::get().font_page_heading);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert!(page_font, "the actual Preferences title uses the page-heading font");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_keeps_editing_and_all_bindings_and_cancel_restores_the_bundle() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
bind_at(&mut h.app, "solid.extrude", Some(1), "Shift+J", false).unwrap();
|
||||
let original = crate::toolbar_custom::export(&h.app);
|
||||
h.app.keymap.filter = "solid.extrude".into();
|
||||
h.call("ui.prefs", json!({"open":"Shortcuts"}));
|
||||
h.step(&json!({"click":{"handle":"keymap:capture:solid.extrude:1"}})).unwrap();
|
||||
assert_eq!(h.app.keymap.capturing.as_deref(), Some("solid.extrude"));
|
||||
h.step(&json!({"click":{"handle":"keymap:done"}})).unwrap();
|
||||
assert!(h.app.keymap.capturing.is_none());
|
||||
assert!(!h.app.keymap.read_only, "Done stops capture without changing the independent edit state");
|
||||
assert_eq!(keys(&h.app, "solid.extrude"), ["E", "Shift+J"]);
|
||||
h.step(&json!({"click":{"handle":"keymap:clear:solid.extrude"}})).unwrap();
|
||||
assert!(keys(&h.app, "solid.extrude").is_empty());
|
||||
assert!(h.app.keymap.custom.contains_key("solid.extrude"), "Clear stores a disabled override");
|
||||
h.step(&json!({"click":{"handle":"keymap:reset:solid.extrude"}})).unwrap();
|
||||
assert_eq!(keys(&h.app, "solid.extrude"), ["E"]);
|
||||
assert!(!h.app.keymap.custom.contains_key("solid.extrude"), "Reset restores the registry default");
|
||||
h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap();
|
||||
assert_eq!(crate::toolbar_custom::export(&h.app), original, "Cancel restores the complete customization bundle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_page_defaults_stops_recording_without_resetting_favorites_or_toolbar() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.preferences.ai_features = false;
|
||||
bind_at(&mut h.app, "solid.extrude", Some(0), "Shift+J", false).unwrap();
|
||||
h.app.sbox.pinned = vec!["solid.box".into(), "solid.extrude".into()];
|
||||
h.app.toolbar_custom.panels.insert("DESIGN/MODEL/CREATE".into(), vec!["solid.extrude".into(), "solid.box".into()]);
|
||||
let favorites = h.app.sbox.pinned.clone();
|
||||
let toolbar = h.app.toolbar_custom.prefs();
|
||||
h.app.keymap.filter = "solid.extrude".into();
|
||||
h.call("ui.prefs", json!({"open":"Shortcuts"}));
|
||||
assert!(!h.app.keymap.custom.is_empty(), "the changed binding enables page defaults");
|
||||
h.step(&json!({"click":{"handle":"keymap:capture:solid.extrude"}})).unwrap();
|
||||
assert_eq!(h.app.keymap.capturing.as_deref(), Some("solid.extrude"));
|
||||
h.step(&json!({"key":"ShiftLeft","shift":true,"pressed":true})).unwrap();
|
||||
assert!(h.app.keymap.recorder.modifiers.shift, "the active recorder holds modifier state");
|
||||
h.step(&json!({"click":{"handle":"prefs:defaults"}})).unwrap();
|
||||
assert!(h.app.prefs_window.open && h.app.keymap.capturing.is_none());
|
||||
assert!(h.app.keymap.recorder.key.is_none());
|
||||
assert_eq!(h.app.keymap.recorder.modifiers, egui::Modifiers::NONE);
|
||||
assert!(h.app.keymap.custom.is_empty());
|
||||
assert_eq!(keys(&h.app, "solid.extrude"), ["E"]);
|
||||
assert_eq!(h.app.sbox.pinned, favorites);
|
||||
assert_eq!(h.app.toolbar_custom.prefs(), toolbar);
|
||||
h.step(&json!({"key":"ShiftLeft","pressed":false})).unwrap();
|
||||
h.step(&json!({"key":"K","pressed":true})).unwrap();
|
||||
h.step(&json!({"key":"K","pressed":false})).unwrap();
|
||||
h.frames(65);
|
||||
assert!(h.app.keymap.capturing.is_none() && h.app.keymap.custom.is_empty(), "the next chord cannot recreate an override after defaults");
|
||||
assert_eq!(keys(&h.app, "solid.extrude"), ["E"]);
|
||||
assert_eq!(h.app.sbox.pinned, favorites);
|
||||
assert_eq!(h.app.toolbar_custom.prefs(), toolbar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_saved_binding_keeps_its_own_recorder_slot() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
let bindings = ["E", "Shift+J", "Shift+K", "Shift+L", "Alt+J", "Alt+K", "Alt+L", "Ctrl+Alt+Shift+J"];
|
||||
for (slot, key) in bindings.iter().enumerate() {
|
||||
bind_at(&mut h.app, "solid.extrude", Some(slot), key, true).unwrap();
|
||||
}
|
||||
h.app.keymap.filter = "solid.extrude".into();
|
||||
h.call("ui.prefs", json!({"open":"Shortcuts"}));
|
||||
for slot in 1..MAX_KEYS {
|
||||
let handle = format!("keymap:capture:solid.extrude:{slot}");
|
||||
assert!(scenario::handle_at(&handle).is_some(), "binding {slot} remains reachable");
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"keymap:capture:solid.extrude:7"}})).unwrap();
|
||||
assert_eq!(h.app.keymap.slot, 7);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.prefs_window.open && h.app.keymap.capturing.is_none());
|
||||
assert_eq!(keys(&h.app, "solid.extrude"), bindings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_chord_column_fits_all_modifiers() {
|
||||
let mut h = Harness::new();
|
||||
@@ -1661,7 +1925,7 @@ mod card_tests {
|
||||
h.step(&json!({"click":{"handle":"keymap:capture:sketch.mirror"}})).unwrap();
|
||||
h.step(&json!({"key":"M","pressed":true,"shift":true,"cmd":true,"ctrl":true,"alt":true})).unwrap();
|
||||
let preview = h.app.keymap.recorder.preview().unwrap();
|
||||
let cell = scenario::rect_of("dialog:table:keymap:sketch.mirror:cell:Keyboard Shortcuts").unwrap();
|
||||
let cell = scenario::rect_of("keymap:bindings:sketch.mirror").unwrap();
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut needed = 0.0;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
@@ -1711,7 +1975,7 @@ mod card_tests {
|
||||
let answer = path.to_string_lossy().to_string();
|
||||
h.app.services.pick_save = Some(Box::new(move |_, _| Box::pin(std::future::ready(Some(answer.clone())))));
|
||||
vfs::scoped(Some(vfs::PathScope::new([root.clone()])), || {
|
||||
h.step(&json!({"widget":"Export…"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"keymap:export"}})).unwrap();
|
||||
h.frames(4);
|
||||
});
|
||||
let message = h.app.keymap.message.clone();
|
||||
@@ -1733,6 +1997,7 @@ mod card_tests {
|
||||
h.app.keymap.filter = "solid.extrude".into();
|
||||
h.call("ui.help", json!({"item":"shortcuts"}));
|
||||
assert!(h.app.keymap.read_only);
|
||||
assert!(scenario::rect_of("prefs:page_heading").is_none(), "Help retains its external read-only presentation");
|
||||
assert!(scenario::handle_at("keymap:filter").is_some());
|
||||
for action in ["capture", "clear", "reset"] {
|
||||
assert!(scenario::handle_at(&format!("keymap:{action}:solid.extrude")).is_none());
|
||||
@@ -1762,7 +2027,7 @@ mod card_tests {
|
||||
let answer = path.to_string_lossy().to_string();
|
||||
h.app.services.pick_open = Some(Box::new(move |_| Box::pin(std::future::ready(Some(answer.clone())))));
|
||||
vfs::scoped(Some(vfs::PathScope::new([root.clone()])), || {
|
||||
h.step(&json!({"widget":"Import…"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"keymap:import"}})).unwrap();
|
||||
h.frames(4);
|
||||
});
|
||||
let message = h.app.keymap.message.clone();
|
||||
@@ -1778,7 +2043,7 @@ mod card_tests {
|
||||
|
||||
/// #7409: the read-only list (Home › Keyboard shortcuts, Help › Keyboard Shortcuts) looked
|
||||
/// like the editable page with Import… missing. It says it is read-only, and Edit shortcuts
|
||||
/// turns the same page into the editable one: Import…, Reset All and recording are there.
|
||||
/// turns the same page into the editable one: Import, page defaults and recording are there.
|
||||
#[test]
|
||||
fn read_only_shortcuts_say_so_and_edit_shortcuts_unlocks_them() {
|
||||
for open in ["home", "help"] {
|
||||
@@ -1793,15 +2058,17 @@ mod card_tests {
|
||||
}
|
||||
h.frames(4);
|
||||
assert!(h.app.keymap.read_only, "{open}");
|
||||
assert!(scenario::rect_of("prefs:page_heading").is_none(), "{open}: external read-only style is retained");
|
||||
assert!(h.widget("Read-only list: shortcuts can't be changed here.").is_some(), "{open}");
|
||||
assert!(h.widget("Import…").is_none(), "{open}: no Import… while read-only");
|
||||
assert!(scenario::handle_at("keymap:capture:solid.extrude").is_none());
|
||||
h.step(&json!({"widget":"Edit shortcuts"})).unwrap();
|
||||
h.frames(4);
|
||||
assert!(!h.app.keymap.read_only, "{open}: editable after Edit shortcuts");
|
||||
assert!(scenario::rect_of("prefs:page_heading").is_some(), "{open}: Edit shortcuts enters the Preferences composition");
|
||||
assert!(h.widget("Read-only list: shortcuts can't be changed here.").is_none(), "{open}");
|
||||
assert!(h.widget("Edit shortcuts").is_none(), "{open}");
|
||||
assert!(h.widget("Import…").is_some() && h.widget("Reset All").is_some(), "{open}");
|
||||
assert!(scenario::handle_at("keymap:import").is_some() && h.widget("Restore page defaults").is_some(), "{open}");
|
||||
assert!(scenario::handle_at("keymap:capture:solid.extrude").is_some(), "{open}: a binding can be recorded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ pub mod network_prompt;
|
||||
pub mod op_auto;
|
||||
pub mod open_elsewhere;
|
||||
pub(crate) mod open_meshing;
|
||||
pub mod panel_runtime;
|
||||
pub mod params_dialog;
|
||||
pub mod personal_sheet_rules;
|
||||
pub(crate) mod pick_index;
|
||||
@@ -509,6 +510,8 @@ pub(crate) enum EscapeOwner {
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
/// Common panel foundation, internal opt-in; stays on the app across document tabs.
|
||||
pub(crate) panel_runtime: Option<panel_runtime::AppPanelRuntime>,
|
||||
pub blur: blur::State,
|
||||
pub(crate) viewport_status: ui_kit::StatusPresentation,
|
||||
pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture,
|
||||
@@ -784,6 +787,7 @@ impl SolveApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
solidcraft_engine::slicer_files::clean_old();
|
||||
let mut app = SolveApp {
|
||||
panel_runtime: None,
|
||||
viewport_status: Default::default(),
|
||||
blur: Default::default(),
|
||||
viewport_status_fixture: Default::default(),
|
||||
@@ -1159,6 +1163,9 @@ impl SolveApp {
|
||||
|
||||
/// Run a command programmatically (JSON parameters, no dialogs).
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
if solidcraft_engine::panels::commands::is_command(id) {
|
||||
return self.run_panel_command(id, ¶ms).map_err(solidcraft_engine::panels::commands::error_text);
|
||||
}
|
||||
if self.file_load.busy() || self.file_picker.is_some() || self.saving.is_some() {
|
||||
return Err("a file operation is in progress".into());
|
||||
}
|
||||
@@ -1456,8 +1463,6 @@ impl SolveApp {
|
||||
self.applying = None;
|
||||
self.editing_state = None;
|
||||
self.pending_feature_edit = None;
|
||||
// So does what it had computing: none of it may land in the new design.
|
||||
self.cancel_computing();
|
||||
self.tool = None;
|
||||
self.preview.reset();
|
||||
self.cam = Camera::default();
|
||||
@@ -1552,6 +1557,12 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
fn start_with_variant_memory(&mut self, id: &str, remember_variant: bool, opener: Option<egui::Rect>, section_edit: bool) {
|
||||
if solidcraft_engine::panels::commands::is_command(id) {
|
||||
if let Err(why) = self.run(id, json!({})) {
|
||||
self.set_status(why, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.tree.clear_folder_pick();
|
||||
if id == "timeline.play" {
|
||||
if let Err(why) = self.run(id, json!({})) {
|
||||
@@ -2796,6 +2807,7 @@ impl SolveApp {
|
||||
|
||||
/// Per-frame logic before layout.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
panel_runtime::tick_app(self);
|
||||
// Sleep/wake often skips frames without a device-lost signal; refresh egui GPU textures
|
||||
// when the frame clock jumps so the chrome is not stuck blank (#8749).
|
||||
let frame_time = ctx.input(|i| i.time);
|
||||
|
||||
@@ -494,7 +494,7 @@ fn navigation_board_metrics_and_state_colors_are_shared_in_both_themes() {
|
||||
}
|
||||
assert_eq!((NAV_BORDER, NAV_PADDING, NAV_GAP, NAV_BOTTOM, NAV_EDGE), (1.0, 3.0, 2.0, 7.0, 12.0));
|
||||
assert_eq!((NAV_SEPARATOR, NAV_SEPARATOR_HEIGHT, NAV_MENU_LEFT, NAV_MENU_RIGHT, NAV_MENU_GAP, NAV_CHEVRON), (9.0, 18.0, 7.0, 6.0, 5.0, 12.0));
|
||||
assert_eq!((NAV_STAGE_HEIGHT, NAV_STAGE_INSET, NAV_TILE_HEIGHT, NAV_TILE_ADD), (58.0, 12.0, 32.0, 22.0));
|
||||
assert_eq!((NAV_STAGE_HEIGHT, NAV_STAGE_INSET, NAV_TILE_HEIGHT, NAV_TILE_ADD), (96.0, 12.0, 32.0, 22.0));
|
||||
assert_eq!(
|
||||
(NAV_MARKER_HEIGHT, NAV_GHOST_OPACITY, NAV_TILE_DRAG_OPACITY, NAV_IDLE_OPACITY, NAV_FADE_SECONDS, NAV_TOOLTIP_DELAY),
|
||||
(24.0, 0.7, 0.55, 0.45, 0.12, 0.8)
|
||||
@@ -589,7 +589,9 @@ fn navigation_board_position_aliases_migrate_and_editor_alignment_follows_positi
|
||||
let bar = NavigationBar { size, ..bar.clone() };
|
||||
let preview = editor_layout(stage, &bar, |_| 0.0);
|
||||
let live = measure(stage, &bar, NavCaps::MODEL, |_| 0.0);
|
||||
assert_eq!(preview.rect.center().y, stage.center().y);
|
||||
assert_eq!(preview.rect.bottom(), stage.bottom() - NAV_BORDER - NAV_STAGE_INSET);
|
||||
let top = editor_layout(stage, &NavigationBar { edge: crate::navigation_bar::Edge::Top, ..bar.clone() }, |_| 0.0);
|
||||
assert_eq!(top.rect.top(), stage.top() + NAV_BORDER + NAV_STAGE_INSET);
|
||||
assert_eq!(preview.rect.height(), NavigationMetrics::for_size(size).height());
|
||||
match position {
|
||||
Position::Centre => assert_eq!(preview.rect.center().x, stage.center().x),
|
||||
|
||||
@@ -119,7 +119,12 @@ pub(crate) fn editor_layout(stage: Rect, prefs: &NavigationBar, label_width: imp
|
||||
Position::Centre => stage.center().x - bar.rect.width() / 2.0,
|
||||
Position::Right => stage.right() - ui_kit::NAV_BORDER - ui_kit::NAV_STAGE_INSET - bar.rect.width(),
|
||||
};
|
||||
let delta = pos2(x, stage.center().y - bar.rect.height() / 2.0) - bar.rect.min;
|
||||
// The same inset from the top or bottom edge as from the side, as in the live view.
|
||||
let y = match prefs.edge {
|
||||
navigation_bar::Edge::Top => stage.top() + ui_kit::NAV_BORDER + ui_kit::NAV_STAGE_INSET,
|
||||
navigation_bar::Edge::Bottom => stage.bottom() - ui_kit::NAV_BORDER - ui_kit::NAV_STAGE_INSET - bar.rect.height(),
|
||||
};
|
||||
let delta = pos2(x, y) - bar.rect.min;
|
||||
bar.rect = bar.rect.translate(delta);
|
||||
for (_, rect) in &mut bar.slots {
|
||||
*rect = rect.translate(delta);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::panels::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Later content adapters supply their own services. The app integration exposes
|
||||
/// only an immutable Session during refresh; a layout tick cannot run a document
|
||||
/// command. Explicit content commands stay on the separate supplied sink.
|
||||
pub trait AppPanelServices {
|
||||
fn refresh_read_only(&mut self, session: &Session) -> PanelResult<()>;
|
||||
fn services(&mut self) -> ContentServices<'_>;
|
||||
}
|
||||
|
||||
pub struct AppPanelRuntime {
|
||||
runtime: PanelRuntime,
|
||||
services: Box<dyn AppPanelServices>,
|
||||
last_error: Option<PanelError>,
|
||||
catalogue: Box<dyn commands::LayoutCatalogue>,
|
||||
native: Box<dyn NativeHostAdapter>,
|
||||
adapters_set: bool,
|
||||
}
|
||||
impl AppPanelRuntime {
|
||||
pub fn new(runtime: PanelRuntime, services: Box<dyn AppPanelServices>) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
services,
|
||||
last_error: None,
|
||||
catalogue: Box::<commands::InMemoryCatalogue>::default(),
|
||||
native: Box::new(UnavailableNative),
|
||||
adapters_set: false,
|
||||
}
|
||||
}
|
||||
pub fn last_error(&self) -> Option<&PanelError> {
|
||||
self.last_error.as_ref()
|
||||
}
|
||||
pub fn runtime(&self) -> &PanelRuntime {
|
||||
&self.runtime
|
||||
}
|
||||
/// Internal capability injection. Production preferences/native realization
|
||||
/// remain peer work. Installation is once-only and precedes native hosts.
|
||||
pub fn set_command_adapters(&mut self, catalogue: Box<dyn commands::LayoutCatalogue>, native: Box<dyn NativeHostAdapter>) -> PanelResult<()> {
|
||||
if self.adapters_set {
|
||||
return Err(error(Rule::Lifecycle, "application panel adapters are already installed"));
|
||||
}
|
||||
if self.runtime.layout().layout().sections.iter().any(|s| matches!(&s.location, SectionLocation::Window { .. })) {
|
||||
return Err(error(Rule::Lifecycle, "cannot replace adapters with live native hosts"));
|
||||
}
|
||||
self.catalogue = catalogue;
|
||||
self.native = native;
|
||||
self.adapters_set = true;
|
||||
Ok(())
|
||||
}
|
||||
/// Actual application boundaries refresh the current document/model context
|
||||
/// even when no frame tick ran between two sequential programmatic steps.
|
||||
fn refresh_context(&mut self, session: &Session) -> PanelResult<()> {
|
||||
self.services.refresh_read_only(session)?;
|
||||
let mut environment = self.runtime.environment().clone();
|
||||
environment.document = Some(DocumentId::new(session.document_id.clone())?);
|
||||
let services = self.services.services();
|
||||
self.runtime.set_environment(environment, services.local)?;
|
||||
self.runtime.refresh_context(services.models, services.local)
|
||||
}
|
||||
pub fn command(&mut self, session: &Session, id: &str, params: &serde_json::Value) -> PanelResult<serde_json::Value> {
|
||||
self.refresh_context(session)?;
|
||||
let registry = self.runtime.layout().registry().clone();
|
||||
let mut services = self.services.services();
|
||||
let mut sink = RuntimeActionSink { runtime: &mut self.runtime, native: self.native.as_mut(), services: &mut services };
|
||||
commands::execute(
|
||||
id,
|
||||
params,
|
||||
&mut commands::PanelCommandContext {
|
||||
sink: &mut sink,
|
||||
registry: ®istry,
|
||||
catalogue: self.catalogue.as_mut(),
|
||||
web: cfg!(target_arch = "wasm32"),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn command_batch(&mut self, session: &Session, steps: &[serde_json::Value]) -> PanelResult<serde_json::Value> {
|
||||
// Unsupported intent mixtures refuse before even context lifecycle or
|
||||
// availability publication; supported calls then read current context.
|
||||
commands::preflight_batch(steps, &self.runtime.layout().layout().workspace)?;
|
||||
self.refresh_context(session)?;
|
||||
let registry = self.runtime.layout().registry().clone();
|
||||
let mut services = self.services.services();
|
||||
let mut sink = RuntimeActionSink { runtime: &mut self.runtime, native: self.native.as_mut(), services: &mut services };
|
||||
commands::execute_batch(
|
||||
steps,
|
||||
&mut commands::PanelCommandContext {
|
||||
sink: &mut sink,
|
||||
registry: ®istry,
|
||||
catalogue: self.catalogue.as_mut(),
|
||||
web: cfg!(target_arch = "wasm32"),
|
||||
},
|
||||
)
|
||||
}
|
||||
/// All forwarding borrows the exact provider injected at enablement.
|
||||
fn with_services<R>(&mut self, use_services: impl FnOnce(&mut PanelRuntime, &mut ContentServices<'_>) -> PanelResult<R>) -> PanelResult<R> {
|
||||
let mut services = self.services.services();
|
||||
use_services(&mut self.runtime, &mut services)
|
||||
}
|
||||
pub fn dispatch(&mut self, session: &Session, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
self.refresh_context(session)?;
|
||||
let mut services = self.services.services();
|
||||
RuntimeActionSink { runtime: &mut self.runtime, native: self.native.as_mut(), services: &mut services }.dispatch(action)
|
||||
}
|
||||
pub fn mount(&mut self, resolved: &ResolvedLayout, host: &mut dyn PanelHost) -> PanelResult<()> {
|
||||
self.with_services(|runtime, services| runtime.mount(resolved, host, services))
|
||||
}
|
||||
pub fn content_action(&mut self, session: &Session, instance: &InstanceId, action: &ControlId) -> PanelResult<()> {
|
||||
self.refresh_context(session)?;
|
||||
self.with_services(|runtime, services| runtime.content_action(instance, action, services))
|
||||
}
|
||||
pub fn tick(&mut self, session: &Session) -> PanelResult<()> {
|
||||
let refresh = self.services.refresh_read_only(session);
|
||||
let mut environment = self.runtime.environment().clone();
|
||||
environment.document = Some(DocumentId::new(session.document_id.clone())?);
|
||||
let mut services = self.services.services();
|
||||
self.runtime.set_environment(environment, services.local)?;
|
||||
let update = self.runtime.update(&mut services);
|
||||
refresh.and(update)
|
||||
}
|
||||
/// Explicit teardown preflights all owners before any controller is destroyed.
|
||||
pub fn shutdown(&mut self) -> PanelResult<()> {
|
||||
if self.runtime.layout().layout().sections.iter().any(|section| matches!(§ion.location, SectionLocation::Window { .. })) {
|
||||
return Err(error(Rule::Lifecycle, "redock native panels before disabling their application adapter"));
|
||||
}
|
||||
let services = self.services.services();
|
||||
self.runtime.shutdown(services.local)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::SolveApp {
|
||||
pub fn run_panel_command(&mut self, id: &str, params: &serde_json::Value) -> PanelResult<serde_json::Value> {
|
||||
// Parse even without a runtime: hostile/unknown fields never become a
|
||||
// misleading capability success or reach the document command path.
|
||||
match self.panel_runtime.as_mut() {
|
||||
Some(integration) => integration.command(&self.session, id, params),
|
||||
None => {
|
||||
commands::parse_command(id, params)?;
|
||||
Err(error(Rule::Capability, "application panel runtime is not enabled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn run_panel_batch(&mut self, steps: &[serde_json::Value]) -> PanelResult<serde_json::Value> {
|
||||
self.panel_runtime
|
||||
.as_mut()
|
||||
.ok_or_else(|| error(Rule::Capability, "application panel runtime is not enabled"))?
|
||||
.command_batch(&self.session, steps)
|
||||
}
|
||||
/// Internal opt-in only: no preference, control method, command or default change.
|
||||
pub fn enable_panel_runtime(&mut self, runtime: PanelRuntime, services: Box<dyn AppPanelServices>) -> PanelResult<()> {
|
||||
if self.panel_runtime.is_some() {
|
||||
return Err(error(Rule::Lifecycle, "panel runtime already enabled"));
|
||||
}
|
||||
let mut integration = AppPanelRuntime::new(runtime, services);
|
||||
integration.tick(&self.session)?;
|
||||
self.panel_runtime = Some(integration);
|
||||
Ok(())
|
||||
}
|
||||
pub fn panel_runtime(&self) -> Option<&PanelRuntime> {
|
||||
self.panel_runtime.as_ref().map(|integration| &integration.runtime)
|
||||
}
|
||||
pub fn panel_runtime_mut(&mut self) -> Option<&mut AppPanelRuntime> {
|
||||
self.panel_runtime.as_mut()
|
||||
}
|
||||
pub fn dispatch_panel_layout(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.dispatch(&self.session, action)
|
||||
}
|
||||
pub fn mount_panels(&mut self, resolved: &ResolvedLayout, host: &mut dyn PanelHost) -> PanelResult<()> {
|
||||
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.mount(resolved, host)
|
||||
}
|
||||
pub fn panel_content_action(&mut self, instance: &InstanceId, action: &ControlId) -> PanelResult<()> {
|
||||
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.content_action(&self.session, instance, action)
|
||||
}
|
||||
pub fn tick_panel_runtime(&mut self) -> PanelResult<()> {
|
||||
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.tick(&self.session)
|
||||
}
|
||||
pub fn disable_panel_runtime(&mut self) -> PanelResult<()> {
|
||||
if let Some(integration) = &mut self.panel_runtime {
|
||||
integration.shutdown()?;
|
||||
}
|
||||
self.panel_runtime = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// No platform window is claimed without an injected native implementation.
|
||||
struct UnavailableNative;
|
||||
impl NativeHostAdapter for UnavailableNative {
|
||||
fn capabilities(&self) -> HostCapabilities {
|
||||
HostCapabilities::default()
|
||||
}
|
||||
fn prepare(&self, effects: &[HostEffect]) -> PanelResult<()> {
|
||||
if effects
|
||||
.iter()
|
||||
.any(|effect| matches!(effect, HostEffect::CreateNative { .. } | HostEffect::RebindNative { .. } | HostEffect::ReleaseNative { .. }))
|
||||
{
|
||||
return Err(error(Rule::Capability, "native adapter not injected"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn commit(&mut self, _effects: &[HostEffect]) {}
|
||||
}
|
||||
|
||||
pub(crate) fn tick_app(app: &mut crate::SolveApp) {
|
||||
if let Some(integration) = &mut app.panel_runtime {
|
||||
// Errors remain inspectable without inventing untranslated production UI.
|
||||
integration.last_error = integration.tick(&app.session).err();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
//! Shared executable mount/editor/native fixtures. These are synthetic specimens,
|
||||
//! not production panel migrations, UI commands or evidence of visual correctness.
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_engine::panels::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FixtureState {
|
||||
pub updates: u64,
|
||||
pub renders: u64,
|
||||
pub cancels: u64,
|
||||
pub reopens: u64,
|
||||
pub applies: u64,
|
||||
pub finishes: u64,
|
||||
pub reparents: u64,
|
||||
pub destroys: u64,
|
||||
pub transaction_live: bool,
|
||||
pub reject_cancel: bool,
|
||||
pub reject_reopen: bool,
|
||||
pub fail_update: bool,
|
||||
pub invalid_availability: bool,
|
||||
pub last_state: Option<ControllerState>,
|
||||
pub draft: String,
|
||||
pub last_clip: Option<egui::Rect>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FixtureProbe {
|
||||
states: Rc<RefCell<BTreeMap<ControllerKey, Rc<RefCell<FixtureState>>>>>,
|
||||
}
|
||||
impl FixtureProbe {
|
||||
pub fn state(&self, key: &ControllerKey) -> PanelResult<FixtureState> {
|
||||
let states = self.states.try_borrow().map_err(|_| error(Rule::Lifecycle, "fixture probe borrowed"))?;
|
||||
let state = states.get(key).ok_or_else(|| error(Rule::MissingTarget, "fixture controller missing"))?;
|
||||
let value = state.try_borrow().map_err(|_| error(Rule::Lifecycle, "fixture state borrowed"))?.clone();
|
||||
Ok(value)
|
||||
}
|
||||
pub fn edit(&self, key: &ControllerKey, edit: impl FnOnce(&mut FixtureState)) -> PanelResult<()> {
|
||||
let states = self.states.try_borrow().map_err(|_| error(Rule::Lifecycle, "fixture probe borrowed"))?;
|
||||
let state = states.get(key).ok_or_else(|| error(Rule::MissingTarget, "fixture controller missing"))?;
|
||||
let mut value = state.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "fixture state borrowed"))?;
|
||||
edit(&mut value);
|
||||
if value.draft.len() > 4096 {
|
||||
value.draft = value.draft.chars().take(1024).collect();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&self, key: ControllerKey, state: Rc<RefCell<FixtureState>>) -> PanelResult<()> {
|
||||
let mut states = self.states.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "fixture probe borrowed"))?;
|
||||
if states.len() >= MAX_RETAINED_CONTROLLERS && !states.contains_key(&key) {
|
||||
return Err(error(Rule::CollectionLimit, "fixture state cap"));
|
||||
}
|
||||
states.insert(key, state);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureController {
|
||||
state: Rc<RefCell<FixtureState>>,
|
||||
editor: bool,
|
||||
instance: InstanceId,
|
||||
probe: FixtureProbe,
|
||||
key: ControllerKey,
|
||||
}
|
||||
|
||||
impl PanelController for FixtureController {
|
||||
fn availability(&self, owner: &ContentOwner, models: &dyn PanelModels) -> Availability {
|
||||
if self.state.try_borrow().is_ok_and(|state| state.invalid_availability) {
|
||||
return Availability::Unavailable { reason: String::new() };
|
||||
}
|
||||
if self.editor {
|
||||
match models.read(owner, "fixture.editable") {
|
||||
Ok(serde_json::Value::Bool(true)) => Availability::Available,
|
||||
_ => Availability::Unavailable { reason: "synthetic editor requires editable document context".into() },
|
||||
}
|
||||
} else {
|
||||
Availability::Available
|
||||
}
|
||||
}
|
||||
fn update(&mut self, _owner: &ContentOwner, _services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
self.probe.publish(self.key.clone(), self.state.clone())?;
|
||||
let mut state = self.state.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "fixture state borrowed"))?;
|
||||
state.updates = state.updates.saturating_add(1);
|
||||
if state.fail_update {
|
||||
return Err(error(Rule::Lifecycle, "synthetic service update failed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn presentation(&self) -> PanelPresentation {
|
||||
let summary = self.state.try_borrow().ok().map(|s| (s.updates, s.transaction_live));
|
||||
PanelPresentation {
|
||||
title: format!("Specimen {}", self.instance.0),
|
||||
icon: "panel".into(),
|
||||
status: summary.map(|(updates, live)| format!("updates={updates}; transaction={live}")),
|
||||
attention: self.editor,
|
||||
actions: if self.editor {
|
||||
["apply", "cancel", "finish"]
|
||||
.into_iter()
|
||||
.map(|id| RuntimeAction { id: ControlId(id.into()), label: id.into(), enabled: true, disabled_reason: None })
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
fn prepare_lifecycle(&self, event: &LifecycleEvent) -> PanelResult<()> {
|
||||
let state = self.state.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "fixture state borrowed"))?;
|
||||
if self.editor && state.transaction_live && state.reject_cancel && matches!(event, LifecycleEvent::Close | LifecycleEvent::DestroyOwner) {
|
||||
return Err(error(Rule::Lifecycle, "synthetic editor cannot safely cancel"));
|
||||
}
|
||||
if state.reject_reopen && matches!(event, LifecycleEvent::Reopen) {
|
||||
return Err(error(Rule::Lifecycle, "synthetic controller cannot safely reopen"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn lifecycle(&mut self, event: &LifecycleEvent) {
|
||||
// Publishing diagnostics happens after commit, never in a staged factory.
|
||||
let _ = self.probe.publish(self.key.clone(), self.state.clone());
|
||||
// This fixture's preflight prohibits concurrent borrows. An adapter must
|
||||
// make commit infallible; it must not defer fallible work into this hook.
|
||||
if let Ok(mut state) = self.state.try_borrow_mut() {
|
||||
match event {
|
||||
LifecycleEvent::Close => {
|
||||
if self.editor && state.transaction_live {
|
||||
state.cancels = state.cancels.saturating_add(1);
|
||||
state.transaction_live = false;
|
||||
}
|
||||
}
|
||||
LifecycleEvent::Reopen => {
|
||||
state.reopens = state.reopens.saturating_add(1);
|
||||
if self.editor {
|
||||
state.transaction_live = true;
|
||||
}
|
||||
}
|
||||
LifecycleEvent::Reparent => state.reparents = state.reparents.saturating_add(1),
|
||||
LifecycleEvent::DestroyOwner => {
|
||||
if self.editor && state.transaction_live {
|
||||
state.cancels = state.cancels.saturating_add(1);
|
||||
state.transaction_live = false;
|
||||
}
|
||||
state.destroys = state.destroys.saturating_add(1);
|
||||
state.last_state = Some(ControllerState { destroyed: true, ..ControllerState::default() });
|
||||
}
|
||||
LifecycleEvent::State(value) => state.last_state = Some(value.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn render(&mut self, slot: ContentSlot, ui: &mut egui::Ui, _context: &PanelContext, _services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
if slot == ContentSlot::Body {
|
||||
let mut state = self.state.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "fixture state borrowed"))?;
|
||||
state.renders = state.renders.saturating_add(1);
|
||||
state.last_clip = Some(ui.clip_rect());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn action(&mut self, owner: &ContentOwner, action: &ControlId, services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
if !self.editor {
|
||||
return Err(error(Rule::MissingTarget, "log has no editor actions"));
|
||||
}
|
||||
let mut state = self.state.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "fixture state borrowed"))?;
|
||||
match action.0.as_str() {
|
||||
"apply" => {
|
||||
// Only this explicit content command reaches the model sink.
|
||||
services.commands.execute(owner, "fixture.apply", serde_json::json!({"draft":state.draft}))?;
|
||||
state.applies = state.applies.saturating_add(1);
|
||||
}
|
||||
"cancel" => {
|
||||
if state.reject_cancel {
|
||||
return Err(error(Rule::Lifecycle, "synthetic cancel rejected"));
|
||||
}
|
||||
state.cancels = state.cancels.saturating_add(1);
|
||||
state.transaction_live = false;
|
||||
}
|
||||
"finish" => {
|
||||
services.commands.execute(owner, "fixture.finish", serde_json::json!({"draft":state.draft}))?;
|
||||
state.finishes = state.finishes.saturating_add(1);
|
||||
state.transaction_live = false;
|
||||
}
|
||||
_ => return Err(error(Rule::MissingTarget, "unknown synthetic action")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bind_synthetic_controllers(runtime: &mut PanelRuntime, probe: &FixtureProbe) -> PanelResult<()> {
|
||||
for (kind, editor, ownership) in [
|
||||
("fixture.application", false, Ownership::Application),
|
||||
("fixture.editor", true, Ownership::Document),
|
||||
("fixture.log", false, Ownership::Workspace),
|
||||
("fixture.pin", false, Ownership::Workspace),
|
||||
] {
|
||||
runtime.register(PanelKind::new(kind)?, fixture_controller_binding(probe, editor, ownership))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bind the same synthetic lifecycle to another registered kind/ownership policy.
|
||||
/// This lets capacity fixtures exercise several document editors without adding
|
||||
/// production registrations or changing the pure layout registry.
|
||||
pub fn fixture_controller_binding(probe: &FixtureProbe, editor: bool, ownership: Ownership) -> ControllerBinding {
|
||||
let probe = probe.clone();
|
||||
ControllerBinding {
|
||||
ownership,
|
||||
create: Box::new(move |instance, owner| {
|
||||
let key = ControllerKey { instance: instance.id.clone(), owner: owner.clone() };
|
||||
Ok(Box::new(FixtureController {
|
||||
state: Rc::new(RefCell::new(FixtureState { transaction_live: editor && instance.requested_open, ..FixtureState::default() })),
|
||||
editor,
|
||||
instance: instance.id.clone(),
|
||||
probe: probe.clone(),
|
||||
key,
|
||||
}))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FakeModels {
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// Synthetic app provider for command/control fixtures, with the same retained
|
||||
/// model/command/local-service bindings as PanelMountHarness.
|
||||
#[derive(Default)]
|
||||
pub struct FakeAppPanelServices {
|
||||
pub models: FakeModels,
|
||||
pub commands: FakeContentCommands,
|
||||
pub local: FakeLocalServices,
|
||||
}
|
||||
impl AppPanelServices for FakeAppPanelServices {
|
||||
fn refresh_read_only(&mut self, _session: &solidcraft_engine::Session) -> PanelResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn services(&mut self) -> ContentServices<'_> {
|
||||
ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local }
|
||||
}
|
||||
}
|
||||
impl PanelModels for FakeModels {
|
||||
fn read(&self, _owner: &ContentOwner, key: &str) -> PanelResult<serde_json::Value> {
|
||||
match key {
|
||||
"fixture.editable" => Ok(serde_json::Value::Bool(self.editable)),
|
||||
_ => Err(error(Rule::MissingTarget, "unknown synthetic model read")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FakeContentCommands {
|
||||
pub calls: u64,
|
||||
pub fail: bool,
|
||||
}
|
||||
impl ContentCommandSink for FakeContentCommands {
|
||||
fn execute(&mut self, _owner: &ContentOwner, command: &str, input: serde_json::Value) -> PanelResult<serde_json::Value> {
|
||||
valid_id(command)?;
|
||||
bounded_json(&input)?;
|
||||
if serde_json::to_vec(&input).map_err(|_| error(Rule::Schema, "invalid synthetic command"))?.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "synthetic command cap"));
|
||||
}
|
||||
if self.fail {
|
||||
return Err(error(Rule::Lifecycle, "synthetic content command failed"));
|
||||
}
|
||||
self.calls = self.calls.saturating_add(1);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FakeLocalServices {
|
||||
pub scopes: BTreeMap<InstanceId, LocalScope>,
|
||||
pub keyboard: BTreeMap<LocalScope, (InstanceId, ControlId)>,
|
||||
pub requests: VecDeque<(LocalScope, LocalRequest)>,
|
||||
pub releases: u64,
|
||||
}
|
||||
impl FakeLocalServices {
|
||||
pub fn bind(&mut self, instance: InstanceId, scope: LocalScope) -> PanelResult<()> {
|
||||
valid_id(&instance.0)?;
|
||||
scope.validate()?;
|
||||
if self.scopes.len() >= MAX_INSTANCES && !self.scopes.contains_key(&instance) {
|
||||
return Err(error(Rule::CollectionLimit, "local scope cap"));
|
||||
}
|
||||
if let Some(old) = self.scopes.get(&instance).cloned() {
|
||||
if old == scope {
|
||||
return Ok(());
|
||||
}
|
||||
self.release(&old, &instance);
|
||||
}
|
||||
self.scopes.insert(instance, scope);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl PanelLocalServices for FakeLocalServices {
|
||||
fn request(&mut self, scope: &LocalScope, request: LocalRequest) -> PanelResult<()> {
|
||||
scope.validate()?;
|
||||
request.validate()?;
|
||||
if self.scopes.get(request.instance()) != Some(scope) {
|
||||
return Err(error(Rule::Placement, "cross-document or cross-viewport service request"));
|
||||
}
|
||||
match &request {
|
||||
LocalRequest::KeyboardOwner { instance, control } => {
|
||||
if self.keyboard.get(scope).is_some_and(|(owner, _)| owner != instance) {
|
||||
return Err(error(Rule::Lifecycle, "keyboard owned by another panel"));
|
||||
}
|
||||
self.keyboard.insert(scope.clone(), (instance.clone(), control.clone()));
|
||||
}
|
||||
LocalRequest::ReleaseKeyboard { instance } => {
|
||||
if self.keyboard.get(scope).is_some_and(|(owner, _)| owner == instance) {
|
||||
self.keyboard.remove(scope);
|
||||
}
|
||||
}
|
||||
LocalRequest::ClearOverlays { instance } => {
|
||||
self.requests.retain(|(at, req)| at != scope || req.instance() != instance || !matches!(req, LocalRequest::Overlay { .. }))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if self.requests.len() >= MAX_INSTANCES {
|
||||
self.requests.pop_front();
|
||||
}
|
||||
self.requests.push_back((scope.clone(), request));
|
||||
Ok(())
|
||||
}
|
||||
fn release(&mut self, scope: &LocalScope, instance: &InstanceId) {
|
||||
if self.keyboard.get(scope).is_some_and(|(owner, _)| owner == instance) {
|
||||
self.keyboard.remove(scope);
|
||||
}
|
||||
self.requests.retain(|(at, request)| at != scope || request.instance() != instance);
|
||||
if self.scopes.get(instance) == Some(scope) {
|
||||
self.scopes.remove(instance);
|
||||
}
|
||||
self.releases = self.releases.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FakeNativeAdapter {
|
||||
pub capabilities: HostCapabilities,
|
||||
pub hosts: BTreeMap<HostId, SectionId>,
|
||||
pub fail_prepare: bool,
|
||||
pub commits: u64,
|
||||
}
|
||||
impl FakeNativeAdapter {
|
||||
pub fn new(native: bool) -> Self {
|
||||
Self {
|
||||
capabilities: HostCapabilities { native_windows: native, persistence: true, max_native_hosts: if native { 8 } else { 0 } },
|
||||
hosts: BTreeMap::new(),
|
||||
fail_prepare: false,
|
||||
commits: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl NativeHostAdapter for FakeNativeAdapter {
|
||||
fn capabilities(&self) -> HostCapabilities {
|
||||
self.capabilities.clone()
|
||||
}
|
||||
fn prepare(&self, effects: &[HostEffect]) -> PanelResult<()> {
|
||||
if self.fail_prepare {
|
||||
return Err(error(Rule::Capability, "synthetic native preparation failure"));
|
||||
}
|
||||
if effects.len() > MAX_MUTATIONS * 3 {
|
||||
return Err(error(Rule::CollectionLimit, "native effect cap"));
|
||||
}
|
||||
let mut hosts = self.hosts.clone();
|
||||
for effect in effects {
|
||||
match effect {
|
||||
HostEffect::CreateNative { host, section } => {
|
||||
if !self.capabilities.native_windows {
|
||||
return Err(error(Rule::Capability, "native windows unsupported"));
|
||||
}
|
||||
if hosts.insert(host.clone(), section.clone()).is_some() {
|
||||
return Err(error(Rule::DuplicateId, "native host already exists"));
|
||||
}
|
||||
}
|
||||
HostEffect::RebindNative { host, section } => {
|
||||
if !self.capabilities.native_windows {
|
||||
return Err(error(Rule::Capability, "native rebind unsupported"));
|
||||
}
|
||||
if !hosts.contains_key(host) {
|
||||
return Err(error(Rule::MissingTarget, "native rebind host absent"));
|
||||
}
|
||||
hosts.insert(host.clone(), section.clone());
|
||||
}
|
||||
HostEffect::ReleaseNative { host } => {
|
||||
hosts.remove(host);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if hosts.len() > self.capabilities.max_native_hosts {
|
||||
return Err(error(Rule::Capability, "native host capacity"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn commit(&mut self, effects: &[HostEffect]) {
|
||||
for effect in effects {
|
||||
match effect {
|
||||
HostEffect::CreateNative { host, section } | HostEffect::RebindNative { host, section } => {
|
||||
self.hosts.insert(host.clone(), section.clone());
|
||||
}
|
||||
HostEffect::ReleaseNative { host } => {
|
||||
self.hosts.remove(host);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.commits = self.commits.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MountRecord {
|
||||
pub geometry: ResolvedPanel,
|
||||
pub presentation: PanelPresentation,
|
||||
pub context: PanelContext,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FakePanelHost {
|
||||
pub mounts: VecDeque<MountRecord>,
|
||||
pub slot_calls: u64,
|
||||
pub context: egui::Context,
|
||||
}
|
||||
impl PanelHost for FakePanelHost {
|
||||
fn mount(
|
||||
&mut self,
|
||||
geometry: &ResolvedPanel,
|
||||
presentation: &PanelPresentation,
|
||||
context: &PanelContext,
|
||||
content: &mut dyn PanelController,
|
||||
services: &mut ContentServices<'_>,
|
||||
) -> PanelResult<()> {
|
||||
geometry.validate()?;
|
||||
presentation.validate()?;
|
||||
context.validate()?;
|
||||
let mut failure = None;
|
||||
let ctx = self.context.clone();
|
||||
let _output = ctx.run_ui(egui::RawInput::default(), |root_ui| {
|
||||
crate::ui_kit::Container::new(root_ui.ctx(), crate::ui_kit::Surface::Viewport).central().show(root_ui, |ui| {
|
||||
for slot in [ContentSlot::Header, ContentSlot::Body, ContentSlot::Footer] {
|
||||
if failure.is_some() {
|
||||
break;
|
||||
}
|
||||
if let Err(reason) = content.render(slot, ui, context, services) {
|
||||
failure = Some(reason);
|
||||
}
|
||||
self.slot_calls = self.slot_calls.saturating_add(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(reason) = failure {
|
||||
return Err(reason);
|
||||
}
|
||||
if self.mounts.len() >= MAX_INSTANCES {
|
||||
self.mounts.pop_front();
|
||||
}
|
||||
self.mounts.push_back(MountRecord { geometry: geometry.clone(), presentation: presentation.clone(), context: context.clone() });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable harness uses actual reducer, controller, host and services interfaces.
|
||||
pub struct PanelMountHarness {
|
||||
pub runtime: PanelRuntime,
|
||||
pub probe: FixtureProbe,
|
||||
pub models: FakeModels,
|
||||
pub commands: FakeContentCommands,
|
||||
pub local: FakeLocalServices,
|
||||
pub native: FakeNativeAdapter,
|
||||
pub host: FakePanelHost,
|
||||
pub persistence: FakePersistence,
|
||||
pub catalogue: commands::InMemoryCatalogue,
|
||||
}
|
||||
impl PanelMountHarness {
|
||||
pub fn command(&mut self, id: &str, params: &serde_json::Value) -> PanelResult<serde_json::Value> {
|
||||
let registry = self.runtime.layout().registry().clone();
|
||||
let mut services = ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local };
|
||||
let mut sink = RuntimeActionSink { runtime: &mut self.runtime, native: &mut self.native, services: &mut services };
|
||||
commands::execute(
|
||||
id,
|
||||
params,
|
||||
&mut commands::PanelCommandContext { sink: &mut sink, registry: ®istry, catalogue: &mut self.catalogue, web: false },
|
||||
)
|
||||
}
|
||||
pub fn command_batch(&mut self, steps: &[serde_json::Value]) -> PanelResult<serde_json::Value> {
|
||||
let registry = self.runtime.layout().registry().clone();
|
||||
let mut services = ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local };
|
||||
let mut sink = RuntimeActionSink { runtime: &mut self.runtime, native: &mut self.native, services: &mut services };
|
||||
commands::execute_batch(
|
||||
steps,
|
||||
&mut commands::PanelCommandContext { sink: &mut sink, registry: ®istry, catalogue: &mut self.catalogue, web: false },
|
||||
)
|
||||
}
|
||||
pub fn apply_layout(&mut self, layout: Layout, expected_revision: Option<u64>) -> PanelResult<LayoutReply> {
|
||||
self.runtime.dispatch(
|
||||
LayoutAction::Replace { expected_revision, layout },
|
||||
&mut self.native,
|
||||
&mut ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local },
|
||||
)
|
||||
}
|
||||
pub fn new(native: bool) -> PanelResult<Self> {
|
||||
Self::configured(native, true)
|
||||
}
|
||||
pub fn without_content(native: bool) -> PanelResult<Self> {
|
||||
Self::configured(native, false)
|
||||
}
|
||||
fn configured(native: bool, bind: bool) -> PanelResult<Self> {
|
||||
let layout = fixture_service(native)?;
|
||||
let catalogue = fixture_command_catalogue(layout.registry())?;
|
||||
let environment = RuntimeEnvironment {
|
||||
workspace: layout.layout().workspace.clone(),
|
||||
document: Some(DocumentId::new("fixture.document")?),
|
||||
window: HostId::new("fixture.window")?,
|
||||
viewport: ViewportId::new("fixture.viewport")?,
|
||||
direction: Direction::Ltr,
|
||||
motion: Motion::Reduced,
|
||||
};
|
||||
let mut runtime = PanelRuntime::new(layout, environment)?;
|
||||
let probe = FixtureProbe::default();
|
||||
if bind {
|
||||
bind_synthetic_controllers(&mut runtime, &probe)?;
|
||||
}
|
||||
Ok(Self {
|
||||
runtime,
|
||||
probe,
|
||||
models: FakeModels { editable: true },
|
||||
commands: FakeContentCommands::default(),
|
||||
local: FakeLocalServices::default(),
|
||||
native: FakeNativeAdapter::new(native),
|
||||
host: FakePanelHost::default(),
|
||||
persistence: FakePersistence::default(),
|
||||
catalogue,
|
||||
})
|
||||
}
|
||||
pub fn dispatch(&mut self, mutations: Vec<Mutation>) -> PanelResult<LayoutReply> {
|
||||
self.runtime.dispatch(
|
||||
transaction(mutations),
|
||||
&mut self.native,
|
||||
&mut ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local },
|
||||
)
|
||||
}
|
||||
pub fn tick(&mut self) -> PanelResult<()> {
|
||||
self.runtime.update(&mut ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local })
|
||||
}
|
||||
pub fn mount(&mut self, capacity: LogicalRect) -> PanelResult<ResolvedLayout> {
|
||||
self.tick()?;
|
||||
let environment = self.runtime.environment().clone();
|
||||
let resolved = resolve_geometry(self.runtime.layout(), capacity, environment.direction, 48.0)?;
|
||||
for panel in &resolved.panels {
|
||||
let viewport = match &self.runtime.layout().layout().section(&panel.section)?.location {
|
||||
SectionLocation::Window { host, .. } => ViewportId::new(host.0.clone())?,
|
||||
_ => environment.viewport.clone(),
|
||||
};
|
||||
self.local.bind(
|
||||
panel.instance.clone(),
|
||||
LocalScope { workspace: environment.workspace.clone(), document: environment.document.clone(), viewport },
|
||||
)?;
|
||||
}
|
||||
self.runtime.mount(
|
||||
&resolved,
|
||||
&mut self.host,
|
||||
&mut ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local },
|
||||
)?;
|
||||
Ok(resolved)
|
||||
}
|
||||
pub fn save(&mut self) -> PanelResult<()> {
|
||||
let layout = self.runtime.layout().layout();
|
||||
let bytes = encode(&LayoutEnvelope::new(layout.clone()), self.runtime.layout().registry())?;
|
||||
self.persistence.save(&layout.workspace, &bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use solidcraft_engine::panels::*;
|
||||
|
||||
/// Content receives this value separately from state/model/command services. All
|
||||
/// geometry is already in logical units; do not multiply it by egui UI scale.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PanelContext {
|
||||
pub instance: InstanceId,
|
||||
pub owner: super::ContentOwner,
|
||||
pub workspace: WorkspaceId,
|
||||
pub document: Option<DocumentId>,
|
||||
pub viewport: ViewportId,
|
||||
pub native_host: Option<HostId>,
|
||||
pub outer: LogicalRect,
|
||||
pub usable_body: LogicalRect,
|
||||
pub flow: Flow,
|
||||
pub size_class: SizeClass,
|
||||
pub focused: bool,
|
||||
pub direction: Direction,
|
||||
pub motion: Motion,
|
||||
}
|
||||
impl PanelContext {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
valid_id(&self.instance.0)?;
|
||||
self.owner.validate()?;
|
||||
LocalScope { workspace: self.workspace.clone(), document: self.document.clone(), viewport: self.viewport.clone() }.validate()?;
|
||||
self.outer.validate()?;
|
||||
self.usable_body.validate()?;
|
||||
if !self.outer.contains_rect(self.usable_body) {
|
||||
return Err(error(Rule::Bounds, "presentation body outside host"));
|
||||
}
|
||||
if let Some(host) = &self.native_host {
|
||||
valid_id(&host.0)?;
|
||||
if self.viewport.0 != host.0 {
|
||||
return Err(error(Rule::Placement, "native presentation has wrong local viewport"));
|
||||
}
|
||||
}
|
||||
if let super::ContentOwner::Document(document) = &self.owner
|
||||
&& self.document.as_ref() != Some(document)
|
||||
{
|
||||
return Err(error(Rule::Placement, "presentation document differs from content owner"));
|
||||
}
|
||||
if let super::ContentOwner::Workspace(workspace) = &self.owner
|
||||
&& self.workspace != *workspace
|
||||
{
|
||||
return Err(error(Rule::Placement, "presentation workspace differs from content owner"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Motion {
|
||||
Reduced,
|
||||
Animated,
|
||||
}
|
||||
impl Motion {
|
||||
/// Reparent/focus must not wait for animation completion, including at zero duration.
|
||||
pub fn duration_ms(self, requested: u16) -> u16 {
|
||||
if self == Self::Reduced { 0 } else { requested.min(500) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct LocalScope {
|
||||
pub workspace: WorkspaceId,
|
||||
pub document: Option<DocumentId>,
|
||||
pub viewport: ViewportId,
|
||||
}
|
||||
impl LocalScope {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
valid_id(&self.workspace.0)?;
|
||||
valid_id(&self.viewport.0)?;
|
||||
if let Some(id) = &self.document {
|
||||
valid_id(&id.0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LocalRequest {
|
||||
KeyboardOwner { instance: InstanceId, control: ControlId },
|
||||
ReleaseKeyboard { instance: InstanceId },
|
||||
Focus { instance: InstanceId, control: ControlId },
|
||||
FileDrop { instance: InstanceId, name: String, bytes: Vec<u8> },
|
||||
PointerDrop { instance: InstanceId, at: Point, payload: String },
|
||||
Overlay { instance: InstanceId, region: InputRegion },
|
||||
ClearOverlays { instance: InstanceId },
|
||||
Clipboard { instance: InstanceId, text: String },
|
||||
Pick { instance: InstanceId, at: Point },
|
||||
}
|
||||
impl LocalRequest {
|
||||
pub fn instance(&self) -> &InstanceId {
|
||||
match self {
|
||||
Self::KeyboardOwner { instance, .. }
|
||||
| Self::ReleaseKeyboard { instance }
|
||||
| Self::Focus { instance, .. }
|
||||
| Self::FileDrop { instance, .. }
|
||||
| Self::PointerDrop { instance, .. }
|
||||
| Self::Overlay { instance, .. }
|
||||
| Self::ClearOverlays { instance }
|
||||
| Self::Clipboard { instance, .. }
|
||||
| Self::Pick { instance, .. } => instance,
|
||||
}
|
||||
}
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
valid_id(&self.instance().0)?;
|
||||
match self {
|
||||
Self::KeyboardOwner { control, .. } | Self::Focus { control, .. } => valid_id(&control.0)?,
|
||||
Self::FileDrop { name, bytes, .. } => {
|
||||
if name.is_empty() || name.len() > 256 || bytes.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(error(Rule::CollectionLimit, "file drop bound"));
|
||||
}
|
||||
}
|
||||
Self::PointerDrop { at, payload, .. } => {
|
||||
at.validate()?;
|
||||
if payload.len() > 4096 {
|
||||
return Err(error(Rule::CollectionLimit, "pointer drop payload bound"));
|
||||
}
|
||||
}
|
||||
Self::Overlay { region, .. } => {
|
||||
valid_id(®ion.control.0)?;
|
||||
region.bounds.validate()?;
|
||||
if region.accessible_name.is_empty() || region.accessible_name.len() > 256 {
|
||||
return Err(error(Rule::Bounds, "overlay semantic name bound"));
|
||||
}
|
||||
}
|
||||
Self::Clipboard { text, .. } if text.len() > MAX_ENVELOPE_BYTES => return Err(error(Rule::CollectionLimit, "clipboard bound")),
|
||||
Self::Pick { at, .. } => at.validate()?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Services must reject cross-document/viewport delivery. A native redock drops
|
||||
/// host-local focus/overlays but retains the content controller and its owner.
|
||||
pub trait PanelLocalServices {
|
||||
fn request(&mut self, scope: &LocalScope, request: LocalRequest) -> PanelResult<()>;
|
||||
fn release(&mut self, scope: &LocalScope, instance: &InstanceId);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeAction {
|
||||
pub id: ControlId,
|
||||
pub label: String,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PanelPresentation {
|
||||
/// Already localised by content; fixtures use specimen text only.
|
||||
pub title: String,
|
||||
pub icon: String,
|
||||
pub status: Option<String>,
|
||||
pub attention: bool,
|
||||
pub actions: Vec<RuntimeAction>,
|
||||
}
|
||||
impl PanelPresentation {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
if self.title.is_empty() || self.title.len() > 256 || self.status.as_ref().is_some_and(|s| s.len() > 1024) || self.actions.len() > MAX_TABS {
|
||||
return Err(error(Rule::CollectionLimit, "presentation bound"));
|
||||
}
|
||||
valid_id(&self.icon)?;
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for action in &self.actions {
|
||||
valid_id(&action.id.0)?;
|
||||
if !ids.insert(&action.id) {
|
||||
return Err(error(Rule::DuplicateId, "duplicate content action"));
|
||||
}
|
||||
if action.label.is_empty()
|
||||
|| action.label.len() > 256
|
||||
|| action.disabled_reason.as_ref().is_some_and(|r| r.is_empty() || r.len() > 256)
|
||||
|| !action.enabled && action.disabled_reason.is_none()
|
||||
{
|
||||
return Err(error(Rule::Bounds, "content action label/reason bound"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContentSlot {
|
||||
Header,
|
||||
Body,
|
||||
Footer,
|
||||
}
|
||||
|
||||
/// Shared host adapter seam. Geometry is resolved by the host; content chooses
|
||||
/// its own scrolling within slots. The egui adapter below owns one kit Container.
|
||||
pub trait PanelHost {
|
||||
fn mount(
|
||||
&mut self,
|
||||
geometry: &ResolvedPanel,
|
||||
presentation: &PanelPresentation,
|
||||
context: &PanelContext,
|
||||
content: &mut dyn PanelController,
|
||||
services: &mut ContentServices<'_>,
|
||||
) -> PanelResult<()>;
|
||||
}
|
||||
|
||||
/// Two-phase host effects. `prepare` is side-effect-free and fallible; `commit`
|
||||
/// cannot fail. Native adapters must stage resources before acknowledging prepare
|
||||
/// and retain/restorable resources until the layout transaction commits.
|
||||
pub trait NativeHostAdapter {
|
||||
fn capabilities(&self) -> HostCapabilities;
|
||||
fn prepare(&self, effects: &[HostEffect]) -> PanelResult<()>;
|
||||
fn commit(&mut self, effects: &[HostEffect]);
|
||||
}
|
||||
|
||||
/// Existing ui_kit is the outer boundary. This adapter paints into host-provided
|
||||
/// space; it does not invent windows, docking gestures or a second camera path.
|
||||
pub struct EguiPanelHost<'a> {
|
||||
pub ui: &'a mut egui::Ui,
|
||||
}
|
||||
|
||||
impl PanelHost for EguiPanelHost<'_> {
|
||||
fn mount(
|
||||
&mut self,
|
||||
geometry: &ResolvedPanel,
|
||||
presentation: &PanelPresentation,
|
||||
context: &PanelContext,
|
||||
content: &mut dyn PanelController,
|
||||
services: &mut ContentServices<'_>,
|
||||
) -> PanelResult<()> {
|
||||
geometry.validate()?;
|
||||
presentation.validate()?;
|
||||
context.validate()?;
|
||||
if geometry.instance != context.instance || geometry.outer != context.outer || geometry.body != context.usable_body {
|
||||
return Err(error(Rule::Placement, "host geometry differs from presentation context"));
|
||||
}
|
||||
// This reference adapter draws/interacts with one full rectangular card.
|
||||
// Rich sparse/translucent regions belong to the surface host adapter.
|
||||
if geometry.painted.len() != 1
|
||||
|| geometry.painted.first() != Some(&geometry.outer)
|
||||
|| geometry.input.len() != 1
|
||||
|| !geometry.input.first().is_some_and(|region| region.role == InputRole::Surface && region.bounds == geometry.outer)
|
||||
{
|
||||
return Err(error(Rule::Capability, "reference egui host requires one full painted/input surface"));
|
||||
}
|
||||
let rect = |r: LogicalRect| egui::Rect::from_min_size(egui::pos2(r.origin.x, r.origin.y), egui::vec2(r.size.width, r.size.height));
|
||||
let outer = rect(geometry.outer);
|
||||
let body = rect(geometry.body);
|
||||
let parent_clip = self.ui.clip_rect().intersect(outer);
|
||||
let container = crate::ui_kit::Container::new(self.ui.ctx(), crate::ui_kit::Surface::Panel);
|
||||
let frame = container.frame(self.ui.ctx(), crate::theme::Tokens::get().dialog_body, crate::frame::Side::Left).inner_margin(0);
|
||||
let mut failure = None;
|
||||
self.ui.scope_builder(
|
||||
egui::UiBuilder::new().id_salt(("panel_runtime", &geometry.instance.0, &context.owner, &context.viewport.0)).max_rect(outer),
|
||||
|ui| {
|
||||
ui.set_clip_rect(ui.clip_rect().intersect(parent_clip));
|
||||
container.show_frame(ui, frame, outer, |ui| {
|
||||
// Slots have explicit bounds. No mandatory ScrollArea around content.
|
||||
let header = egui::Rect::from_min_max(outer.min, egui::pos2(outer.right(), body.top()));
|
||||
let footer = egui::Rect::from_min_max(egui::pos2(outer.left(), body.bottom()), outer.max);
|
||||
for (slot, bounds) in [(ContentSlot::Header, header), (ContentSlot::Body, body), (ContentSlot::Footer, footer)] {
|
||||
if failure.is_some() {
|
||||
break;
|
||||
}
|
||||
ui.scope_builder(egui::UiBuilder::new().id_salt(slot as u8).max_rect(bounds), |ui| {
|
||||
ui.set_clip_rect(ui.clip_rect().intersect(bounds));
|
||||
if slot == ContentSlot::Header {
|
||||
// Dynamic title/status have semantic metadata; content owns
|
||||
// additional controls and returns requests through services.
|
||||
let tokens = crate::theme::Tokens::get();
|
||||
let ink = if presentation.attention { tokens.form_primary } else { tokens.form_ink };
|
||||
let mut response = ui.label(egui::RichText::new(&presentation.title).color(ink));
|
||||
if let Some(status) = &presentation.status {
|
||||
response = response.on_hover_text(status);
|
||||
}
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &presentation.title));
|
||||
}
|
||||
if slot == ContentSlot::Footer && !presentation.actions.is_empty() {
|
||||
egui::ScrollArea::horizontal().id_salt("panel_actions").show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for action in &presentation.actions {
|
||||
let response = ui
|
||||
.push_id(&action.id.0, |ui| {
|
||||
ui.add_enabled_ui(action.enabled, |ui| {
|
||||
crate::ui_kit::button(ui, action.label.as_str(), crate::ui_kit::Button::Small)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
let clicked = response.clicked();
|
||||
if let Some(reason) = &action.disabled_reason {
|
||||
response.on_disabled_hover_text(reason);
|
||||
}
|
||||
if clicked
|
||||
&& failure.is_none()
|
||||
&& let Err(reason) = content.action(&context.owner, &action.id, services)
|
||||
{
|
||||
failure = Some(reason);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Err(reason) = content.render(slot, ui, context, services) {
|
||||
failure = Some(reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.set_min_size(outer.size());
|
||||
});
|
||||
},
|
||||
);
|
||||
if let Some(reason) = failure {
|
||||
return Err(reason);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
use super::{ContentServices, PanelController};
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Opt-in common panel runtime. No production registrations, gestures or preferences.
|
||||
//! Model/commands, controller state, presentation and layout have separate owners.
|
||||
mod app;
|
||||
mod fixtures;
|
||||
mod host;
|
||||
mod runtime;
|
||||
|
||||
pub use app::*;
|
||||
pub use fixtures::*;
|
||||
pub use host::*;
|
||||
pub use runtime::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,799 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solidcraft_engine::panels::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Retention spans every open document, independently of the active layout.
|
||||
pub const MAX_RETAINED_DOCUMENT_OWNERS: usize = crate::documents::MAX_OPEN;
|
||||
/// Application, workspace and window owners share a separate bounded budget.
|
||||
pub const MAX_RETAINED_OTHER_OWNERS: usize = MAX_INSTANCES;
|
||||
/// Total retained owners; also bounds the separate destroyed-owner tombstone set.
|
||||
pub const MAX_RETAINED_OWNERS: usize = MAX_RETAINED_DOCUMENT_OWNERS + MAX_RETAINED_OTHER_OWNERS;
|
||||
/// Each owner can retain a full layout's controllers, including hidden ones.
|
||||
pub const MAX_RETAINED_CONTROLLERS_PER_OWNER: usize = MAX_INSTANCES;
|
||||
/// Layout instance limits remain unchanged; retention never evicts live content.
|
||||
pub const MAX_RETAINED_CONTROLLERS: usize = MAX_RETAINED_OWNERS * MAX_RETAINED_CONTROLLERS_PER_OWNER;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Ownership {
|
||||
Application,
|
||||
Workspace,
|
||||
Document,
|
||||
Window,
|
||||
}
|
||||
|
||||
/// Window ownership is explicit content ownership, independent of native placement.
|
||||
/// Redocking does not turn a document editor into a new window-owned controller.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ContentOwner {
|
||||
Application,
|
||||
Workspace(WorkspaceId),
|
||||
Document(DocumentId),
|
||||
Window(HostId),
|
||||
}
|
||||
impl ContentOwner {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
match self {
|
||||
Self::Application => Ok(()),
|
||||
Self::Workspace(id) => valid_id(&id.0),
|
||||
Self::Document(id) => valid_id(&id.0),
|
||||
Self::Window(id) => valid_id(&id.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeEnvironment {
|
||||
pub workspace: WorkspaceId,
|
||||
pub document: Option<DocumentId>,
|
||||
pub window: HostId,
|
||||
pub viewport: ViewportId,
|
||||
pub direction: Direction,
|
||||
pub motion: Motion,
|
||||
}
|
||||
impl RuntimeEnvironment {
|
||||
pub fn validate(&self) -> PanelResult<()> {
|
||||
valid_id(&self.workspace.0)?;
|
||||
valid_id(&self.window.0)?;
|
||||
valid_id(&self.viewport.0)?;
|
||||
if let Some(doc) = &self.document {
|
||||
valid_id(&doc.0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn owner(&self, policy: Ownership) -> PanelResult<ContentOwner> {
|
||||
Ok(match policy {
|
||||
Ownership::Application => ContentOwner::Application,
|
||||
Ownership::Workspace => ContentOwner::Workspace(self.workspace.clone()),
|
||||
Ownership::Document => {
|
||||
ContentOwner::Document(self.document.clone().ok_or_else(|| error(Rule::Availability, "document-bound panel needs a document"))?)
|
||||
}
|
||||
Ownership::Window => ContentOwner::Window(self.window.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Content model access is owner-scoped and read-only. Layout code never receives
|
||||
/// a Session. Consumers may implement richer views behind the same trait.
|
||||
pub trait PanelModels {
|
||||
fn read(&self, owner: &ContentOwner, key: &str) -> PanelResult<serde_json::Value>;
|
||||
}
|
||||
|
||||
/// Explicit content commands have their own typed owner. They are separate from
|
||||
/// layout actions and are never implicitly sent when a panel is hidden/reparented.
|
||||
pub trait ContentCommandSink {
|
||||
fn execute(&mut self, owner: &ContentOwner, command: &str, input: serde_json::Value) -> PanelResult<serde_json::Value>;
|
||||
}
|
||||
|
||||
pub struct ContentServices<'a> {
|
||||
pub models: &'a dyn PanelModels,
|
||||
pub commands: &'a mut dyn ContentCommandSink,
|
||||
pub local: &'a mut dyn PanelLocalServices,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ControllerState {
|
||||
pub requested_open: bool,
|
||||
pub available: Availability,
|
||||
pub presented: bool,
|
||||
pub activated: bool,
|
||||
pub destroyed: bool,
|
||||
}
|
||||
impl Default for ControllerState {
|
||||
fn default() -> Self {
|
||||
Self { requested_open: false, available: Availability::Available, presented: false, activated: false, destroyed: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LifecycleEvent {
|
||||
State(ControllerState),
|
||||
/// Only explicit close calls this; deactivation never cancels a transaction.
|
||||
Close,
|
||||
Reopen,
|
||||
Reparent,
|
||||
DestroyOwner,
|
||||
}
|
||||
|
||||
pub trait PanelController {
|
||||
/// Availability may depend on document/tool context, independent of visibility.
|
||||
fn availability(&self, owner: &ContentOwner, models: &dyn PanelModels) -> Availability;
|
||||
/// Runs for retained controllers, including hidden, closed and inactive documents.
|
||||
fn update(&mut self, owner: &ContentOwner, services: &mut ContentServices<'_>) -> PanelResult<()>;
|
||||
fn presentation(&self) -> PanelPresentation;
|
||||
/// MUST be side-effect-free. Failure rejects the whole layout transaction.
|
||||
/// Validate cancellation and reserve anything fallible before committing.
|
||||
/// State notifications are observational and cannot veto a layout change.
|
||||
fn prepare_lifecycle(&self, event: &LifecycleEvent) -> PanelResult<()>;
|
||||
/// Infallible application of an already prepared lifecycle transition.
|
||||
fn lifecycle(&mut self, event: &LifecycleEvent);
|
||||
fn render(&mut self, slot: ContentSlot, ui: &mut egui::Ui, context: &PanelContext, services: &mut ContentServices<'_>) -> PanelResult<()>;
|
||||
/// Apply/Finish/Cancel/Stop stay explicit content actions, never layout mutations.
|
||||
fn action(&mut self, owner: &ContentOwner, action: &ControlId, services: &mut ContentServices<'_>) -> PanelResult<()>;
|
||||
}
|
||||
|
||||
pub struct ControllerBinding {
|
||||
pub ownership: Ownership,
|
||||
/// Staging may be discarded on failure; do not publish external side effects.
|
||||
pub create: Box<dyn Fn(&PanelInstance, &ContentOwner) -> PanelResult<Box<dyn PanelController>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ControllerKey {
|
||||
pub instance: InstanceId,
|
||||
pub owner: ContentOwner,
|
||||
}
|
||||
|
||||
struct RetainedController {
|
||||
controller: Box<dyn PanelController>,
|
||||
/// A controller created closed, or explicitly closed after creation, needs
|
||||
/// Reopen before reuse. Snapshot hiding never sets this lifecycle flag.
|
||||
needs_reopen: bool,
|
||||
state: ControllerState,
|
||||
scope: LocalScope,
|
||||
section: Option<SectionId>,
|
||||
native_host: Option<HostId>,
|
||||
flow: Option<Flow>,
|
||||
availability: Availability,
|
||||
}
|
||||
|
||||
/// Layout stays on the app, controllers on their content owners, host services on
|
||||
/// their local scopes. None of these values enter document serialization or undo.
|
||||
pub struct PanelRuntime {
|
||||
layout: LayoutService,
|
||||
bindings: BTreeMap<PanelKind, ControllerBinding>,
|
||||
controllers: BTreeMap<ControllerKey, RetainedController>,
|
||||
environment: RuntimeEnvironment,
|
||||
destroyed_owners: BTreeSet<ContentOwner>,
|
||||
destroyed: bool,
|
||||
update_errors: Vec<RuntimeUpdateError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeUpdateError {
|
||||
pub controller: Option<ControllerKey>,
|
||||
pub error: PanelError,
|
||||
}
|
||||
|
||||
/// A scoped live action sink for host and future command routing. It includes the
|
||||
/// runtime lifecycle guard rather than exposing a mutable bare LayoutService.
|
||||
pub struct RuntimeActionSink<'a, 'services> {
|
||||
pub runtime: &'a mut PanelRuntime,
|
||||
pub native: &'a mut dyn NativeHostAdapter,
|
||||
pub services: &'a mut ContentServices<'services>,
|
||||
}
|
||||
impl ActionSink for RuntimeActionSink<'_, '_> {
|
||||
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
|
||||
self.runtime.layout.project(action)
|
||||
}
|
||||
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
|
||||
let mut reply = self.runtime.dispatch(action, self.native, self.services)?;
|
||||
let native = self.native.capabilities();
|
||||
reply.capabilities.native_windows &= native.native_windows;
|
||||
reply.capabilities.max_native_hosts = reply.capabilities.max_native_hosts.min(native.max_native_hosts);
|
||||
Ok(reply)
|
||||
}
|
||||
fn inspect(&self) -> LayoutReply {
|
||||
let mut reply = self.runtime.layout.inspect();
|
||||
let native = self.native.capabilities();
|
||||
reply.capabilities.native_windows &= native.native_windows;
|
||||
reply.capabilities.max_native_hosts = reply.capabilities.max_native_hosts.min(native.max_native_hosts);
|
||||
reply
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelRuntime {
|
||||
pub fn new(layout: LayoutService, environment: RuntimeEnvironment) -> PanelResult<Self> {
|
||||
environment.validate()?;
|
||||
if layout.layout().workspace != environment.workspace {
|
||||
return Err(error(Rule::Placement, "runtime workspace differs from layout service"));
|
||||
}
|
||||
Ok(Self {
|
||||
layout,
|
||||
bindings: BTreeMap::new(),
|
||||
controllers: BTreeMap::new(),
|
||||
environment,
|
||||
destroyed_owners: BTreeSet::new(),
|
||||
destroyed: false,
|
||||
update_errors: Vec::new(),
|
||||
})
|
||||
}
|
||||
pub fn register(&mut self, kind: PanelKind, binding: ControllerBinding) -> PanelResult<()> {
|
||||
self.require_live()?;
|
||||
self.layout.registry().get(&kind)?;
|
||||
if self.bindings.contains_key(&kind) {
|
||||
return Err(error(Rule::DuplicateId, "controller binding already registered"));
|
||||
}
|
||||
if self.bindings.len() >= MAX_INSTANCES {
|
||||
return Err(error(Rule::CollectionLimit, "binding cap"));
|
||||
}
|
||||
self.bindings.insert(kind, binding);
|
||||
Ok(())
|
||||
}
|
||||
pub fn layout(&self) -> &LayoutService {
|
||||
&self.layout
|
||||
}
|
||||
pub fn environment(&self) -> &RuntimeEnvironment {
|
||||
&self.environment
|
||||
}
|
||||
pub fn controller_count(&self) -> usize {
|
||||
self.controllers.len()
|
||||
}
|
||||
pub fn update_errors(&self) -> &[RuntimeUpdateError] {
|
||||
&self.update_errors
|
||||
}
|
||||
fn require_live(&self) -> PanelResult<()> {
|
||||
if self.destroyed {
|
||||
return Err(error(Rule::Lifecycle, "runtime destroyed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn state(&self, key: &ControllerKey) -> Option<&ControllerState> {
|
||||
self.controllers.get(key).map(|c| &c.state)
|
||||
}
|
||||
pub fn presentation(&self, key: &ControllerKey) -> PanelResult<PanelPresentation> {
|
||||
let value = self.controllers.get(key).ok_or_else(|| error(Rule::MissingTarget, "controller not retained"))?.controller.presentation();
|
||||
value.validate()?;
|
||||
Ok(value)
|
||||
}
|
||||
fn key(&self, instance: &PanelInstance) -> PanelResult<ControllerKey> {
|
||||
self.key_in(instance, &self.environment)
|
||||
}
|
||||
fn key_in(&self, instance: &PanelInstance, environment: &RuntimeEnvironment) -> PanelResult<ControllerKey> {
|
||||
let binding = self.bindings.get(&instance.kind).ok_or_else(|| error(Rule::Availability, "no content binding available"))?;
|
||||
let owner = environment.owner(binding.ownership)?;
|
||||
if self.destroyed_owners.contains(&owner) {
|
||||
return Err(error(Rule::Availability, "content owner destroyed"));
|
||||
}
|
||||
Ok(ControllerKey { instance: instance.id.clone(), owner })
|
||||
}
|
||||
fn scope(&self) -> LocalScope {
|
||||
LocalScope {
|
||||
workspace: self.environment.workspace.clone(),
|
||||
document: self.environment.document.clone(),
|
||||
viewport: self.environment.viewport.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes active context without dropping editors/conversations of inactive
|
||||
/// documents. Their independent service updates keep running under their owner.
|
||||
pub fn set_environment(&mut self, environment: RuntimeEnvironment, local: &mut dyn PanelLocalServices) -> PanelResult<()> {
|
||||
self.require_live()?;
|
||||
environment.validate()?;
|
||||
if environment.workspace != self.layout.layout().workspace {
|
||||
return Err(error(Rule::Placement, "apply the saved workspace layout through LayoutAction::Replace"));
|
||||
}
|
||||
if self.environment == environment {
|
||||
return Ok(());
|
||||
}
|
||||
// Capacity/factory refusal must leave the active owner and local input
|
||||
// untouched, just like a refused layout replacement.
|
||||
let staged = self.stage_controllers(self.layout.layout(), &environment)?;
|
||||
let mut events = Vec::new();
|
||||
for (key, retained) in &self.controllers {
|
||||
let mut state = retained.state.clone();
|
||||
state.presented = false;
|
||||
state.activated = false;
|
||||
events.push((key.clone(), state));
|
||||
}
|
||||
for (key, state) in events {
|
||||
if let Some(retained) = self.controllers.get_mut(&key) {
|
||||
local.release(&retained.scope, &key.instance);
|
||||
retained.controller.lifecycle(&LifecycleEvent::State(state.clone()));
|
||||
retained.state = state;
|
||||
}
|
||||
}
|
||||
self.environment = environment;
|
||||
self.controllers.extend(staged);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage_controllers(&self, layout: &Layout, environment: &RuntimeEnvironment) -> PanelResult<BTreeMap<ControllerKey, RetainedController>> {
|
||||
let mut staged = BTreeMap::new();
|
||||
let mut owner_counts = BTreeMap::<ContentOwner, usize>::new();
|
||||
for key in self.controllers.keys() {
|
||||
*owner_counts.entry(key.owner.clone()).or_default() += 1;
|
||||
}
|
||||
let mut document_owners = owner_counts.keys().filter(|owner| matches!(owner, ContentOwner::Document(_))).count();
|
||||
let mut other_owners = owner_counts.len() - document_owners;
|
||||
for p in &layout.instances {
|
||||
let key = match self.key_in(p, environment) {
|
||||
Ok(key) => key,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if self.controllers.contains_key(&key) || staged.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
if self.controllers.len() + staged.len() >= MAX_RETAINED_CONTROLLERS {
|
||||
return Err(error(Rule::CollectionLimit, "retained controller cap"));
|
||||
}
|
||||
if !owner_counts.contains_key(&key.owner) {
|
||||
if matches!(&key.owner, ContentOwner::Document(_)) {
|
||||
if document_owners >= MAX_RETAINED_DOCUMENT_OWNERS {
|
||||
return Err(error(Rule::CollectionLimit, "retained document owner cap"));
|
||||
}
|
||||
document_owners += 1;
|
||||
} else {
|
||||
if other_owners >= MAX_RETAINED_OTHER_OWNERS {
|
||||
return Err(error(Rule::CollectionLimit, "retained application/workspace/window owner cap"));
|
||||
}
|
||||
other_owners += 1;
|
||||
}
|
||||
}
|
||||
let count = owner_counts.entry(key.owner.clone()).or_default();
|
||||
if *count >= MAX_RETAINED_CONTROLLERS_PER_OWNER {
|
||||
return Err(error(Rule::CollectionLimit, "retained controller cap for content owner"));
|
||||
}
|
||||
*count += 1;
|
||||
let binding = self.bindings.get(&p.kind).ok_or_else(|| error(Rule::Availability, "binding disappeared"))?;
|
||||
let controller = (binding.create)(p, &key.owner)?;
|
||||
staged.insert(
|
||||
key,
|
||||
RetainedController {
|
||||
controller,
|
||||
needs_reopen: !p.requested_open,
|
||||
state: ControllerState::default(),
|
||||
scope: LocalScope {
|
||||
workspace: environment.workspace.clone(),
|
||||
document: environment.document.clone(),
|
||||
viewport: environment.viewport.clone(),
|
||||
},
|
||||
section: None,
|
||||
native_host: None,
|
||||
flow: None,
|
||||
availability: Availability::Available,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// Refresh command/query availability from the current read-only provider.
|
||||
/// This does not tick content, evaluate models or invent presentation geometry.
|
||||
/// A failed retained service stays failed until a real update recovers it.
|
||||
pub fn refresh_context(&mut self, models: &dyn PanelModels, local: &mut dyn PanelLocalServices) -> PanelResult<()> {
|
||||
self.require_live()?;
|
||||
let staged = self.stage_controllers(self.layout.layout(), &self.environment)?;
|
||||
let mut availability = AvailabilityMap::new();
|
||||
for instance in &self.layout.layout().instances {
|
||||
let state = match self.key(instance) {
|
||||
Ok(key) => match self.controllers.get(&key).or_else(|| staged.get(&key)) {
|
||||
Some(retained) => {
|
||||
let state = retained.controller.availability(&key.owner, models);
|
||||
if matches!(&state,Availability::Unavailable { reason } if reason.is_empty() || reason.len() > 256) {
|
||||
return Err(error(Rule::Availability, "controller availability reason bound"));
|
||||
}
|
||||
if self.update_errors.iter().any(|failure| failure.controller.as_ref() == Some(&key)) {
|
||||
retained.availability.clone()
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
None => Availability::Unavailable { reason: "content controller unavailable".into() },
|
||||
},
|
||||
Err(reason) => Availability::Unavailable { reason: reason.detail },
|
||||
};
|
||||
availability.insert(instance.id.clone(), state);
|
||||
}
|
||||
self.layout.set_availability(availability.clone())?;
|
||||
self.controllers.extend(staged);
|
||||
for instance in &self.layout.layout().instances {
|
||||
if let Ok(key) = self.key(instance)
|
||||
&& let Some(retained) = self.controllers.get_mut(&key)
|
||||
&& let Some(state) = availability.get(&instance.id)
|
||||
{
|
||||
retained.availability = state.clone();
|
||||
}
|
||||
}
|
||||
self.sync_states(None, local);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Independent tick: all retained services, then current-owner availability.
|
||||
/// No geometry or painting is required for this method.
|
||||
pub fn update(&mut self, services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
if self.destroyed {
|
||||
return Err(error(Rule::Lifecycle, "runtime destroyed"));
|
||||
}
|
||||
self.update_errors.clear();
|
||||
match self.stage_controllers(self.layout.layout(), &self.environment) {
|
||||
Ok(staged) => self.controllers.extend(staged),
|
||||
Err(reason) => self.update_errors.push(RuntimeUpdateError { controller: None, error: error(reason.rule, reason.detail) }),
|
||||
}
|
||||
for (key, retained) in &mut self.controllers {
|
||||
let update = retained.controller.update(&key.owner, services);
|
||||
let availability = retained.controller.availability(&key.owner, services.models);
|
||||
let invalid = matches!(&availability, Availability::Unavailable { reason } if reason.is_empty() || reason.len() > 256);
|
||||
let failed = update.is_err() || invalid;
|
||||
if let Err(reason) = update {
|
||||
self.update_errors.push(RuntimeUpdateError { controller: Some(key.clone()), error: error(reason.rule, reason.detail) });
|
||||
}
|
||||
if invalid {
|
||||
self.update_errors.push(RuntimeUpdateError {
|
||||
controller: Some(key.clone()),
|
||||
error: error(Rule::Availability, "controller availability reason bound"),
|
||||
});
|
||||
}
|
||||
if failed {
|
||||
retained.availability = Availability::Unavailable {
|
||||
reason: if invalid { "invalid availability report".into() } else { "content service update failed".into() },
|
||||
};
|
||||
} else {
|
||||
retained.availability = availability;
|
||||
}
|
||||
}
|
||||
let mut availability = AvailabilityMap::new();
|
||||
for p in &self.layout.layout().instances {
|
||||
let state = match self.key(p) {
|
||||
Ok(key) => self
|
||||
.controllers
|
||||
.get(&key)
|
||||
.map(|c| c.availability.clone())
|
||||
.unwrap_or(Availability::Unavailable { reason: "content controller unavailable".into() }),
|
||||
Err(reason) => Availability::Unavailable { reason: reason.detail },
|
||||
};
|
||||
availability.insert(p.id.clone(), state);
|
||||
}
|
||||
if let Err(reason) = self.layout.set_availability(availability) {
|
||||
self.update_errors.push(RuntimeUpdateError { controller: None, error: reason });
|
||||
}
|
||||
self.sync_states(None, services.local);
|
||||
// At most two errors per retained controller, plus staging/reconciliation.
|
||||
if self.update_errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(error(Rule::Lifecycle, format!("{} panel service failures; inspect update_errors", self.update_errors.len())))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle preflight and native preflight happen before layout commit. A
|
||||
/// failed close/cancel leaves layout, interaction, controllers and host intact.
|
||||
pub fn dispatch(
|
||||
&mut self,
|
||||
action: LayoutAction,
|
||||
native: &mut dyn NativeHostAdapter,
|
||||
services: &mut ContentServices<'_>,
|
||||
) -> PanelResult<LayoutReply> {
|
||||
if action == LayoutAction::Inspect {
|
||||
return Ok(self.layout.inspect());
|
||||
}
|
||||
if self.destroyed {
|
||||
return Err(error(Rule::Lifecycle, "runtime destroyed"));
|
||||
}
|
||||
let requests: Vec<_> = match &action {
|
||||
LayoutAction::Apply { mutations, .. } => mutations
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
Mutation::Open { instance, .. }
|
||||
| Mutation::Focus { instance }
|
||||
| Mutation::Activate { instance }
|
||||
| Mutation::Peek { instance } => Some(instance.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
LayoutAction::Inspect | LayoutAction::Replace { .. } => Vec::new(),
|
||||
};
|
||||
let replacement = matches!(&action, LayoutAction::Replace { .. });
|
||||
let mut explicit_close = BTreeSet::new();
|
||||
let mut explicit_destroy = BTreeSet::new();
|
||||
let mut explicit_open = BTreeSet::new();
|
||||
if let LayoutAction::Apply { mutations, .. } = &action {
|
||||
for mutation in mutations {
|
||||
match mutation {
|
||||
Mutation::Close { instance } => {
|
||||
explicit_close.insert(instance.clone());
|
||||
}
|
||||
Mutation::Destroy { instance } => {
|
||||
explicit_destroy.insert(instance.clone());
|
||||
}
|
||||
Mutation::Open { instance, .. } | Mutation::Focus { instance } => {
|
||||
explicit_open.insert(instance.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut prepared = self.layout.prepare(action)?;
|
||||
let mut environment = self.environment.clone();
|
||||
environment.workspace = prepared.layout().workspace.clone();
|
||||
let capabilities = native.capabilities();
|
||||
if capabilities.max_native_hosts > MAX_SECTIONS {
|
||||
return Err(error(Rule::Capability, "native adapter reports invalid capacity"));
|
||||
}
|
||||
if prepared.effects().iter().any(|e| matches!(e, HostEffect::CreateNative { .. })) && !capabilities.native_windows {
|
||||
return Err(error(Rule::Capability, "native adapter is unavailable"));
|
||||
}
|
||||
let staged = self.stage_controllers(prepared.layout(), &environment)?;
|
||||
let mut availability = AvailabilityMap::new();
|
||||
for instance in &prepared.layout().instances {
|
||||
let state = match self.key_in(instance, &environment) {
|
||||
Ok(key) => match self.controllers.get(&key).or_else(|| staged.get(&key)) {
|
||||
Some(retained) => {
|
||||
let state = retained.controller.availability(&key.owner, services.models);
|
||||
if matches!(&state, Availability::Unavailable { reason } if reason.is_empty() || reason.len() > 256) {
|
||||
return Err(error(Rule::Availability, "controller availability reason bound"));
|
||||
}
|
||||
// A failed retained service cannot become healthy solely by moving it.
|
||||
if self.update_errors.iter().any(|e| e.controller.as_ref() == Some(&key)) { retained.availability.clone() } else { state }
|
||||
}
|
||||
None => Availability::Unavailable { reason: "content controller unavailable".into() },
|
||||
},
|
||||
Err(reason) => Availability::Unavailable { reason: reason.detail },
|
||||
};
|
||||
availability.insert(instance.id.clone(), state);
|
||||
}
|
||||
prepared.set_availability(availability.clone())?;
|
||||
for id in requests {
|
||||
if let Ok(instance) = prepared.layout().instance(&id)
|
||||
&& let Some(state) = availability.get(&instance.id)
|
||||
{
|
||||
state.require()?;
|
||||
}
|
||||
}
|
||||
let mut events: Vec<(ControllerKey, LifecycleEvent)> = Vec::new();
|
||||
for (key, retained) in self.controllers.iter().chain(staged.iter()) {
|
||||
let before = self.layout.layout().instances.iter().find(|p| p.id == key.instance);
|
||||
let after = prepared.layout().instances.iter().find(|p| p.id == key.instance);
|
||||
let affected = !matches!(&key.owner, ContentOwner::Workspace(workspace) if *workspace != self.environment.workspace);
|
||||
if explicit_destroy.contains(&key.instance) {
|
||||
retained.controller.prepare_lifecycle(&LifecycleEvent::DestroyOwner)?;
|
||||
events.push((key.clone(), LifecycleEvent::DestroyOwner));
|
||||
} else if affected && explicit_close.contains(&key.instance) {
|
||||
retained.controller.prepare_lifecycle(&LifecycleEvent::Close)?;
|
||||
events.push((key.clone(), LifecycleEvent::Close));
|
||||
} else if !matches!(&key.owner, ContentOwner::Workspace(workspace) if *workspace != environment.workspace)
|
||||
&& (replacement || explicit_open.contains(&key.instance))
|
||||
&& retained.needs_reopen
|
||||
&& after.is_some_and(|p| p.requested_open)
|
||||
{
|
||||
retained.controller.prepare_lifecycle(&LifecycleEvent::Reopen)?;
|
||||
events.push((key.clone(), LifecycleEvent::Reopen));
|
||||
}
|
||||
let old = self.layout.layout().containing(&key.instance).ok().map(|s| (&s.id, &s.location));
|
||||
let new = prepared.layout().containing(&key.instance).ok().map(|s| (&s.id, &s.location));
|
||||
let same_controller = after.is_some_and(|p| self.key_in(p, &environment).is_ok_and(|k| k == *key))
|
||||
&& before.is_some_and(|p| self.key(p).is_ok_and(|k| k == *key));
|
||||
if same_controller
|
||||
&& (old != new || self.environment.workspace != environment.workspace)
|
||||
&& before.is_some_and(|p| p.requested_open)
|
||||
&& after.is_some_and(|p| p.requested_open)
|
||||
{
|
||||
retained.controller.prepare_lifecycle(&LifecycleEvent::Reparent)?;
|
||||
events.push((key.clone(), LifecycleEvent::Reparent));
|
||||
}
|
||||
}
|
||||
// Effects are available from the same candidate, before irreversible work.
|
||||
native.prepare(prepared.effects())?;
|
||||
let reply = self.layout.commit(prepared)?;
|
||||
self.controllers.extend(staged);
|
||||
if replacement {
|
||||
for (key, retained) in &self.controllers {
|
||||
services.local.release(&retained.scope, &key.instance);
|
||||
}
|
||||
}
|
||||
self.environment = environment;
|
||||
let current_keys: BTreeSet<_> = self.layout.layout().instances.iter().filter_map(|p| self.key(p).ok()).collect();
|
||||
for (key, retained) in &mut self.controllers {
|
||||
if current_keys.contains(key)
|
||||
&& let Some(state) = availability.get(&key.instance)
|
||||
{
|
||||
retained.availability = state.clone();
|
||||
}
|
||||
}
|
||||
for (key, event) in events {
|
||||
if let Some(retained) = self.controllers.get_mut(&key) {
|
||||
if matches!(event, LifecycleEvent::Close | LifecycleEvent::Reparent | LifecycleEvent::DestroyOwner) {
|
||||
services.local.release(&retained.scope, &key.instance);
|
||||
}
|
||||
retained.controller.lifecycle(&event);
|
||||
match event {
|
||||
LifecycleEvent::Close => retained.needs_reopen = true,
|
||||
LifecycleEvent::Reopen => retained.needs_reopen = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if event == LifecycleEvent::DestroyOwner {
|
||||
self.controllers.remove(&key);
|
||||
}
|
||||
}
|
||||
native.commit(&reply.effects);
|
||||
// Presentation will be reconciled by mount/update; layout changes do not
|
||||
// force content drawing or touch model revisions/undo/tessellation.
|
||||
self.sync_states(None, services.local);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn sync_states(&mut self, resolved: Option<&ResolvedLayout>, local: &mut dyn PanelLocalServices) {
|
||||
let active_keys: BTreeSet<_> = self.layout.layout().instances.iter().filter_map(|p| self.key(p).ok()).collect();
|
||||
let scope = self.scope();
|
||||
let mut pending = Vec::new();
|
||||
for (key, retained) in &self.controllers {
|
||||
let instance = self.layout.layout().instance(&key.instance).ok();
|
||||
let current = active_keys.contains(key);
|
||||
let available = retained.availability.clone();
|
||||
let requested_open = current && instance.is_some_and(|p| p.requested_open);
|
||||
let section = if current { self.layout.layout().containing(&key.instance).ok() } else { None };
|
||||
let section_id = section.map(|s| s.id.clone());
|
||||
let host = section.and_then(|s| match &s.location {
|
||||
SectionLocation::Window { host, .. } => Some(host.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let peek = &self.layout.interaction().peek;
|
||||
let selected = section.is_some_and(|s| (!s.collapsed && s.active == key.instance) || peek.as_ref() == Some(&key.instance));
|
||||
let geometry_presented = match resolved {
|
||||
Some(r) => r.panels.iter().any(|p| p.instance == key.instance),
|
||||
None => retained.state.presented && retained.section == section_id && retained.native_host == host && selected,
|
||||
};
|
||||
let presented = current && requested_open && available == Availability::Available && geometry_presented;
|
||||
let activated = presented && selected;
|
||||
let state = ControllerState { requested_open, available, presented, activated, destroyed: false };
|
||||
pending.push((key.clone(), state, section_id, host, current));
|
||||
}
|
||||
for (key, state, section, host, current) in pending {
|
||||
if let Some(retained) = self.controllers.get_mut(&key) {
|
||||
let mut target_scope = scope.clone();
|
||||
if let Some(host) = &host {
|
||||
target_scope.viewport = ViewportId(host.0.clone());
|
||||
}
|
||||
if retained.state.presented && !state.presented || current && (retained.scope != target_scope || retained.native_host != host) {
|
||||
local.release(&retained.scope, &key.instance);
|
||||
}
|
||||
if state != retained.state {
|
||||
retained.controller.lifecycle(&LifecycleEvent::State(state.clone()));
|
||||
}
|
||||
retained.state = state;
|
||||
retained.section = section;
|
||||
retained.native_host = host;
|
||||
if current {
|
||||
retained.scope = target_scope;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mount(&mut self, resolved: &ResolvedLayout, host: &mut dyn PanelHost, services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
self.require_live()?;
|
||||
resolved.validate()?;
|
||||
for record in &resolved.unpresented {
|
||||
self.layout.layout().section(&record.section)?;
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for geometry in &resolved.panels {
|
||||
geometry.validate()?;
|
||||
if !ids.insert(&geometry.instance) {
|
||||
return Err(error(Rule::ExactlyOnce, "panel mounted twice"));
|
||||
}
|
||||
let section = self.layout.layout().containing(&geometry.instance)?;
|
||||
if section.id != geometry.section
|
||||
|| section.location.zone() != geometry.zone
|
||||
|| !self.layout.layout().instance(&geometry.instance)?.requested_open
|
||||
{
|
||||
return Err(error(Rule::Placement, "resolved host differs from live layout"));
|
||||
}
|
||||
let peek = self.layout.interaction().peek.as_ref() == Some(&geometry.instance);
|
||||
if section.active != geometry.instance && !peek || section.collapsed && !peek {
|
||||
return Err(error(Rule::Placement, "cannot mount inactive or collapsed tab content"));
|
||||
}
|
||||
let (min, max) = tab_bounds(self.layout.layout(), self.layout.registry(), section)?;
|
||||
if !geometry.outer.size.within(min, max) {
|
||||
return Err(error(Rule::Bounds, "host violates tab outer bounds"));
|
||||
}
|
||||
if self.layout.availability(&geometry.instance) != Availability::Available {
|
||||
return Err(error(Rule::Availability, "cannot mount unavailable content"));
|
||||
}
|
||||
let key = self.key(self.layout.layout().instance(&geometry.instance)?)?;
|
||||
self.controllers.get(&key).ok_or_else(|| error(Rule::Availability, "content controller unavailable"))?.availability.require()?;
|
||||
}
|
||||
self.sync_states(Some(resolved), services.local);
|
||||
let interaction = self.layout.interaction().clone();
|
||||
for geometry in &resolved.panels {
|
||||
let instance = self.layout.layout().instance(&geometry.instance)?;
|
||||
let key = self.key(instance)?;
|
||||
let retained = self.controllers.get_mut(&key).ok_or_else(|| error(Rule::MissingTarget, "call update before mount"))?;
|
||||
let (flow, size_class) = responsive(geometry.zone, geometry.body.size, retained.flow)?;
|
||||
let context = PanelContext {
|
||||
instance: geometry.instance.clone(),
|
||||
owner: key.owner.clone(),
|
||||
workspace: self.environment.workspace.clone(),
|
||||
document: self.environment.document.clone(),
|
||||
viewport: retained.scope.viewport.clone(),
|
||||
native_host: retained.native_host.clone(),
|
||||
outer: geometry.outer,
|
||||
usable_body: geometry.body,
|
||||
flow,
|
||||
size_class,
|
||||
focused: interaction.focused.as_ref() == Some(&geometry.instance),
|
||||
direction: self.environment.direction,
|
||||
motion: self.environment.motion,
|
||||
};
|
||||
let presentation = retained.controller.presentation();
|
||||
presentation.validate()?;
|
||||
host.mount(geometry, &presentation, &context, retained.controller.as_mut(), services)?;
|
||||
retained.flow = Some(flow);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn content_action(&mut self, instance: &InstanceId, action: &ControlId, services: &mut ContentServices<'_>) -> PanelResult<()> {
|
||||
self.require_live()?;
|
||||
valid_id(&action.0)?;
|
||||
self.layout.availability(instance).require()?;
|
||||
let key = self.key(self.layout.layout().instance(instance)?)?;
|
||||
let retained = self.controllers.get_mut(&key).ok_or_else(|| error(Rule::MissingTarget, "controller not retained"))?;
|
||||
retained.availability.require()?;
|
||||
let presentation = retained.controller.presentation();
|
||||
presentation.validate()?;
|
||||
let command =
|
||||
presentation.actions.iter().find(|a| a.id == *action).ok_or_else(|| error(Rule::MissingTarget, "content action not advertised"))?;
|
||||
if !command.enabled {
|
||||
return Err(error(Rule::Availability, command.disabled_reason.as_deref().unwrap_or("content action unavailable")));
|
||||
}
|
||||
retained.controller.action(&key.owner, action, services)
|
||||
}
|
||||
|
||||
pub fn destroy_owner(&mut self, owner: &ContentOwner, local: &mut dyn PanelLocalServices) -> PanelResult<()> {
|
||||
self.require_live()?;
|
||||
owner.validate()?;
|
||||
if self.destroyed_owners.len() >= MAX_RETAINED_OWNERS && !self.destroyed_owners.contains(owner) {
|
||||
return Err(error(Rule::CollectionLimit, "destroyed owner cap"));
|
||||
}
|
||||
for (key, retained) in &self.controllers {
|
||||
if key.owner == *owner {
|
||||
retained.controller.prepare_lifecycle(&LifecycleEvent::DestroyOwner)?;
|
||||
}
|
||||
}
|
||||
self.controllers.retain(|key, retained| {
|
||||
if key.owner != *owner {
|
||||
return true;
|
||||
}
|
||||
local.release(&retained.scope, &key.instance);
|
||||
retained.controller.lifecycle(&LifecycleEvent::DestroyOwner);
|
||||
false
|
||||
});
|
||||
self.destroyed_owners.insert(owner.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Explicitly rebind a previously destroyed owner; it receives fresh content
|
||||
/// controllers on update, never a silent resurrection during hidden ticks.
|
||||
pub fn revive_owner(&mut self, owner: &ContentOwner) -> PanelResult<()> {
|
||||
self.require_live()?;
|
||||
owner.validate()?;
|
||||
self.destroyed_owners.remove(owner);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self, local: &mut dyn PanelLocalServices) -> PanelResult<()> {
|
||||
for retained in self.controllers.values() {
|
||||
retained.controller.prepare_lifecycle(&LifecycleEvent::DestroyOwner)?;
|
||||
}
|
||||
for (key, retained) in &mut self.controllers {
|
||||
local.release(&retained.scope, &key.instance);
|
||||
retained.controller.lifecycle(&LifecycleEvent::DestroyOwner);
|
||||
}
|
||||
self.controllers.clear();
|
||||
self.destroyed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+312
-79
@@ -143,93 +143,119 @@ fn matches_query(plugin: &Plugin, query: &str) -> bool {
|
||||
|
||||
/// The Plugins page. Desktop only: the browser build has no per-user plugin folder.
|
||||
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
ui.label(crate::i18n::tr("ui.plugins_desktop"));
|
||||
return;
|
||||
}
|
||||
let Some(dir) = plugins_dir() else {
|
||||
ui.label(crate::i18n::tr("ui.plugins_desktop"));
|
||||
return;
|
||||
};
|
||||
let path = dir.display().to_string();
|
||||
let searching = crate::prefs_search::querying(ui);
|
||||
// The page's own box is for browsing Plugins. The dialog search is the filter while it is active.
|
||||
if !searching {
|
||||
crate::ui_kit::search_field(ui, ui.id().with("plugins_search"), &mut app.prefs_window.plugin_query, crate::i18n::tr("ui.plugins_search"));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if searching {
|
||||
crate::prefs_search::begin_section(ui, crate::i18n::tr("ui.plugins"));
|
||||
} else {
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(crate::i18n::format("ui.plugins_note", &[("path", &path)])).size(t.font_small).color(t.text_dim)).wrap(),
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if !searching && crate::ui_kit::button(ui, crate::i18n::tr_label("Show in folder"), crate::ui_kit::Button::Normal).clicked() {
|
||||
// `show_in_folder` opens the parent of the path it is given, so the argument is a name
|
||||
// inside the plugins directory (it need not exist).
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
app.set_status(format!("Could not create the plugins folder: {e}"), true);
|
||||
} else if let Some(show) = &app.services.show_in_folder {
|
||||
show(&dir.join("plugin.json").display().to_string());
|
||||
let dir = if cfg!(target_arch = "wasm32") { None } else { plugins_dir() };
|
||||
page_in_dir(app, ui, dir.as_deref());
|
||||
}
|
||||
|
||||
fn page_in_dir(app: &mut SolveApp, ui: &mut egui::Ui, dir: Option<&Path>) {
|
||||
crate::ui_kit::settings_page(ui, |ui| {
|
||||
crate::ui_kit::settings_heading(ui, crate::i18n::tr("ui.plugins"), crate::i18n::tr("prefs.plugins_intro"));
|
||||
let Some(dir) = dir else {
|
||||
ui.label(crate::i18n::tr("ui.plugins_desktop"));
|
||||
return;
|
||||
};
|
||||
let t = crate::theme::Tokens::get();
|
||||
let path = dir.display().to_string();
|
||||
let searching = crate::prefs_search::querying(ui);
|
||||
// The page's own box is for browsing Plugins. The dialog search is the filter while it is active.
|
||||
if !searching {
|
||||
crate::ui_kit::search_field(ui, ui.id().with("plugins_search"), &mut app.prefs_window.plugin_query, crate::i18n::tr("ui.plugins_search"));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let (plugins, skipped) = scan(&dir);
|
||||
let query = app.prefs_window.plugin_query.clone();
|
||||
if plugins.is_empty() && !searching {
|
||||
ui.label(crate::i18n::tr("ui.plugins_empty"));
|
||||
}
|
||||
let mut shown = 0usize;
|
||||
for plugin in plugins.iter().filter(|plugin| searching || matches_query(plugin, &query)) {
|
||||
if searching && !crate::prefs_search::texts_hit(ui, &[&plugin.name, &plugin.id, &plugin.description, &plugin.version]) {
|
||||
continue;
|
||||
}
|
||||
shown += 1;
|
||||
if searching {
|
||||
crate::prefs_search::reveal(ui);
|
||||
crate::prefs_search::push_force(ui);
|
||||
}
|
||||
let mut on = enabled(app, &plugin.id);
|
||||
ui.horizontal(|ui| {
|
||||
let check = crate::ui_kit::checkbox(ui, "", &mut on);
|
||||
crate::scenario::publish_handle(&format!("prefs:plugin:{}", plugin.id), check.rect.center());
|
||||
if check.changed() {
|
||||
if on {
|
||||
app.preferences.disabled_plugins.remove(&plugin.id);
|
||||
} else if app.preferences.disabled_plugins.len() < MAX_DISABLED {
|
||||
app.preferences.disabled_plugins.insert(plugin.id.clone());
|
||||
let (plugins, skipped) = scan(dir);
|
||||
let query = app.prefs_window.plugin_query.clone();
|
||||
crate::ui_kit::settings_list_section(
|
||||
ui,
|
||||
"plugins_installed",
|
||||
crate::i18n::tr("prefs.plugins_installed"),
|
||||
Some(&plugins.len().to_string()),
|
||||
|ui| {
|
||||
if plugins.is_empty() && (!searching || crate::prefs_search::shows_all(ui)) {
|
||||
crate::ui_kit::text(ui, crate::i18n::tr("ui.plugins_empty"), crate::ui_kit::Text::Hint("plugins_empty"));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.y = t.space_8;
|
||||
let mut shown = 0usize;
|
||||
for plugin in plugins.iter().filter(|plugin| searching || matches_query(plugin, &query)) {
|
||||
if searching
|
||||
&& !crate::prefs_search::shows_all(ui)
|
||||
&& !crate::prefs_search::texts_hit(ui, &[&plugin.name, &plugin.id, &plugin.description, &plugin.version])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
shown += 1;
|
||||
if searching {
|
||||
crate::prefs_search::reveal(ui);
|
||||
crate::prefs_search::push_force(ui);
|
||||
}
|
||||
plugin_card(app, ui, dir, plugin);
|
||||
if searching {
|
||||
crate::prefs_search::pop_force(ui);
|
||||
}
|
||||
}
|
||||
if !searching && !plugins.is_empty() && shown == 0 {
|
||||
ui.label(crate::i18n::tr("ui.plugins_none"));
|
||||
}
|
||||
},
|
||||
);
|
||||
if !searching && query.trim().is_empty() && !skipped.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::format("ui.plugins_skipped", &[("count", &skipped.len().to_string())]))
|
||||
.size(t.font_small)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
for skip in skipped.iter().take(8) {
|
||||
let who = if skip.folder.is_empty() { "folder" } else { skip.folder.as_str() };
|
||||
ui.label(RichText::new(format!("{who}: {}", skip.reason)).size(t.font_small).color(t.text_dim));
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new(&plugin.name).strong());
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::format("ui.plugins_version", &[("version", &plugin.version)])).size(t.font_small).color(t.text_dim),
|
||||
);
|
||||
if !plugin.description.is_empty() {
|
||||
ui.label(RichText::new(&plugin.description).size(t.font_small).color(t.text_dim));
|
||||
}
|
||||
if !searching {
|
||||
ui.horizontal(|ui| {
|
||||
let button = crate::ui_kit::icon_label_button(ui, crate::i18n::tr("prefs.plugins_show_folder"), "folder", true, false)
|
||||
.on_hover_text(crate::i18n::format("ui.plugins_note", &[("path", &path)]));
|
||||
crate::scenario::publish_target("prefs:plugins:folder", button.rect);
|
||||
if button.clicked() {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
app.set_status(format!("Could not create the plugins folder: {e}"), true);
|
||||
} else if let Some(show) = &app.services.show_in_folder {
|
||||
// The service opens the parent of the supplied file, even if it is absent.
|
||||
show(&dir.join("plugin.json").display().to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
if searching {
|
||||
crate::prefs_search::pop_force(ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn plugin_card(app: &mut SolveApp, ui: &mut egui::Ui, dir: &Path, plugin: &Plugin) {
|
||||
use crate::ui_kit;
|
||||
let mut on = enabled(app, &plugin.id);
|
||||
let was_on = on;
|
||||
let card =
|
||||
ui.push_id(&plugin.id, |ui| ui_kit::settings_plugin_card(ui, &plugin.id, &plugin.name, &plugin.version, &plugin.description, &mut on)).inner;
|
||||
let response = card.response;
|
||||
let (folder, check) = card.inner;
|
||||
crate::scenario::publish_target(&format!("prefs:plugin:{}:folder", plugin.id), folder.rect);
|
||||
crate::scenario::publish_target(&format!("prefs:plugin:{}", plugin.id), check.rect);
|
||||
if folder.clicked()
|
||||
&& let Some(show) = &app.services.show_in_folder
|
||||
{
|
||||
show(&dir.join(&plugin.id).join("plugin.json").display().to_string());
|
||||
}
|
||||
if was_on != on {
|
||||
if on {
|
||||
app.preferences.disabled_plugins.remove(&plugin.id);
|
||||
} else if app.preferences.disabled_plugins.len() < MAX_DISABLED {
|
||||
app.preferences.disabled_plugins.insert(plugin.id.clone());
|
||||
}
|
||||
}
|
||||
if !searching && !plugins.is_empty() && shown == 0 {
|
||||
ui.label(crate::i18n::tr("ui.plugins_none"));
|
||||
crate::scenario::publish_rect(&format!("prefs:plugin:{}:card", plugin.id), response.rect);
|
||||
let texts = [&plugin.name, &plugin.id, &plugin.description, &plugin.version];
|
||||
if let Some(text) = texts.iter().find(|text| crate::prefs_search::texts_hit(ui, &[*text])) {
|
||||
crate::prefs_search::remember(ui, text, response.rect);
|
||||
}
|
||||
if !searching && query.trim().is_empty() && !skipped.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::format("ui.plugins_skipped", &[("count", &skipped.len().to_string())])).size(t.font_small).color(t.text_dim),
|
||||
);
|
||||
for skip in skipped.iter().take(8) {
|
||||
let who = if skip.folder.is_empty() { "folder" } else { skip.folder.as_str() };
|
||||
ui.label(RichText::new(format!("{who}: {}", skip.reason)).size(t.font_small).color(t.text_dim));
|
||||
}
|
||||
for text in texts {
|
||||
crate::prefs_search::follow(ui, text, response.rect);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +263,213 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn page_frame(app: &mut SolveApp, ctx: &egui::Context, dir: &Path, events: Vec<egui::Event>) {
|
||||
crate::scenario::clear_handles();
|
||||
let mut output = crate::test_support::frame(
|
||||
ctx,
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(700.0, 800.0))),
|
||||
events,
|
||||
focused: true,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| crate::ui_kit::scope(ui, |ui| page_in_dir(app, ui, Some(dir))),
|
||||
);
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn click_page(app: &mut SolveApp, ctx: &egui::Context, dir: &Path, handle: &str) {
|
||||
let at = crate::scenario::handle_at(handle).unwrap();
|
||||
page_frame(app, ctx, dir, crate::test_support::pointer(at, true));
|
||||
page_frame(app, ctx, dir, crate::test_support::pointer(at, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_production_composition_publishes_page_heading_and_section_geometry() {
|
||||
let previous = crate::theme::is_dark();
|
||||
for dark in [false, true] {
|
||||
crate::theme::set_dark(dark);
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let dir = h.dir.join("page-geometry");
|
||||
let ctx = crate::fonts::test_context();
|
||||
for _ in 0..3 {
|
||||
page_frame(&mut h.app, &ctx, &dir, vec![]);
|
||||
}
|
||||
// The injected path goes through the production composition, including its
|
||||
// heading and settings_page scope; the fixture adds only the window's form style.
|
||||
let heading = crate::scenario::rect_of("prefs:page_heading").unwrap();
|
||||
let section = crate::scenario::rect_of("prefs:section:plugins_installed").unwrap();
|
||||
let body = crate::scenario::rect_of("prefs:section:plugins_installed:body").unwrap();
|
||||
let t = crate::theme::Tokens::get();
|
||||
assert!(heading.height() >= t.space_24 && heading.bottom() < section.top());
|
||||
assert!((section.left() - heading.left()).abs() <= 1.0 && (section.right() - heading.right()).abs() <= 1.0);
|
||||
assert!((section.height() - (t.control_height + t.space_2)).abs() <= 1.0);
|
||||
assert!((body.top() - section.bottom() - t.space_10).abs() <= 1.0);
|
||||
assert!(!dir.exists(), "production layout does not create the plugin folder");
|
||||
}
|
||||
crate::theme::set_dark(previous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_card_actions_share_a_row_at_normal_width_and_stay_inside_narrow_cards() {
|
||||
let previous = crate::theme::is_dark();
|
||||
for dark in [false, true] {
|
||||
crate::theme::set_dark(dark);
|
||||
for language in ["en", "de"] {
|
||||
let _language = crate::i18n::scope(language);
|
||||
for width in [646.0, 200.0] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let dir = h.dir.join("plugin-action-layout");
|
||||
write(&dir, "public-one", r#"{"id":"public-one","name":"Public one","version":"1.2.3","description":"Public fixture."}"#);
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut clip = egui::Rect::NOTHING;
|
||||
let mut painted = Vec::new();
|
||||
for _ in 0..3 {
|
||||
crate::scenario::clear_handles();
|
||||
let mut output = crate::test_support::frame(
|
||||
&ctx,
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width + 16.0, 800.0))),
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
ui.set_width(width);
|
||||
clip = ui.clip_rect();
|
||||
crate::ui_kit::scope(ui, |ui| page_in_dir(&mut h.app, ui, Some(&dir)));
|
||||
},
|
||||
);
|
||||
output.textures_delta.clear();
|
||||
painted = output.shapes;
|
||||
}
|
||||
let card = crate::scenario::rect_of("prefs:plugin:public-one:card").unwrap();
|
||||
let folder = crate::scenario::rect_of("prefs:plugin:public-one:folder").unwrap();
|
||||
let checkbox = crate::scenario::rect_of("prefs:plugin:public-one").unwrap();
|
||||
assert!(card.width() <= width + 1.0, "{language}, {width}: card stays within the page");
|
||||
for action in [folder, checkbox] {
|
||||
assert!(card.expand(1.0).contains_rect(action), "{language}, {width}: action stays inside its card");
|
||||
assert!(clip.contains_rect(action), "{language}, {width}: action is not clipped");
|
||||
}
|
||||
if width == 646.0 {
|
||||
assert!((folder.center().y - checkbox.center().y).abs() <= 1.0, "{language}: normal-width actions share one row");
|
||||
assert!(folder.right() <= checkbox.left(), "actions do not overlap");
|
||||
assert!((card.height() - 62.0).abs() <= 1.0, "36 pt content plus 12 pt padding and 1 pt border on each side");
|
||||
assert_eq!(folder.height(), 24.0);
|
||||
assert_eq!(checkbox.size(), egui::vec2(16.0, 16.0));
|
||||
let t = crate::theme::Tokens::get();
|
||||
let icon = crate::scenario::rect_of("prefs:plugin:public-one:icon").unwrap();
|
||||
assert_eq!(icon.size(), egui::vec2(36.0, 36.0));
|
||||
assert!((icon.left() - card.left() - 13.0).abs() <= 1.0, "12 pt padding inside the border");
|
||||
for control in [icon, folder, checkbox] {
|
||||
assert!((control.center().y - card.center().y).abs() <= 1.0, "all card content is vertically centred");
|
||||
}
|
||||
assert!((checkbox.right() - card.right() + 13.0).abs() <= 1.0, "right padding matches left");
|
||||
let mut quiet = true;
|
||||
let mut semibold_title = false;
|
||||
for shape in &painted {
|
||||
crate::test_support::each_shape(&shape.shape, &mut |shape| match shape {
|
||||
egui::Shape::Rect(rect) if rect.rect == folder && rect.fill != egui::Color32::TRANSPARENT => quiet = false,
|
||||
egui::Shape::Text(text) if text.galley.job.text == "Public one" => {
|
||||
semibold_title = text
|
||||
.galley
|
||||
.job
|
||||
.sections
|
||||
.iter()
|
||||
.all(|section| section.format.font_id == crate::fonts::semibold(t.font_button));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
assert!(quiet, "Show in folder has no button background");
|
||||
assert!(semibold_title, "the title uses the actual 13 pt semibold face");
|
||||
}
|
||||
assert!(enabled(&h.app, "public-one"), "layout does not change plugin state");
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::theme::set_dark(previous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_cards_open_their_own_folder_and_cancel_restores_enabled_state() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let previous = crate::theme::is_dark();
|
||||
for dark in [false, true] {
|
||||
crate::theme::set_dark(dark);
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let dir = h.dir.join("public-plugins");
|
||||
write(&dir, "public-one", r#"{"id":"public-one","name":"Public one","version":"1.2.3","description":"Public fixture."}"#);
|
||||
write(&dir, "public-two", r#"{"id":"public-two","name":"Public two","version":"2","description":"Another fixture."}"#);
|
||||
let opened = Rc::new(RefCell::new(Vec::new()));
|
||||
let seen = opened.clone();
|
||||
h.app.services.show_in_folder = Some(Box::new(move |path| seen.borrow_mut().push(PathBuf::from(path))));
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"open":"Plugins"})).unwrap();
|
||||
let ctx = crate::fonts::test_context();
|
||||
for _ in 0..3 {
|
||||
page_frame(&mut h.app, &ctx, &dir, vec![]);
|
||||
}
|
||||
assert!(opened.borrow().is_empty(), "rendering never opens a folder");
|
||||
click_page(&mut h.app, &ctx, &dir, "prefs:plugin:public-two:folder");
|
||||
assert_eq!(*opened.borrow(), [dir.join("public-two/plugin.json")]);
|
||||
click_page(&mut h.app, &ctx, &dir, "prefs:plugin:public-one");
|
||||
assert!(!enabled(&h.app, "public-one") && enabled(&h.app, "public-two"));
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"defaults"})).unwrap();
|
||||
assert!(enabled(&h.app, "public-one"));
|
||||
page_frame(&mut h.app, &ctx, &dir, vec![]);
|
||||
click_page(&mut h.app, &ctx, &dir, "prefs:plugin:public-one");
|
||||
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"cancel"})).unwrap();
|
||||
assert!(enabled(&h.app, "public-one") && enabled(&h.app, "public-two"));
|
||||
}
|
||||
crate::theme::set_dark(previous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_plugins_folder_creates_only_the_requested_fixture_root() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let dir = h.dir.join("not-created-yet");
|
||||
let opened = Rc::new(RefCell::new(Vec::new()));
|
||||
let seen = opened.clone();
|
||||
h.app.services.show_in_folder = Some(Box::new(move |path| seen.borrow_mut().push(PathBuf::from(path))));
|
||||
let ctx = crate::fonts::test_context();
|
||||
for _ in 0..3 {
|
||||
page_frame(&mut h.app, &ctx, &dir, vec![]);
|
||||
}
|
||||
assert!(!dir.exists() && opened.borrow().is_empty());
|
||||
click_page(&mut h.app, &ctx, &dir, "prefs:plugins:folder");
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(*opened.borrow(), [dir.join("plugin.json")]);
|
||||
assert!(!dir.join("plugin.json").exists(), "the reveal service needs only a sentinel path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_metadata_search_owns_one_navigation_target_and_reveals_the_real_card() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let dir = h.dir.join("public-plugin-search");
|
||||
write(&dir, "public-one", r#"{"id":"public-one","name":"Public one","version":"1.2.3","description":"Unique description."}"#);
|
||||
let ctx = crate::fonts::test_context();
|
||||
for query in ["Public one", "public-one", "1.2.3", "Unique description"] {
|
||||
for _ in 0..3 {
|
||||
crate::scenario::clear_handles();
|
||||
let mut output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
|
||||
ui.set_width(646.0);
|
||||
let _search = crate::prefs_search::begin(ui, query);
|
||||
crate::prefs_search::begin_page(ui, crate::prefs::PAGE_PLUGINS, "Extend > Plugins");
|
||||
crate::ui_kit::scope(ui, |ui| page_in_dir(&mut h.app, ui, Some(&dir)));
|
||||
crate::prefs_search::store_nav(ui);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
let (_, _, hits) = crate::prefs_search::nav(&ctx);
|
||||
assert_eq!(hits.len(), 1, "{query}: one real plugin result");
|
||||
assert_eq!(hits[0].page, crate::prefs::PAGE_PLUGINS);
|
||||
crate::prefs_search::set_reveal(&ctx, Some(crate::prefs_search::reveal_for(hits[0].page, &hits[0].label)));
|
||||
page_frame(&mut h.app, &ctx, &dir, vec![]);
|
||||
let card = crate::scenario::rect_of("prefs:plugin:public-one:card").unwrap();
|
||||
assert!(crate::scenario::handle_at("prefs:revealed").unwrap().distance(card.center()) <= 1.0);
|
||||
assert!(enabled(&h.app, "public-one"), "searching and revealing do not toggle the plugin");
|
||||
}
|
||||
}
|
||||
|
||||
fn write(dir: &Path, folder: &str, body: &str) {
|
||||
let d = dir.join(folder);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
|
||||
+647
-94
@@ -537,6 +537,12 @@ fn place_search_cursor(ctx: &egui::Context, id: egui::Id, chars: usize) {
|
||||
}
|
||||
|
||||
fn open_result(app: &mut SolveApp, page: usize, label: &str) {
|
||||
if page == PAGE_SHORTCUTS {
|
||||
crate::keymap::reveal(app);
|
||||
}
|
||||
if page == PAGE_PLUGINS {
|
||||
app.prefs_window.plugin_query.clear();
|
||||
}
|
||||
app.prefs_window.filter.clear();
|
||||
app.prefs_window.search_from = None;
|
||||
app.prefs_window.section = page;
|
||||
@@ -1025,7 +1031,7 @@ preference_settings! {
|
||||
/// Show the radial ring and enable hold/flick gestures for viewport context menus.
|
||||
radial_context_menu: bool = false, PAGE_NAVIGATION => check("Radial context menu");
|
||||
/// Work on the model directly in the viewport (pull a face to extrude or cut); off by default.
|
||||
direct_interaction: bool = false, PAGE_TOOLS => check("Direct interaction", "Work on the model directly in the viewport. Press a flat face or a profile and pull: up extrudes, down into a body cuts, and sideways movement sets the step size");
|
||||
direct_interaction: bool = false, PAGE_TOOLS;
|
||||
/// Mouse gestures: a ring of tools on the right button.
|
||||
mouse_gestures: crate::mouse_gesture::Config = Default::default(), PAGE_NAVIGATION;
|
||||
/// The wheel zooms out when turned forward.
|
||||
@@ -1092,7 +1098,7 @@ preference_settings! {
|
||||
/// Anti-aliasing samples (applies on restart).
|
||||
msaa: u32 = 4, PAGE_GRAPHICS;
|
||||
/// Share shading and Continuity; false preserves their independent legacy behavior.
|
||||
single_surface_analysis: bool = true, PAGE_GRAPHICS => check_id("prefs.single_surface_analysis");
|
||||
single_surface_analysis: bool = true, PAGE_GRAPHICS => check_id("prefs.graphics_page.single_surface_analysis");
|
||||
/// Linux: the window system the app opens with at its next start (#3083): `auto` (XWayland
|
||||
/// whenever there is an X display, native Wayland on niri), `wayland` or `x11`.
|
||||
window_system: String = "auto".into(), PAGE_GRAPHICS;
|
||||
@@ -1110,13 +1116,13 @@ preference_settings! {
|
||||
section_hatch: SectionHatch = SectionHatch::ByPart, PAGE_GRAPHICS;
|
||||
/// The grid turns to the upright origin plane facing a side view (#1470). Off by default: the
|
||||
/// grid stays on the ground plane (XY) whatever the view.
|
||||
grid_follows_view: bool = false => check("Grid follows the view", "Side views show the grid on the upright plane facing them instead of the ground");
|
||||
grid_follows_view: bool = false => check_id("ui.grid_follows_view");
|
||||
/// Draw reference geometry such as inactive sketches and datum planes through shaded bodies.
|
||||
/// Off by default to preserve the depth-tested viewport behaviour.
|
||||
reference_geometry_through_bodies: bool = false, PAGE_GRAPHICS => check_id("settings.reference_geometry_through_bodies");
|
||||
reference_geometry_through_bodies: bool = false, PAGE_GRAPHICS => check_id("prefs.graphics_page.reference_geometry_through_bodies");
|
||||
/// A small dot at every vertex wherever edges are drawn (#7834). Off by default: the viewport
|
||||
/// draws edges only, as it always has.
|
||||
vertex_markers: bool = false => check("Vertex markers", "Show a small dot at every vertex wherever edges are drawn");
|
||||
vertex_markers: bool = false;
|
||||
/// A preview of a feature that changes a body draws the surface it adds in the preview
|
||||
/// colour, working out face by face which surface that is. Off: the body is drawn as itself
|
||||
/// and nothing is worked out (`preview::Colors::tint_new`).
|
||||
@@ -1133,9 +1139,9 @@ preference_settings! {
|
||||
/// down (1, 1.5 or 2; 1 is off).
|
||||
supersampling: f32 = 1.0, PAGE_GRAPHICS;
|
||||
/// Supersample only while the view holds still (full speed while orbiting).
|
||||
supersampling_adaptive: bool = true, PAGE_GRAPHICS => check_tr("ui.supersampling_adaptive");
|
||||
supersampling_adaptive: bool = true, PAGE_GRAPHICS => check_tr("prefs.graphics_page.supersampling_adaptive");
|
||||
/// Show the frame rate in the view's corner (the view then redraws every frame).
|
||||
show_frame_rate: bool = false, PAGE_GRAPHICS => check_id("ui.frame_rate");
|
||||
show_frame_rate: bool = false, PAGE_GRAPHICS;
|
||||
/// Record where the time goes (SMLib's calls, kernel operations, features, commands, imports)
|
||||
/// for `diagnostics.profile` (docs/perf.md). Off by default: nothing is recorded.
|
||||
profile_operations: bool = false, PAGE_GRAPHICS => check_tr("prefs.profile_operations");
|
||||
@@ -1147,7 +1153,7 @@ preference_settings! {
|
||||
/// Off by default; View ▸ View angles and `view.angle_readout` turn it on.
|
||||
show_view_angles: bool = false, PAGE_GRAPHICS;
|
||||
/// Show world X/Y/Z component dimensions beside the total distance in Measure.
|
||||
measure_axis_distances: bool = true, PAGE_TOOLS => check_id("settings.measure_axis_distances");
|
||||
measure_axis_distances: bool = true, PAGE_TOOLS;
|
||||
/// Perspective view's vertical field of view in radians (#5364), 15° to 90°. The view reads a
|
||||
/// saved value outside that range as the default ([`perspective_fov`]).
|
||||
perspective_fov: f64 = PERSPECTIVE_FOV, PAGE_GRAPHICS, accept(15f64.to_radians()..=90f64.to_radians());
|
||||
@@ -1228,23 +1234,23 @@ preference_settings! {
|
||||
/// Show command names beneath ribbon icons, independently of workspace layout.
|
||||
ribbon_labels: bool = true, PAGE_LAYOUT;
|
||||
/// Show engine-derived persistent selection in the ribbon; off retains neutral buttons.
|
||||
persistent_ribbon_pressed: bool = true, PAGE_LAYOUT => check_id("prefs.persistent_ribbon_pressed");
|
||||
viewport_analysis_status: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_analysis_status");
|
||||
viewport_section_status: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_section_status");
|
||||
viewport_status_details: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_status_details");
|
||||
viewport_status_summary: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_status_summary");
|
||||
persistent_ribbon_pressed: bool = true, PAGE_LAYOUT;
|
||||
viewport_analysis_status: bool = true, PAGE_GRAPHICS;
|
||||
viewport_section_status: bool = true, PAGE_GRAPHICS;
|
||||
viewport_status_details: bool = true, PAGE_GRAPHICS;
|
||||
viewport_status_summary: bool = true, PAGE_GRAPHICS;
|
||||
/// Keep the SKETCH tab in the ribbon, right after MODEL, while no sketch is open; its drawing
|
||||
/// tools then start a sketch first (#7904).
|
||||
sketch_tab_always: bool = false, PAGE_LAYOUT => check_id("prefs.sketch_tab_always");
|
||||
sketch_tab_always: bool = false, PAGE_LAYOUT;
|
||||
/// Each design tab keeps its open dialog or tool, and its toolbar tab, while another design is
|
||||
/// shown, and has them back when it is shown again (#9360). Off by default: a switch cancels
|
||||
/// them and shows the MODEL tab.
|
||||
keep_work_on_tab_switch: bool = false, PAGE_LAYOUT => check_id("prefs.keep_work_on_tab_switch");
|
||||
keep_work_on_tab_switch: bool = false, PAGE_LAYOUT;
|
||||
/// Hovering a result in Search commands shows the command's place in the ribbon: its tab,
|
||||
/// its button or its row in a section's drop-down (#7287). A command started from the search
|
||||
/// blinks there, and a running command that sits in a drop-down marks its section label. Off
|
||||
/// by default: the ribbon stays as it was.
|
||||
ribbon_reveal: bool = false, PAGE_LAYOUT => check_id("prefs.ribbon_reveal");
|
||||
ribbon_reveal: bool = false, PAGE_LAYOUT;
|
||||
/// Where a dialog's OK and Cancel sit: under its inputs (the default) or in its title row (#6708).
|
||||
dialog_buttons: crate::ui_kit::DialogButtons = Default::default(), PAGE_LAYOUT;
|
||||
/// Where notices and offers stack: the view's bottom-right corner (the default) or the bottom
|
||||
@@ -1257,7 +1263,7 @@ preference_settings! {
|
||||
ribbon_icon_grid: bool = false, PAGE_LAYOUT;
|
||||
/// Leave the commands the ribbon shows as buttons (pinned ones) out of its panel drop-downs,
|
||||
/// which then list only the rest (#4739).
|
||||
ribbon_menus_skip_pinned: bool = false, PAGE_LAYOUT => check_tr("ui.ribbon_menus_skip_pinned");
|
||||
ribbon_menus_skip_pinned: bool = false, PAGE_LAYOUT;
|
||||
/// Interface scale (1 = 100 %).
|
||||
ui_scale: f32 = 1.0, PAGE_APPEARANCE, accept(0.5..=2.5);
|
||||
/// Interface font: an installed font family (or a font file), or empty for the built-in one
|
||||
@@ -1265,13 +1271,13 @@ preference_settings! {
|
||||
ui_font: String = String::new(), PAGE_APPEARANCE;
|
||||
/// While a dialog or a sketch is open, OK and Cancel in the view's top right corner, with the
|
||||
/// axis view moved down below them (#6709). Off by default.
|
||||
view_confirm_corner: bool = false, PAGE_LAYOUT => check_id("prefs.view_confirm_corner");
|
||||
view_confirm_corner: bool = false, PAGE_LAYOUT;
|
||||
/// A thin bar along the window's bottom edge: what is being edited, the open sketch's state
|
||||
/// and the design's units (#7243). Off by default.
|
||||
status_bar: bool = false, PAGE_LAYOUT => check_id("prefs.status_bar");
|
||||
status_bar: bool = false, PAGE_LAYOUT;
|
||||
/// The selection's length, area or distance in the view's bottom right corner (#7000). Off by
|
||||
/// default.
|
||||
quick_measure: bool = false, PAGE_GRAPHICS => check_id("prefs.quick_measure");
|
||||
quick_measure: bool = false, PAGE_GRAPHICS;
|
||||
/// Put the feature history to the left of the browser instead of below the viewport.
|
||||
timeline_left: bool = false, PAGE_LAYOUT;
|
||||
/// Measured outer width; the contents react to this width without a names mode.
|
||||
@@ -1292,7 +1298,7 @@ preference_settings! {
|
||||
history_sketches: HistorySketches = HistorySketches::default(), PAGE_LAYOUT;
|
||||
/// A Play History button in the History controls, between Previous and Next (#3688). Off
|
||||
/// gives the four controls History had before; right-clicking the controls toggles it too.
|
||||
history_play_button: bool = true, PAGE_LAYOUT => check_id("prefs.history_play_button");
|
||||
history_play_button: bool = true, PAGE_LAYOUT;
|
||||
/// The vertical history (left, or under the browser) starts with the active component's Bodies
|
||||
/// folder and the Origin, as the browser shows them (#6490). The bottom strip is unchanged.
|
||||
timeline_origin_bodies: bool = false, PAGE_LAYOUT;
|
||||
@@ -1326,10 +1332,10 @@ preference_settings! {
|
||||
browser_bodies_folders: BodiesFolders = BodiesFolders::Several, PAGE_LAYOUT;
|
||||
/// Each tree row's eye between its fold arrow and its radio, as before #8718, instead of in a
|
||||
/// column at the rows' right end. Off by default.
|
||||
browser_eye_left: bool = false, PAGE_LAYOUT => check_id("prefs.browser_eye_left");
|
||||
browser_eye_left: bool = false, PAGE_LAYOUT;
|
||||
/// Every component placement in the browser starts open, as before #9274, instead of closed
|
||||
/// until the user opens it. Off by default.
|
||||
browser_components_open: bool = false, PAGE_LAYOUT => check_id("prefs.browser_components_open");
|
||||
browser_components_open: bool = false, PAGE_LAYOUT;
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
zoom_to_cursor: bool = true, PAGE_NAVIGATION => check("Zoom to the cursor", "The wheel zooms toward the point under the cursor (off: toward the view's center)");
|
||||
/// Speed multiplier for wheel zoom, independent of the 3D mouse.
|
||||
@@ -1356,14 +1362,14 @@ preference_settings! {
|
||||
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
|
||||
up_axis: String = "z".into(), PAGE_DESIGN;
|
||||
/// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`).
|
||||
offer_stand_up: bool = true, PAGE_DESIGN => check("Offer to stand up Y-up files", "After a STEP or IGES file opens with Z up, offer a quarter turn about X");
|
||||
offer_stand_up: bool = true, PAGE_DESIGN => check_tr("ui.offer_to_stand_up_y_up_files");
|
||||
/// How the offer is made for a file whose header names no program known to save Y up: a note
|
||||
/// over the view (the default), the dialog, or nothing (#7717).
|
||||
stand_up_unknown: StandUpUnknown = StandUpUnknown::default(), PAGE_DESIGN;
|
||||
/// Extrude: with a profile picked, a face, vertex or parallel plane picked off its plane
|
||||
/// switches the extent to To Object (off: picks only add profiles, as before). A click into the
|
||||
/// Profiles box makes the next picks profiles again.
|
||||
extrude_auto_to_object: bool = true, PAGE_TOOLS => check("Extrude to a picked target", "In Extrude, with a profile picked, picking a face, vertex or parallel plane off its plane switches the extent to To Object");
|
||||
extrude_auto_to_object: bool = true, PAGE_TOOLS => check_tr("ui.extrude_auto_to_object");
|
||||
/// Extrude turns the view to isometric each time it opens, so the depth can be seen (#9176).
|
||||
/// Off (the default): the view stays as it is.
|
||||
extrude_view_isometric: bool = false, PAGE_TOOLS => check_id("prefs.extrude_view_isometric");
|
||||
@@ -1385,7 +1391,7 @@ preference_settings! {
|
||||
/// Handles on the preview of a feature placed on a face (Rest, Boss, Snap Fit): a marker that
|
||||
/// drags it across the model and a ring that turns it. Off by default: the feature is then placed
|
||||
/// by its click only, as before, and its Position input takes faces only.
|
||||
placement_handles: bool = false, PAGE_TOOLS => check("Placement handles", "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring");
|
||||
placement_handles: bool = false, PAGE_TOOLS => check_tr("ui.placement_handles");
|
||||
/// The files a design names (linked designs, font files) may be on any network location, for
|
||||
/// organisations that keep their designs on file servers. Off (the default): only on the
|
||||
/// servers in `network_hosts`, or one the user allows when asked (`network_prompt`).
|
||||
@@ -1579,7 +1585,7 @@ preference_settings! {
|
||||
/// fixed screen size at every zoom, as before.
|
||||
hide_constraint_glyphs_zoomed_out: bool = true, PAGE_SKETCH => check_id("prefs.hide_constraint_glyphs_zoomed_out");
|
||||
/// Drive Joints shows the moved parts' old place as a ghost (its Show ghost box, #5905).
|
||||
drive_joints_ghost: bool = true, PAGE_TOOLS;
|
||||
drive_joints_ghost: bool = true, PAGE_TOOLS => check_tr("prefs.design_tools.ghost");
|
||||
/// Plugin ids turned off (Preferences › Plugins). An installed plugin whose id is absent is on.
|
||||
disabled_plugins: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(), PAGE_PLUGINS;
|
||||
}
|
||||
@@ -2212,7 +2218,10 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
PAGE_ASSISTANT => app.ui.agent_cursor = Default::default(),
|
||||
PAGE_TOOLS => app.ui.tool_cursor = false,
|
||||
PAGE_DESIGN => {}
|
||||
PAGE_SHORTCUTS => crate::keymap::reset(app, None),
|
||||
PAGE_SHORTCUTS => {
|
||||
crate::keymap::stop_capture(app);
|
||||
crate::keymap::reset(app, None);
|
||||
}
|
||||
PAGE_GRAPHICS => {
|
||||
app.ui.visual_style = 0;
|
||||
app.ui.show_grid = true;
|
||||
@@ -2533,27 +2542,41 @@ pub const WAYLAND_DROP_ISSUE: &str = "https://github.com/rust-windowing/winit/is
|
||||
/// which one is running now, and when native Wayland is in use or chosen, that some features are
|
||||
/// limited there, linking to [`WAYLAND_DROP_ISSUE`].
|
||||
#[cfg(target_os = "linux")]
|
||||
fn window_system_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
fn window_system_row(app: &mut SolveApp, ui: &mut egui::Ui, label_width: f32) {
|
||||
let t = Tokens::get();
|
||||
let label = |id: &str| crate::i18n::tr(&format!("ui.window_system_{id}")).to_string();
|
||||
let labels: Vec<_> = WINDOW_SYSTEMS.iter().copied().map(label).collect();
|
||||
let labels: Vec<_> = labels.iter().map(String::as_str).collect();
|
||||
// The note below says that it applies after a restart, and which one runs now.
|
||||
let response = ui.allocate_ui_with_layout(egui::vec2(t.form_card_width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
let mut selected = WINDOW_SYSTEMS.iter().position(|id| *id == app.preferences.window_system).unwrap_or(0);
|
||||
if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments::default())
|
||||
&& let Some(id) = WINDOW_SYSTEMS.get(selected)
|
||||
{
|
||||
app.preferences.window_system = (*id).into();
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_handle("prefs:window_system", response.response.rect.center());
|
||||
let now = app.services.window_system.map(label);
|
||||
let text = match &now {
|
||||
Some(now) => crate::i18n::format("settings.window_system_note_now", &[("now", now)]),
|
||||
None => app.tr("settings.msaa_applies_on_restart").to_string(),
|
||||
};
|
||||
ui_kit::text(ui, &text, ui_kit::Text::Hint("window_system"));
|
||||
let row_width = (label_width + t.space_14 + t.space_24 * 10.0).min(ui.available_width());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(row_width, t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
ui_kit::settings_row_help(ui, app.tr("ui.window_system"), &text, |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2((t.space_24 * 10.0).min(ui.available_width()), t.control_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
let mut selected = WINDOW_SYSTEMS.iter().position(|id| *id == app.preferences.window_system).unwrap_or(0);
|
||||
let before = selected;
|
||||
let response = ui_kit::dropdown(ui, "window_system", &mut selected, &labels);
|
||||
crate::scenario::publish_target("prefs:window_system", response.rect);
|
||||
if selected != before
|
||||
&& let Some(id) = WINDOW_SYSTEMS.get(selected)
|
||||
{
|
||||
app.preferences.window_system = (*id).into();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui_kit::settings_badge(ui, "Linux", false);
|
||||
});
|
||||
let choice = app.preferences.window_system.as_str();
|
||||
if choice == "wayland" || (choice == "auto" && app.services.window_system == Some("wayland")) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -2816,8 +2839,21 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
fn draw_page(app: &mut SolveApp, ui: &mut egui::Ui, index: usize, t: &Tokens) {
|
||||
match index {
|
||||
PAGE_SHORTCUTS => {
|
||||
ui_kit::settings_heading_with_tag(ui, app.tr("prefs.shortcuts"), "", Some(crate::settings_profiles::tag));
|
||||
crate::keymap::page(app, ui);
|
||||
let read_only = app.keymap.read_only;
|
||||
let mut show = |ui: &mut egui::Ui| {
|
||||
ui_kit::settings_heading_with_tag(
|
||||
ui,
|
||||
app.tr("prefs.shortcuts"),
|
||||
app.tr("prefs.shortcuts_intro"),
|
||||
Some(crate::settings_profiles::tag),
|
||||
);
|
||||
crate::keymap::page(app, ui);
|
||||
};
|
||||
if read_only {
|
||||
show(ui);
|
||||
} else {
|
||||
ui_kit::settings_page(ui, show);
|
||||
}
|
||||
}
|
||||
PAGE_ASSISTANT => crate::agent_chat::settings::page(app, ui),
|
||||
PAGE_PLUGINS => crate::plugins::page(app, ui),
|
||||
@@ -4549,10 +4585,12 @@ mod tests {
|
||||
navbar_click(&mut h, "prefs:navbar:slot:visual_style");
|
||||
navbar_click(&mut h, "prefs:navbar:shows:0");
|
||||
assert_eq!(h.app.preferences.navigation_bar.menu_display("visual_style"), MenuDisplay::IconAndValue);
|
||||
navbar_click(&mut h, "prefs:navbar:position:0");
|
||||
navbar_click(&mut h, "prefs:navbar:anchor:0");
|
||||
assert_eq!(h.app.preferences.navigation_bar.position, Position::Left);
|
||||
navbar_click(&mut h, "prefs:navbar:position:2");
|
||||
assert_eq!(h.app.preferences.navigation_bar.edge, crate::navigation_bar::Edge::Top);
|
||||
navbar_click(&mut h, "prefs:navbar:anchor:5");
|
||||
assert_eq!(h.app.preferences.navigation_bar.position, Position::Right);
|
||||
assert_eq!(h.app.preferences.navigation_bar.edge, crate::navigation_bar::Edge::Bottom);
|
||||
navbar_click(&mut h, "prefs:navbar:size:0");
|
||||
assert_eq!(h.app.preferences.navigation_bar.size, Size::Compact);
|
||||
navbar_click(&mut h, "prefs:navbar:size:1");
|
||||
@@ -4566,6 +4604,11 @@ mod tests {
|
||||
let before = h.app.preferences.navigation_bar.clone();
|
||||
navbar_click(&mut h, "prefs:navbar:preview_as:1");
|
||||
assert_eq!(h.app.preferences.navigation_bar, before, "canvas preview is transient");
|
||||
navbar_click(&mut h, "prefs:navbar:add:zoom");
|
||||
assert_eq!(h.app.preferences.navigation_bar.slots.last().map(String::as_str), Some("zoom"));
|
||||
let shared = h.app.preferences.navigation_bar.clone();
|
||||
navbar_click(&mut h, "prefs:navbar:preview_as:0");
|
||||
assert_eq!(h.app.preferences.navigation_bar, shared, "Render edits persist in the shared Design preview");
|
||||
navbar_click(&mut h, "prefs:navbar:show");
|
||||
assert!(!h.app.ui.show_navigation_bar);
|
||||
navbar_click(&mut h, "prefs:navbar:show");
|
||||
@@ -4585,11 +4628,16 @@ mod tests {
|
||||
let search = crate::scenario::rect_of("prefs:search:frame").unwrap();
|
||||
assert!(header.contains_rect(search), "titlebar search stays inside the window at {width}");
|
||||
assert!(search.width() <= 230.0 && search.width() >= 100.0, "usable bounded search at {width}");
|
||||
let body = crate::scenario::rect_of("prefs:page_body").unwrap();
|
||||
for index in 0..6 {
|
||||
let cell = crate::scenario::rect_of(&format!("prefs:navbar:anchor:{index}")).unwrap();
|
||||
assert!(cell.left() >= body.left() && cell.right() <= body.right() + 0.5, "anchor {index} at {width}: {cell:?} outside {body:?}");
|
||||
assert!(cell.width() > 20.0 && cell.height() > 20.0, "anchor {index} stays a usable target at {width}");
|
||||
}
|
||||
for (key, label, labels) in [
|
||||
("position", "Position", &["Left", "Center", "Right"][..]),
|
||||
("size", "Size", &["Compact", "Regular"][..]),
|
||||
("buttons", "Buttons", &["Icons", "Icons and labels"][..]),
|
||||
("idle", "When idle", &["Always visible", "Fade", "Hide until hovered"][..]),
|
||||
("buttons", "Buttons", &["Icons", "Icons + labels"][..]),
|
||||
("idle", "When idle", &["Always", "Fade", "Hover"][..]),
|
||||
] {
|
||||
let body = crate::scenario::rect_of("prefs:page_body").unwrap();
|
||||
let row = crate::scenario::rect_of(&format!("prefs:navbar:{key}")).unwrap();
|
||||
@@ -4597,20 +4645,8 @@ mod tests {
|
||||
assert_eq!(row.height(), crate::ui_kit::row_height(), "standard control height");
|
||||
let dropdown = crate::scenario::rect_of(&format!("dialog:segments:{label}")).is_none();
|
||||
if !dropdown {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let natural: f32 = labels
|
||||
.iter()
|
||||
.map(|option| {
|
||||
h.ctx
|
||||
.fonts_mut(|fonts| fonts.layout_no_wrap((*option).to_owned(), crate::fonts::semibold(t.font_label), t.form_ink))
|
||||
.size()
|
||||
.x
|
||||
.ceil()
|
||||
+ t.space_16
|
||||
})
|
||||
.sum();
|
||||
let control = crate::scenario::rect_of(&format!("dialog:control:{label}")).unwrap();
|
||||
assert!((row.width() - natural.min(control.width())).abs() < 0.5, "{key} hugs its content at {width}");
|
||||
assert!((row.width() - control.width()).abs() < 0.5, "{key} fills its property column at {width}");
|
||||
}
|
||||
if dropdown {
|
||||
// Standard Segments fall back to the kit dropdown when their labels cannot fit.
|
||||
@@ -4697,15 +4733,27 @@ mod tests {
|
||||
h.frames(3);
|
||||
}
|
||||
let body = crate::scenario::rect_of("prefs:page_body").unwrap();
|
||||
let card = crate::scenario::rect_of("prefs:navbar:inspector").unwrap();
|
||||
assert!(
|
||||
(card.left() - body.left()).abs() < 0.5 && (card.right() - body.right()).abs() < 0.5,
|
||||
"{slot:?} at {width}: {card:?} vs {body:?}"
|
||||
);
|
||||
if slot.is_some() {
|
||||
let card = crate::scenario::rect_of("prefs:navbar:inspector").unwrap();
|
||||
assert!(
|
||||
(card.left() - body.left()).abs() < 0.5 && (card.right() - body.right()).abs() < 0.5,
|
||||
"{slot:?} at {width}: {card:?} vs {body:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(crate::scenario::rect_of("prefs:navbar:inspector").is_none(), "inspector belongs to a selected slot");
|
||||
}
|
||||
let status = crate::scenario::rect_of("prefs:navbar:status").unwrap();
|
||||
let reset = crate::scenario::rect_of("prefs:navbar:reset").unwrap();
|
||||
let show = crate::scenario::rect_of("prefs:navbar:show").unwrap();
|
||||
assert_eq!(status.right(), body.right());
|
||||
assert!(status.left() >= body.left() && status.right() <= body.right() + 0.5, "header actions stay inside the page");
|
||||
let stage = crate::scenario::rect_of("prefs:navbar:stage").unwrap();
|
||||
let count = crate::scenario::rect_of("prefs:navbar:count").unwrap();
|
||||
assert!(status.bottom() <= stage.top(), "Show and Reset belong above the shared stage");
|
||||
let bar = crate::scenario::rect_of("prefs:navbar:bar").unwrap();
|
||||
assert!(stage.contains_rect(count), "the real slot count sits in the stage");
|
||||
assert!(!count.intersects(bar), "the count never covers the bar");
|
||||
let properties = crate::scenario::rect_of("dialog:row:Position").unwrap();
|
||||
assert!(stage.bottom() <= properties.top(), "the stage stays before properties");
|
||||
assert!(status.contains_rect(reset) && status.contains_rect(show));
|
||||
assert!(show.right() <= reset.left());
|
||||
assert_eq!(reset.center().y, show.center().y);
|
||||
@@ -4767,24 +4815,42 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_bar_preview_stage_follows_position_live() {
|
||||
use crate::navigation_bar::Position;
|
||||
fn navigation_bar_preview_stage_follows_anchor_live() {
|
||||
use crate::navigation_bar::{Edge, Position};
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1100}));
|
||||
h.call("ui.prefs", json!({"open":"Interface/Navigation bar","navigation_bar":{"slots":["pan","fit","display"]}}));
|
||||
for (index, position) in [(0, Position::Left), (1, Position::Centre), (2, Position::Right)] {
|
||||
navbar_click(&mut h, &format!("prefs:navbar:position:{index}"));
|
||||
for (index, edge, position) in [
|
||||
(0, Edge::Top, Position::Left),
|
||||
(1, Edge::Top, Position::Centre),
|
||||
(2, Edge::Top, Position::Right),
|
||||
(3, Edge::Bottom, Position::Left),
|
||||
(4, Edge::Bottom, Position::Centre),
|
||||
(5, Edge::Bottom, Position::Right),
|
||||
] {
|
||||
navbar_click(&mut h, &format!("prefs:navbar:anchor:{index}"));
|
||||
h.frames(3);
|
||||
let stage = crate::scenario::rect_of("prefs:navbar:stage").unwrap();
|
||||
let bar = crate::scenario::rect_of("prefs:navbar:bar").unwrap();
|
||||
assert_eq!(h.app.preferences.navigation_bar.position, position);
|
||||
let count = crate::scenario::rect_of("prefs:navbar:count").unwrap();
|
||||
assert_eq!((h.app.preferences.navigation_bar.edge, h.app.preferences.navigation_bar.position), (edge, position));
|
||||
assert_eq!(stage.height(), crate::ui_kit::NAV_STAGE_HEIGHT);
|
||||
assert_eq!(bar.center().y, stage.center().y);
|
||||
let inset = crate::ui_kit::NAV_BORDER + crate::ui_kit::NAV_STAGE_INSET;
|
||||
match edge {
|
||||
Edge::Top => {
|
||||
assert_eq!(bar.top(), stage.top() + inset);
|
||||
assert!(count.top() >= bar.bottom(), "the count sits below a top bar");
|
||||
}
|
||||
Edge::Bottom => {
|
||||
assert_eq!(bar.bottom(), stage.bottom() - inset);
|
||||
assert!(count.bottom() <= bar.top(), "the count sits above a bottom bar");
|
||||
}
|
||||
}
|
||||
match position {
|
||||
Position::Left => assert_eq!(bar.left(), stage.left() + crate::ui_kit::NAV_BORDER + crate::ui_kit::NAV_STAGE_INSET),
|
||||
Position::Left => assert_eq!(bar.left(), stage.left() + inset),
|
||||
Position::Centre => assert_eq!(bar.center().x, stage.center().x),
|
||||
Position::Right => assert_eq!(bar.right(), stage.right() - crate::ui_kit::NAV_BORDER - crate::ui_kit::NAV_STAGE_INSET),
|
||||
Position::Right => assert_eq!(bar.right(), stage.right() - inset),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5863,7 +5929,7 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let mut previous = 0;
|
||||
for label in ["Coarse", "Normal", "Fine", "Ultra"] {
|
||||
for (label, segments) in [("Coarse", 8), ("Normal", 16), ("Fine", 32), ("Ultra", 64)] {
|
||||
let card = crate::scenario::rect_of(&format!("prefs:option:{label}")).expect("quality card");
|
||||
let facets = shapes
|
||||
.iter()
|
||||
@@ -5877,10 +5943,170 @@ mod tests {
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
assert!(facets > previous, "{label} must visibly add detail to the preceding quality: {facets} <= {previous}");
|
||||
assert_eq!(facets, segments + 1, "{label} uses the canonical faceting caption, with the closing vertex");
|
||||
previous = facets;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_precision_controls_use_preferred_units_and_keep_document_and_transactions() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", json!({"language":"en", "theme":theme}));
|
||||
// A nonblank document keeps its units while the new-design defaults are edited.
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
let doc = h.app.session.doc.to_json();
|
||||
let original = h.app.preferences.clone();
|
||||
h.call("ui.prefs", json!({"open":"General"}));
|
||||
h.step(&json!({"click":{"handle":"prefs:page:Design"}})).unwrap();
|
||||
h.app.prefs_window.filter = "Units and precision".into();
|
||||
h.frames(5);
|
||||
for unit in UNITS {
|
||||
h.step(&json!({"click":{"handle":format!("prefs:default_units:{unit}")}})).unwrap();
|
||||
h.frames(2);
|
||||
let sample = format_length(12.5 * unit_scale(unit).0, unit, 2);
|
||||
assert!(h.widget(&sample).is_some(), "{theme}: {unit} uses the real unit-aware sample {sample}");
|
||||
assert_eq!(h.app.session.doc.to_json(), doc);
|
||||
}
|
||||
for (key, text) in [("length_decimals", "4"), ("angle_decimals", "3"), ("parameter_list_rows", "75")] {
|
||||
let handle = format!("prefs:{key}");
|
||||
super::reveal_preference(&mut h, &handle);
|
||||
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":text})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
assert_eq!((h.app.preferences.length_decimals, h.app.preferences.angle_decimals, h.app.preferences.parameter_list_rows), (4, 3, 75));
|
||||
assert!(h.widget(&format_length(12.5 * unit_scale("ft").0, "ft", 4)).is_some());
|
||||
assert!(h.widget("45.000°").is_some());
|
||||
assert_eq!(saved_prefs(&h.app)["preferences"]["length_decimals"], original.length_decimals);
|
||||
h.call("ui.prefs", json!({"button":"apply"}));
|
||||
let applied = h.app.preferences.clone();
|
||||
let mut restarted = SolveApp::new(Default::default(), Default::default());
|
||||
restarted.load_prefs(&h.app.prefs());
|
||||
assert_eq!(
|
||||
(
|
||||
restarted.preferences.default_units.as_str(),
|
||||
restarted.preferences.length_decimals,
|
||||
restarted.preferences.angle_decimals,
|
||||
restarted.preferences.parameter_list_rows
|
||||
),
|
||||
("ft", 4, 3, 75)
|
||||
);
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert_eq!((h.app.preferences.length_decimals, h.app.preferences.angle_decimals, h.app.preferences.parameter_list_rows), (2, 1, 50));
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences, applied);
|
||||
assert_eq!(h.app.session.doc.to_json(), doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_unknown_program_dropdown_keeps_note_default_and_stand_up_dependency() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", json!({"language":"en","open":"Design"}));
|
||||
assert_eq!(h.app.preferences.stand_up_unknown, StandUpUnknown::Note);
|
||||
h.app.prefs_window.filter = "Files from unknown programs".into();
|
||||
h.frames(5);
|
||||
super::reveal_preference(&mut h, "prefs:stand_up_unknown");
|
||||
h.step(&json!({"click":{"handle":"prefs:stand_up_unknown"}})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:list:prefs.stand_up_unknown.ask"}})).unwrap();
|
||||
assert_eq!(h.app.preferences.stand_up_unknown, StandUpUnknown::Ask);
|
||||
h.app.prefs_window.filter = "Offer to stand up Y-up files".into();
|
||||
h.frames(5);
|
||||
super::reveal_preference(&mut h, "prefs:offer_stand_up");
|
||||
h.step(&json!({"click":{"handle":"prefs:offer_stand_up"}})).unwrap();
|
||||
assert!(!h.app.preferences.offer_stand_up);
|
||||
h.app.prefs_window.filter = "Files from unknown programs".into();
|
||||
h.frames(5);
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
let dropdown =
|
||||
tree.nodes.iter().find(|(_, node)| node.role() == egui::accesskit::Role::ComboBox && node.label() == Some("Files from unknown programs"));
|
||||
assert!(dropdown.expect("unknown-program dropdown").1.is_disabled());
|
||||
assert_eq!(h.app.preferences.stand_up_unknown, StandUpUnknown::Ask, "disabling the offer keeps the stored choice");
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(h.app.preferences.offer_stand_up);
|
||||
assert_eq!(h.app.preferences.stand_up_unknown, StandUpUnknown::Note);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences.stand_up_unknown, StandUpUnknown::Note);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_ghost_and_pointer_tiles_edit_only_their_state_and_preserve_transactions() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", json!({"language":"en","theme":theme,"quick_measure":true}));
|
||||
let original = h.app.preferences.clone();
|
||||
h.call("ui.prefs", json!({"open":"General"}));
|
||||
h.step(&json!({"click":{"handle":"prefs:page:Tools"}})).unwrap();
|
||||
let mut expected = serde_json::to_value(&original).unwrap();
|
||||
for (query, field) in [
|
||||
("Show moved parts' old place as a ghost", "drive_joints_ghost"),
|
||||
("Axis distances in Measure", "measure_axis_distances"),
|
||||
("Direct interaction", "direct_interaction"),
|
||||
] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
let handle = format!("prefs:{field}");
|
||||
super::reveal_preference(&mut h, &handle);
|
||||
h.step(&json!({"click":{"handle":handle}})).unwrap();
|
||||
expected[field] = json!(!expected[field].as_bool().unwrap());
|
||||
assert_eq!(serde_json::to_value(&h.app.preferences).unwrap(), expected, "{theme}: {field} edits only its own preference");
|
||||
}
|
||||
h.app.prefs_window.filter = "Tool at the cursor".into();
|
||||
h.frames(5);
|
||||
super::reveal_preference(&mut h, "view:tool_cursor");
|
||||
h.step(&json!({"click":{"handle":"view:tool_cursor"}})).unwrap();
|
||||
assert!(h.app.ui.tool_cursor);
|
||||
assert_eq!(serde_json::to_value(&h.app.preferences).unwrap(), expected, "cursor stays app UI state");
|
||||
assert_eq!(saved_prefs(&h.app)["preferences"], serde_json::to_value(&original).unwrap());
|
||||
h.call("ui.prefs", json!({"button":"apply"}));
|
||||
let applied = h.app.preferences.clone();
|
||||
let mut restarted = SolveApp::new(Default::default(), Default::default());
|
||||
restarted.load_prefs(&h.app.prefs());
|
||||
assert_eq!(restarted.preferences, applied);
|
||||
assert!(restarted.ui.tool_cursor);
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(h.app.preferences.drive_joints_ghost && h.app.preferences.measure_axis_distances);
|
||||
assert!(!h.app.preferences.direct_interaction && !h.app.ui.tool_cursor);
|
||||
assert!(h.app.preferences.quick_measure, "Tools defaults leave Graphics quick measurement alone");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences, applied);
|
||||
assert!(h.app.ui.tool_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_extrude_view_controls_keep_their_dependency_without_clearing_the_saved_choice() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", json!({"language":"en","open":"Tools"}));
|
||||
h.app.prefs_window.filter = "Extrude".into();
|
||||
h.frames(5);
|
||||
for (key, isometric, returns) in [
|
||||
("extrude_view_return", false, false),
|
||||
("extrude_view_isometric", true, false),
|
||||
("extrude_view_return", true, true),
|
||||
("extrude_view_isometric", false, true),
|
||||
("extrude_view_return", false, true),
|
||||
] {
|
||||
let handle = format!("prefs:{key}");
|
||||
super::reveal_preference(&mut h, &handle);
|
||||
h.step(&json!({"click":{"handle":handle}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!((h.app.preferences.extrude_view_isometric, h.app.preferences.extrude_view_return), (isometric, returns));
|
||||
assert!(h.app.preferences.auto_operation);
|
||||
assert!(h.app.preferences.extrude_auto_to_object);
|
||||
}
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(!h.app.preferences.extrude_view_isometric && !h.app.preferences.extrude_view_return);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_preferences_reset_only_on_their_new_page() {
|
||||
use super::*;
|
||||
@@ -5888,6 +6114,16 @@ mod tests {
|
||||
("default_units", json!("ft"), PAGE_DESIGN),
|
||||
("length_decimals", json!(5), PAGE_DESIGN),
|
||||
("angle_decimals", json!(4), PAGE_DESIGN),
|
||||
("parameter_list_rows", json!(75), PAGE_DESIGN),
|
||||
("step_values", json!(true), PAGE_DESIGN),
|
||||
("value_boxes_in_view", json!(false), PAGE_DESIGN),
|
||||
("up_axis", json!("y"), PAGE_DESIGN),
|
||||
("offer_stand_up", json!(false), PAGE_DESIGN),
|
||||
("stand_up_unknown", json!("ask"), PAGE_DESIGN),
|
||||
("hide_origin", json!(true), PAGE_DESIGN),
|
||||
("update_changed_links", json!(false), PAGE_DESIGN),
|
||||
("double_click_components", json!(false), PAGE_DESIGN),
|
||||
("insert_lists_open_designs", json!(false), PAGE_DESIGN),
|
||||
("theme", json!("light"), PAGE_APPEARANCE),
|
||||
("ui_scale", json!(1.25), PAGE_APPEARANCE),
|
||||
("ui_font", json!("test font"), PAGE_APPEARANCE),
|
||||
@@ -6056,10 +6292,12 @@ mod tests {
|
||||
#[test]
|
||||
fn preferences_moved_controls_have_one_drawn_home() {
|
||||
let cases = [
|
||||
("Show tool at cursor", PAGE_TOOLS),
|
||||
("Tool at the cursor", PAGE_TOOLS),
|
||||
("Direct interaction", PAGE_TOOLS),
|
||||
("Show X/Y/Z distances", PAGE_TOOLS),
|
||||
("Move holes by clicking or dragging", PAGE_TOOLS),
|
||||
("Axis distances in Measure", PAGE_TOOLS),
|
||||
("Move by click or drag", PAGE_TOOLS),
|
||||
("Snap to centers", PAGE_TOOLS),
|
||||
("Show moved parts' old place as a ghost", PAGE_TOOLS),
|
||||
("Extrude to a picked target", PAGE_TOOLS),
|
||||
("Turn the view to isometric when Extrude opens", PAGE_TOOLS),
|
||||
("Go back to the earlier view when Extrude closes", PAGE_TOOLS),
|
||||
@@ -6068,11 +6306,11 @@ mod tests {
|
||||
("Origin plane names", PAGE_DESIGN),
|
||||
("Status bar", PAGE_LAYOUT),
|
||||
("Color vision", PAGE_APPEARANCE),
|
||||
("Quick measurement in the view's corner", PAGE_GRAPHICS),
|
||||
("Show viewport analysis status", PAGE_GRAPHICS),
|
||||
("Show viewport section status", PAGE_GRAPHICS),
|
||||
("Live viewport details", PAGE_GRAPHICS),
|
||||
("Fold viewport status items", PAGE_GRAPHICS),
|
||||
("Quick measurement", PAGE_GRAPHICS),
|
||||
("Analysis", PAGE_GRAPHICS),
|
||||
("Section", PAGE_GRAPHICS),
|
||||
("Live details", PAGE_GRAPHICS),
|
||||
("Fold items", PAGE_GRAPHICS),
|
||||
("Allow network locations in designs", PAGE_GENERAL),
|
||||
("Print face highlight", PAGE_GENERAL),
|
||||
];
|
||||
@@ -8548,6 +8786,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_cards_and_toolbar_chips_preserve_transactions_in_both_themes() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1200,"height":1100}));
|
||||
h.call("ui.prefs", json!({"theme":theme,"quick_measure":true,"invert_curved_roll_arrows":true}));
|
||||
h.call("ui.prefs", json!({"open":"Layout"}));
|
||||
let before = h.app.preferences.clone();
|
||||
let doc = h.app.session.doc.to_json();
|
||||
for (key, left, under, browser) in
|
||||
[("left", true, false, false), ("under_browser", true, true, false), ("browser", false, false, true), ("bottom", false, false, false)]
|
||||
{
|
||||
scroll_to_and_click(&mut h, &format!("prefs:history:{key}"));
|
||||
assert_eq!(
|
||||
(h.app.preferences.timeline_left, h.app.preferences.timeline_under_browser, h.app.preferences.history_in_browser),
|
||||
(left, under, browser)
|
||||
);
|
||||
}
|
||||
for (key, floating) in [("card", true), ("column", false), ("card", true)] {
|
||||
scroll_to_and_click(&mut h, &format!("prefs:browser:{key}"));
|
||||
assert_eq!(h.app.preferences.browser_float, floating);
|
||||
}
|
||||
for tab in crate::workspace::choosable_tabs() {
|
||||
scroll_to_and_click(&mut h, &format!("prefs:toolbar_tab:{tab}"));
|
||||
assert!(!crate::workspace::tab_turned_on(&h.app, tab));
|
||||
scroll_to_and_click(&mut h, &format!("prefs:toolbar_tab:{tab}"));
|
||||
assert!(crate::workspace::tab_turned_on(&h.app, tab));
|
||||
}
|
||||
assert_eq!(h.app.session.doc.to_json(), doc);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences, before, "Cancel restores every Layout edit");
|
||||
h.call("ui.prefs", json!({"open":"Layout"}));
|
||||
scroll_to_and_click(&mut h, "prefs:browser:card");
|
||||
h.call("ui.prefs", json!({"button":"apply"}));
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(!h.app.preferences.browser_float);
|
||||
assert!(h.app.preferences.quick_measure && h.app.preferences.invert_curved_roll_arrows, "Layout reset retains Graphics and Navigation");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(h.app.preferences.browser_float, "Cancel after Apply returns to the applied state");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_preferences_widget_click_reveals_scrolled_content_and_keeps_folds_closed() {
|
||||
let steps: serde_json::Value = serde_json::from_str(include_str!("../tests/scenarios/history_sketches_under_feature.json")).unwrap();
|
||||
@@ -8566,7 +8847,7 @@ mod tests {
|
||||
assert!(!page.contains(egui::pos2(at[0] as f32, at[1] as f32)), "the real control begins scrolled out of view");
|
||||
assert!((at[1] as f32) < page.top(), "scrolling to the bottom puts the target above the viewport");
|
||||
assert_eq!(h.app.preferences.history_sketches, HistorySketches::InOrder);
|
||||
h.step(&json!({"widget":"Under their feature", "near":"Sketches"})).unwrap();
|
||||
h.step(&json!({"widget":"Under their feature", "near":"Sketches in History"})).unwrap();
|
||||
assert_eq!(h.app.preferences.history_sketches, HistorySketches::UnderFeature);
|
||||
let at = h.widget("Under their feature").unwrap();
|
||||
assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains(egui::pos2(at[0] as f32, at[1] as f32)));
|
||||
@@ -9778,16 +10059,6 @@ mod ui_scale_regression_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// View ▸ View angles and Preferences ▸ Graphics run the same command (#6032).
|
||||
pub(crate) fn view_angles_checkbox(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let mut enabled = app.preferences.show_view_angles;
|
||||
let response = ui_kit::checkbox(ui, crate::i18n::tr("ui.view_angles"), &mut enabled).on_hover_text(crate::i18n::tr("ui.view_angles_help"));
|
||||
crate::scenario::publish_handle("view:view_angles", response.rect.center());
|
||||
if response.changed() {
|
||||
let _ = app.run("view.angle_readout", json!({"enabled": enabled}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ambient_occlusion_tests {
|
||||
use super::*;
|
||||
@@ -9865,6 +10136,288 @@ pub(crate) fn graphics_preset(prefs: &Prefs) -> &'static str {
|
||||
mod graphics_preset_tests {
|
||||
use super::*;
|
||||
|
||||
fn click_graphics(h: &mut crate::scenario::Harness, query: &str, handle: &str) {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
reveal_preference(h, handle);
|
||||
h.step(&json!({"click":{"handle":handle}})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_cards_follow_manual_changes_and_preserve_non_preset_settings_in_both_themes() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.prefs", json!({"theme":theme,"language":"en","msaa":2,"dark_parts_shading":true,"frame_rate_details":true}));
|
||||
h.call("ui.prefs", json!({"open":"Graphics"}));
|
||||
let camera = h.app.cam;
|
||||
let design = h.app.session.doc.to_json();
|
||||
click_graphics(&mut h, "Quality", "view:graphics_preset:quality");
|
||||
assert_eq!(graphics_preset(&h.app.preferences), "quality");
|
||||
assert_eq!(
|
||||
(h.app.preferences.display_quality.as_str(), h.app.preferences.supersampling, h.app.preferences.supersampling_adaptive),
|
||||
("fine", 2.0, true)
|
||||
);
|
||||
assert!(h.app.preferences.ambient_occlusion);
|
||||
assert_eq!(h.app.preferences.msaa, 2);
|
||||
assert!(h.app.preferences.dark_parts_shading && h.app.preferences.frame_rate_details);
|
||||
let quality = h.app.preferences.clone();
|
||||
click_graphics(&mut h, "Custom", "view:graphics_preset:custom");
|
||||
assert_eq!(h.app.preferences, quality, "Custom represents the mixture and sets nothing");
|
||||
click_graphics(&mut h, "Ultra", "prefs:display_quality:ultra");
|
||||
assert_eq!(h.app.preferences.display_quality, "ultra");
|
||||
assert_eq!(graphics_preset(&h.app.preferences), "custom");
|
||||
click_graphics(&mut h, "Performance", "view:graphics_preset:performance");
|
||||
assert_eq!(graphics_preset(&h.app.preferences), "performance");
|
||||
assert_eq!(
|
||||
(h.app.preferences.ambient_occlusion, h.app.preferences.supersampling, h.app.preferences.display_quality.as_str()),
|
||||
(false, 1.0, "normal")
|
||||
);
|
||||
h.call("ui.prefs", json!({"supersampling_adaptive":false}));
|
||||
assert_eq!(graphics_preset(&h.app.preferences), "performance", "Performance ignores the stored adaptive flag");
|
||||
assert_eq!(h.app.preferences.msaa, 2);
|
||||
assert!(h.app.preferences.dark_parts_shading && h.app.preferences.frame_rate_details);
|
||||
assert_eq!(h.app.cam, camera);
|
||||
assert_eq!(h.app.session.doc.to_json(), design);
|
||||
assert!(h.app.session.undo.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_tiles_and_status_chips_edit_real_values_and_cancel_in_both_themes() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.prefs", json!({"theme":theme,"language":"en"}));
|
||||
h.call("ui.prefs", json!({"open":"Graphics"}));
|
||||
let original = h.app.preferences.clone();
|
||||
let session = (h.app.ui.show_grid, h.app.ui.ground_shadow, h.app.ui.perspective);
|
||||
for (query, handle) in [("Grid", "prefs:grid"), ("Ground shadow", "prefs:ground_shadow"), ("Perspective", "prefs:perspective")] {
|
||||
click_graphics(&mut h, query, handle);
|
||||
}
|
||||
assert_eq!((h.app.ui.show_grid, h.app.ui.ground_shadow, h.app.ui.perspective), (!session.0, !session.1, !session.2));
|
||||
for (query, field) in [
|
||||
("Ambient occlusion", "ambient_occlusion"),
|
||||
("View angles", "show_view_angles"),
|
||||
("Vertex markers", "vertex_markers"),
|
||||
("Quick measurement", "quick_measure"),
|
||||
("Frame rate", "show_frame_rate"),
|
||||
("Analysis", "viewport_analysis_status"),
|
||||
("Section", "viewport_section_status"),
|
||||
("Live details", "viewport_status_details"),
|
||||
("Fold items", "viewport_status_summary"),
|
||||
] {
|
||||
let before = serde_json::to_value(&h.app.preferences).unwrap();
|
||||
click_graphics(&mut h, query, &format!("prefs:{field}"));
|
||||
let after = serde_json::to_value(&h.app.preferences).unwrap();
|
||||
let mut expected = before.clone();
|
||||
expected[field] = json!(!before[field].as_bool().unwrap());
|
||||
assert_eq!(after, expected, "{theme}: {field} changes precisely its own setting");
|
||||
}
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences, original);
|
||||
assert_eq!((h.app.ui.show_grid, h.app.ui.ground_shadow, h.app.ui.perspective), session);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_overlay_cards_apply_persist_and_reset_without_adding_unpaged_fields() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.prefs", json!({"language":"en"}));
|
||||
h.call("ui.prefs", json!({"open":"Graphics"}));
|
||||
for (query, handle) in [
|
||||
("View angles", "prefs:show_view_angles"),
|
||||
("Vertex markers", "prefs:vertex_markers"),
|
||||
("Quick measurement", "prefs:quick_measure"),
|
||||
("Frame rate", "prefs:show_frame_rate"),
|
||||
("Frame rate details", "prefs:frame_rate_details"),
|
||||
("Analysis", "prefs:viewport_analysis_status"),
|
||||
("Section", "prefs:viewport_section_status"),
|
||||
("Live details", "prefs:viewport_status_details"),
|
||||
("Fold items", "prefs:viewport_status_summary"),
|
||||
] {
|
||||
click_graphics(&mut h, query, handle);
|
||||
}
|
||||
h.call("ui.prefs", json!({"button":"apply"}));
|
||||
let applied = h.app.preferences.clone();
|
||||
let mut restored = SolveApp::new(Default::default(), Default::default());
|
||||
restored.load_prefs(&h.app.prefs());
|
||||
assert!(restored.preferences.show_view_angles && restored.preferences.vertex_markers && restored.preferences.quick_measure);
|
||||
assert!(restored.preferences.show_frame_rate && restored.preferences.frame_rate_details);
|
||||
assert!(!restored.preferences.viewport_analysis_status && !restored.preferences.viewport_section_status);
|
||||
assert!(!restored.preferences.viewport_status_details && !restored.preferences.viewport_status_summary);
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(!h.app.preferences.show_view_angles && !h.app.preferences.quick_measure);
|
||||
assert!(!h.app.preferences.show_frame_rate && !h.app.preferences.frame_rate_details);
|
||||
assert!(h.app.preferences.viewport_analysis_status && h.app.preferences.viewport_section_status);
|
||||
assert!(h.app.preferences.viewport_status_details && h.app.preferences.viewport_status_summary);
|
||||
assert!(h.app.preferences.vertex_markers, "the visible unpaged control remains excluded from page defaults");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences, applied, "Cancel after Apply restores the applied overlay state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_redesign_keeps_newer_main_controls_and_their_transactions() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", json!({"theme":theme,"language":"en","open":"Graphics"}));
|
||||
let original = h.app.preferences.clone();
|
||||
for (query, handle) in [
|
||||
("Tint new surface in a preview", "prefs:preview_tint_new_faces"),
|
||||
("Show removed material in red", "prefs:cut_preview_tint"),
|
||||
("Profile operations", "prefs:profile_operations"),
|
||||
] {
|
||||
click_graphics(&mut h, query, handle);
|
||||
}
|
||||
h.app.prefs_window.filter = "Section hatch".into();
|
||||
h.frames(5);
|
||||
reveal_preference(&mut h, "dialog:Section hatch:One style");
|
||||
h.step(&json!({"click":{"handle":"dialog:Section hatch:One style"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!h.app.preferences.preview_tint_new_faces && !h.app.preferences.cut_preview_tint);
|
||||
assert!(h.app.preferences.profile_operations);
|
||||
assert_eq!(h.app.preferences.section_hatch, SectionHatch::One);
|
||||
h.call("ui.prefs", json!({"button":"apply"}));
|
||||
let applied = h.app.preferences.clone();
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(h.app.preferences.preview_tint_new_faces && h.app.preferences.cut_preview_tint);
|
||||
assert!(!h.app.preferences.profile_operations);
|
||||
assert_eq!(h.app.preferences.section_hatch, SectionHatch::ByPart);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences, applied);
|
||||
h.call("ui.prefs", json!({"profile_operations":false}));
|
||||
assert_eq!(h.app.preferences.persistent_ribbon_pressed, original.persistent_ribbon_pressed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_style_dropdown_uses_the_real_indices_and_icon_masters() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.prefs", json!({"theme":theme,"language":"en"}));
|
||||
h.call("ui.prefs", json!({"open":"Graphics"}));
|
||||
for (index, (_, label)) in crate::navigation_bar::VISUAL_STYLES.into_iter().enumerate() {
|
||||
click_graphics(&mut h, "Visual style", "prefs:visual_style");
|
||||
h.step(&json!({"click":{"handle":format!("dialog:Visual style:{label}")}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.ui.visual_style, index as u8);
|
||||
let icon = crate::navigation_bar::visual_style_icon(index as u8);
|
||||
let painted = crate::scenario::rect_of(&format!("dialog:dropdown_icon:{icon}")).unwrap();
|
||||
assert!(crate::scenario::rect_of("prefs:visual_style").unwrap().contains_rect(painted));
|
||||
}
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.ui.visual_style, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_style_popup_owns_escape_and_leaves_snapshot_cancel_to_the_next_escape() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1100}));
|
||||
h.call("ui.prefs", json!({"language":"en","theme":theme}));
|
||||
let before = h.app.preferences.clone();
|
||||
h.call("ui.prefs", json!({"open":"Graphics","dark_parts_shading":true}));
|
||||
let edited = h.app.preferences.clone();
|
||||
assert_ne!(before, edited);
|
||||
reveal_preference(&mut h, "prefs:visual_style");
|
||||
h.step(&json!({"click":{"handle":"prefs:visual_style"}})).unwrap();
|
||||
h.frames(2);
|
||||
let popup = "dialog:Visual style:Wireframe";
|
||||
assert!(crate::scenario::handle_at(popup).is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.prefs_window.open && h.app.prefs_window.section == PAGE_GRAPHICS);
|
||||
assert!(h.app.prefs_window.snapshot.is_some());
|
||||
assert_eq!(h.app.preferences, edited, "first Escape retains unapplied Graphics edits");
|
||||
assert!(crate::scenario::handle_at(popup).is_none());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.prefs_window.open && crate::scenario::handle_at(popup).is_some(), "Enter reopens the focused style trigger");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.prefs_window.open && h.app.prefs_window.snapshot.is_some());
|
||||
assert!(crate::scenario::handle_at(popup).is_none());
|
||||
assert_eq!(h.app.preferences, edited);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!h.app.prefs_window.open && h.app.prefs_window.snapshot.is_none());
|
||||
assert_eq!(h.app.preferences, before, "Escape without a popup performs normal Preferences Cancel");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_disabled_controls_keep_stored_values_and_msaa_waits_for_restart() {
|
||||
use egui::accesskit::Role;
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-graphics-cards-{}-{theme}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("graphics.json");
|
||||
std::fs::write(&path, r#"{"backend":"vulkan","msaa":4}"#).unwrap();
|
||||
h.app.services.graphics_path = Some(path.to_string_lossy().into_owned());
|
||||
h.app.msaa_supported = vec![1, 2, 4];
|
||||
let active_samples = h.app.viewport.gpu.as_ref().map(|gpu| gpu.samples);
|
||||
h.call("ui.prefs", json!({"theme":theme,"language":"en","frame_rate_details":true}));
|
||||
h.call("ui.prefs", json!({"open":"Graphics"}));
|
||||
click_graphics(&mut h, "Anti-aliasing", "prefs:msaa:8");
|
||||
assert_eq!(h.app.preferences.msaa, 4, "an unavailable card cannot coerce MSAA");
|
||||
assert!(h.widget("Restart needed").is_some());
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
assert!(tree.nodes.iter().any(|(_, node)| node.role() == Role::RadioButton && node.label() == Some("8×") && node.is_disabled()));
|
||||
click_graphics(&mut h, "Anti-aliasing", "prefs:msaa:2");
|
||||
assert_eq!(h.app.preferences.msaa, 2);
|
||||
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["msaa"], 2);
|
||||
assert_eq!(saved["backend"], "vulkan");
|
||||
assert_eq!(
|
||||
h.app.viewport.gpu.as_ref().map(|gpu| gpu.samples),
|
||||
active_samples,
|
||||
"changing the saved level leaves the running renderer alone"
|
||||
);
|
||||
click_graphics(&mut h, "Only when the view is still", "prefs:supersampling_adaptive");
|
||||
assert!(h.app.preferences.supersampling_adaptive, "disabled adaptive control retains its saved value");
|
||||
click_graphics(&mut h, "Frame rate details", "prefs:frame_rate_details");
|
||||
assert!(h.app.preferences.frame_rate_details, "visible disabled details control retains its saved value");
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
assert!(
|
||||
tree.nodes.iter().any(|(_, node)| node.role() == Role::CheckBox && node.label() == Some("Frame rate details") && node.is_disabled())
|
||||
);
|
||||
click_graphics(&mut h, "Frame rate", "prefs:show_frame_rate");
|
||||
click_graphics(&mut h, "Frame rate details", "prefs:frame_rate_details");
|
||||
assert!(!h.app.preferences.frame_rate_details, "enabling Frame rate enables its details checkbox");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences.msaa, 4);
|
||||
assert!(h.app.preferences.frame_rate_details);
|
||||
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["msaa"], 4, "Cancel restores startup persistence too");
|
||||
assert_eq!(saved["backend"], "vulkan");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_page_keeps_the_untouched_radian_default_and_unpaged_reset_exclusions() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.prefs", json!({"theme":theme,"language":"en","vertex_markers":true,"grid_follows_view":true}));
|
||||
h.call("ui.prefs", json!({"open":"Graphics"}));
|
||||
for (perspective, auto_ortho) in [(false, false), (true, false), (false, true)] {
|
||||
h.app.ui.perspective = perspective;
|
||||
h.app.ui.auto_ortho_faces = auto_ortho;
|
||||
h.app.prefs_window.filter = "Field of view".into();
|
||||
h.frames(5);
|
||||
assert_eq!(h.app.preferences.perspective_fov, 0.6, "drawing a whole-degree readout never rounds the default");
|
||||
}
|
||||
for page in 0..PAGES.len() {
|
||||
defaults(&mut h.app, page);
|
||||
assert!(h.app.preferences.vertex_markers && h.app.preferences.grid_follows_view, "{page}: neither unpaged field joins resets");
|
||||
}
|
||||
assert_eq!(h.app.preferences.perspective_fov, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_preset_popup_accepts_quality_and_performance() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
+209
-119
@@ -3,137 +3,227 @@ use super::*;
|
||||
|
||||
pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
ui_kit::settings_heading(ui, app.tr("prefs.design"), app.tr("prefs.design_note"));
|
||||
ui_kit::settings_section(ui, "units", app.tr("prefs.units_precision"), |ui| {
|
||||
ui_kit::settings_form(ui, t.space_24 * 7.0 + t.space_2, |ui| {
|
||||
ui_kit::row(ui, app.tr("ui.units_of_new_designs"), |ui| {
|
||||
let mut selected = UNITS.iter().position(|u| *u == app.preferences.default_units).unwrap_or(0);
|
||||
ui_kit::segmented(ui, &mut selected, UNITS, ui_kit::Segments { intrinsic: true, ..Default::default() });
|
||||
if let Some(unit) = UNITS.get(selected)
|
||||
&& app.preferences.default_units != *unit
|
||||
{
|
||||
app.preferences.default_units = (*unit).into();
|
||||
default_units_changed(app);
|
||||
}
|
||||
let label_width = [
|
||||
"ui.units_of_new_designs",
|
||||
"ui.length_decimals",
|
||||
"ui.angle_decimals",
|
||||
"prefs.parameter_list_rows",
|
||||
"prefs.origin_plane_names",
|
||||
"prefs.stand_up_unknown",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|id| ui_kit::ContentWidth::label(ui, app.tr(id)))
|
||||
.fold(0.0, f32::max)
|
||||
+ t.space_16
|
||||
+ t.space_4;
|
||||
ui_kit::settings_page(ui, |ui| {
|
||||
ui_kit::settings_heading(ui, app.tr("prefs.design"), app.tr("prefs.design_note"));
|
||||
ui_kit::settings_section(ui, "units", app.tr("prefs.units_precision"), |ui| {
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
ui_kit::row(ui, app.tr("ui.units_of_new_designs"), |ui| {
|
||||
let mut selected = UNITS.iter().position(|u| *u == app.preferences.default_units).unwrap_or(0);
|
||||
ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
UNITS,
|
||||
ui_kit::Segments {
|
||||
intrinsic: true,
|
||||
targets: &[
|
||||
"prefs:default_units:mm",
|
||||
"prefs:default_units:cm",
|
||||
"prefs:default_units:m",
|
||||
"prefs:default_units:in",
|
||||
"prefs:default_units:ft",
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if let Some(unit) = UNITS.get(selected)
|
||||
&& app.preferences.default_units != *unit
|
||||
{
|
||||
app.preferences.default_units = (*unit).into();
|
||||
default_units_changed(app);
|
||||
}
|
||||
});
|
||||
ui_kit::row(ui, app.tr("ui.length_decimals"), |ui| {
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().interact_size.x = t.space_24 * 2.0 + t.space_8;
|
||||
let response = ui_kit::number_field(ui, &mut app.preferences.length_decimals, 0..=6, 1.0, "");
|
||||
crate::scenario::publish_target("prefs:length_decimals", response.rect);
|
||||
});
|
||||
ui.add_space(t.space_6);
|
||||
let sample = format_length(
|
||||
12.5 * unit_scale(&app.preferences.default_units).0,
|
||||
&app.preferences.default_units,
|
||||
app.preferences.length_decimals,
|
||||
);
|
||||
ui_kit::settings_precision_sample(ui, &sample);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("ui.angle_decimals"), |ui| {
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().interact_size.x = t.space_24 * 2.0 + t.space_8;
|
||||
let response = ui_kit::number_field(ui, &mut app.preferences.angle_decimals, 0..=6, 1.0, "");
|
||||
crate::scenario::publish_target("prefs:angle_decimals", response.rect);
|
||||
});
|
||||
ui.add_space(t.space_6);
|
||||
ui_kit::settings_precision_sample(ui, &format!("{:.*}°", usize::from(app.preferences.angle_decimals.min(6)), 45.0));
|
||||
});
|
||||
ui_kit::settings_row_help(ui, app.tr("prefs.parameter_list_rows"), app.tr("prefs.parameter_list_rows_tip"), |ui| {
|
||||
let response = ui_kit::named_control(ui, app.tr("prefs.design_tools.rows"), |ui| {
|
||||
ui_kit::settings_number(ui, &mut app.preferences.parameter_list_rows, PARAMETER_LIST_ROWS, 1.0)
|
||||
});
|
||||
crate::scenario::publish_target("prefs:parameter_list_rows", response.rect);
|
||||
crate::scenario::publish_target(&format!("dialog:{}", app.tr("prefs.parameter_list_rows")), response.rect);
|
||||
});
|
||||
});
|
||||
ui_kit::row(ui, app.tr("ui.length_decimals"), |ui| {
|
||||
ui_kit::settings_number(ui, &mut app.preferences.length_decimals, 0..=6, 1.0);
|
||||
ui.add_space(t.space_6);
|
||||
let sample = format_length(
|
||||
12.5 * unit_scale(&app.preferences.default_units).0,
|
||||
&app.preferences.default_units,
|
||||
app.preferences.length_decimals,
|
||||
);
|
||||
ui_kit::settings_precision_sample(ui, &sample);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("ui.angle_decimals"), |ui| {
|
||||
ui_kit::settings_number(ui, &mut app.preferences.angle_decimals, 0..=6, 1.0);
|
||||
ui.add_space(t.space_6);
|
||||
ui_kit::settings_precision_sample(ui, &format!("{:.*}°", usize::from(app.preferences.angle_decimals.min(6)), 45.0));
|
||||
});
|
||||
ui_kit::row(ui, app.tr("prefs.parameter_list_rows"), |ui| {
|
||||
let tip = app.tr("prefs.parameter_list_rows_tip").to_string();
|
||||
ui_kit::settings_number(ui, &mut app.preferences.parameter_list_rows, PARAMETER_LIST_ROWS, 1.0).on_hover_text(tip);
|
||||
});
|
||||
});
|
||||
let tip = app.tr("ui.step_values_tip").to_string();
|
||||
let step = setting::step_values(app, ui).on_hover_text(tip);
|
||||
crate::scenario::publish_handle("prefs:step_values", step.rect.center());
|
||||
let tip = app.tr("prefs.value_boxes_in_view_tip").to_string();
|
||||
setting::value_boxes_in_view(app, ui).on_hover_text(tip);
|
||||
});
|
||||
// When SMLib's full check runs while features are computed (`diagnostics.validation`).
|
||||
ui_kit::settings_section(ui, "validation", app.tr("prefs.validation"), |ui| {
|
||||
use solidcraft_engine::kernel::Validation;
|
||||
let label = |v: &Validation| match v {
|
||||
Validation::EachStep => "prefs.validation.each_step",
|
||||
Validation::EachFeature => "prefs.validation.each_feature",
|
||||
Validation::Off => "prefs.validation.off",
|
||||
};
|
||||
let labels: Vec<String> = Validation::ALL.iter().map(|v| app.tr(label(v)).to_string()).collect();
|
||||
let options: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
let mut selected = Validation::ALL.iter().position(|v| *v == app.preferences.validation).unwrap_or(1);
|
||||
ui_kit::row(ui, app.tr("prefs.validation.label"), |ui| {
|
||||
ui_kit::segmented(ui, &mut selected, &options, ui_kit::Segments { intrinsic: true, ..Default::default() });
|
||||
});
|
||||
if let Some(v) = Validation::ALL.get(selected) {
|
||||
app.preferences.validation = *v;
|
||||
}
|
||||
ui_kit::text(ui, app.tr("prefs.validation_note"), ui_kit::Text::Hint("validation"));
|
||||
});
|
||||
ui_kit::settings_section(ui, "orientation", app.tr("prefs.orientation"), |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (id, label, y_up) in [("z", "Z up", false), ("y", "Y up", true)] {
|
||||
let subtitle = Some(if id == "z" { app.tr("prefs.default") } else { "" });
|
||||
if ui_kit::option_card(ui, app.tr_label(label), subtitle, app.preferences.up_axis == id, true, ui_kit::PreferenceFigure::UpAxis(y_up))
|
||||
.clicked()
|
||||
{
|
||||
app.preferences.up_axis = id.into();
|
||||
}
|
||||
for (setting, help, target, legacy) in [
|
||||
(
|
||||
setting::step_values as fn(&mut SolveApp, &mut egui::Ui) -> egui::Response,
|
||||
"ui.step_values_tip",
|
||||
"step_values",
|
||||
"Step values with the arrow keys and the wheel",
|
||||
),
|
||||
(
|
||||
setting::value_boxes_in_view,
|
||||
"prefs.value_boxes_in_view_tip",
|
||||
"value_boxes_in_view",
|
||||
"Show values beside their handles in the view",
|
||||
),
|
||||
] {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting(app, ui).on_hover_text(app.tr(help));
|
||||
crate::scenario::publish_target(&format!("prefs:{target}"), response.rect);
|
||||
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
ui_kit::help_icon(ui, app.tr(help));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui_kit::text(
|
||||
ui,
|
||||
crate::i18n::tr_label("Which axis points up in the views (axis view, Home, Look At). Designs are unchanged."),
|
||||
ui_kit::Text::Hint("orientation"),
|
||||
);
|
||||
setting::offer_stand_up(app, ui);
|
||||
ui_kit::text(ui, app.tr("prefs.stand_up_note"), ui_kit::Text::Hint("stand_up"));
|
||||
// How a file from a program not known to save Y up is offered (#7717).
|
||||
let offered = app.preferences.offer_stand_up;
|
||||
ui_kit::settings_block_hint(
|
||||
ui,
|
||||
app.tr("prefs.stand_up_unknown"),
|
||||
app.tr("prefs.stand_up_unknown_note"),
|
||||
t.space_24 * 8.0 + t.space_8,
|
||||
|ui| {
|
||||
ui.add_enabled_ui(offered, |ui| {
|
||||
let unknown = &mut app.preferences.stand_up_unknown;
|
||||
ui_kit::settings_control_width(ui, t.space_24 * 7.0 + t.space_12, |ui| {
|
||||
ui_kit::dropdown_menu(ui, "pref_stand_up_unknown", crate::i18n::tr(unknown.label_id()), |ui| {
|
||||
for choice in StandUpUnknown::ALL {
|
||||
if ui_kit::list_picker(ui, choice.label_id(), crate::i18n::tr(choice.label_id()), *unknown == choice).clicked() {
|
||||
*unknown = choice;
|
||||
// When SMLib's full check runs while features are computed (`diagnostics.validation`).
|
||||
ui_kit::settings_section(ui, "validation", app.tr("prefs.validation"), |ui| {
|
||||
use solidcraft_engine::kernel::Validation;
|
||||
let label = |v: &Validation| match v {
|
||||
Validation::EachStep => "prefs.validation.each_step",
|
||||
Validation::EachFeature => "prefs.validation.each_feature",
|
||||
Validation::Off => "prefs.validation.off",
|
||||
};
|
||||
let labels: Vec<String> = Validation::ALL.iter().map(|v| app.tr(label(v)).to_string()).collect();
|
||||
let options: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
let mut selected = Validation::ALL.iter().position(|v| *v == app.preferences.validation).unwrap_or(1);
|
||||
ui_kit::row(ui, app.tr("prefs.validation.label"), |ui| {
|
||||
ui_kit::segmented(ui, &mut selected, &options, ui_kit::Segments { intrinsic: true, ..Default::default() });
|
||||
});
|
||||
if let Some(v) = Validation::ALL.get(selected) {
|
||||
app.preferences.validation = *v;
|
||||
}
|
||||
ui_kit::text(ui, app.tr("prefs.validation_note"), ui_kit::Text::Hint("validation"));
|
||||
});
|
||||
ui_kit::settings_section(ui, "orientation", app.tr("prefs.orientation"), |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_10;
|
||||
for (id, label, y_up) in [("z", "Z up", false), ("y", "Y up", true)] {
|
||||
let subtitle = Some(if id == "z" { app.tr("prefs.default") } else { "" });
|
||||
let response = ui_kit::option_card(
|
||||
ui,
|
||||
app.tr_label(label),
|
||||
subtitle,
|
||||
app.preferences.up_axis == id,
|
||||
true,
|
||||
ui_kit::PreferenceFigure::UpAxis(y_up),
|
||||
);
|
||||
crate::scenario::publish_target(&format!("prefs:up_axis:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
app.preferences.up_axis = id.into();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting::offer_stand_up(app, ui).on_hover_text(app.tr("prefs.stand_up_note"));
|
||||
crate::scenario::publish_target("prefs:offer_stand_up", response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
ui_kit::help_icon(ui, app.tr("prefs.stand_up_note"));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui_kit::settings_section(ui, "imported_files", app.tr("prefs.design_tools.imported_files"), |ui| {
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
ui_kit::settings_row_help(ui, app.tr("prefs.stand_up_unknown"), app.tr("prefs.stand_up_unknown_note"), |ui| {
|
||||
let offered = app.preferences.offer_stand_up;
|
||||
ui.add_enabled_ui(offered, |ui| {
|
||||
ui_kit::settings_control_width(ui, t.space_24 * 8.0 + t.space_8, |ui| {
|
||||
let mut selected = StandUpUnknown::ALL.iter().position(|v| *v == app.preferences.stand_up_unknown).unwrap_or(1);
|
||||
let options = [app.tr("prefs.design_tools.ask"), app.tr("prefs.design_tools.note"), app.tr("prefs.design_tools.off")];
|
||||
let response = ui_kit::dropdown(ui, "pref_stand_up_unknown", &mut selected, &options);
|
||||
crate::scenario::publish_target("prefs:stand_up_unknown", response.rect);
|
||||
crate::scenario::publish_target("dialog:For files from unknown programs", response.rect);
|
||||
for (choice, option) in StandUpUnknown::ALL.iter().zip(options) {
|
||||
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{}:{option}", app.tr("prefs.stand_up_unknown"))) {
|
||||
crate::scenario::publish_target(&format!("dialog:list:{}", choice.label_id()), rect);
|
||||
}
|
||||
}
|
||||
if let Some(choice) = StandUpUnknown::ALL.get(selected) {
|
||||
app.preferences.stand_up_unknown = *choice;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
origin_plane_names(app, ui);
|
||||
ui_kit::settings_section(ui, "linked_designs", app.tr("prefs.linked_designs"), |ui| {
|
||||
setting::update_changed_links(app, ui);
|
||||
setting::double_click_components(app, ui);
|
||||
setting::insert_lists_open_designs(app, ui);
|
||||
});
|
||||
});
|
||||
origin_plane_names(app, ui, label_width);
|
||||
ui_kit::settings_section(ui, "linked_designs", app.tr("prefs.linked_designs"), |ui| {
|
||||
let response = setting::update_changed_links(app, ui);
|
||||
crate::scenario::publish_target("prefs:update_changed_links", response.rect);
|
||||
crate::scenario::publish_target("dialog:Update changed linked designs when a tab comes back", response.rect);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting::double_click_components(app, ui).on_hover_text(app.tr("prefs.design_tools.double_click_help"));
|
||||
crate::scenario::publish_target("prefs:double_click_components", response.rect);
|
||||
crate::scenario::publish_target("dialog:Double-click a component to activate it, or to edit a linked one in place", response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
ui_kit::help_icon(ui, app.tr("prefs.design_tools.double_click_help"));
|
||||
}
|
||||
});
|
||||
let response = setting::insert_lists_open_designs(app, ui);
|
||||
crate::scenario::publish_target("prefs:insert_lists_open_designs", response.rect);
|
||||
crate::scenario::publish_target("dialog:Insert Design lists the designs open in other tabs first", response.rect);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Origin plane names: each choice shows the three names it gives (`XY / XZ / YZ`), with a note
|
||||
/// that only the names shown change.
|
||||
fn origin_plane_names(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::settings_section(ui, "origin_plane_names", app.tr("prefs.origin_plane_names"), |ui| {
|
||||
/// These choices change only displayed names; the saved plane IDs remain XY, XZ and YZ.
|
||||
fn origin_plane_names(app: &mut SolveApp, ui: &mut egui::Ui, label_width: f32) {
|
||||
ui_kit::settings_section(ui, "origin_plane_names", app.tr("ui.origin"), |ui| {
|
||||
let choices: Vec<String> =
|
||||
OriginPlaneNames::ALL.iter().map(|names| OriginPlaneNames::PLANES.map(|plane| names.label(plane)).join(" / ")).collect();
|
||||
OriginPlaneNames::ALL.iter().map(|names| OriginPlaneNames::PLANES.map(|plane| names.label(plane)).join(" . ")).collect();
|
||||
let options: Vec<&str> = choices.iter().map(String::as_str).collect();
|
||||
let mut selected = OriginPlaneNames::ALL.iter().position(|names| *names == app.preferences.origin_plane_names).unwrap_or(0);
|
||||
if ui_kit::settings_segments(ui, app.tr("prefs.origin_plane_names"), &mut selected, &options)
|
||||
&& let Some(names) = OriginPlaneNames::ALL.get(selected)
|
||||
{
|
||||
app.preferences.origin_plane_names = *names;
|
||||
}
|
||||
// Handles that don't depend on the language, by the choice's saved value.
|
||||
for (choice, key) in choices.iter().zip(["xyz", "top_front_right"]) {
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{}:{choice}", app.tr("prefs.origin_plane_names"))) {
|
||||
crate::scenario::publish_handle(&format!("prefs:origin_plane_names:{key}"), at);
|
||||
}
|
||||
}
|
||||
let top = OriginPlaneNames::TopFrontRight;
|
||||
let note =
|
||||
crate::i18n::format("prefs.origin_plane_names_note", &[("top", top.label("XY")), ("front", top.label("XZ")), ("right", top.label("YZ"))]);
|
||||
ui_kit::text(ui, ¬e, ui_kit::Text::Hint("origin_plane_names"));
|
||||
setting::hide_origin(app, ui);
|
||||
ui_kit::text(ui, app.tr("ui.hide_origin_note"), ui_kit::Text::Hint("hide_origin"));
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
ui_kit::row(ui, app.tr("prefs.origin_plane_names"), |ui| {
|
||||
let mut selected = OriginPlaneNames::ALL.iter().position(|names| *names == app.preferences.origin_plane_names).unwrap_or(0);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&options,
|
||||
ui_kit::Segments {
|
||||
intrinsic: true,
|
||||
targets: &["prefs:origin_plane_names:xyz", "prefs:origin_plane_names:top_front_right"],
|
||||
..Default::default()
|
||||
},
|
||||
) && let Some(names) = OriginPlaneNames::ALL.get(selected)
|
||||
{
|
||||
app.preferences.origin_plane_names = *names;
|
||||
}
|
||||
for (choice, key) in choices.iter().zip(["xyz", "top_front_right"]) {
|
||||
if let Some(rect) = crate::scenario::rect_of(&format!("prefs:origin_plane_names:{key}")) {
|
||||
crate::scenario::publish_target(
|
||||
&format!("dialog:{}:{}", app.tr("prefs.origin_plane_names"), choice.replace(" . ", " / ")),
|
||||
rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let response = setting::hide_origin(app, ui);
|
||||
crate::scenario::publish_target("prefs:hide_origin", response.rect);
|
||||
crate::scenario::publish_target("dialog:Hide origin by default", response.rect);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,255 +1,537 @@
|
||||
//! Graphics preferences use the shared settings form and illustrative option cards.
|
||||
//! Graphics preferences compose shared controls and canonical figures, preserving transactions.
|
||||
use super::*;
|
||||
use ui_kit::PreferenceFigure;
|
||||
|
||||
pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
ui_kit::settings_heading(ui, app.tr("prefs.graphics"), app.tr("prefs.graphics_note"));
|
||||
if let Some(note) = &app.graphics_fallback {
|
||||
ui_kit::text(ui, note, ui_kit::Text::Hint("graphics_fallback"));
|
||||
}
|
||||
if app.services.graphics_path.is_some() {
|
||||
ui_kit::settings_section(ui, "graphics_card", app.tr("prefs.graphics_card"), |ui| {
|
||||
let adapters = &app.services.graphics_adapters;
|
||||
let selected = adapters.iter().position(|(name, _)| *name == app.preferences.graphics_adapter);
|
||||
let missing = !app.preferences.graphics_adapter.is_empty() && selected.is_none();
|
||||
let current = selected.and_then(|i| adapters.get(i)).map_or(app.tr("prefs.graphics_card_auto"), |(_, label)| label.as_str());
|
||||
let mut requested = None;
|
||||
let response = ui_kit::dropdown_menu(ui, "graphics_card", current, |ui| {
|
||||
if ui_kit::list_picker(ui, "graphics_card:auto", app.tr("prefs.graphics_card_auto"), selected.is_none()).clicked() {
|
||||
requested = Some(String::new());
|
||||
}
|
||||
for (index, (name, label)) in adapters.iter().enumerate() {
|
||||
if ui_kit::list_picker(ui, &format!("graphics_card:{index}"), label, selected == Some(index)).clicked() {
|
||||
requested = Some(name.clone());
|
||||
let t = Tokens::get();
|
||||
let help_label_width = ["prefs.graphics_page.supersampling", "prefs.graphics_card", "ui.window_system"]
|
||||
.into_iter()
|
||||
.map(|id| ui_kit::ContentWidth::label(ui, app.tr(id)))
|
||||
.fold(0.0, f32::max)
|
||||
+ t.space_16
|
||||
+ t.space_4;
|
||||
ui_kit::settings_page(ui, |ui| {
|
||||
ui_kit::settings_heading(ui, app.tr("prefs.graphics"), app.tr("prefs.graphics_page.subtitle"));
|
||||
ui_kit::settings_section(ui, "preset", app.tr("navbar.preset"), |ui| {
|
||||
let width = ((ui.available_width() - t.space_10 * 2.0) / 3.0).max(t.space_24 * 8.0 + t.space_12).min(ui.available_width());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
|
||||
for (id, label, word) in [
|
||||
("performance", "ui.graphics_performance", "prefs.graphics_page.fastest"),
|
||||
("quality", "ui.graphics_quality", "prefs.graphics_page.sharpest"),
|
||||
("custom", "ui.graphics_custom", "prefs.graphics_page.own_mix"),
|
||||
] {
|
||||
let response = ui_kit::preset_card(
|
||||
ui,
|
||||
app.tr(label),
|
||||
app.tr(word),
|
||||
graphics_preset(&app.preferences) == id,
|
||||
true,
|
||||
&|p, rect, ink| paint_preset_glyph(p, rect, id, ink),
|
||||
width,
|
||||
)
|
||||
.on_hover_text(app.tr("ui.graphics_preset_help"));
|
||||
crate::scenario::publish_target(&format!("view:graphics_preset:{id}"), response.rect);
|
||||
if id == "performance" {
|
||||
crate::scenario::publish_target("view:graphics_preset", response.rect);
|
||||
}
|
||||
if response.clicked() && id != "custom" {
|
||||
let _ = app.run("view.graphics_preset", json!({"preset":id}));
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_target("prefs:graphics_card", response.rect);
|
||||
if let Some(name) = requested {
|
||||
let _ = super::set(app, &json!({"graphics_adapter": name}));
|
||||
}
|
||||
ui_kit::text(ui, app.tr("prefs.graphics_card_restart"), ui_kit::Text::Hint("graphics_card_restart"));
|
||||
if missing {
|
||||
ui_kit::text(ui, app.tr("prefs.graphics_card_missing"), ui_kit::Text::Hint("graphics_card_missing"));
|
||||
} else if !app.preferences.graphics_adapter.is_empty() {
|
||||
// Status / Help show the adapter actually in use; the dropdown shows the saved
|
||||
// preference. When they differ the saved card was not applied (#8341).
|
||||
let active = app.viewport.adapter.rsplit(" · ").next().unwrap_or("");
|
||||
if !active.is_empty() && !active.eq_ignore_ascii_case(&app.preferences.graphics_adapter) {
|
||||
ui_kit::text(ui, app.tr("prefs.graphics_card_unused"), ui_kit::Text::Hint("graphics_card_unused"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response =
|
||||
ui.allocate_ui_with_layout(egui::vec2(t.form_card_width + t.space_24 * 2.0, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
let presets = ["performance", "quality", "custom"];
|
||||
let mut selected = presets.iter().position(|preset| *preset == graphics_preset(&app.preferences)).unwrap_or(2);
|
||||
let labels = [app.tr("ui.graphics_performance"), app.tr("ui.graphics_quality"), app.tr("ui.graphics_custom")];
|
||||
let changed = ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments::default());
|
||||
if changed && let Some(preset) = presets.get(selected).filter(|preset| **preset != "custom") {
|
||||
let _ = app.run("view.graphics_preset", json!({"preset":preset}));
|
||||
ui_kit::settings_section(ui, "quality", app.tr("prefs.curved_surfaces"), |ui| {
|
||||
use solidcraft_engine::doc::display_quality::DisplayQuality;
|
||||
let current = DisplayQuality::parse(&app.preferences.display_quality).unwrap_or_default();
|
||||
let figures: [fn(&egui::Painter, egui::Rect); 4] = [
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Quality(8)),
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Quality(16)),
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Quality(32)),
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Quality(64)),
|
||||
];
|
||||
let notes = ["prefs.quality_fastest", "prefs.quality_default", "prefs.quality_smoother", "prefs.quality_slowest"];
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
|
||||
for ((q, figure), note) in DisplayQuality::ALL.into_iter().zip(figures).zip(notes) {
|
||||
let label_id = format!("ui.display_quality_{}", q.id());
|
||||
let label = crate::i18n::tr(&label_id);
|
||||
let width = (t.space_24 * 5.0 + t.space_10).max(ui_kit::ContentWidth::name(ui, label) + t.space_12).min(ui.max_rect().width());
|
||||
let response = ui_kit::picture_card(
|
||||
ui,
|
||||
&ui_kit::Card {
|
||||
label,
|
||||
sub: Some(app.tr(note)),
|
||||
selected: current == q,
|
||||
enabled: true,
|
||||
options: None,
|
||||
check: current == q,
|
||||
aspect: 120.0 / 64.0,
|
||||
figure: &figure,
|
||||
},
|
||||
width,
|
||||
)
|
||||
.on_hover_text(app.tr("settings.display_quality_note"));
|
||||
crate::scenario::publish_target(&format!("prefs:display_quality:{}", q.id()), response.rect);
|
||||
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
|
||||
if response.clicked() {
|
||||
app.preferences.display_quality = q.id().into();
|
||||
}
|
||||
}
|
||||
});
|
||||
let rect = response.response.rect;
|
||||
crate::scenario::publish_handle("view:graphics_preset", rect.center());
|
||||
for (index, preset) in ["performance", "quality", "custom"].into_iter().enumerate() {
|
||||
crate::scenario::publish_handle(
|
||||
&format!("view:graphics_preset:{preset}"),
|
||||
egui::pos2(rect.left() + rect.width() * (index as f32 + 0.5) / 3.0, rect.center().y),
|
||||
);
|
||||
}
|
||||
response.response.on_hover_text(app.tr("ui.graphics_preset_help"));
|
||||
});
|
||||
ui_kit::text(ui, app.tr("prefs.graphics_presets_note"), ui_kit::Text::Hint("preset"));
|
||||
ui_kit::settings_section(ui, "quality", app.tr("prefs.curved_surfaces"), |ui| {
|
||||
use solidcraft_engine::doc::display_quality::DisplayQuality;
|
||||
ui_kit::text(ui, app.tr("ui.display_quality"), ui_kit::Text::Heading("display_quality"));
|
||||
let current = DisplayQuality::parse(&app.preferences.display_quality).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for ((q, facets), note) in DisplayQuality::ALL.into_iter().zip([5, 8, 14, 64]).zip([
|
||||
"prefs.quality_fastest",
|
||||
"prefs.quality_default",
|
||||
"prefs.quality_smoother",
|
||||
"prefs.quality_slowest",
|
||||
]) {
|
||||
if ui_kit::option_card(
|
||||
ui,
|
||||
crate::i18n::tr(&format!("ui.display_quality_{}", q.id())),
|
||||
Some(app.tr(note)),
|
||||
current == q,
|
||||
true,
|
||||
ui_kit::PreferenceFigure::Quality(facets),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.preferences.display_quality = q.id().into();
|
||||
});
|
||||
ui_kit::settings_section(ui, "edges", app.tr("prefs.edges"), |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui_kit::text(ui, app.tr("ui.anti_aliasing"), ui_kit::Text::Heading("antialiasing"));
|
||||
ui_kit::settings_badge(ui, app.tr("prefs.graphics_page.restart"), true);
|
||||
ui_kit::help_icon(ui, app.tr("settings.msaa_applies_on_restart"));
|
||||
});
|
||||
let figures: [fn(&egui::Painter, egui::Rect); 4] = [
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Antialiasing(1)),
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Antialiasing(2)),
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Antialiasing(4)),
|
||||
|p, r| paint_graphics_figure(p, r, PreferenceFigure::Antialiasing(8)),
|
||||
];
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
|
||||
for (m, figure) in MSAA.into_iter().zip(figures) {
|
||||
let enabled = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
|
||||
let label = if m == 1 { app.tr("ui.colour_vision_off").to_owned() } else { format!("{m}×") };
|
||||
let response = ui_kit::picture_card(
|
||||
ui,
|
||||
&ui_kit::Card {
|
||||
label: &label,
|
||||
sub: Some(if enabled { "" } else { app.tr("prefs.not_on_gpu") }),
|
||||
selected: app.preferences.msaa == m,
|
||||
enabled,
|
||||
options: None,
|
||||
check: app.preferences.msaa == m,
|
||||
aspect: 120.0 / 64.0,
|
||||
figure: &figure,
|
||||
},
|
||||
(t.space_24 * 5.0 + t.space_10).min(ui.max_rect().width()),
|
||||
)
|
||||
.on_hover_text(app.tr("settings.msaa_applies_on_restart"))
|
||||
.on_disabled_hover_text(app.tr("settings.msaa_applies_on_restart"));
|
||||
crate::scenario::publish_target(&format!("prefs:msaa:{m}"), response.rect);
|
||||
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
|
||||
if enabled && response.clicked() {
|
||||
app.preferences.msaa = m;
|
||||
}
|
||||
}
|
||||
});
|
||||
if app.msaa_compatibility {
|
||||
ui_kit::text(ui, app.tr("prefs.anti_aliasing_amd_dx12"), ui_kit::Text::Hint("msaa_compatibility"));
|
||||
}
|
||||
});
|
||||
ui_kit::text(ui, app.tr("settings.display_quality_note"), ui_kit::Text::Hint("quality"));
|
||||
});
|
||||
ui_kit::settings_section(ui, "edges", app.tr("prefs.edges"), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui_kit::text(ui, app.tr("ui.anti_aliasing"), ui_kit::Text::Heading("antialiasing"));
|
||||
ui_kit::settings_badge(ui, app.tr("prefs.applies_on_restart"), true);
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for m in MSAA {
|
||||
let enabled = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
|
||||
let label = if m == 1 { app.tr("ui.colour_vision_off").to_owned() } else { format!("{m}×") };
|
||||
let response = ui_kit::option_card(
|
||||
ui,
|
||||
&label,
|
||||
if enabled { None } else { Some(app.tr("prefs.not_on_gpu")) },
|
||||
app.preferences.msaa == m,
|
||||
enabled,
|
||||
ui_kit::PreferenceFigure::Antialiasing(m),
|
||||
)
|
||||
.on_hover_text(app.tr("settings.msaa_applies_on_restart"));
|
||||
crate::scenario::publish_target(&format!("prefs:msaa:{m}"), response.rect);
|
||||
if enabled && response.clicked() {
|
||||
app.preferences.msaa = m;
|
||||
ui_kit::settings_form(ui, help_label_width, |ui| {
|
||||
ui_kit::settings_row_help(ui, app.tr("prefs.graphics_page.supersampling"), app.tr("settings.supersampling_note"), |ui| {
|
||||
let mut selected = SUPERSAMPLING.iter().position(|f| *f == app.preferences.supersampling).unwrap_or(0);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[app.tr("ui.colour_vision_off"), "1.5×", "2×"],
|
||||
ui_kit::Segments {
|
||||
targets: &["prefs:supersampling:1", "prefs:supersampling:1.5", "prefs:supersampling:2"],
|
||||
..Default::default()
|
||||
},
|
||||
) && let Some(factor) = SUPERSAMPLING.get(selected)
|
||||
{
|
||||
app.preferences.supersampling = *factor;
|
||||
}
|
||||
});
|
||||
let label = app.tr("prefs.graphics_page.supersampling");
|
||||
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{label}")) {
|
||||
crate::scenario::publish_target("dialog:Supersampling (SSAA)", rect);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui_kit::text(ui, app.tr("prefs.anti_aliasing_note"), ui_kit::Text::Hint("msaa"));
|
||||
if app.msaa_compatibility {
|
||||
ui_kit::text(ui, app.tr("prefs.anti_aliasing_amd_dx12"), ui_kit::Text::Hint("msaa_compatibility"));
|
||||
}
|
||||
ui.add_space(t.space_6);
|
||||
ui_kit::text(ui, app.tr("ui.supersampling"), ui_kit::Text::Heading("supersampling"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(t.form_card_width - t.space_24, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
let mut selected = SUPERSAMPLING.iter().position(|f| *f == app.preferences.supersampling).unwrap_or(0);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("ui.colour_vision_off"), "1.5×", "2×"], ui_kit::Segments::default())
|
||||
&& let Some(factor) = SUPERSAMPLING.get(selected)
|
||||
{
|
||||
app.preferences.supersampling = *factor;
|
||||
for (option, legacy) in [(app.tr("ui.colour_vision_off"), "Off"), ("1.5×", "1.5×"), ("2×", "2×")] {
|
||||
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{label}:{option}")) {
|
||||
crate::scenario::publish_target(&format!("dialog:Supersampling (SSAA):{legacy}"), rect);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(app.preferences.supersampling > 1.0, |ui| {
|
||||
setting::supersampling_adaptive(app, ui);
|
||||
let response = setting::supersampling_adaptive(app, ui);
|
||||
crate::scenario::publish_target("prefs:supersampling_adaptive", response.rect);
|
||||
crate::scenario::publish_target("dialog:Only when the view is still", response.rect);
|
||||
});
|
||||
let response = setting::dark_parts_shading(app, ui).on_hover_text(app.tr("prefs.dark_parts_shading_help"));
|
||||
crate::scenario::publish_target("prefs:dark_parts_shading", response.rect);
|
||||
});
|
||||
ui_kit::settings_section(ui, "current_view", app.tr("prefs.graphics_page.current_view"), |ui| {
|
||||
ui_kit::settings_badge(ui, app.tr("prefs.graphics_page.view_bar"), false);
|
||||
let width = ((ui.available_width() - t.space_10 * 3.0) / 4.0).max(t.space_24 * 5.0 + t.space_10).min(ui.available_width());
|
||||
let tiles: [(_, _, _, fn(&egui::Painter, egui::Rect)); 4] = [
|
||||
("grid", "navbar.grid", app.ui.show_grid, ui_kit::paint_view_grid),
|
||||
("ground_shadow", "ui.ground_shadow", app.ui.ground_shadow, ui_kit::paint_view_shadow),
|
||||
("perspective", "navbar.perspective", app.ui.perspective, ui_kit::paint_view_perspective),
|
||||
("ambient_occlusion", "ui.ambient_occlusion", app.preferences.ambient_occlusion, |p, r| {
|
||||
paint_graphics_figure(p, r, PreferenceFigure::ContactShading(true));
|
||||
}),
|
||||
];
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
|
||||
for (id, label, selected, figure) in tiles {
|
||||
let response = ui_kit::toggle_tile(
|
||||
ui,
|
||||
&ui_kit::Card {
|
||||
label: app.tr(label),
|
||||
sub: None,
|
||||
selected,
|
||||
enabled: true,
|
||||
options: None,
|
||||
check: false,
|
||||
aspect: 120.0 / 64.0,
|
||||
figure: &figure,
|
||||
},
|
||||
width,
|
||||
);
|
||||
crate::scenario::publish_target(&format!("prefs:{id}"), response.rect);
|
||||
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
match id {
|
||||
"grid" => app.ui.show_grid = !selected,
|
||||
"ground_shadow" => app.ui.ground_shadow = !selected,
|
||||
"perspective" => app.ui.perspective = !selected,
|
||||
_ => {
|
||||
let _ = app.run("view.ambient_occlusion", json!({"enabled":!selected}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui_kit::settings_form(ui, t.form_label_width + t.space_24, |ui| {
|
||||
ui_kit::row(ui, app.tr_label("Visual style"), |ui| {
|
||||
let options = crate::navigation_bar::VISUAL_STYLES.map(|(_, label)| crate::i18n::tr_label(label));
|
||||
let choices: Vec<_> =
|
||||
options.into_iter().zip(["style_shaded_edges", "style_shaded", "style_wireframe", "style_hidden_edges"]).collect();
|
||||
let mut selected = usize::from(app.ui.visual_style.min(3));
|
||||
let before = selected;
|
||||
let response = ui
|
||||
.allocate_ui_with_layout(
|
||||
egui::vec2((t.space_24 * 10.0).min(ui.available_width()), t.control_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| ui_kit::dropdown_icons(ui, "pref_style", &mut selected, &choices),
|
||||
)
|
||||
.inner;
|
||||
crate::scenario::publish_target("prefs:visual_style", response.rect);
|
||||
if selected != before
|
||||
&& let Some((style, _)) = crate::navigation_bar::VISUAL_STYLES.get(selected)
|
||||
{
|
||||
let _ = app.run("view.visual_style", json!({"style":style}));
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(app.ui.perspective || app.ui.auto_ortho_faces, |ui| field_of_view_row(app, ui));
|
||||
});
|
||||
});
|
||||
ui_kit::text(ui, app.tr("prefs.supersampling_brief"), ui_kit::Text::Hint("supersampling"))
|
||||
.on_hover_text(app.tr("settings.supersampling_note"));
|
||||
ui.add_space(t.space_6);
|
||||
ui_kit::text(ui, app.tr_label("Ambient occlusion"), ui_kit::Text::Heading("contact_shading"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (on, label) in [(false, "ui.colour_vision_off"), (true, "prefs.contact_shading")] {
|
||||
let response = ui_kit::option_card(
|
||||
ui_kit::settings_section(ui, "in_view", app.tr("prefs.graphics_page.in_view"), |ui| {
|
||||
let width = ((ui.available_width() - t.space_10 * 3.0) / 4.0).max(t.space_24 * 5.0 + t.space_10).min(ui.available_width());
|
||||
let tiles: [(_, _, _, _, fn(&egui::Painter, egui::Rect)); 4] = [
|
||||
("show_view_angles", "ui.view_angles", "ui.view_angles_help", app.preferences.show_view_angles, ui_kit::paint_view_angles),
|
||||
(
|
||||
"vertex_markers",
|
||||
"Vertex markers",
|
||||
"Show a small dot at every vertex wherever edges are drawn",
|
||||
app.preferences.vertex_markers,
|
||||
ui_kit::paint_vertex_markers,
|
||||
),
|
||||
(
|
||||
"quick_measure",
|
||||
"prefs.graphics_page.quick_measure",
|
||||
"prefs.quick_measure_note",
|
||||
app.preferences.quick_measure,
|
||||
ui_kit::paint_quick_measure,
|
||||
),
|
||||
("show_frame_rate", "ui.frame_rate", "settings.frame_rate_note", app.preferences.show_frame_rate, ui_kit::paint_frame_rate),
|
||||
];
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
|
||||
for (id, label, help, selected, figure) in tiles {
|
||||
let label = if id == "vertex_markers" { app.tr_label(label) } else { app.tr(label) };
|
||||
let help = if id == "vertex_markers" { app.tr_label(help) } else { app.tr(help) };
|
||||
let response = ui_kit::toggle_tile(
|
||||
ui,
|
||||
&ui_kit::Card {
|
||||
label,
|
||||
sub: None,
|
||||
selected,
|
||||
enabled: true,
|
||||
options: None,
|
||||
check: false,
|
||||
aspect: 120.0 / 64.0,
|
||||
figure: &figure,
|
||||
},
|
||||
width,
|
||||
)
|
||||
.on_hover_text(help);
|
||||
crate::scenario::publish_target(&format!("prefs:{id}"), response.rect);
|
||||
crate::scenario::publish_target(&format!("dialog:{label}"), response.rect);
|
||||
if id == "quick_measure" {
|
||||
crate::scenario::publish_target(&format!("dialog:{}", app.tr("prefs.quick_measure")), response.rect);
|
||||
}
|
||||
if id == "show_view_angles" {
|
||||
crate::scenario::publish_target("view:view_angles", response.rect);
|
||||
}
|
||||
if response.clicked() {
|
||||
match id {
|
||||
"show_view_angles" => {
|
||||
let _ = app.run("view.angle_readout", json!({"enabled":!selected}));
|
||||
}
|
||||
"vertex_markers" => app.preferences.vertex_markers = !selected,
|
||||
"quick_measure" => app.preferences.quick_measure = !selected,
|
||||
_ => app.preferences.show_frame_rate = !selected,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(app.preferences.show_frame_rate, |ui| {
|
||||
let response = ui_kit::checkbox(ui, app.tr("ui.frame_rate_details"), &mut app.preferences.frame_rate_details)
|
||||
.on_hover_text(app.tr("settings.frame_rate_details_note"));
|
||||
crate::scenario::publish_target("prefs:frame_rate_details", response.rect);
|
||||
});
|
||||
let profile = setting::profile_operations(app, ui).on_hover_text(app.tr("prefs.profile_operations_note"));
|
||||
crate::scenario::publish_target("prefs:profile_operations", profile.rect);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui_kit::settings_inline_note(ui, app.tr("prefs.graphics_page.status_chips"));
|
||||
for (id, label, help) in [
|
||||
("viewport_analysis_status", "ui.analysis", "prefs.viewport_analysis_status_help"),
|
||||
("viewport_section_status", "Section", "prefs.viewport_section_status_help"),
|
||||
("viewport_status_details", "prefs.graphics_page.live_details", "prefs.viewport_status_details_help"),
|
||||
("viewport_status_summary", "prefs.graphics_page.fold_items", "prefs.viewport_status_summary_help"),
|
||||
] {
|
||||
let value = match id {
|
||||
"viewport_analysis_status" => &mut app.preferences.viewport_analysis_status,
|
||||
"viewport_section_status" => &mut app.preferences.viewport_section_status,
|
||||
"viewport_status_details" => &mut app.preferences.viewport_status_details,
|
||||
_ => &mut app.preferences.viewport_status_summary,
|
||||
};
|
||||
let response = ui_kit::choice_chip(
|
||||
ui,
|
||||
id,
|
||||
if id == "viewport_section_status" { crate::i18n::tr_label(label) } else { crate::i18n::tr(label) },
|
||||
*value,
|
||||
)
|
||||
.on_hover_text(crate::i18n::tr(help));
|
||||
crate::scenario::publish_target(&format!("prefs:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
*value = !*value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui_kit::settings_section(ui, "surfaces_edges", app.tr("prefs.graphics_page.surfaces_edges"), |ui| {
|
||||
let response = ui_kit::checkbox(ui, app.tr("ui.cosmetic_thread_markings"), &mut app.preferences.cosmetic_threads)
|
||||
.on_hover_text(app.tr("ui.cosmetic_thread_markings_help"));
|
||||
crate::scenario::publish_target("prefs:cosmetic_threads", response.rect);
|
||||
setting::sketch_halo(app, ui);
|
||||
ui_kit::row(ui, app.tr("prefs.section_hatch"), |ui| {
|
||||
let styles = [crate::prefs::SectionHatch::One, crate::prefs::SectionHatch::ByPart];
|
||||
let mut selected = styles.iter().position(|s| *s == app.preferences.section_hatch).unwrap_or(1);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
app.tr(label),
|
||||
None,
|
||||
app.preferences.ambient_occlusion == on,
|
||||
true,
|
||||
ui_kit::PreferenceFigure::ContactShading(on),
|
||||
);
|
||||
if on != app.preferences.ambient_occlusion {
|
||||
crate::scenario::publish_handle("view:ambient_occlusion", response.rect.center());
|
||||
&mut selected,
|
||||
&[app.tr("prefs.section_hatch_one"), app.tr("prefs.section_hatch_by_part")],
|
||||
Default::default(),
|
||||
) && let Some(style) = styles.get(selected)
|
||||
{
|
||||
app.preferences.section_hatch = *style;
|
||||
}
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.ambient_occlusion", json!({"enabled":on}));
|
||||
});
|
||||
let tint_new = setting::preview_tint_new_faces(app, ui).on_hover_text(app.tr("prefs.preview_tint_new_faces_help"));
|
||||
crate::scenario::publish_target("prefs:preview_tint_new_faces", tint_new.rect);
|
||||
let cut_tint = setting::cut_preview_tint(app, ui).on_hover_text(app.tr("prefs.cut_preview_tint_help"));
|
||||
crate::scenario::publish_target("prefs:cut_preview_tint", cut_tint.rect);
|
||||
let reference =
|
||||
setting::reference_geometry_through_bodies(app, ui).on_hover_text(app.tr("settings.reference_geometry_through_bodies_help"));
|
||||
crate::scenario::publish_target("dialog:Show reference geometry through bodies", reference.rect);
|
||||
ui.add_enabled_ui(app.ui.show_grid, |ui| {
|
||||
setting::grid_follows_view(app, ui).on_hover_text(app.tr("ui.grid_follows_view_hint"));
|
||||
});
|
||||
let response = setting::single_surface_analysis(app, ui).on_hover_text(app.tr("prefs.single_surface_analysis_help"));
|
||||
crate::scenario::publish_target("prefs:single_surface_analysis", response.rect);
|
||||
crate::scenario::publish_target("dialog:Use a single surface analysis", response.rect);
|
||||
if response.changed() {
|
||||
sync_surface_analysis(app);
|
||||
}
|
||||
});
|
||||
ui_kit::settings_section(ui, "computer", app.tr("prefs.general.this_computer"), |ui| {
|
||||
computer_card(ui, |ui| {
|
||||
computer_header(app, ui);
|
||||
if let Some(note) = &app.graphics_fallback {
|
||||
ui_kit::text(ui, note, ui_kit::Text::Hint("graphics_fallback"));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui_kit::text(ui, app.tr("prefs.contact_shading_note"), ui_kit::Text::Hint("ao"));
|
||||
let response = setting::dark_parts_shading(app, ui).on_hover_text(app.tr("prefs.dark_parts_shading_help"));
|
||||
crate::scenario::publish_handle("prefs:dark_parts_shading", response.rect.center());
|
||||
});
|
||||
ui_kit::settings_section(ui, "viewport", app.tr("prefs.viewport"), |ui| {
|
||||
let analysis_policy = setting::single_surface_analysis(app, ui).on_hover_text(app.tr("prefs.single_surface_analysis_help"));
|
||||
crate::scenario::publish_target("prefs:single_surface_analysis", analysis_policy.rect);
|
||||
if analysis_policy.changed() {
|
||||
sync_surface_analysis(app);
|
||||
}
|
||||
|
||||
let response = ui_kit::checkbox(ui, app.tr("ui.cosmetic_thread_markings"), &mut app.preferences.cosmetic_threads)
|
||||
.on_hover_text(app.tr("ui.cosmetic_thread_markings_help"));
|
||||
crate::scenario::publish_handle("prefs:cosmetic_threads", response.rect.center());
|
||||
ui_kit::row(ui, app.tr("prefs.section_hatch"), |ui| {
|
||||
let styles = [crate::prefs::SectionHatch::One, crate::prefs::SectionHatch::ByPart];
|
||||
let mut selected = styles.iter().position(|s| *s == app.preferences.section_hatch).unwrap_or(1);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.section_hatch_one"), app.tr("prefs.section_hatch_by_part")], Default::default())
|
||||
&& let Some(style) = styles.get(selected)
|
||||
{
|
||||
app.preferences.section_hatch = *style;
|
||||
}
|
||||
});
|
||||
ui.columns(2, |columns| {
|
||||
if let Some(left) = columns.get_mut(0) {
|
||||
ui_kit::checkbox(left, crate::i18n::tr_label("Grid"), &mut app.ui.show_grid);
|
||||
ui_kit::checkbox(left, crate::i18n::tr_label("Perspective view"), &mut app.ui.perspective);
|
||||
left.add_enabled_ui(app.ui.perspective || app.ui.auto_ortho_faces, |ui| field_of_view_row(app, ui));
|
||||
setting::show_frame_rate(app, left).on_hover_text(app.tr("settings.frame_rate_note"));
|
||||
left.add_enabled_ui(app.preferences.show_frame_rate, |ui| {
|
||||
ui_kit::checkbox(ui, app.tr("ui.frame_rate_details"), &mut app.preferences.frame_rate_details)
|
||||
.on_hover_text(app.tr("settings.frame_rate_details_note"));
|
||||
});
|
||||
setting::profile_operations(app, left).on_hover_text(app.tr("prefs.profile_operations_note"));
|
||||
crate::prefs::view_angles_checkbox(app, left);
|
||||
setting::reference_geometry_through_bodies(app, left).on_hover_text(app.tr("settings.reference_geometry_through_bodies_help"));
|
||||
let tint_new = setting::preview_tint_new_faces(app, left).on_hover_text(app.tr("prefs.preview_tint_new_faces_help"));
|
||||
crate::scenario::publish_target("prefs:preview_tint_new_faces", tint_new.rect);
|
||||
let cut_tint = setting::cut_preview_tint(app, left).on_hover_text(app.tr("prefs.cut_preview_tint_help"));
|
||||
crate::scenario::publish_target("prefs:cut_preview_tint", cut_tint.rect);
|
||||
setting::sketch_halo(app, left);
|
||||
setting::vertex_markers(app, left);
|
||||
}
|
||||
if let Some(right) = columns.get_mut(1) {
|
||||
ui_kit::checkbox(right, crate::i18n::tr_label("Ground shadow"), &mut app.ui.ground_shadow);
|
||||
right.add_enabled_ui(app.ui.show_grid, |ui| {
|
||||
setting::grid_follows_view(app, ui);
|
||||
});
|
||||
}
|
||||
});
|
||||
ui_kit::settings_form(ui, t.space_24 * 6.0 + t.space_6, |ui| {
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::quick_measure(app, ui).on_hover_text(app.tr("prefs.quick_measure_note"));
|
||||
crate::scenario::publish_target("prefs:quick_measure", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::viewport_analysis_status(app, ui).on_hover_text(app.tr("prefs.viewport_analysis_status_help"));
|
||||
crate::scenario::publish_target("prefs:viewport_analysis_status", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::viewport_section_status(app, ui).on_hover_text(app.tr("prefs.viewport_section_status_help"));
|
||||
crate::scenario::publish_target("prefs:viewport_section_status", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::viewport_status_details(app, ui).on_hover_text(app.tr("prefs.viewport_status_details_help"));
|
||||
crate::scenario::publish_target("prefs:viewport_status_details", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::viewport_status_summary(app, ui).on_hover_text(app.tr("prefs.viewport_status_summary_help"));
|
||||
crate::scenario::publish_target("prefs:viewport_status_summary", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, app.tr_label("Visual style"), |ui| {
|
||||
let mut selected = usize::from(app.ui.visual_style.min(3));
|
||||
ui_kit::dropdown(ui, "pref_style", &mut selected, &["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"]);
|
||||
app.ui.visual_style = selected as u8;
|
||||
if app.services.graphics_path.is_some() {
|
||||
graphics_card_row(app, ui, help_label_width);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
window_system_row(app, ui, help_label_width);
|
||||
setting::render_previous_geometry_budget(app, ui).on_hover_text(app.tr("prefs.render_previous_geometry_budget_help"));
|
||||
});
|
||||
});
|
||||
});
|
||||
#[cfg(target_os = "linux")]
|
||||
ui_kit::settings_section(ui, "window_system", app.tr("ui.window_system"), |ui| window_system_row(app, ui));
|
||||
setting::render_previous_geometry_budget(app, ui).on_hover_text(app.tr("prefs.render_previous_geometry_budget_help"));
|
||||
ui.horizontal(|ui| {
|
||||
ui_kit::text(ui, app.tr_label("Renderer"), ui_kit::Text::Inline);
|
||||
let renderer = match (&app.viewport.gpu, &app.help.gpu) {
|
||||
(Some(_), Some(g)) => {
|
||||
ui_kit::settings_badge(ui, "GPU", false);
|
||||
g.to_owned()
|
||||
}
|
||||
(Some(_), None) => "GPU".into(),
|
||||
_ => crate::i18n::tr("prefs.cpu").into(),
|
||||
};
|
||||
ui_kit::text(ui, &renderer, ui_kit::Text::Inline);
|
||||
});
|
||||
}
|
||||
|
||||
/// Graphics uses the refined canonical pictures while retaining the shared legacy figure API.
|
||||
fn paint_graphics_figure(p: &egui::Painter, rect: egui::Rect, figure: PreferenceFigure) {
|
||||
let paint: fn(&egui::Painter, egui::Rect) = match figure {
|
||||
PreferenceFigure::Quality(8) => ui_kit::paint_tess_coarse,
|
||||
PreferenceFigure::Quality(16) => ui_kit::paint_tess_normal,
|
||||
PreferenceFigure::Quality(32) => ui_kit::paint_tess_fine,
|
||||
PreferenceFigure::Quality(64) => ui_kit::paint_tess_ultra,
|
||||
PreferenceFigure::Antialiasing(1) => ui_kit::paint_aa_off,
|
||||
PreferenceFigure::Antialiasing(2) => ui_kit::paint_aa_2x,
|
||||
PreferenceFigure::Antialiasing(4) => ui_kit::paint_aa_4x,
|
||||
PreferenceFigure::Antialiasing(8) => ui_kit::paint_aa_8x,
|
||||
PreferenceFigure::ContactShading(true) => ui_kit::paint_ao_on,
|
||||
_ => {
|
||||
ui_kit::paint_preference_figure(p, rect, figure, true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
paint(p, rect);
|
||||
}
|
||||
|
||||
/// One stroked glyph of a preset card on the design's 24 x 24 grid: a bolt, a gem or three sliders.
|
||||
fn paint_preset_glyph(p: &egui::Painter, rect: egui::Rect, preset: &str, ink: egui::Color32) {
|
||||
let s = rect.width().min(rect.height()) / 24.0;
|
||||
let at = |x: f32, y: f32| rect.center() + egui::vec2(x - 12.0, y - 12.0) * s;
|
||||
let stroke = egui::Stroke::new(1.9 * s, ink);
|
||||
let line = |points: &[(f32, f32)], closed: bool| {
|
||||
let points: Vec<egui::Pos2> = points.iter().map(|&(x, y)| at(x, y)).collect();
|
||||
p.add(if closed { egui::epaint::PathShape::closed_line(points, stroke) } else { egui::epaint::PathShape::line(points, stroke) });
|
||||
};
|
||||
match preset {
|
||||
"performance" => line(&[(13.0, 3.0), (5.0, 13.0), (11.0, 13.0), (10.0, 21.0), (18.0, 11.0), (12.0, 11.0)], true),
|
||||
"quality" => {
|
||||
line(&[(6.0, 4.0), (18.0, 4.0), (21.0, 9.0), (12.0, 20.0), (3.0, 9.0)], true);
|
||||
line(&[(3.0, 9.0), (21.0, 9.0)], false);
|
||||
line(&[(9.0, 4.0), (12.0, 20.0), (15.0, 4.0)], false);
|
||||
}
|
||||
_ => {
|
||||
for (y, knob) in [(6.0, 9.0), (12.0, 15.0), (18.0, 8.0)] {
|
||||
line(&[(5.0, y), (19.0, y)], false);
|
||||
p.circle_filled(at(knob, y), 2.0 * s, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitor on the device tile, on the same grid.
|
||||
fn paint_monitor(p: &egui::Painter, rect: egui::Rect, ink: egui::Color32) {
|
||||
let s = rect.width().min(rect.height()) / 24.0;
|
||||
let at = |x: f32, y: f32| rect.center() + egui::vec2(x - 12.0, y - 12.0) * s;
|
||||
let stroke = egui::Stroke::new(1.8 * s, ink);
|
||||
p.rect_stroke(egui::Rect::from_two_pos(at(3.0, 4.0), at(21.0, 16.0)), 2.0 * s, stroke, egui::StrokeKind::Middle);
|
||||
p.line_segment([at(8.0, 20.0), at(16.0, 20.0)], stroke);
|
||||
p.line_segment([at(12.0, 16.0), at(12.0, 20.0)], stroke);
|
||||
}
|
||||
|
||||
/// The section's card: one grey surface holding the device, its pickers and its checkbox. A search
|
||||
/// shows the rows without it, like every other card.
|
||||
fn computer_card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = Tokens::get();
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
add(ui);
|
||||
return;
|
||||
}
|
||||
egui::Frame::NONE
|
||||
.fill(t.pref_well)
|
||||
.stroke(egui::Stroke::new(1.0, t.dialog_border))
|
||||
.corner_radius(f32::from(t.radius_lg) + t.space_2)
|
||||
.inner_margin(egui::Margin::same(t.space_14 as i8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = t.space_12;
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// The device line: a tile with a monitor, the adapter's name, its backend and renderer, and a badge.
|
||||
fn computer_header(app: &SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let adapter = app.viewport.adapter.as_str();
|
||||
let (backend, name) = adapter.split_once(" · ").unwrap_or(("", adapter));
|
||||
let renderer_kind = if app.viewport.gpu.is_some() { "GPU" } else { app.tr("prefs.cpu") };
|
||||
let renderer_details = match (&app.viewport.gpu, &app.help.gpu) {
|
||||
(Some(_), Some(g)) => g.as_str(),
|
||||
_ => renderer_kind,
|
||||
};
|
||||
let renderer = format!("{renderer_kind} {}", app.tr_label("Renderer").to_lowercase());
|
||||
// Without an adapter name (a software renderer reports none) the title is the renderer itself.
|
||||
let (name, line) = if name.is_empty() {
|
||||
(app.tr_label("Renderer"), renderer_kind.to_owned())
|
||||
} else if backend.is_empty() {
|
||||
(name, renderer)
|
||||
} else {
|
||||
(name, format!("{backend} · {renderer}"))
|
||||
};
|
||||
let tile = t.space_24 + t.space_12;
|
||||
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), tile), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_12;
|
||||
ui_kit::settings_badge(ui, renderer_kind, app.viewport.gpu.is_none());
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(tile, tile), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, f32::from(t.radius_lg) + t.space_2 * 0.5, t.selected_bg);
|
||||
paint_monitor(ui.painter(), egui::Rect::from_center_size(rect.center(), rect.size() * 0.56), t.form_primary);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
ui_kit::text(ui, name, ui_kit::Text::NameCell(true)).on_hover_text(adapter);
|
||||
ui.label(egui::RichText::new(line).monospace().size(t.font_small).color(t.form_dim)).on_hover_text(renderer_details);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The saved adapter picker retains validation, persistence and all fallback notices.
|
||||
fn graphics_card_row(app: &mut SolveApp, ui: &mut egui::Ui, label_width: f32) {
|
||||
let t = Tokens::get();
|
||||
let selected = app.services.graphics_adapters.iter().position(|(name, _)| *name == app.preferences.graphics_adapter);
|
||||
let missing = !app.preferences.graphics_adapter.is_empty() && selected.is_none();
|
||||
let row_width = (label_width + t.space_14 + t.space_24 * 10.0).min(ui.available_width());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(row_width, t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
ui_kit::settings_row_help(ui, app.tr("prefs.graphics_card"), app.tr("prefs.graphics_card_restart"), |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2((t.space_24 * 10.0).min(ui.available_width()), t.control_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
let adapters = &app.services.graphics_adapters;
|
||||
let selected = adapters.iter().position(|(name, _)| *name == app.preferences.graphics_adapter);
|
||||
let current =
|
||||
selected.and_then(|i| adapters.get(i)).map_or(app.tr("prefs.graphics_card_auto"), |(_, label)| label.as_str());
|
||||
let mut requested = None;
|
||||
let response = ui_kit::dropdown_menu(ui, "graphics_card", current, |ui| {
|
||||
if ui_kit::list_picker(ui, "graphics_card:auto", app.tr("prefs.graphics_card_auto"), selected.is_none()).clicked() {
|
||||
requested = Some(String::new());
|
||||
}
|
||||
for (index, (name, label)) in adapters.iter().enumerate() {
|
||||
if ui_kit::list_picker(ui, &format!("graphics_card:{index}"), label, selected == Some(index)).clicked() {
|
||||
requested = Some(name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_target("prefs:graphics_card", response.rect);
|
||||
if let Some(name) = requested {
|
||||
let _ = super::set(app, &json!({"graphics_adapter": name}));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui_kit::settings_badge(ui, app.tr("prefs.graphics_page.restart"), true);
|
||||
});
|
||||
if missing {
|
||||
ui_kit::text(ui, app.tr("prefs.graphics_card_missing"), ui_kit::Text::Hint("graphics_card_missing"));
|
||||
} else if !app.preferences.graphics_adapter.is_empty() {
|
||||
// Status / Help show the adapter actually in use; the dropdown shows the saved
|
||||
// preference. When they differ the saved card was not applied (#8341).
|
||||
let active = app.viewport.adapter.rsplit(" · ").next().unwrap_or("");
|
||||
if !active.is_empty() && !active.eq_ignore_ascii_case(&app.preferences.graphics_adapter) {
|
||||
ui_kit::text(ui, app.tr("prefs.graphics_card_unused"), ui_kit::Text::Hint("graphics_card_unused"));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Perspective view's Field of view (#5364): the kit slider in degrees, typed or dragged, kept to
|
||||
/// whole degrees and written back in radians. No step on the slider itself: it would round the
|
||||
/// default 0.6 (34.4°) just by being drawn, so an untouched angle stays exactly as saved.
|
||||
|
||||
@@ -200,254 +200,396 @@ pub(super) fn appearance(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
|
||||
pub(super) fn layout(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
ui_kit::settings_heading_with_tag(
|
||||
ui,
|
||||
app.tr("prefs.structure.layout"),
|
||||
app.tr("prefs.structure.layout_note"),
|
||||
Some(crate::settings_profiles::tag),
|
||||
);
|
||||
ui_kit::settings_section(ui, "layout", app.tr("prefs.layout"), |ui| {
|
||||
ui_kit::settings_pair(
|
||||
ui_kit::settings_page(ui, |ui| {
|
||||
ui_kit::settings_heading_with_tag(
|
||||
ui,
|
||||
|ui| {
|
||||
ui_kit::settings_form(ui, t.form_label_width + t.space_16 + t.space_6, |ui| {
|
||||
ui_kit::row(ui, app.tr("prefs.workspace"), |ui| {
|
||||
let mut selected = usize::from(app.ui.layout == crate::shell::LayoutMode::Docked);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[crate::i18n::tr("ui.layout_floating"), crate::i18n::tr("ui.layout_compact")],
|
||||
Default::default(),
|
||||
) {
|
||||
let _ = app.run("view.layout", json!({"mode": if selected == 1 { "compact" } else { "floating" }}));
|
||||
}
|
||||
});
|
||||
ui_kit::row(ui, app.tr("prefs.toolbar"), |ui| {
|
||||
let mut selected = usize::from(!app.preferences.ribbon_labels);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.labels"), app.tr("prefs.icons_only")], Default::default()) {
|
||||
app.preferences.ribbon_labels = selected == 0;
|
||||
}
|
||||
});
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::persistent_ribbon_pressed(app, ui).on_hover_text(app.tr("prefs.persistent_ribbon_pressed_help"));
|
||||
crate::scenario::publish_target("prefs:persistent_ribbon_pressed", check.rect);
|
||||
});
|
||||
// The SKETCH tab outside a sketch too (#7904).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::sketch_tab_always(app, ui).on_hover_text(app.tr("prefs.sketch_tab_always_note"));
|
||||
crate::scenario::publish_target("prefs:sketch_tab_always", check.rect);
|
||||
});
|
||||
// Each design tab keeps its dialog, tool and toolbar tab across a switch (#9360).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::keep_work_on_tab_switch(app, ui).on_hover_text(app.tr("prefs.keep_work_on_tab_switch_note"));
|
||||
crate::scenario::publish_target("prefs:keep_work_on_tab_switch", check.rect);
|
||||
});
|
||||
// Search results point out their place in the ribbon (#7287).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::ribbon_reveal(app, ui).on_hover_text(app.tr("prefs.ribbon_reveal_note"));
|
||||
crate::scenario::publish_target("prefs:ribbon_reveal", check.rect);
|
||||
});
|
||||
// Where every feature dialog puts its OK and Cancel (#6708).
|
||||
ui_kit::row(ui, app.tr("prefs.dialog_buttons"), |ui| {
|
||||
let placements = [crate::ui_kit::DialogButtons::Bottom, crate::ui_kit::DialogButtons::Top];
|
||||
let mut selected = placements.iter().position(|p| *p == app.preferences.dialog_buttons).unwrap_or(0);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.bottom"), app.tr("prefs.top")], Default::default())
|
||||
&& let Some(placement) = placements.get(selected)
|
||||
{
|
||||
app.preferences.dialog_buttons = *placement;
|
||||
}
|
||||
});
|
||||
// Handles that don't depend on the language, by the saved value.
|
||||
let row = app.tr("prefs.dialog_buttons");
|
||||
for (label, key) in [("prefs.bottom", "bottom"), ("prefs.top", "top")] {
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{row}:{}", app.tr(label))) {
|
||||
crate::scenario::publish_handle(&format!("prefs:dialog_buttons:{key}"), at);
|
||||
}
|
||||
}
|
||||
// Where notices and offers stack (#7893).
|
||||
ui_kit::row(ui, app.tr("prefs.toast_place"), |ui| {
|
||||
let places = [crate::viewport::ToastPlace::Corner, crate::viewport::ToastPlace::Centre];
|
||||
let mut selected = places.iter().position(|p| *p == app.preferences.toast_place).unwrap_or(0);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.bottom_right"), app.tr("prefs.bottom_centre")], Default::default())
|
||||
&& let Some(place) = places.get(selected)
|
||||
{
|
||||
app.preferences.toast_place = *place;
|
||||
}
|
||||
});
|
||||
let row = app.tr("prefs.toast_place");
|
||||
for (label, key) in [("prefs.bottom_right", "corner"), ("prefs.bottom_centre", "centre")] {
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{row}:{}", app.tr(label))) {
|
||||
crate::scenario::publish_handle(&format!("prefs:toast_place:{key}"), at);
|
||||
}
|
||||
}
|
||||
// The ribbon's panel drop-downs: a list, or a grid of large icons (#4739).
|
||||
ui_kit::row(ui, app.tr("prefs.menus"), |ui| {
|
||||
let mut selected = usize::from(app.preferences.ribbon_icon_grid);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.menu_list"), app.tr("prefs.menu_grid")], Default::default()) {
|
||||
app.preferences.ribbon_icon_grid = selected == 1;
|
||||
}
|
||||
});
|
||||
setting::ribbon_menus_skip_pinned(app, ui);
|
||||
ui_kit::row(ui, app.tr("prefs.history"), |ui| {
|
||||
let mut selected = if app.preferences.history_in_browser {
|
||||
3
|
||||
} else if app.preferences.timeline_left {
|
||||
if app.preferences.timeline_under_browser { 2 } else { 1 }
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[
|
||||
crate::i18n::tr("prefs.bottom"),
|
||||
crate::i18n::tr("prefs.left"),
|
||||
crate::i18n::tr("prefs.under_browser"),
|
||||
crate::i18n::tr("prefs.in_browser"),
|
||||
],
|
||||
ui_kit::Segments { proportional: true, compact: true, ..Default::default() },
|
||||
) {
|
||||
let dock = match selected {
|
||||
1 => "left",
|
||||
2 => "under_browser",
|
||||
3 => "browser",
|
||||
_ => "bottom",
|
||||
};
|
||||
let _ = app.run("view.history_layout", json!({ "dock": dock }));
|
||||
}
|
||||
});
|
||||
for (short, legacy) in [
|
||||
("prefs.bottom", "ui.timeline_bottom"),
|
||||
("prefs.left", "ui.timeline_left_sidebar"),
|
||||
("prefs.under_browser", "ui.history_below_browser"),
|
||||
] {
|
||||
let row = app.tr("prefs.history");
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{row}:{}", app.tr(short))) {
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}:{}", app.tr(legacy)), at);
|
||||
}
|
||||
}
|
||||
// The History at the left and the one in the browser can list a sketch under its
|
||||
// feature (#6489); the timeline at the bottom can't.
|
||||
ui_kit::row(ui, app.tr("prefs.history_sketches"), |ui| {
|
||||
ui.add_enabled_ui(app.preferences.timeline_left || app.preferences.history_in_browser, |ui| {
|
||||
let mut selected = usize::from(app.preferences.history_sketches == HistorySketches::UnderFeature);
|
||||
app.tr("prefs.structure.layout"),
|
||||
app.tr("prefs.structure.layout_note"),
|
||||
Some(crate::settings_profiles::tag),
|
||||
);
|
||||
// Retain the section key for existing reveal/open callers.
|
||||
ui_kit::settings_section(ui, "layout", app.tr("prefs.layout_page.window"), |ui| {
|
||||
ui_kit::settings_pair(
|
||||
ui,
|
||||
|ui| {
|
||||
ui_kit::settings_form(ui, t.form_label_width + t.space_16, |ui| {
|
||||
ui_kit::row(ui, app.tr("prefs.workspace"), |ui| {
|
||||
let mut selected = usize::from(app.ui.layout == crate::shell::LayoutMode::Docked);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[app.tr("prefs.history_sketches_in_order"), app.tr("prefs.history_sketches_under_feature")],
|
||||
ui_kit::Segments { proportional: true, compact: true, ..Default::default() },
|
||||
&[crate::i18n::tr("ui.layout_floating"), crate::i18n::tr("ui.layout_compact")],
|
||||
ui_kit::Segments { intrinsic: true, ..Default::default() },
|
||||
) {
|
||||
app.preferences.history_sketches =
|
||||
if selected == 1 { HistorySketches::UnderFeature } else { HistorySketches::InOrder };
|
||||
let _ = app.run("view.layout", json!({"mode": if selected == 1 { "compact" } else { "floating" }}));
|
||||
}
|
||||
});
|
||||
});
|
||||
// Only the history on the left or under the browser has room for these rows.
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
ui.add_enabled_ui(app.preferences.timeline_left, |ui| {
|
||||
let check = ui_kit::checkbox(ui, app.tr("prefs.history_origin_bodies"), &mut app.preferences.timeline_origin_bodies)
|
||||
.on_hover_text(app.tr("prefs.history_origin_bodies_note"))
|
||||
.on_disabled_hover_text(app.tr("prefs.history_origin_bodies_note"));
|
||||
crate::scenario::publish_target("prefs:history_origin_bodies", check.rect);
|
||||
});
|
||||
});
|
||||
// Play History between Previous and Next, for people who want it there (#3688).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::history_play_button(app, ui).on_hover_text(app.tr("prefs.history_play_button_note"));
|
||||
crate::scenario::publish_target("prefs:history_play_button", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("prefs.browser"), |ui| {
|
||||
ui.add_enabled_ui(app.ui.layout == crate::shell::LayoutMode::Floating, |ui| {
|
||||
let mut selected = usize::from(app.preferences.browser_float);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.opaque"), app.tr("prefs.translucent")], Default::default()) {
|
||||
app.preferences.browser_float = selected == 1;
|
||||
ui_kit::row(ui, app.tr("prefs.toolbar"), |ui| {
|
||||
let mut selected = usize::from(!app.preferences.ribbon_labels);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[app.tr("prefs.labels"), app.tr("prefs.icons_only")],
|
||||
ui_kit::Segments { intrinsic: true, ..Default::default() },
|
||||
) {
|
||||
app.preferences.ribbon_labels = selected == 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
// Out of sight until the pointer comes to it (#8692); the browser sharing its
|
||||
// column with History keeps its place.
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
use crate::browser::BrowserShow;
|
||||
ui.add_enabled_ui(!crate::timeline_left::stacked(app), |ui| {
|
||||
let mut selected = usize::from(app.preferences.browser_show == BrowserShow::Hover);
|
||||
let options = [app.tr("prefs.browser_shown"), app.tr("prefs.browser_reveal_on_hover")];
|
||||
let segments = ui.scope(|ui| ui_kit::segmented(ui, &mut selected, &options, Default::default()));
|
||||
segments.response.on_hover_text(app.tr("prefs.browser_show_note"));
|
||||
if segments.inner {
|
||||
app.preferences.browser_show = if selected == 1 { BrowserShow::Hover } else { BrowserShow::Shown };
|
||||
ui_kit::row(ui, app.tr("prefs.menus"), |ui| {
|
||||
let mut selected = usize::from(app.preferences.ribbon_icon_grid);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[app.tr("prefs.menu_list"), app.tr("prefs.menu_grid")],
|
||||
ui_kit::Segments { intrinsic: true, ..Default::default() },
|
||||
) {
|
||||
app.preferences.ribbon_icon_grid = selected == 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
// Where command dialogs open (#6707).
|
||||
ui_kit::row(ui, app.tr("prefs.dialogs"), |ui| {
|
||||
use crate::dialog_column::DialogPlace;
|
||||
let mut selected = usize::from(app.preferences.dialog_place == DialogPlace::LeftColumn);
|
||||
let options = [app.tr("prefs.dialogs_edge"), app.tr("prefs.dialogs_left_column")];
|
||||
let segments = ui.scope(|ui| ui_kit::segmented(ui, &mut selected, &options, Default::default()));
|
||||
segments.response.on_hover_text(app.tr("prefs.dialogs_note"));
|
||||
if segments.inner {
|
||||
app.preferences.dialog_place = if selected == 1 { DialogPlace::LeftColumn } else { DialogPlace::Edge };
|
||||
ui_kit::row(ui, app.tr("prefs.toast_place"), |ui| {
|
||||
let places = [crate::viewport::ToastPlace::Corner, crate::viewport::ToastPlace::Centre];
|
||||
let mut selected = places.iter().position(|p| *p == app.preferences.toast_place).unwrap_or(0);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[app.tr("prefs.bottom_right"), app.tr("prefs.bottom_centre")],
|
||||
ui_kit::Segments { intrinsic: true, ..Default::default() },
|
||||
) && let Some(place) = places.get(selected)
|
||||
{
|
||||
app.preferences.toast_place = *place;
|
||||
}
|
||||
});
|
||||
let row = app.tr("prefs.toast_place");
|
||||
for (label, key) in [("prefs.bottom_right", "corner"), ("prefs.bottom_centre", "centre")] {
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{row}:{}", app.tr(label))) {
|
||||
crate::scenario::publish_handle(&format!("prefs:toast_place:{key}"), at);
|
||||
}
|
||||
}
|
||||
layout_check(ui, app.tr("prefs.status_bar"), app.tr("prefs.status_bar_note"), &mut app.preferences.status_bar, "status_bar");
|
||||
});
|
||||
// Where a dialog's and a sketch's OK and Cancel also sit (#6709).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::view_confirm_corner(app, ui).on_hover_text(app.tr("prefs.view_confirm_corner_note"));
|
||||
crate::scenario::publish_target("prefs:view_confirm_corner", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("prefs.bodies_folders"), |ui| {
|
||||
let modes = BodiesFolders::ALL;
|
||||
let labels: Vec<_> = modes.iter().map(|m| app.tr(m.label_id())).collect();
|
||||
let mut selected = modes.iter().position(|m| *m == app.preferences.browser_bodies_folders).unwrap_or(0);
|
||||
if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { proportional: true, compact: true, ..Default::default() })
|
||||
&& let Some(mode) = modes.get(selected)
|
||||
{
|
||||
app.preferences.browser_bodies_folders = *mode;
|
||||
}
|
||||
});
|
||||
// Placements open in the browser from the start, as before #9274.
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::browser_components_open(app, ui).on_hover_text(app.tr("prefs.browser_components_open_note"));
|
||||
crate::scenario::publish_target("prefs:browser_components_open", check.rect);
|
||||
});
|
||||
// The eye beside the fold arrow, where it was before #8718.
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::browser_eye_left(app, ui).on_hover_text(app.tr("prefs.browser_eye_left_note"));
|
||||
crate::scenario::publish_target("prefs:browser_eye_left", check.rect);
|
||||
});
|
||||
// What is being edited, the sketch's state and the units, along the bottom (#7243).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::status_bar(app, ui).on_hover_text(app.tr("prefs.status_bar_note"));
|
||||
crate::scenario::publish_target("prefs:status_bar", check.rect);
|
||||
});
|
||||
ui_kit::text(ui, app.tr("prefs.layout_note"), ui_kit::Text::Hint("layout"));
|
||||
ui_kit::text(ui, app.tr("prefs.history_sketches_note"), ui_kit::Text::Hint("history_sketches"));
|
||||
ui_kit::LayoutPreview {
|
||||
compact: app.ui.layout == crate::shell::LayoutMode::Docked,
|
||||
labels: app.preferences.ribbon_labels,
|
||||
history: if app.preferences.history_in_browser {
|
||||
3
|
||||
} else if app.preferences.timeline_left {
|
||||
if app.preferences.timeline_under_browser { 2 } else { 1 }
|
||||
} else {
|
||||
0
|
||||
},
|
||||
history: history_choice(app),
|
||||
translucent: app.preferences.browser_float,
|
||||
dialog_left: app.preferences.dialog_place == crate::dialog_column::DialogPlace::LeftColumn,
|
||||
}
|
||||
})
|
||||
},
|
||||
ui_kit::layout_preview,
|
||||
);
|
||||
});
|
||||
super::navbar::show(app, ui);
|
||||
// The same choice as the toolbar's own menu (the plus after the tabs, or right-click a tab).
|
||||
ui_kit::settings_section(ui, "toolbar_tabs", app.tr("prefs.toolbar_tabs"), |ui| {
|
||||
for tab in crate::workspace::choosable_tabs() {
|
||||
let mut on = crate::workspace::tab_turned_on(app, tab);
|
||||
let response = ui_kit::checkbox(ui, &crate::toolbar::ribbon_label(app, tab), &mut on);
|
||||
crate::scenario::publish_target(&format!("prefs:toolbar_tab:{tab}"), response.rect);
|
||||
if response.changed() {
|
||||
crate::workspace::set_tab_shown(app, tab, on);
|
||||
}
|
||||
},
|
||||
ui_kit::layout_preview,
|
||||
);
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.keep_work_on_tab_switch"),
|
||||
app.tr("prefs.keep_work_on_tab_switch_note"),
|
||||
&mut app.preferences.keep_work_on_tab_switch,
|
||||
"keep_work_on_tab_switch",
|
||||
);
|
||||
});
|
||||
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:section:{}", app.tr("prefs.layout_page.window"))) {
|
||||
crate::scenario::publish_target(&format!("dialog:section:{}", app.tr("prefs.layout")), rect);
|
||||
}
|
||||
ui_kit::settings_section(ui, "panels", app.tr("prefs.layout_page.panels"), |ui| {
|
||||
let options = [
|
||||
ui_kit::PictureChoice { key: "bottom", label: app.tr("prefs.bottom"), figure: ui_kit::paint_history_bottom },
|
||||
ui_kit::PictureChoice { key: "left", label: app.tr("prefs.left"), figure: ui_kit::paint_history_left },
|
||||
ui_kit::PictureChoice { key: "under_browser", label: app.tr("prefs.under_browser"), figure: ui_kit::paint_history_under_browser },
|
||||
ui_kit::PictureChoice { key: "browser", label: app.tr("prefs.in_browser"), figure: ui_kit::paint_history_in_browser },
|
||||
];
|
||||
if let Some(selected) = ui_kit::settings_picture_choices(ui, "history", app.tr("prefs.history"), &options, history_choice(app), true)
|
||||
&& let Some(option) = options.get(selected)
|
||||
{
|
||||
let _ = app.run("view.history_layout", json!({"dock":option.key}));
|
||||
}
|
||||
for (short, legacy) in [
|
||||
("prefs.bottom", "ui.timeline_bottom"),
|
||||
("prefs.left", "ui.timeline_left_sidebar"),
|
||||
("prefs.under_browser", "ui.history_below_browser"),
|
||||
] {
|
||||
let row = app.tr("prefs.history");
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{row}:{}", app.tr(short))) {
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}:{}", app.tr(legacy)), at);
|
||||
}
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_16;
|
||||
let cards_width = (t.space_24 * 10.0 + t.space_2).min(ui.max_rect().width());
|
||||
ui.allocate_ui_with_layout(egui::vec2(cards_width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
let options = [
|
||||
ui_kit::PictureChoice { key: "column", label: app.tr("prefs.layout_page.column"), figure: ui_kit::paint_browser_column },
|
||||
ui_kit::PictureChoice { key: "card", label: app.tr("prefs.layout_page.card"), figure: ui_kit::paint_browser_card },
|
||||
];
|
||||
if let Some(selected) = ui_kit::settings_picture_choices(
|
||||
ui,
|
||||
"browser",
|
||||
app.tr("prefs.browser"),
|
||||
&options,
|
||||
usize::from(app.preferences.browser_float),
|
||||
app.ui.layout == crate::shell::LayoutMode::Floating,
|
||||
) {
|
||||
app.preferences.browser_float = selected == 1;
|
||||
}
|
||||
for (key, legacy) in [("column", "prefs.opaque"), ("card", "prefs.translucent")] {
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("prefs:browser:{key}")) {
|
||||
crate::scenario::publish_handle(&format!("dialog:{}:{}", app.tr("prefs.browser"), app.tr(legacy)), at);
|
||||
}
|
||||
}
|
||||
});
|
||||
let labels = [app.tr("prefs.browser_shown"), app.tr("prefs.layout_page.on_hover")];
|
||||
let width = labels.iter().map(|label| ui_kit::ContentWidth::name(ui, label)).fold(0.0, f32::max) * 2.0 + t.space_16 * 3.0 + t.space_6;
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width.min(ui.max_rect().width()), t.control_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
browser_visibility(app, ui);
|
||||
},
|
||||
);
|
||||
});
|
||||
ui_kit::settings_form(ui, t.form_label_width + t.space_24 * 3.0, |ui| {
|
||||
ui_kit::settings_row_help(ui, app.tr("prefs.history_sketches"), app.tr("prefs.history_sketches_note"), |ui| {
|
||||
ui.add_enabled_ui(app.preferences.timeline_left || app.preferences.history_in_browser, |ui| {
|
||||
let mut selected = usize::from(app.preferences.history_sketches == HistorySketches::UnderFeature);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[app.tr("prefs.history_sketches_in_order"), app.tr("prefs.history_sketches_under_feature")],
|
||||
ui_kit::Segments { intrinsic: true, ..Default::default() },
|
||||
) {
|
||||
app.preferences.history_sketches = if selected == 1 { HistorySketches::UnderFeature } else { HistorySketches::InOrder };
|
||||
}
|
||||
});
|
||||
});
|
||||
for (key, label) in [("in_order", "prefs.history_sketches_in_order"), ("under_feature", "prefs.history_sketches_under_feature")] {
|
||||
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{}:{}", app.tr("prefs.history_sketches"), app.tr(label))) {
|
||||
crate::scenario::publish_target(&format!("prefs:history_sketches:{key}"), rect);
|
||||
crate::scenario::publish_target(&format!("dialog:Sketches:{}", app.tr(label)), rect);
|
||||
}
|
||||
}
|
||||
ui_kit::row(ui, app.tr("prefs.bodies_folders"), |ui| {
|
||||
let modes = BodiesFolders::ALL;
|
||||
let labels: Vec<_> = modes.iter().map(|m| app.tr(m.label_id())).collect();
|
||||
let mut selected = modes.iter().position(|m| *m == app.preferences.browser_bodies_folders).unwrap_or(0);
|
||||
if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { intrinsic: true, ..Default::default() })
|
||||
&& let Some(mode) = modes.get(selected)
|
||||
{
|
||||
app.preferences.browser_bodies_folders = *mode;
|
||||
}
|
||||
if let Some(rect) =
|
||||
crate::scenario::rect_of(&format!("dialog:{}:{}", app.tr("prefs.bodies_folders"), app.tr("prefs.bodies_folders_several")))
|
||||
{
|
||||
crate::scenario::publish_target(&format!("dialog:{}:Several bodies only", app.tr("prefs.bodies_folders")), rect);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_16, t.space_6);
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.layout_page.play_history"),
|
||||
app.tr("prefs.history_play_button_note"),
|
||||
&mut app.preferences.history_play_button,
|
||||
"history_play_button",
|
||||
);
|
||||
ui.add_enabled_ui(app.preferences.timeline_left, |ui| {
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.layout_page.origin_bodies"),
|
||||
app.tr("prefs.history_origin_bodies_note"),
|
||||
&mut app.preferences.timeline_origin_bodies,
|
||||
"history_origin_bodies",
|
||||
);
|
||||
});
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.browser_components_open"),
|
||||
app.tr("prefs.browser_components_open_note"),
|
||||
&mut app.preferences.browser_components_open,
|
||||
"browser_components_open",
|
||||
);
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.browser_eye_left"),
|
||||
app.tr("prefs.browser_eye_left_note"),
|
||||
&mut app.preferences.browser_eye_left,
|
||||
"browser_eye_left",
|
||||
);
|
||||
});
|
||||
});
|
||||
ui_kit::settings_section(ui, "dialogs", app.tr("prefs.dialogs"), |ui| {
|
||||
ui_kit::settings_form(ui, t.form_label_width + t.space_24, |ui| {
|
||||
ui_kit::settings_row_help(ui, app.tr("prefs.layout_page.open"), app.tr("prefs.dialogs_note"), |ui| {
|
||||
use crate::dialog_column::DialogPlace;
|
||||
let mut selected = usize::from(app.preferences.dialog_place == DialogPlace::LeftColumn);
|
||||
let options = [app.tr("prefs.dialogs_edge"), app.tr("prefs.dialogs_left_column")];
|
||||
let segments =
|
||||
ui.scope(|ui| ui_kit::segmented(ui, &mut selected, &options, ui_kit::Segments { intrinsic: true, ..Default::default() }));
|
||||
segments.response.on_hover_text(app.tr("prefs.dialogs_note"));
|
||||
if segments.inner {
|
||||
app.preferences.dialog_place = if selected == 1 { DialogPlace::LeftColumn } else { DialogPlace::Edge };
|
||||
}
|
||||
});
|
||||
for option in ["prefs.dialogs_edge", "prefs.dialogs_left_column"] {
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{}:{}", app.tr("prefs.layout_page.open"), app.tr(option))) {
|
||||
crate::scenario::publish_handle(&format!("dialog:{}:{}", app.tr("prefs.dialogs"), app.tr(option)), at);
|
||||
}
|
||||
}
|
||||
ui_kit::row(ui, app.tr("prefs.layout_page.ok_cancel"), |ui| {
|
||||
let placements = [crate::ui_kit::DialogButtons::Bottom, crate::ui_kit::DialogButtons::Top];
|
||||
let mut selected = placements.iter().position(|p| *p == app.preferences.dialog_buttons).unwrap_or(0);
|
||||
if ui_kit::segmented(
|
||||
ui,
|
||||
&mut selected,
|
||||
&[app.tr("prefs.bottom"), app.tr("prefs.top")],
|
||||
ui_kit::Segments { intrinsic: true, ..Default::default() },
|
||||
) && let Some(placement) = placements.get(selected)
|
||||
{
|
||||
app.preferences.dialog_buttons = *placement;
|
||||
}
|
||||
});
|
||||
// Handles that don't depend on the language, by the saved value.
|
||||
let row = app.tr("prefs.layout_page.ok_cancel");
|
||||
for (label, key) in [("prefs.bottom", "bottom"), ("prefs.top", "top")] {
|
||||
if let Some(at) = crate::scenario::handle_at(&format!("dialog:{row}:{}", app.tr(label))) {
|
||||
crate::scenario::publish_handle(&format!("prefs:dialog_buttons:{key}"), at);
|
||||
crate::scenario::publish_handle(&format!("dialog:{}:{}", app.tr("prefs.dialog_buttons"), app.tr(label)), at);
|
||||
}
|
||||
}
|
||||
});
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.layout_page.view_corner"),
|
||||
app.tr("prefs.view_confirm_corner_note"),
|
||||
&mut app.preferences.view_confirm_corner,
|
||||
"view_confirm_corner",
|
||||
);
|
||||
});
|
||||
ui_kit::settings_section(ui, "toolbar_tabs", app.tr("prefs.toolbar_tabs"), |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for tab in crate::workspace::choosable_tabs() {
|
||||
let label = crate::toolbar::ribbon_label(app, tab);
|
||||
let on = crate::workspace::tab_turned_on(app, tab);
|
||||
if ui_kit::settings_tab_chip(ui, tab, &label, crate::workspace::tab_icon(tab), on) {
|
||||
crate::workspace::set_tab_shown(app, tab, !on);
|
||||
}
|
||||
}
|
||||
});
|
||||
let labels = [
|
||||
app.tr("prefs.sketch_tab_always"),
|
||||
app.tr("prefs.layout_page.active_commands"),
|
||||
app.tr("prefs.ribbon_reveal"),
|
||||
app.tr("prefs.layout_page.pinned_menus"),
|
||||
];
|
||||
let widest =
|
||||
labels.iter().map(|label| ui_kit::ContentWidth::label(ui, label)).fold(0.0, f32::max) + t.space_14 + t.space_6 * 2.0 + t.space_16;
|
||||
let columns = if ui.available_width() >= widest * 2.0 + t.space_16 { 2 } else { 1 };
|
||||
let width = (ui.available_width() - t.space_16 * (columns - 1) as f32) / columns as f32;
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = t.space_6;
|
||||
for row in (0..4).step_by(columns) {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_16;
|
||||
for index in row..(row + columns).min(4) {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width.max(0.0), t.control_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| match index {
|
||||
0 => {
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.sketch_tab_always"),
|
||||
app.tr("prefs.sketch_tab_always_note"),
|
||||
&mut app.preferences.sketch_tab_always,
|
||||
"sketch_tab_always",
|
||||
);
|
||||
}
|
||||
1 => {
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.layout_page.active_commands"),
|
||||
app.tr("prefs.persistent_ribbon_pressed_help"),
|
||||
&mut app.preferences.persistent_ribbon_pressed,
|
||||
"persistent_ribbon_pressed",
|
||||
);
|
||||
}
|
||||
2 => {
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.ribbon_reveal"),
|
||||
app.tr("prefs.ribbon_reveal_note"),
|
||||
&mut app.preferences.ribbon_reveal,
|
||||
"ribbon_reveal",
|
||||
);
|
||||
}
|
||||
3 => {
|
||||
layout_check(
|
||||
ui,
|
||||
app.tr("prefs.layout_page.pinned_menus"),
|
||||
app.tr("ui.ribbon_menus_skip_pinned"),
|
||||
&mut app.preferences.ribbon_menus_skip_pinned,
|
||||
"ribbon_menus_skip_pinned",
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
super::navbar::show(app, ui);
|
||||
});
|
||||
}
|
||||
|
||||
fn history_choice(app: &SolveApp) -> usize {
|
||||
if app.preferences.history_in_browser {
|
||||
3
|
||||
} else if app.preferences.timeline_left {
|
||||
if app.preferences.timeline_under_browser { 2 } else { 1 }
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep native checkbox responses and one real search target; guidance stays on hover.
|
||||
fn layout_check(ui: &mut egui::Ui, label: &str, help: &str, value: &mut bool, key: &str) {
|
||||
let t = Tokens::get();
|
||||
let width = (ui_kit::ContentWidth::label(ui, label) + t.space_14 + t.space_6 * 2.0 + t.space_16).min(ui.max_rect().width());
|
||||
ui.allocate_ui_with_layout(egui::vec2(width.max(0.0), t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui_kit::settings_form(ui, 0.0, |ui| {
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_6;
|
||||
let check_width = (ui.available_width() - t.space_16 - t.space_6).max(0.0);
|
||||
let response = ui_kit::settings_control_width(ui, check_width, |ui| {
|
||||
ui_kit::checkbox(ui, label, value).on_hover_text(help).on_disabled_hover_text(help)
|
||||
});
|
||||
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
||||
ui_kit::help_icon(ui, help);
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn browser_visibility(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
use crate::browser::BrowserShow;
|
||||
ui_kit::settings_form(ui, 0.0, |ui| {
|
||||
ui_kit::row(ui, app.tr("prefs.browser_shown"), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = Tokens::get().space_6;
|
||||
crate::prefs_search::note_current(ui, app.tr("prefs.layout_page.on_hover"));
|
||||
ui.add_enabled_ui(!crate::timeline_left::stacked(app), |ui| {
|
||||
let mut selected = usize::from(app.preferences.browser_show == BrowserShow::Hover);
|
||||
if ui_kit::settings_segments(
|
||||
ui,
|
||||
app.tr("prefs.browser_shown"),
|
||||
&mut selected,
|
||||
&[app.tr("prefs.browser_shown"), app.tr("prefs.layout_page.on_hover")],
|
||||
) {
|
||||
app.preferences.browser_show = if selected == 1 { BrowserShow::Hover } else { BrowserShow::Shown };
|
||||
}
|
||||
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:segments:{}", app.tr("prefs.browser_shown"))) {
|
||||
crate::scenario::publish_target("prefs:browser_show", rect);
|
||||
}
|
||||
ui_kit::help_icon(ui, app.tr("prefs.browser_show_note"));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+256
-102
@@ -23,18 +23,6 @@ struct Source {
|
||||
type Drag = ui_kit::ReorderPayload<Source>;
|
||||
|
||||
pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// The bar editor is drawn by hand, not through the kit rows the search hides, so while a
|
||||
// search runs it stays out unless the section's own title or a setting's label matches.
|
||||
if crate::prefs_search::querying(ui) {
|
||||
let labels: Vec<String> = ["title", "preview_as", "position", "size", "buttons", "idle", "show", "reset_default"]
|
||||
.iter()
|
||||
.map(|key| app.tr(&format!("navbar.custom.{key}")).to_owned())
|
||||
.collect();
|
||||
let texts: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
if !crate::prefs_search::texts_hit(ui, &texts) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let scroll = app.prefs_window.navbar_scroll;
|
||||
let mut state = std::mem::take(&mut app.prefs_window.navbar);
|
||||
if state.slots != app.preferences.navigation_bar.slots {
|
||||
@@ -47,24 +35,49 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui_kit::settings_section(ui, "navigation_bar", crate::i18n::tr("navbar.custom.title"), |ui| {
|
||||
let width = ui.available_width();
|
||||
ui_kit::text(ui, app.tr("navbar.custom.intro"), ui_kit::Text::Hint("navbar_intro"));
|
||||
segments(ui, "preview_as", &mut usize::from(state.render), &["navbar.custom.design", "navbar.custom.render"], |i| state.render = i == 1);
|
||||
preview(app, ui, &mut state, width);
|
||||
let card = ui_kit::settings_card(ui, |ui| inspector(app, ui, &mut state));
|
||||
scenario::publish_rect("prefs:navbar:inspector", card.response.rect);
|
||||
available(app, ui, &mut state);
|
||||
let t = Tokens::get();
|
||||
let header = ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_16;
|
||||
let label_width = ui_kit::ContentWidth::label(ui, app.tr("navbar.custom.preview_as")).max(t.form_label_width);
|
||||
let tabs_width = [app.tr("navbar.custom.design"), app.tr("navbar.custom.render")]
|
||||
.iter()
|
||||
.map(|label| ui_kit::ContentWidth::name(ui, label) + t.space_16)
|
||||
.sum::<f32>();
|
||||
let preview_width = (label_width + t.space_14 + tabs_width + t.space_16 + t.space_6).min(width);
|
||||
ui.allocate_ui_with_layout(vec2(preview_width, t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
segments(ui, "preview_as", &mut usize::from(state.render), &["navbar.custom.design", "navbar.custom.render"], |i| {
|
||||
state.render = i == 1
|
||||
});
|
||||
});
|
||||
});
|
||||
let actions_minimum = ui_kit::ContentWidth::label(ui, app.tr("navbar.custom.show"))
|
||||
+ t.space_14
|
||||
+ t.space_6
|
||||
+ t.space_8
|
||||
+ ui_kit::ContentWidth::button(ui, app.tr("navbar.custom.reset_default"));
|
||||
let actions_width = (width - preview_width - t.space_16).max(actions_minimum).min(width);
|
||||
ui.allocate_ui_with_layout(vec2(actions_width, t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
header_actions(app, ui, &mut state);
|
||||
});
|
||||
});
|
||||
scenario::publish_rect("prefs:navbar:header", header.response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
preview(app, ui, &mut state, width);
|
||||
if state.selected.is_some() {
|
||||
let card = ui_kit::settings_card(ui, |ui| inspector(app, ui, &mut state));
|
||||
scenario::publish_rect("prefs:navbar:inspector", card.response.rect);
|
||||
}
|
||||
available(app, ui, &mut state);
|
||||
}
|
||||
ui_kit::divider(ui, "navbar_appearance");
|
||||
appearance(app, ui);
|
||||
ui_kit::settings_inline_note(
|
||||
ui,
|
||||
&crate::i18n::format("navbar.custom.count", &[("count", &app.preferences.navigation_bar.slots.len().to_string())]),
|
||||
);
|
||||
|
||||
if let Some(message) =
|
||||
state.notice.or_else(|| (app.preferences.navigation_bar.slots.len() >= navigation_bar::MAX_SLOTS).then_some("navbar.custom.full"))
|
||||
{
|
||||
ui.label(egui::RichText::new(app.tr(message)).color(Tokens::get().warning));
|
||||
}
|
||||
footer(app, ui, &mut state);
|
||||
});
|
||||
if let Some(rect) = scenario::rect_of(&format!("dialog:section:{}", app.tr("navbar.custom.title"))) {
|
||||
scenario::publish_rect("prefs:section:navigation_bar", rect);
|
||||
@@ -78,28 +91,55 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.prefs_window.navbar = state;
|
||||
}
|
||||
|
||||
fn segments(ui: &mut egui::Ui, key: &str, selected: &mut usize, options: &[&str], mut change: impl FnMut(usize)) {
|
||||
fn segments(ui: &mut egui::Ui, key: &str, selected: &mut usize, options: &[&str], change: impl FnMut(usize)) {
|
||||
let label_key = format!("navbar.custom.{key}");
|
||||
let label = crate::i18n::tr(&label_key);
|
||||
let labels: Vec<_> = options.iter().map(|key| crate::i18n::tr(key)).collect();
|
||||
ui_kit::row(ui, label, |ui| {
|
||||
if ui_kit::segmented(ui, selected, &labels, ui_kit::Segments { intrinsic: true, ..Default::default() }) {
|
||||
change(*selected);
|
||||
}
|
||||
if let Some(rect) = scenario::rect_of(&format!("dialog:segments:{label}")) {
|
||||
scenario::publish_target(&format!("prefs:navbar:{key}"), rect);
|
||||
} else if let Some(rect) = scenario::rect_of(&format!("dialog:widget:{label}")) {
|
||||
scenario::publish_target(&format!("prefs:navbar:{key}"), rect);
|
||||
}
|
||||
for (index, option) in labels.iter().enumerate() {
|
||||
if let Some(rect) = scenario::rect_of(&format!("dialog:{label}:{option}")) {
|
||||
scenario::publish_target(&format!("prefs:navbar:{key}:{index}"), rect);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui_kit::row(ui, label, |ui| segment_control(ui, key, selected, options, change));
|
||||
}
|
||||
|
||||
fn footer(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
||||
fn segment_control(ui: &mut egui::Ui, key: &str, selected: &mut usize, options: &[&str], mut change: impl FnMut(usize)) {
|
||||
ui.spacing_mut().item_spacing.x = Tokens::get().space_6;
|
||||
let label_key = format!("navbar.custom.{key}");
|
||||
let label = crate::i18n::tr(&label_key);
|
||||
let labels: Vec<_> = options
|
||||
.iter()
|
||||
.map(|key| {
|
||||
crate::i18n::tr(match *key {
|
||||
"navbar.custom.always" => "prefs.layout_page.always",
|
||||
"navbar.custom.hide_idle" => "prefs.layout_page.hover",
|
||||
"navbar.custom.labels" => "prefs.layout_page.icons_labels",
|
||||
_ => key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if ui_kit::segmented(ui, selected, &labels, ui_kit::Segments { intrinsic: matches!(key, "preview_as" | "shows"), ..Default::default() }) {
|
||||
change(*selected);
|
||||
}
|
||||
if key == "preview_as" {
|
||||
ui_kit::help_icon(ui, crate::i18n::tr("navbar.custom.intro"));
|
||||
}
|
||||
if let Some(rect) = scenario::rect_of(&format!("dialog:segments:{label}")) {
|
||||
scenario::publish_target(&format!("prefs:navbar:{key}"), rect);
|
||||
} else if let Some(rect) = scenario::rect_of(&format!("dialog:widget:{label}")) {
|
||||
scenario::publish_target(&format!("prefs:navbar:{key}"), rect);
|
||||
}
|
||||
for (index, option) in labels.iter().enumerate() {
|
||||
if let Some(rect) = scenario::rect_of(&format!("dialog:{label}:{option}")) {
|
||||
scenario::publish_target(&format!("prefs:navbar:{key}:{index}"), rect);
|
||||
if let Some(legacy) = options.get(index) {
|
||||
scenario::publish_target(&format!("dialog:{label}:{}", crate::i18n::tr(legacy)), rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn option(ui: &mut egui::Ui, key: &str, selected: &mut usize, options: &[&str], change: impl FnMut(usize)) {
|
||||
let label_key = format!("navbar.custom.{key}");
|
||||
let label = crate::i18n::tr(&label_key);
|
||||
ui_kit::settings_compound_field(ui, label, label, |ui| segment_control(ui, key, selected, options, change));
|
||||
}
|
||||
|
||||
fn header_actions(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
||||
let t = Tokens::get();
|
||||
let label = app.tr("navbar.custom.reset_default");
|
||||
let width = ui_kit::ContentWidth::button(ui, label).max(t.control_height);
|
||||
@@ -107,14 +147,22 @@ fn footer(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
||||
let reset = Rect::from_min_max(egui::pos2((rect.right() - width).max(rect.left()), rect.top()), rect.max);
|
||||
let check = Rect::from_min_max(rect.min, egui::pos2((reset.left() - t.space_8).max(rect.left()), rect.bottom()));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(check).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
|
||||
let mut visible = app.ui.show_navigation_bar;
|
||||
let response = ui_kit::checkbox(ui, app.tr("navbar.custom.show"), &mut visible);
|
||||
scenario::publish_target("prefs:navbar:show", response.rect);
|
||||
if response.changed() {
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":visible}));
|
||||
}
|
||||
ui_kit::settings_form(ui, 0.0, |ui| {
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let mut visible = app.ui.show_navigation_bar;
|
||||
let response = ui_kit::checkbox(ui, app.tr("navbar.custom.show"), &mut visible);
|
||||
scenario::publish_target("prefs:navbar:show", response.rect);
|
||||
if response.changed() {
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":visible}));
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
let response = ui_kit::button_at(ui, "navbar_reset", reset, label, ui_kit::Button::Normal);
|
||||
let response = ui
|
||||
.scope_builder(egui::UiBuilder::new().max_rect(reset), |ui| {
|
||||
ui_kit::settings_form(ui, 0.0, |ui| ui_kit::row(ui, label, |ui| ui_kit::button(ui, label, ui_kit::Button::Normal)))
|
||||
})
|
||||
.inner;
|
||||
scenario::publish_target("prefs:navbar:reset", response.rect);
|
||||
scenario::publish_rect("prefs:navbar:status", rect);
|
||||
if response.clicked() {
|
||||
@@ -166,15 +214,42 @@ fn preview(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State, width: f32)
|
||||
});
|
||||
}
|
||||
}
|
||||
slot_count(app, ui, stage, bar.rect);
|
||||
let target = if egui::DragAndDrop::payload::<Drag>(ui.ctx()).is_some_and(|payload| payload.id.index.is_some()) {
|
||||
bar.rect.intersect(stage)
|
||||
} else {
|
||||
stage
|
||||
};
|
||||
drop_on_preview(app, ui, state, target.intersect(ui.clip_rect()), &cells);
|
||||
drop_on_preview(app, ui, state, target.intersect(ui.clip_rect()), &cells, bar.rect.center().y);
|
||||
}
|
||||
|
||||
fn drop_on_preview(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State, target: Rect, cells: &[(usize, Rect)]) {
|
||||
/// The slot count sits in the stage on the side of the bar facing the middle of the view, aligned with the bar.
|
||||
fn slot_count(app: &SolveApp, ui: &mut egui::Ui, stage: Rect, bar: Rect) {
|
||||
let t = Tokens::get();
|
||||
let view = Tokens::view();
|
||||
let text = crate::i18n::format(
|
||||
"prefs.layout_page.slot_count",
|
||||
&[("count", &app.preferences.navigation_bar.slots.len().to_string()), ("max", &navigation_bar::MAX_SLOTS.to_string())],
|
||||
);
|
||||
let galley = ui.painter().layout_no_wrap(text.clone(), egui::FontId::proportional(t.font_body), view.text_dim);
|
||||
let size = galley.size();
|
||||
let x = match app.preferences.navigation_bar.position {
|
||||
Position::Left => stage.left() + ui_kit::NAV_BORDER + ui_kit::NAV_STAGE_INSET + ui_kit::NAV_PADDING,
|
||||
Position::Centre => stage.center().x - size.x / 2.0,
|
||||
Position::Right => stage.right() - ui_kit::NAV_BORDER - ui_kit::NAV_STAGE_INSET - ui_kit::NAV_PADDING - size.x,
|
||||
};
|
||||
let y = match app.preferences.navigation_bar.edge {
|
||||
Edge::Top => bar.bottom() + t.space_8,
|
||||
Edge::Bottom => bar.top() - t.space_8 - size.y,
|
||||
};
|
||||
let rect = Rect::from_min_size(egui::pos2(x, y), size);
|
||||
ui.painter().with_clip_rect(stage).galley(rect.min, galley, view.text_dim);
|
||||
let response = ui.interact(rect, ui.id().with("navbar_slot_count"), Sense::hover());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text.as_str()));
|
||||
scenario::publish_rect("prefs:navbar:count", rect);
|
||||
}
|
||||
|
||||
fn drop_on_preview(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State, target: Rect, cells: &[(usize, Rect)], marker_y: f32) {
|
||||
let Some(payload) = egui::DragAndDrop::payload::<Drag>(ui.ctx()) else { return };
|
||||
let Some(pointer) = ui.input(|i| i.pointer.interact_pos()) else { return };
|
||||
let over = target.contains(pointer) && ui.rect_contains_pointer(target);
|
||||
@@ -189,8 +264,8 @@ fn drop_on_preview(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State, tar
|
||||
let marker = ui_kit::insertion_marker_with_ink(
|
||||
ui.painter(),
|
||||
x,
|
||||
target.center().y - ui_kit::NAV_MARKER_HEIGHT / 2.0,
|
||||
target.center().y + ui_kit::NAV_MARKER_HEIGHT / 2.0,
|
||||
marker_y - ui_kit::NAV_MARKER_HEIGHT / 2.0,
|
||||
marker_y + ui_kit::NAV_MARKER_HEIGHT / 2.0,
|
||||
Tokens::get().form_primary,
|
||||
);
|
||||
scenario::publish_target("prefs:navbar:marker", marker);
|
||||
@@ -239,11 +314,9 @@ fn inspector(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
||||
.selected
|
||||
.and_then(|index| app.preferences.navigation_bar.slots.get(index).and_then(|id| navigation_bar::find(id)).map(|slot| (index, slot)))
|
||||
else {
|
||||
ui_kit::text(ui, app.tr("navbar.custom.select"), ui_kit::Text::Hint("navbar_select"));
|
||||
return;
|
||||
};
|
||||
inspector_header(app, ui, state, index, slot);
|
||||
ui_kit::text(ui, app.tr(slot.description), ui_kit::Text::Hint("navbar_description"));
|
||||
if slot.id == "visual_style" {
|
||||
let mut selected = usize::from(app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconOnly);
|
||||
segments(ui, "shows", &mut selected, &["navbar.custom.icon_value", "navbar.custom.icon_only"], |index| {
|
||||
@@ -290,6 +363,7 @@ fn inspector_header(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State, in
|
||||
}
|
||||
ui_kit::text(ui, app.tr(slot.label), ui_kit::Text::NameCell(true));
|
||||
});
|
||||
ui.interact(name, ui.make_persistent_id("slot_help"), Sense::hover()).on_hover_text(app.tr(slot.description));
|
||||
scenario::publish_rect("prefs:navbar:inspector:name", name);
|
||||
scenario::publish_rect("prefs:navbar:inspector:header", bounds);
|
||||
let mut actions_ui = ui.new_child(egui::UiBuilder::new().id_salt("slot_actions").max_rect(bounds));
|
||||
@@ -330,15 +404,44 @@ fn available(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = Tokens::get().space_6;
|
||||
let caption = crate::i18n::format("navbar.custom.available", &[("available", app.tr("ui.workspace_available"))]);
|
||||
ui_kit::text(ui, &caption, ui_kit::Text::Hint("navbar_available"));
|
||||
ui.horizontal(|ui| {
|
||||
ui_kit::settings_inline_note(ui, app.tr("ui.workspace_available"));
|
||||
ui_kit::help_icon(ui, &caption);
|
||||
// Separators are not buttons: their source sits apart, at the end of the heading.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(slot) = navigation_bar::find("separator") {
|
||||
let hint = app.tr("navbar.custom.separator_hint");
|
||||
let dragging =
|
||||
egui::DragAndDrop::payload::<Drag>(ui.ctx()).is_some_and(|payload| payload.from_menu && payload.id.slot == slot.id);
|
||||
let (tile, add) = ui_kit::navigation_separator_source(ui, slot.id, app.tr(slot.label), dragging);
|
||||
let tile = tile.on_hover_text(hint);
|
||||
scenario::publish_target(&format!("prefs:navbar:tile:{}", slot.id), tile.rect);
|
||||
scenario::publish_target(&format!("prefs:navbar:add:{}", slot.id), add.rect);
|
||||
if tile.drag_started() {
|
||||
tile.dnd_set_drag_payload(Drag {
|
||||
id: Source { slot: slot.id.to_owned(), index: None, slots: app.preferences.navigation_bar.slots.clone() },
|
||||
from_menu: true,
|
||||
});
|
||||
}
|
||||
if add.clicked() {
|
||||
let index = app.preferences.navigation_bar.slots.len();
|
||||
state.notice = app.preferences.navigation_bar.insert(index, slot.id).err();
|
||||
if state.notice.is_none() {
|
||||
state.selected = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(Tokens::get().space_8, Tokens::get().space_8);
|
||||
for slot in navigation_bar::CATALOGUE {
|
||||
if slot.kind != Kind::Separator && app.preferences.navigation_bar.slots.iter().any(|id| id == slot.id) {
|
||||
if slot.kind == Kind::Separator || app.preferences.navigation_bar.slots.iter().any(|id| id == slot.id) {
|
||||
continue;
|
||||
}
|
||||
let dragging = egui::DragAndDrop::payload::<Drag>(ui.ctx()).is_some_and(|payload| payload.from_menu && payload.id.slot == slot.id);
|
||||
let (tile, add) = ui_kit::navigation_tile(ui, slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style), dragging);
|
||||
let tile = tile.on_hover_text(&caption);
|
||||
scenario::publish_target(&format!("prefs:navbar:tile:{}", slot.id), tile.rect);
|
||||
scenario::publish_target(&format!("prefs:navbar:add:{}", slot.id), add.rect);
|
||||
if tile.drag_started() {
|
||||
@@ -359,51 +462,102 @@ fn available(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
||||
});
|
||||
}
|
||||
|
||||
fn appearance(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let bar = &mut app.preferences.navigation_bar;
|
||||
ui_kit::settings_form(ui, Tokens::get().form_label_width, |ui| {
|
||||
segments(
|
||||
ui,
|
||||
"position",
|
||||
&mut match bar.position {
|
||||
Position::Left => 0,
|
||||
Position::Centre => 1,
|
||||
Position::Right => 2,
|
||||
},
|
||||
&["navbar.custom.position_left", "navbar.custom.position_centre", "navbar.custom.position_right"],
|
||||
|i| {
|
||||
bar.position = match i {
|
||||
0 => Position::Left,
|
||||
2 => Position::Right,
|
||||
_ => Position::Centre,
|
||||
}
|
||||
},
|
||||
);
|
||||
segments(ui, "edge", &mut usize::from(bar.edge == Edge::Top), &["navbar.custom.edge_bottom", "navbar.custom.edge_top"], |i| {
|
||||
bar.edge = if i == 1 { Edge::Top } else { Edge::Bottom }
|
||||
fn option_grid(bar: &mut NavigationBar, ui: &mut egui::Ui, first: usize) {
|
||||
let t = Tokens::get();
|
||||
let columns = if ui.available_width() >= t.space_24 * 24.0 {
|
||||
3
|
||||
} else if ui.available_width() >= t.space_24 * 16.0 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let width = (ui.available_width() - t.space_16 * (columns - 1) as f32) / columns as f32;
|
||||
for row in (first..4).step_by(columns) {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_16;
|
||||
for index in row..(row + columns).min(4) {
|
||||
ui.allocate_ui_with_layout(vec2(width.max(0.0), t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| match index {
|
||||
0 => anchor_option(bar, ui),
|
||||
1 => {
|
||||
option(ui, "size", &mut usize::from(bar.size == Size::Regular), &["navbar.custom.compact", "navbar.custom.regular"], |i| {
|
||||
bar.size = if i == 1 { Size::Regular } else { Size::Compact }
|
||||
});
|
||||
}
|
||||
2 => {
|
||||
option(
|
||||
ui,
|
||||
"buttons",
|
||||
&mut usize::from(bar.labels == Labels::Labels),
|
||||
&["navbar.custom.icons", "navbar.custom.labels"],
|
||||
|i| bar.labels = if i == 1 { Labels::Labels } else { Labels::Icons },
|
||||
);
|
||||
}
|
||||
3 => {
|
||||
option(
|
||||
ui,
|
||||
"idle",
|
||||
&mut match bar.idle {
|
||||
Idle::Always => 0,
|
||||
Idle::Fade => 1,
|
||||
Idle::Hide => 2,
|
||||
},
|
||||
&["navbar.custom.always", "navbar.custom.fade", "navbar.custom.hide_idle"],
|
||||
|i| {
|
||||
bar.idle = match i {
|
||||
0 => Idle::Always,
|
||||
2 => Idle::Hide,
|
||||
_ => Idle::Fade,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
});
|
||||
segments(ui, "size", &mut usize::from(bar.size == Size::Regular), &["navbar.custom.compact", "navbar.custom.regular"], |i| {
|
||||
bar.size = if i == 1 { Size::Regular } else { Size::Compact }
|
||||
});
|
||||
segments(ui, "buttons", &mut usize::from(bar.labels == Labels::Labels), &["navbar.custom.icons", "navbar.custom.labels"], |i| {
|
||||
bar.labels = if i == 1 { Labels::Labels } else { Labels::Icons }
|
||||
});
|
||||
segments(
|
||||
ui,
|
||||
"idle",
|
||||
&mut match bar.idle {
|
||||
Idle::Always => 0,
|
||||
Idle::Fade => 1,
|
||||
Idle::Hide => 2,
|
||||
},
|
||||
&["navbar.custom.always", "navbar.custom.fade", "navbar.custom.hide_idle"],
|
||||
|i| {
|
||||
bar.idle = match i {
|
||||
0 => Idle::Always,
|
||||
2 => Idle::Hide,
|
||||
_ => Idle::Fade,
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_option(bar: &mut NavigationBar, ui: &mut egui::Ui) {
|
||||
let selected = usize::from(bar.edge == Edge::Bottom) * 3
|
||||
+ match bar.position {
|
||||
Position::Left => 0,
|
||||
Position::Centre => 1,
|
||||
Position::Right => 2,
|
||||
};
|
||||
let names = [
|
||||
"navbar.custom.anchor.top_left",
|
||||
"navbar.custom.anchor.top_centre",
|
||||
"navbar.custom.anchor.top_right",
|
||||
"navbar.custom.anchor.bottom_left",
|
||||
"navbar.custom.anchor.bottom_centre",
|
||||
"navbar.custom.anchor.bottom_right",
|
||||
]
|
||||
.map(crate::i18n::tr);
|
||||
if let Some(index) = ui_kit::settings_anchor_field(ui, crate::i18n::tr("navbar.custom.position"), selected, &names) {
|
||||
bar.edge = if index >= 3 { Edge::Bottom } else { Edge::Top };
|
||||
bar.position = match index % 3 {
|
||||
0 => Position::Left,
|
||||
2 => Position::Right,
|
||||
_ => Position::Centre,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let bar = &mut app.preferences.navigation_bar;
|
||||
let anchor_size = ui_kit::navigation_anchor_size();
|
||||
// The anchor picker is tall: beside the others when there is room, else the first of them.
|
||||
if ui.available_width() >= t.space_24 * 22.0 {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_16;
|
||||
ui.allocate_ui_with_layout(vec2(anchor_size.x, anchor_size.y + t.space_24), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
anchor_option(bar, ui)
|
||||
});
|
||||
ui.vertical(|ui| option_grid(bar, ui, 1));
|
||||
});
|
||||
} else {
|
||||
option_grid(bar, ui, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,250 @@
|
||||
//! Existing modelling-tool controls, regrouped without changing their semantics.
|
||||
//! Modelling-tool preferences composed from shared controls and canonical figures.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
ui_kit::settings_heading(ui, app.tr("prefs.structure.tools"), app.tr("prefs.structure.tools_note"));
|
||||
// The Hole command's own settings, under its name.
|
||||
ui_kit::settings_section(ui, "create_hole", crate::i18n::tr_label("Create Hole"), |ui| {
|
||||
setting::hole_snaps(app, ui);
|
||||
ui_kit::text(
|
||||
ui,
|
||||
crate::i18n::tr_label("A Single hole snaps to the middle of its face and to the centres of its round edges"),
|
||||
ui_kit::Text::Hint("hole_snaps"),
|
||||
);
|
||||
setting::hole_moves(app, ui);
|
||||
ui_kit::text(
|
||||
ui,
|
||||
crate::i18n::tr_label(
|
||||
"A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out",
|
||||
),
|
||||
ui_kit::Text::Hint("hole_moves"),
|
||||
);
|
||||
});
|
||||
ui_kit::settings_section(ui, "extrude", app.tr_label("Extrude"), |ui| {
|
||||
setting::extrude_auto_to_object(app, ui);
|
||||
// Extrude's view (#9176): isometric when it opens, and back afterwards only with that.
|
||||
setting::extrude_view_isometric(app, ui);
|
||||
let isometric = app.preferences.extrude_view_isometric;
|
||||
ui.add_enabled_ui(isometric, |ui| setting::extrude_view_return(app, ui));
|
||||
let auto = setting::auto_operation(app, ui);
|
||||
crate::scenario::publish_handle("prefs:auto_operation", auto.rect.center());
|
||||
ui_kit::text(ui, app.tr("prefs.auto_operation_note"), ui_kit::Text::Hint("auto_operation"));
|
||||
});
|
||||
ui_kit::settings_section(ui, "placement", app.tr("prefs.placement"), |ui| {
|
||||
setting::placement_handles(app, ui);
|
||||
});
|
||||
ui_kit::settings_section(ui, "joint_move", app.tr("joint_move.settings"), |ui| {
|
||||
setting::assembly_move_handles(app, ui);
|
||||
ui_kit::text(ui, app.tr("joint_move.legacy_hint"), ui_kit::Text::Hint("joint_move_legacy"));
|
||||
ui_kit::settings_form(ui, t.space_24 * 7.0 + t.space_2, |ui| {
|
||||
ui_kit::row(ui, app.tr("joint_move.coarse"), |ui| {
|
||||
ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_coarse, 0.001..=180.0, 0.1);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("joint_move.fine"), |ui| {
|
||||
ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_fine, 0.001..=180.0, 0.01);
|
||||
ui_kit::settings_page(ui, |ui| {
|
||||
ui_kit::settings_heading(ui, app.tr("prefs.structure.tools"), app.tr("prefs.structure.tools_note"));
|
||||
ui_kit::settings_section(ui, "create", app.tr("ui.create"), |ui| {
|
||||
create_card(ui, |ui| {
|
||||
create_row(ui, app.tr("ui.hole"), &["hole"], true, |ui| {
|
||||
for (setting, help, key, legacy) in [
|
||||
(
|
||||
setting::hole_snaps as fn(&mut SolveApp, &mut egui::Ui) -> egui::Response,
|
||||
"prefs.design_tools.hole_snaps_help",
|
||||
"hole_snaps",
|
||||
"Snap to face and edge centres",
|
||||
),
|
||||
(setting::hole_moves, "prefs.design_tools.hole_moves_help", "hole_moves", "Move holes by clicking or dragging"),
|
||||
] {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting(app, ui).on_hover_text(app.tr(help));
|
||||
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
||||
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
ui_kit::help_icon(ui, app.tr(help));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
create_row(ui, app.tr_label("Extrude"), &["extrude"], false, |ui| {
|
||||
for (setting, help, key) in [
|
||||
(
|
||||
setting::extrude_auto_to_object as fn(&mut SolveApp, &mut egui::Ui) -> egui::Response,
|
||||
"ui.extrude_auto_to_object_tip",
|
||||
"extrude_auto_to_object",
|
||||
),
|
||||
(setting::auto_operation, "prefs.auto_operation_note", "auto_operation"),
|
||||
] {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting(app, ui).on_hover_text(app.tr(help));
|
||||
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
ui_kit::help_icon(ui, app.tr(help));
|
||||
}
|
||||
});
|
||||
}
|
||||
// Retain the newer controls and their original dependency after automatic operation.
|
||||
let response = setting::extrude_view_isometric(app, ui);
|
||||
crate::scenario::publish_target("prefs:extrude_view_isometric", response.rect);
|
||||
let isometric = app.preferences.extrude_view_isometric;
|
||||
ui.add_enabled_ui(isometric, |ui| {
|
||||
let response = setting::extrude_view_return(app, ui);
|
||||
crate::scenario::publish_target("prefs:extrude_view_return", response.rect);
|
||||
});
|
||||
});
|
||||
create_row(ui, app.tr("prefs.design_tools.placement_group"), &["rest", "boss", "snap_fit"], false, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting::placement_handles(app, ui).on_hover_text(app.tr("ui.placement_handles_tip"));
|
||||
crate::scenario::publish_target("prefs:placement_handles", response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
ui_kit::help_icon(ui, app.tr("ui.placement_handles_tip"));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
ui_kit::settings_section(ui, "joint_move", app.tr("joint_move.settings"), |ui| {
|
||||
tool_group(ui, "", ui_kit::paint_assembly_move, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting::assembly_move_handles(app, ui).on_hover_text(app.tr("joint_move.legacy_hint"));
|
||||
crate::scenario::publish_target("prefs:assembly_move_handles", response.rect);
|
||||
crate::scenario::publish_target("dialog:Use joint motion handles for assembly Move", response.rect);
|
||||
if !crate::prefs_search::suppress_chrome(ui) {
|
||||
ui_kit::help_icon(ui, app.tr("joint_move.legacy_hint"));
|
||||
}
|
||||
});
|
||||
let response = setting::drive_joints_ghost(app, ui);
|
||||
crate::scenario::publish_target("prefs:drive_joints_ghost", response.rect);
|
||||
let label_width =
|
||||
["joint_move.coarse", "joint_move.fine"].into_iter().map(|id| ui_kit::ContentWidth::label(ui, app.tr(id))).fold(0.0, f32::max)
|
||||
+ t.space_16
|
||||
+ t.space_4;
|
||||
ui_kit::settings_form(ui, label_width, |ui| {
|
||||
ui_kit::settings_row_help(ui, app.tr("joint_move.coarse"), app.tr("joint_move.precision"), |ui| {
|
||||
let response = ui_kit::named_control(ui, app.tr("prefs.design_tools.degrees"), |ui| {
|
||||
ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_coarse, 0.001..=180.0, 0.1)
|
||||
});
|
||||
crate::scenario::publish_target("prefs:joint_rotation_coarse", response.rect);
|
||||
crate::scenario::publish_target("dialog:Near-axis rotation step (°)", response.rect);
|
||||
ui_kit::text(ui, "deg", ui_kit::Text::Inline);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("joint_move.fine"), |ui| {
|
||||
let response = ui_kit::named_control(ui, app.tr("prefs.design_tools.degrees"), |ui| {
|
||||
ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_fine, 0.001..=180.0, 0.01)
|
||||
});
|
||||
crate::scenario::publish_target("prefs:joint_rotation_fine", response.rect);
|
||||
crate::scenario::publish_target("dialog:Far-axis rotation step (°)", response.rect);
|
||||
ui_kit::text(ui, "deg", ui_kit::Text::Inline);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
ui_kit::settings_section(ui, "measure_pointer", app.tr("prefs.structure.measure_pointer"), |ui| {
|
||||
let width = ((ui.available_width() - t.space_10 * 2.0) / 3.0).max(t.space_24 * 8.0 + t.space_16).min(ui.available_width());
|
||||
let tiles: [(_, _, _, fn(&egui::Painter, egui::Rect)); 3] = [
|
||||
(
|
||||
"measure_axis_distances",
|
||||
app.tr("settings.measure_axis_distances"),
|
||||
app.preferences.measure_axis_distances,
|
||||
ui_kit::paint_measure_axis_distances,
|
||||
),
|
||||
("tool_cursor", app.tr_label("Show tool at cursor"), app.ui.tool_cursor, ui_kit::paint_tool_cursor),
|
||||
("direct_interaction", app.tr("ui.direct_interaction"), app.preferences.direct_interaction, ui_kit::paint_direct_interaction),
|
||||
];
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
|
||||
for (key, label, selected, figure) in tiles {
|
||||
let response = ui_kit::toggle_tile(ui, &ui_kit::Card {
|
||||
label, sub: None, selected, enabled: true, options: None, check: false,
|
||||
aspect: 120.0 / 64.0, figure: &figure,
|
||||
}, width);
|
||||
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
||||
crate::scenario::publish_target(&format!("dialog:{label}"), response.rect);
|
||||
match key {
|
||||
"tool_cursor" => {
|
||||
crate::scenario::publish_target("view:tool_cursor", response.rect);
|
||||
crate::scenario::publish_target("dialog:Show tool at cursor", response.rect);
|
||||
}
|
||||
"measure_axis_distances" => crate::scenario::publish_target("dialog:Show X/Y/Z distances", response.rect),
|
||||
_ => {}
|
||||
}
|
||||
if response.clicked() {
|
||||
match key {
|
||||
"measure_axis_distances" => app.preferences.measure_axis_distances = !selected,
|
||||
"tool_cursor" => app.ui.tool_cursor = !selected,
|
||||
_ => app.preferences.direct_interaction = !selected,
|
||||
}
|
||||
}
|
||||
if key == "direct_interaction" {
|
||||
response.on_hover_text(app.tr_label("Work on the model directly in the viewport. Press a flat face or a profile and pull: up extrudes, down into a body cuts, and sideways movement sets the step size"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui_kit::text(ui, app.tr("joint_move.precision"), ui_kit::Text::Hint("joint_move"));
|
||||
});
|
||||
ui_kit::settings_section(ui, "measure_pointer", app.tr("prefs.structure.measure_pointer"), |ui| {
|
||||
let response = setting::measure_axis_distances(app, ui);
|
||||
crate::scenario::publish_rect("prefs:measure_axis_distances", response.rect);
|
||||
response.on_hover_text(app.tr("settings.measure_axis_distances_help"));
|
||||
let cursor = ui_kit::checkbox(ui, crate::i18n::tr_label("Show tool at cursor"), &mut app.ui.tool_cursor);
|
||||
crate::scenario::publish_target("view:tool_cursor", cursor.rect);
|
||||
setting::direct_interaction(app, ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// A single original figure beside a tool's complete setting stack. The columns wrap together
|
||||
/// with their translated labels; search uses the same block gate as other compound preferences.
|
||||
fn tool_group(ui: &mut egui::Ui, label: &str, figure: fn(&egui::Painter, egui::Rect), add: impl FnOnce(&mut egui::Ui)) {
|
||||
let gate = crate::prefs_search::block_gate(ui, label, None);
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
if gate.as_ref().is_some_and(|gate| !gate.show) {
|
||||
crate::prefs_search::run_hidden(ui, label, add);
|
||||
if let Some(gate) = &gate {
|
||||
crate::prefs_search::block_finish(ui, gate, false, before);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let figure_width = t.space_24 * 5.0;
|
||||
let available = ui.available_width();
|
||||
let controls_width = if available >= figure_width + t.space_14 + t.space_24 * 10.0 { available - figure_width - t.space_14 } else { available };
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_14;
|
||||
ui.allocate_ui_with_layout(egui::vec2(figure_width.min(available), 0.0), egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = t.space_6;
|
||||
// The checkbox rows start with a little air; start the figure level with the first box.
|
||||
ui.add_space(t.space_6 - 1.0);
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(figure_width.min(available), t.space_24 * 2.0 + t.space_16), egui::Sense::hover());
|
||||
figure(&ui.painter_at(rect), rect);
|
||||
if !label.is_empty() {
|
||||
ui_kit::text(ui, label, ui_kit::Text::Inline);
|
||||
}
|
||||
});
|
||||
ui.allocate_ui_with_layout(egui::vec2(controls_width, 0.0), egui::Layout::top_down(egui::Align::Min), add);
|
||||
});
|
||||
if let Some(gate) = &gate {
|
||||
crate::prefs_search::block_finish(ui, gate, true, before);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Create section's card: one border around a row per tool.
|
||||
fn create_card(ui: &mut egui::Ui, rows: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = Tokens::get();
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
rows(ui);
|
||||
return;
|
||||
}
|
||||
egui::Frame::NONE.fill(t.control_fill).stroke(egui::Stroke::new(1.0, t.dialog_border)).corner_radius(f32::from(t.radius_lg) + t.space_2).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.set_width(ui.available_width());
|
||||
rows(ui);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// One tool in the Create card: its icons in tiles and its name on the left, its settings on the
|
||||
/// right, a rule above every row but the first. Search uses the same block gate as `tool_group`.
|
||||
fn create_row(ui: &mut egui::Ui, label: &str, icons: &[&str], first: bool, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let gate = crate::prefs_search::block_gate(ui, label, None);
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
if gate.as_ref().is_some_and(|gate| !gate.show) {
|
||||
crate::prefs_search::run_hidden(ui, label, add);
|
||||
if let Some(gate) = &gate {
|
||||
crate::prefs_search::block_finish(ui, gate, false, before);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let chrome = !crate::prefs_search::suppress_chrome(ui);
|
||||
let margin = egui::Margin::symmetric(t.space_14 as i8, t.space_10 as i8);
|
||||
let row = egui::Frame::NONE.inner_margin(margin).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let available = ui.available_width();
|
||||
let left = (available * 0.45).clamp(t.space_24 * 5.0, t.space_24 * 12.0 + t.space_4);
|
||||
let right = if available >= left + t.space_16 + t.space_24 * 8.0 { available - left - t.space_16 } else { available };
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_16;
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(left.min(available), t.space_24 + t.space_12),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.set_min_width(left.min(available));
|
||||
ui.spacing_mut().item_spacing.x = t.space_6;
|
||||
let tile = t.space_24 + t.space_12;
|
||||
for icon in icons {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(tile, tile), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, t.radius_lg, t.pref_well);
|
||||
crate::icons::paint_icon(ui.painter(), rect.shrink(t.space_4), icon, 1.0);
|
||||
}
|
||||
ui.add_space(t.space_4);
|
||||
if !label.is_empty() {
|
||||
ui.label(egui::RichText::new(label).font(crate::fonts::semibold(t.font_label)).color(t.form_ink));
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.allocate_ui_with_layout(egui::vec2(right, 0.0), egui::Layout::top_down(egui::Align::Min), add);
|
||||
});
|
||||
});
|
||||
if chrome && !first {
|
||||
// Not the first row: draw the rule at the row's top edge.
|
||||
ui.painter().hline(row.response.rect.x_range(), row.response.rect.top(), egui::Stroke::new(1.0, t.dialog_rule));
|
||||
}
|
||||
if let Some(gate) = &gate {
|
||||
crate::prefs_search::block_finish(ui, gate, true, before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,16 @@ pub(crate) fn note_current(ui: &Ui, text: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep per-option aliases searchable when a compound row learns its choices.
|
||||
pub(crate) fn note_choices(ui: &Ui, choices: &[&str]) {
|
||||
let mut text = choices.join(" ");
|
||||
if texts_hit(ui, choices) {
|
||||
text.push(' ');
|
||||
text.push_str(&query(ui));
|
||||
}
|
||||
note_current(ui, &text);
|
||||
}
|
||||
|
||||
/// After a hidden run, show the control on the next pass if its value now matches.
|
||||
pub(crate) fn recheck(ui: &Ui, texts: &[&str]) {
|
||||
if wanted(ui, texts) {
|
||||
@@ -563,8 +573,50 @@ pub(crate) fn matches_text(query: &str, texts: &[&str]) -> bool {
|
||||
/// Words that find a setting even though they are not written on it.
|
||||
fn keywords(text: &str) -> &'static [&'static str] {
|
||||
match fold(text).as_str() {
|
||||
"design" => &["design defaults"],
|
||||
"step values with arrows and wheel" => &["step values with the arrow keys and the wheel"],
|
||||
"show values beside their handles" => &["show values beside their handles in the view"],
|
||||
"files from unknown programs" => &["for files from unknown programs"],
|
||||
"hide the origin folder in new designs" => &["hide origin by default"],
|
||||
"update changed links when a tab comes back" => &["update changed linked designs when a tab comes back"],
|
||||
"double-click a component to activate it" => &["double-click a component to activate it, or to edit a linked one in place"],
|
||||
"insert design lists open designs first" => &["insert design lists the designs open in other tabs first"],
|
||||
"snap to centers" | "snap to centres" => &["snap to face and edge centers", "snap to face and edge centres", "hole snapping"],
|
||||
"move by click or drag" => &["move holes by clicking or dragging"],
|
||||
"use joint motion handles for move" => &["use joint motion handles for assembly move"],
|
||||
"near-axis step" => &["near-axis rotation step (°)", "coarse rotation"],
|
||||
"far-axis step" => &["far-axis rotation step (°)", "fine rotation"],
|
||||
"axis distances in measure" => &["show x/y/z distances"],
|
||||
"tool at the cursor" => &["show tool at cursor"],
|
||||
"theme" => &["dark", "light", "appearance"],
|
||||
"availability" => &["assistant controls"],
|
||||
"let ai agents connect" => &["let ai agents connect (control channel / mcp)"],
|
||||
"add solidcraft to ai apps" => &["make solidcraft available to ai apps on this computer"],
|
||||
"run slow commands in the background" => &["run agent commands in the background"],
|
||||
"connect the last agent at start" => &["connect the last-used agent when solidcraft starts"],
|
||||
"open the chat at start" => &["open the assistant panel when solidcraft starts"],
|
||||
"ask first" => &["ask before changes", "ask every time"],
|
||||
"make edits" => &["allow model edits", "auto-allow model edits"],
|
||||
"do everything" => &["allow everything"],
|
||||
"dock the chat" => &["agent chat side"],
|
||||
"show the agent's pointer" => &["show agent pointer"],
|
||||
"follow the camera" => &["turn the camera to sketches"],
|
||||
"speed" => &["motion speed"],
|
||||
"agent pointer" => &["agent cursor"],
|
||||
"assistant button in the application bar" => &["show assistant button in application bar"],
|
||||
"same as above" => &["as set above"],
|
||||
"anti-aliasing" | "antialiasing" => &["antialiasing", "msaa"],
|
||||
"curved surfaces" => &["display quality"],
|
||||
"perspective" => &["perspective view"],
|
||||
"quick measurement" => &["quick measurement in the view's corner"],
|
||||
"analysis" => &["show viewport analysis status"],
|
||||
"section" => &["show viewport section status"],
|
||||
"live details" => &["live viewport details"],
|
||||
"fold items" => &["fold viewport status items"],
|
||||
"reference geometry through bodies" => &["show reference geometry through bodies"],
|
||||
"single surface analysis" => &["use a single surface analysis"],
|
||||
"only while the view is still" => &["only when the view is still"],
|
||||
"supersampling" => &["supersampling (ssaa)"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -740,6 +792,45 @@ pub(crate) fn follow(ui: &Ui, label: &str, rect: Rect) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn graphics_cards_tiles_and_short_status_labels_keep_search_paths_and_reveal() {
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", serde_json::json!({"theme":theme,"language":"en"}));
|
||||
h.call("ui.prefs", serde_json::json!({"open":"Graphics"}));
|
||||
for (query, label, handle) in [
|
||||
("Display quality", "Curved surfaces", "prefs:display_quality:normal"),
|
||||
("MSAA", "Anti-aliasing", "prefs:msaa:4"),
|
||||
("Ambient occlusion", "Ambient occlusion", "prefs:ambient_occlusion"),
|
||||
("Profile operations", "Profile operations", "prefs:profile_operations"),
|
||||
("Quick measurement in the view's corner", "Quick measurement", "prefs:quick_measure"),
|
||||
("Show viewport analysis status", "Analysis", "prefs:viewport_analysis_status"),
|
||||
("Show viewport section status", "Section", "prefs:viewport_section_status"),
|
||||
("Live viewport details", "Live details", "prefs:viewport_status_details"),
|
||||
("Fold viewport status items", "Fold items", "prefs:viewport_status_summary"),
|
||||
] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let matching: Vec<_> =
|
||||
hits.iter().enumerate().filter(|(_, hit)| hit.page == crate::prefs::PAGE_GRAPHICS && hit.label == label).collect();
|
||||
assert_eq!(matching.len(), 1, "{theme}: {query} keeps one real Graphics search target");
|
||||
let index = matching[0].0;
|
||||
set_cursor(&h.ctx, index);
|
||||
h.frames(2);
|
||||
h.step(&serde_json::json!({"key":"Enter"})).unwrap();
|
||||
// Enter restores the full page; let its normal scroll animation settle.
|
||||
h.frames(20);
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_GRAPHICS);
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
let page = crate::scenario::rect_of("dialog:page_body").unwrap();
|
||||
let target = crate::scenario::rect_of(handle).unwrap();
|
||||
assert!(page.contains_rect(target), "{query} reveals the real control: page={page:?}, target={target:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_controls_keep_one_search_result_and_their_page() {
|
||||
use egui::accesskit::Role;
|
||||
@@ -748,12 +839,12 @@ mod tests {
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", serde_json::json!({"width":width,"height":1100}));
|
||||
h.call("ui.prefs", serde_json::json!({"language":"en","open":"Appearance"}));
|
||||
for (query, label, native) in [
|
||||
("3D view colors", "3D view colors", None),
|
||||
("Interface scale", "Interface scale", Some((Role::Slider, "Interface scale", "prefs:scale"))),
|
||||
("Follow a theme file", "Follow a theme file", None),
|
||||
("Background", "Background", Some((Role::Button, "Color", "dialog:Background:Color"))),
|
||||
("Font", "Font", Some((Role::ComboBox, "Interface font", "prefs:ui_font"))),
|
||||
for (query, label, row_target, native) in [
|
||||
("3D view colors", "3D view colors", "dialog:row:3D view colors", None),
|
||||
("Interface scale", "Interface scale", "dialog:row:Interface scale", Some((Role::Slider, "Interface scale", "prefs:scale"))),
|
||||
("Follow a theme file", "Follow a theme file", "prefs:theme_follow_toggle", None),
|
||||
("Background", "Background", "dialog:row:Background", Some((Role::Button, "Color", "dialog:Background:Color"))),
|
||||
("Font", "Font", "dialog:row:Font", Some((Role::ComboBox, "Interface font", "prefs:ui_font"))),
|
||||
] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
@@ -761,9 +852,11 @@ mod tests {
|
||||
let matching: Vec<_> = hits.iter().filter(|hit| hit.label == label).collect();
|
||||
assert_eq!(matching.len(), 1, "width={width}: {query} needs exactly one real navigation hit");
|
||||
assert_eq!(matching[0].page, crate::prefs::PAGE_APPEARANCE);
|
||||
let at = h.widget(label).unwrap();
|
||||
// The search field also contains the query as its accessible value. Use the
|
||||
// indexed row so this check measures the result rather than the search field.
|
||||
let row = crate::scenario::rect_of(row_target).unwrap();
|
||||
let body = crate::scenario::rect_of("prefs:page_body").unwrap();
|
||||
assert!(body.contains(egui::pos2(at[0] as f32, at[1] as f32)));
|
||||
assert!(body.contains(row.center()), "width={width}: {query} row {row:?} outside {body:?}");
|
||||
if let Some((role, name, target)) = native {
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
let controls: Vec<_> =
|
||||
@@ -809,6 +902,41 @@ mod tests {
|
||||
|
||||
/// A multi-word right-to-left label reaches the search in display order while the query is
|
||||
/// typed in reading order: typing the label's words as written must find the row (#10471).
|
||||
#[test]
|
||||
fn layout_controls_keep_one_real_search_target_and_native_names() {
|
||||
use egui::accesskit::Role;
|
||||
for width in [600, 1600] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", serde_json::json!({"width":width,"height":1100}));
|
||||
h.call("ui.prefs", serde_json::json!({"language":"en","open":"Layout"}));
|
||||
for (query, native) in [
|
||||
("History", None),
|
||||
("Browser", None),
|
||||
("Components start open", Some((Role::CheckBox, "prefs:browser_components_open"))),
|
||||
("Menus", None),
|
||||
("Highlight active commands", Some((Role::CheckBox, "prefs:persistent_ribbon_pressed"))),
|
||||
("Show navigation bar", Some((Role::CheckBox, "prefs:navbar:show"))),
|
||||
("Position", None),
|
||||
] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let matching: Vec<_> = hits.iter().filter(|hit| hit.page == crate::prefs::PAGE_LAYOUT && hit.label == query).collect();
|
||||
assert_eq!(matching.len(), 1, "{width}: {query} has one real Layout target");
|
||||
if let Some((role, target)) = native {
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
let nodes: Vec<_> =
|
||||
tree.nodes.iter().filter(|(_, node)| node.role() == role && node.label() == Some(query) && !node.is_disabled()).collect();
|
||||
assert_eq!(nodes.len(), 1, "{query} retains its native accessible name");
|
||||
let bounds = nodes[0].1.bounds().unwrap();
|
||||
let centre = egui::pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32);
|
||||
assert!(crate::scenario::rect_of(target).unwrap().contains(centre));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_right_to_left_labels_typed_in_reading_order() {
|
||||
let _language = crate::i18n::scope("he");
|
||||
@@ -825,6 +953,99 @@ mod tests {
|
||||
|
||||
/// The search field and its empty result name the window they search (#8318): "Search
|
||||
/// settings" under a "Preferences" title read as two names for one window.
|
||||
#[test]
|
||||
fn shared_standalone_cards_and_chips_filter_index_and_reveal_their_bounds() {
|
||||
use crate::ui_kit::{self, Card};
|
||||
for kind in 0..4 {
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut target = Rect::NOTHING;
|
||||
let mut miss = Rect::NOTHING;
|
||||
let mut draw = |filter: &str| {
|
||||
crate::test_support::frame(&ctx, Default::default(), |ui| {
|
||||
let _search = begin(ui, filter);
|
||||
begin_page(ui, crate::prefs::PAGE_LAYOUT, "Layout");
|
||||
for label in ["Performance", "Quality"] {
|
||||
let card =
|
||||
Card { label, sub: None, selected: false, enabled: false, options: None, check: false, aspect: 1.0, figure: &|_, _| {} };
|
||||
let response = match kind {
|
||||
0 => ui_kit::picture_card(ui, &card, 100.0),
|
||||
1 => ui_kit::toggle_tile(ui, &card, 100.0),
|
||||
2 => ui_kit::preset_card(ui, label, "", false, false, &|_, _, _| {}, 100.0),
|
||||
_ => ui_kit::settings_chip(ui, label, Default::default()),
|
||||
};
|
||||
assert!(!response.clicked());
|
||||
if label == "Quality" {
|
||||
target = response.rect;
|
||||
} else {
|
||||
miss = response.rect;
|
||||
}
|
||||
}
|
||||
store_nav(ui);
|
||||
});
|
||||
};
|
||||
draw("Quality");
|
||||
assert!(!miss.is_positive(), "non-matching control {kind} has no layout");
|
||||
assert!(target.is_positive());
|
||||
let (_, _, hits) = nav(&ctx);
|
||||
assert_eq!(hits.len(), 1, "control {kind} owns one real keyboard target");
|
||||
assert_eq!((hits[0].page, hits[0].label.as_str()), (crate::prefs::PAGE_LAYOUT, "Quality"));
|
||||
set_reveal(&ctx, Some(Reveal { page: crate::prefs::PAGE_LAYOUT, label: "Quality".into(), until: f64::MAX }));
|
||||
crate::scenario::clear_handles();
|
||||
// The search is cleared before the selected setting is revealed.
|
||||
crate::test_support::frame(&ctx, Default::default(), |ui| {
|
||||
let _search = begin(ui, "");
|
||||
begin_page(ui, crate::prefs::PAGE_LAYOUT, "Layout");
|
||||
let card = Card {
|
||||
label: "Quality",
|
||||
sub: None,
|
||||
selected: false,
|
||||
enabled: false,
|
||||
options: None,
|
||||
check: false,
|
||||
aspect: 1.0,
|
||||
figure: &|_, _| {},
|
||||
};
|
||||
let response = match kind {
|
||||
0 => ui_kit::picture_card(ui, &card, 100.0),
|
||||
1 => ui_kit::toggle_tile(ui, &card, 100.0),
|
||||
2 => ui_kit::preset_card(ui, "Quality", "", false, false, &|_, _, _| {}, 100.0),
|
||||
_ => ui_kit::settings_chip(ui, "Quality", Default::default()),
|
||||
};
|
||||
assert_eq!(crate::scenario::handle_at("prefs:revealed"), Some(response.rect.center()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_picture_choices_index_the_row_or_option_once_and_learn_aliases() {
|
||||
use crate::ui_kit::{self, PictureChoice};
|
||||
let choices =
|
||||
[PictureChoice { key: "on", label: "Anti-aliasing", figure: |_, _| {} }, PictureChoice { key: "off", label: "Off", figure: |_, _| {} }];
|
||||
for (query, expected) in [("Sampling", "Sampling"), ("Anti-aliasing", "Anti-aliasing"), ("msaa", "Anti-aliasing")] {
|
||||
let ctx = crate::fonts::test_context();
|
||||
for _ in 0..4 {
|
||||
crate::test_support::frame(&ctx, Default::default(), |ui| {
|
||||
let _search = begin(ui, query);
|
||||
begin_page(ui, crate::prefs::PAGE_GRAPHICS, "Graphics");
|
||||
assert_eq!(ui_kit::settings_picture_choices(ui, "shared", "Sampling", &choices, 0, true), None);
|
||||
store_nav(ui);
|
||||
});
|
||||
}
|
||||
let (_, _, hits) = nav(&ctx);
|
||||
assert_eq!(hits.len(), 1, "{query} has one target");
|
||||
assert_eq!(hits[0].label, expected);
|
||||
assert_eq!(hits[0].page, crate::prefs::PAGE_GRAPHICS);
|
||||
set_reveal(&ctx, Some(Reveal { page: crate::prefs::PAGE_GRAPHICS, label: expected.into(), until: f64::MAX }));
|
||||
crate::test_support::frame(&ctx, Default::default(), |ui| {
|
||||
let _search = begin(ui, "");
|
||||
begin_page(ui, crate::prefs::PAGE_GRAPHICS, "Graphics");
|
||||
ui_kit::settings_picture_choices(ui, "shared", "Sampling", &choices, 0, true);
|
||||
let rect = crate::scenario::rect_of(if expected == "Sampling" { "dialog:row:Sampling" } else { "prefs:shared:on" }).unwrap();
|
||||
assert_eq!(crate::scenario::handle_at("prefs:revealed"), Some(rect.center()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_field_names_the_window_it_searches() {
|
||||
let name = crate::i18n::tr("ui.preferences").to_lowercase();
|
||||
@@ -1103,10 +1324,10 @@ mod tests {
|
||||
("status bar", "Status bar"),
|
||||
("sketch tab shown", "Keep the Sketch tab shown"),
|
||||
("search results", "Show search results in the ribbon"),
|
||||
("play history", "Play History button in the History controls"),
|
||||
("view's corner", "OK and Cancel in the view's corner"),
|
||||
("highlight active", "Highlight active ribbon commands"),
|
||||
("reveal on hover", "Reveal on hover"),
|
||||
("play history", "Play History button"),
|
||||
("view's corner", "Also in the view's corner"),
|
||||
("highlight active", "Highlight active commands"),
|
||||
("On hover", "On hover"),
|
||||
];
|
||||
for (query, label) in rows {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
@@ -1120,12 +1341,50 @@ mod tests {
|
||||
h.app.prefs_window.filter = "sketch tab shown".into();
|
||||
h.frames(4);
|
||||
assert!(drawn(&h, "Keep the Sketch tab shown"));
|
||||
assert!(!drawn(&h, "Highlight active ribbon commands"), "its neighbour is not a match");
|
||||
assert!(!drawn(&h, "Highlight active commands"), "its neighbour is not a match");
|
||||
assert!(!drawn(&h, "Show search results in the ribbon"), "its neighbour is not a match");
|
||||
h.app.prefs_window.filter = "zzzz-no-setting".into();
|
||||
h.frames(4);
|
||||
assert!(!drawn(&h, "Status bar"), "a miss shows nothing");
|
||||
}
|
||||
#[test]
|
||||
fn design_and_tools_short_labels_keep_legacy_search_and_reveal_the_real_control() {
|
||||
for language in ["en", "ja"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", serde_json::json!({"language":language,"open":"General"}));
|
||||
for (query, id, page, handle) in [
|
||||
("Step values with the arrow keys and the wheel", "ui.step_values", crate::prefs::PAGE_DESIGN, "prefs:step_values"),
|
||||
("For files from unknown programs", "prefs.stand_up_unknown", crate::prefs::PAGE_DESIGN, "prefs:stand_up_unknown"),
|
||||
("Hide origin by default", "ui.hide_origin_by_default", crate::prefs::PAGE_DESIGN, "prefs:hide_origin"),
|
||||
("Snap to face and edge centres", "Snap to face and edge centres", crate::prefs::PAGE_TOOLS, "prefs:hole_snaps"),
|
||||
("Move holes by clicking or dragging", "Move holes by clicking or dragging", crate::prefs::PAGE_TOOLS, "prefs:hole_moves"),
|
||||
("Show moved parts' old place as a ghost", "prefs.design_tools.ghost", crate::prefs::PAGE_TOOLS, "prefs:drive_joints_ghost"),
|
||||
("Near-axis rotation step (°)", "joint_move.coarse", crate::prefs::PAGE_TOOLS, "prefs:joint_rotation_coarse"),
|
||||
("Far-axis rotation step (°)", "joint_move.fine", crate::prefs::PAGE_TOOLS, "prefs:joint_rotation_fine"),
|
||||
("Show X/Y/Z distances", "settings.measure_axis_distances", crate::prefs::PAGE_TOOLS, "prefs:measure_axis_distances"),
|
||||
("Show tool at cursor", "Show tool at cursor", crate::prefs::PAGE_TOOLS, "view:tool_cursor"),
|
||||
] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let label = h.app.tr(id);
|
||||
let matches: Vec<_> = hits.iter().enumerate().filter(|(_, hit)| hit.page == page && hit.label == label).collect();
|
||||
assert_eq!(matches.len(), 1, "{language}: {query} has one real setting");
|
||||
set_cursor(&h.ctx, matches[0].0);
|
||||
h.frames(2);
|
||||
h.step(&serde_json::json!({"key":"Enter"})).unwrap();
|
||||
// Let the normal scroll animation settle after opening the longer Design page.
|
||||
h.frames(20);
|
||||
assert_eq!(h.app.prefs_window.section, page);
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
let body = crate::scenario::rect_of("dialog:page_body").unwrap();
|
||||
let target = crate::scenario::rect_of(handle).unwrap();
|
||||
assert!(body.contains_rect(target), "{language}: {query} reveals {handle}: body={body:?}, target={target:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_preferences_search_paths_and_enter_follow_window_groups() {
|
||||
for (query, path, page) in [
|
||||
@@ -1151,4 +1410,76 @@ mod tests {
|
||||
assert!(h.widget(query).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_shortcut_results_enter_reveals_the_command_despite_an_old_page_filter() {
|
||||
use serde_json::json;
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.preferences.ai_features = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
h.call("ui.prefs", json!({"open":"Shortcuts","theme":theme}));
|
||||
h.step(&json!({"click":{"handle":"keymap:filter"}})).unwrap();
|
||||
h.step(&json!({"text":"solid.extrude"})).unwrap();
|
||||
let command = solidcraft_engine::command_specs().last().unwrap().id;
|
||||
let original = crate::toolbar_custom::export(&h.app);
|
||||
h.app.prefs_window.filter = command.into();
|
||||
h.frames(4);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let matches: Vec<_> =
|
||||
hits.iter().enumerate().filter(|(_, hit)| hit.page == crate::prefs::PAGE_SHORTCUTS && hit.label.contains(command)).collect();
|
||||
assert_eq!(matches.len(), 1, "a collapsed result owns one real keyboard target");
|
||||
set_cursor(&h.ctx, matches[0].0);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(30);
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_SHORTCUTS);
|
||||
let target = crate::scenario::rect_of(&format!("keymap:capture:{command}")).unwrap();
|
||||
let body = crate::scenario::rect_of("dialog:page_body").unwrap();
|
||||
assert!(body.contains_rect(target), "Enter reveals the actual off-screen command, clearing the old local filter");
|
||||
assert!(crate::scenario::handle_at("prefs:revealed").is_some());
|
||||
assert_eq!(crate::toolbar_custom::export(&h.app), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_permission_options_and_limited_value_have_real_enter_targets_while_ai_is_off() {
|
||||
use serde_json::json;
|
||||
for theme in ["light", "dark"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.preferences.ai_features = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1200}));
|
||||
h.call("ui.prefs", json!({"open":"General","theme":theme}));
|
||||
let original = (h.app.preferences.ai_permissions, h.app.preferences.agent_chat_turn_limit);
|
||||
for (query, target) in [
|
||||
("Ask first", "prefs:ai_mode:ask"),
|
||||
("Make edits", "prefs:ai_mode:edits"),
|
||||
("Do everything", "prefs:ai_mode:all"),
|
||||
("Limited", "prefs:agent_chat_turn_limit:mode"),
|
||||
] {
|
||||
h.app.prefs_window.filter = query.into();
|
||||
h.frames(5);
|
||||
let (_, _, hits) = nav(&h.ctx);
|
||||
let matches: Vec<_> =
|
||||
hits.iter().enumerate().filter(|(_, hit)| hit.page == crate::prefs::PAGE_ASSISTANT && hit.label == query).collect();
|
||||
assert_eq!(matches.len(), 1, "{query} owns a single real setting target");
|
||||
set_cursor(&h.ctx, matches[0].0);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.frames(30);
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_ASSISTANT);
|
||||
let rect = crate::scenario::rect_of(target).unwrap();
|
||||
let revealed = crate::scenario::handle_at("prefs:revealed").unwrap();
|
||||
assert!(revealed.distance(rect.center()) <= 1.0, "{query} reveals its real control");
|
||||
assert_eq!(
|
||||
(h.app.preferences.ai_permissions, h.app.preferences.agent_chat_turn_limit),
|
||||
original,
|
||||
"navigation never selects an option"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user